From 6965f2c20a0fbd876510270be6ce696a5359cd67 Mon Sep 17 00:00:00 2001 From: Patrick Samson Date: Wed, 5 Jan 2011 09:42:25 +0100 Subject: [PATCH] initial code upload --- docs/features.rst | 10 +- docs/index.rst | 20 +- docs/management.rst | 6 +- docs/notification.rst | 47 + docs/quickstart.rst | 17 +- docs/tags-filters.rst | 8 +- docs/views.rst | 2 +- postman/__init__.py | 19 + postman/admin.py | 183 ++ postman/context_processors.py | 8 + postman/fields.py | 120 ++ postman/forms.py | 197 +++ postman/locale/de/LC_MESSAGES/django.mo | Bin 3392 -> 4391 bytes postman/locale/de/LC_MESSAGES/django.po | 133 +- postman/locale/en/LC_MESSAGES/django.po | 8 +- postman/locale/es/LC_MESSAGES/django.mo | Bin 3155 -> 3708 bytes postman/locale/es/LC_MESSAGES/django.po | 120 +- postman/locale/fr/LC_MESSAGES/django.mo | Bin 9830 -> 9800 bytes postman/locale/fr/LC_MESSAGES/django.po | 14 +- postman/locale/it/LC_MESSAGES/django.mo | Bin 3816 -> 4649 bytes postman/locale/it/LC_MESSAGES/django.po | 132 +- postman/locale/nl/LC_MESSAGES/django.mo | Bin 0 -> 3681 bytes postman/locale/nl/LC_MESSAGES/django.po | 519 ++++++ postman/management/__init__.py | 19 + postman/management/commands/__init__.py | 0 .../management/commands/postman_checkup.py | 56 + .../management/commands/postman_cleanup.py | 36 + postman/medias/postman/css/admin.css | 6 + postman/models.py | 483 +++++ .../postman/pendingmessage/change_form.html | 45 + .../postman/pendingmessage/submit_line.html | 8 + .../autocomplete_postman_multiple.html | 13 + .../autocomplete_postman_single.html | 12 + postman/templates/postman/archives.html | 8 + postman/templates/postman/base.html | 12 + postman/templates/postman/base_folder.html | 55 + postman/templates/postman/base_write.html | 23 + postman/templates/postman/email_user.txt | 20 + .../templates/postman/email_user_subject.txt | 1 + postman/templates/postman/email_visitor.txt | 22 + .../postman/email_visitor_subject.txt | 1 + postman/templates/postman/inbox.html | 7 + postman/templates/postman/inc_subject_ex.html | 22 + postman/templates/postman/reply.html | 4 + postman/templates/postman/sent.html | 7 + postman/templates/postman/trash.html | 11 + postman/templates/postman/view.html | 36 + postman/templates/postman/write.html | 3 + postman/templatetags/__init__.py | 0 .../templatetags/pagination_tags_for_tests.py | 27 + postman/templatetags/postman_admin_modify.py | 13 + postman/templatetags/postman_tags.py | 147 ++ postman/test_urls.py | 129 ++ postman/tests.py | 1547 +++++++++++++++++ postman/urls.py | 108 ++ postman/utils.py | 94 + postman/views.py | 294 ++++ 57 files changed, 4613 insertions(+), 219 deletions(-) create mode 100644 docs/notification.rst create mode 100644 postman/__init__.py create mode 100644 postman/admin.py create mode 100644 postman/context_processors.py create mode 100644 postman/fields.py create mode 100644 postman/forms.py create mode 100644 postman/locale/nl/LC_MESSAGES/django.mo create mode 100644 postman/locale/nl/LC_MESSAGES/django.po create mode 100644 postman/management/__init__.py create mode 100644 postman/management/commands/__init__.py create mode 100644 postman/management/commands/postman_checkup.py create mode 100644 postman/management/commands/postman_cleanup.py create mode 100644 postman/medias/postman/css/admin.css create mode 100644 postman/models.py create mode 100644 postman/templates/admin/postman/pendingmessage/change_form.html create mode 100644 postman/templates/admin/postman/pendingmessage/submit_line.html create mode 100644 postman/templates/autocomplete_postman_multiple.html create mode 100644 postman/templates/autocomplete_postman_single.html create mode 100644 postman/templates/postman/archives.html create mode 100644 postman/templates/postman/base.html create mode 100644 postman/templates/postman/base_folder.html create mode 100644 postman/templates/postman/base_write.html create mode 100644 postman/templates/postman/email_user.txt create mode 100644 postman/templates/postman/email_user_subject.txt create mode 100644 postman/templates/postman/email_visitor.txt create mode 100644 postman/templates/postman/email_visitor_subject.txt create mode 100644 postman/templates/postman/inbox.html create mode 100644 postman/templates/postman/inc_subject_ex.html create mode 100644 postman/templates/postman/reply.html create mode 100644 postman/templates/postman/sent.html create mode 100644 postman/templates/postman/trash.html create mode 100644 postman/templates/postman/view.html create mode 100644 postman/templates/postman/write.html create mode 100644 postman/templatetags/__init__.py create mode 100644 postman/templatetags/pagination_tags_for_tests.py create mode 100644 postman/templatetags/postman_admin_modify.py create mode 100644 postman/templatetags/postman_tags.py create mode 100644 postman/test_urls.py create mode 100644 postman/tests.py create mode 100644 postman/urls.py create mode 100644 postman/utils.py create mode 100644 postman/views.py diff --git a/docs/features.rst b/docs/features.rst index 112a5c6..1b690dd 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -199,7 +199,7 @@ Auto-complete field An auto-complete functionality may be useful on the recipients field. To activate the option, set at least the ``arg_default`` key in the -``POSTMAN_AUTOCOMPLETER_APP`` dictionary. If the default ajax_select application is used, +``POSTMAN_AUTOCOMPLETER_APP`` dictionary. If the default ``ajax_select`` application is used, define a matching entry in the ``AJAX_LOOKUP_CHANNELS`` dictionary. Example:: @@ -211,13 +211,15 @@ Example:: 'arg_default': 'postman_users', } -Support for multiple recipients is not turned on by default by django-ajax-selects. +Support for multiple recipients is not turned on by default by `django-ajax-selects`_. To allow this capability, you have to pass the option ``multiple: true``. +.. _`django-ajax-selects`: http://code.google.com/p/django-ajax-selects/ + Make your own templates, based on these two files, given as implementation examples: -* postman/templates/autocomplete_postman_multiple.html -* postman/templates/autocomplete_postman_single.html +* :file:`postman/templates/autocomplete_postman_multiple.html` +* :file:`postman/templates/autocomplete_postman_single.html` These examples include a correction necessary for the support of the 'multiple' option (in version 1.1.4 of django-ajax-selects). diff --git a/docs/index.rst b/docs/index.rst index cb4ee4b..b8edc64 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,10 +22,10 @@ an email address for the reply. The email is obfuscated to the recipient. What is a message ? Roughly a piece of text, about a subject, sent by a sender to a recipient. Each user has access to a collection of messages, stored in folders: - | ``Inbox`` for incoming messages - | ``Sent`` for sent messages - | ``Archives`` for archived messages - | ``Trash`` for messages marked as deleted + | **Inbox** for incoming messages + | **Sent** for sent messages + | **Archives** for archived messages + | **Trash** for messages marked as deleted In folders, messages can be presented in two modes: @@ -53,8 +53,11 @@ It has support for optional additional applications: * Autocomplete recipient field (default is 'django-ajax-selects'), with multiple recipient management -* New message notification (default is 'django-notification') -* Asynchronous mailer (default is 'django-mailer') +* New message notification (default is `django-notification`_) +* Asynchronous mailer (default is `django-mailer`_) + +.. _`django-notification`: http://github.com/jtauber/django-notification/ +.. _`django-mailer`: http://github.com/jtauber/django-mailer/ Moderation ---------- @@ -65,8 +68,8 @@ to the recipient. Possible usages are: * to make sure that no direct contact informations are exchanged when the site is an intermediary and delivers services based on subscription fees. -Messages are first created in a ``pending`` state. A moderator is in charge to change them to -a ``rejected`` or ``accepted`` state. This operation can be done in two ways: +Messages are first created in a *pending* state. A moderator is in charge to change them to +a *rejected* or *accepted* state. This operation can be done in two ways: * By a person, through the Admin site. A specially simplified change view is provided, with one-click buttons to accept or reject the message. @@ -89,6 +92,7 @@ Contents: quickstart moderation + notification views features tags-filters diff --git a/docs/management.rst b/docs/management.rst index a196b4d..ba715f4 100644 --- a/docs/management.rst +++ b/docs/management.rst @@ -20,11 +20,11 @@ So there are some criteria to fulfill by a record to be really deleted from the A management command is provided for this purpose: -**django-admin.py postman_cleanup** +:command:`django-admin.py postman_cleanup` It can be run as a cron job or directly. -The ``--days`` option can be used to specify the minimal number of days a message/conversation +A :option:`--days` option can be used to specify the minimal number of days a message/conversation must have been marked for deletion. Default value is 30 days. @@ -35,6 +35,6 @@ A management command to run a test suite on the messages presently in the databa It checks messages and conversations for possible inconsistencies, in a read-only mode. No change is made on the data. -**django-admin.py postman_checkup** +:command:`django-admin.py postman_checkup` It can be run directly or better as a nightly cron job. diff --git a/docs/notification.rst b/docs/notification.rst new file mode 100644 index 0000000..ec0de66 --- /dev/null +++ b/docs/notification.rst @@ -0,0 +1,47 @@ +Notification +============ + +Parties should be notified of these events: + +* when a message is rejected +* when a message or a reply is received + +.. _for_visitors: + +For visitors +------------ +An email is sent, using these templates: + +* :file:`postman/email_visitor_subject.txt` for the subject +* :file:`postman/email_visitor.txt` for the body + +The available context variables are: + +* ``site``: the Site instance +* ``object``: the Message instance +* ``action``: 'rejection' or 'acceptance' + +Default templates are provided with the application. Same as for the views, you can override them, +and design yours. + +For users +--------- +If a notifier application is configured (see :ref:`optional_settings`), the following labels are used: + +* ``postman_rejection`` to notify the sender of the rejection +* ``postman_message`` to notify the recipient of the reception of a message +* ``postman_reply`` to notify the recipient of the reception of a reply + +Some extra context variables are passed in the call to the notifier application +and so are available in the templates: + +* ``message``: the Message instance +* ``action``: 'rejection' or 'acceptance' + +If no notifier application is used, an email is sent, using these templates: + +* :file:`postman/email_user_subject.txt` for the subject +* :file:`postman/email_user.txt` for the body + +In that case, the information about context variables and templates is the same +as in the :ref:`for_visitors` section above. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 5200128..9653a93 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -41,7 +41,7 @@ Required settings Add ``postman`` to the ``INSTALLED_APPS`` setting of your project. -Run a ``manage.py syncdb``. +Run a :command:`manage.py syncdb` Include the URLconf ``postman.urls`` in your project's root URL configuration. @@ -54,7 +54,7 @@ If you want to make use of a ``postman_unread_count`` context variable in your t add ``postman.context_processors.inbox`` to the ``TEMPLATE_CONTEXT_PROCESSORS`` setting of your project. -You may specify some additional configuration options in your ``settings.py``: +You may specify some additional configuration options in your :file:`settings.py`: ``POSTMAN_DISALLOW_ANONYMOUS`` Set it to True if you do not allow visitors to write to users. @@ -82,6 +82,7 @@ You may specify some additional configuration options in your ``settings.py``: *Defaults to*: None. To disable the moderation feature (no control, no filter): + * Set this option to True * Do not provide any auto-moderation functions @@ -147,7 +148,7 @@ If the django-ajax-selects application is used, the following URLs are reference * {{ MEDIA_URL }}css/jquery.autocomplete.css * {{ MEDIA_URL }}css/indicator.gif -The ``postman/base.html`` template extends a ``base.html`` site template, +The :file:`postman/base.html` template extends a :file:`base.html` site template, in which some blocks are expected: * title: in , at least for a part of the entire title string @@ -157,14 +158,14 @@ in which some blocks are expected: Medias ~~~~~~ -A CSS file is provided with the application, for the Admin site: ``postman/css/admin.css``. +A CSS file is provided with the application, for the Admin site: :file:`postman/css/admin.css`. It is not obligatory but makes the display more confortable. -The file is provided under ``postman/medias/``. It's up to you to make it visible to the URL resolver. +The file is provided under :file:`postman/medias/`. It's up to you to make it visible to the URL resolver. For example: -* In a production environment, set /<MEDIA_URL>/postman/ as a symlink to <Postman_module>/medias/postman/ +* In a production environment, set :file:`/<MEDIA_URL>/postman/` as a symlink to :file:`<Postman_module>/medias/postman/` * In a development environment (django's runserver), you can put in the URLconf, something like:: ('^' + settings.MEDIA_URL.strip('/') + r'/(?P<path>postman/.*)$', 'django.views.static.serve', @@ -175,7 +176,7 @@ See also :ref:`styles` for the stylesheets of views. Examples -------- -``settings.py``:: +:file:`settings.py`:: INSTALLED_APPS = ( # ... @@ -199,6 +200,6 @@ Examples # 'arg_default': 'postman_friends', # no default, mandatory to enable the feature # } # default is {} -``urls.py``:: +:file:`urls.py`:: (r'^messages/', include('postman.urls')), diff --git a/docs/tags-filters.rst b/docs/tags-filters.rst index 440f9c0..d4a8a3f 100644 --- a/docs/tags-filters.rst +++ b/docs/tags-filters.rst @@ -5,15 +5,15 @@ The following tags and filters are available to your templates by loading the li {% load postman_tags %} -Here are the other special libraries in the ``postman/templatetags/`` directory, +Here are the other special libraries in the :file:`postman/templatetags/` directory, that are not intended for your site design: -* ``postman_admin_modify.py``: a library exclusively designed for a customized change_form +* :file:`postman_admin_modify.py`: a library exclusively designed for a customized change_form template used in the Admin site for the moderation of pending messages. -* ``pagination_tags_for_tests.py``: a mock of the django-pagination application template tags, +* :file:`pagination_tags_for_tests.py`: a mock of the django-pagination application template tags, only usable for the test suite in case the real application is not installed. - To rename to ``pagination_tags.py`` during the test session. + To rename to :file:`pagination_tags.py` during the test session. Tags ---- diff --git a/docs/views.rst b/docs/views.rst index 3be4473..8d4ae32 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -5,7 +5,7 @@ Custom views styles ------ -Here is a sample of some CSS rules, usable for ``postman/views.html``:: +Here is a sample of some CSS rules, usable for :file:`postman/views.html`:: .pm_message.pm_deleted { text-decoration: line-through; } .pm_message.pm_deleted .pm_body { display: none; } diff --git a/postman/__init__.py b/postman/__init__.py new file mode 100644 index 0000000..85a5ccd --- /dev/null +++ b/postman/__init__.py @@ -0,0 +1,19 @@ +"""A messaging application for Django""" + +# following PEP 386: N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN] +VERSION = (1, 0, 0) +PREREL = () +POST = 0 +DEV = 0 + +def get_version(): + version = '.'.join(map(str, VERSION)) + if PREREL: + version += PREREL[0] + '.'.join(map(str, PREREL[1:])) + if POST: + version += ".post" + str(POST) + if DEV: + version += ".dev" + str(DEV) + return version + +__version__ = get_version() diff --git a/postman/admin.py b/postman/admin.py new file mode 100644 index 0000000..d1286be --- /dev/null +++ b/postman/admin.py @@ -0,0 +1,183 @@ +from django import forms +from django.contrib import admin +from django.db import transaction +from django.utils.translation import ugettext, ugettext_lazy as _ + +from postman.models import Message, PendingMessage + +class MessageAdminForm(forms.ModelForm): + class Meta: + model = Message + class Media: + css = { "all": ("postman/css/admin.css",) } + + def clean(self): + """Check data validity and coherence.""" + cleaned_data = super(MessageAdminForm, self).clean() + sender = cleaned_data.get('sender') + recipient = cleaned_data.get('recipient') + email = cleaned_data.get('email') + errors = [] + if not sender and not recipient: + errors.append(ugettext("Sender and Recipient cannot be both undefined.")) + if 'sender' in cleaned_data: + del cleaned_data['sender'] + if 'recipient' in cleaned_data: + del cleaned_data['recipient'] + elif sender and recipient: + if email: + errors.append(ugettext("Visitor's email is in excess.")) + if 'email' in cleaned_data: + del cleaned_data['email'] + else: + if not email: + errors.append(ugettext("Visitor's email is missing.")) + if 'email' in cleaned_data: + del cleaned_data['email'] + sent_at = cleaned_data.get('sent_at') + read_at = cleaned_data.get('read_at') + if read_at and read_at < sent_at: + errors.append(ugettext("Reading date must be later to sending date.")) + if 'read_at' in cleaned_data: + del cleaned_data['read_at'] + sender_deleted_at = cleaned_data.get('sender_deleted_at') + if sender_deleted_at and sender_deleted_at < sent_at: + errors.append(ugettext("Deletion date by sender must be later to sending date.")) + if 'sender_deleted_at' in cleaned_data: + del cleaned_data['sender_deleted_at'] + recipient_deleted_at = cleaned_data.get('recipient_deleted_at') + if recipient_deleted_at and recipient_deleted_at < sent_at: + errors.append(ugettext("Deletion date by recipient must be later to sending date.")) + if 'recipient_deleted_at' in cleaned_data: + del cleaned_data['recipient_deleted_at'] + replied_at = cleaned_data.get('replied_at') + obj = self.instance + if replied_at: + len_begin = len(errors) + if replied_at < sent_at: + errors.append(ugettext("Response date must be later to sending date.")) + if not read_at: + errors.append(ugettext("The message cannot be replied without having been read.")) + elif replied_at < read_at: + errors.append(ugettext("Response date must be later to reading date.")) + if not obj.get_replies_count(): + errors.append(ugettext("Response date cannot be set without at least one reply.")) + if not obj.thread_id: + errors.append(ugettext("The message cannot be replied without being in a conversation.")) + if len(errors) > len_begin: + if 'replied_at' in cleaned_data: + del cleaned_data['replied_at'] + # if obj.parent_id and not obj.thread_id:# can't be set by the form + if errors: + raise forms.ValidationError(errors) + + self.initial_status = obj.moderation_status + return cleaned_data + +class MessageAdmin(admin.ModelAdmin): + form = MessageAdminForm + search_fields = ('subject', 'body') + date_hierarchy = 'sent_at' + list_display = ('subject', 'admin_sender', 'admin_recipient', 'sent_at', 'moderation_status') + list_filter = ('moderation_status', ) + fieldsets = ( + (None, {'fields': ( + ('sender', 'recipient', 'email'), + 'sent_at', + )}), + (_('Message'), {'fields': ( + 'subject', + 'body', + ('parent', 'thread'), + )}), + (_('Dates'), {'classes': ('collapse', ), 'fields': ( + ('read_at', 'replied_at'), + ('sender_archived', 'recipient_archived'), + ('sender_deleted_at', 'recipient_deleted_at'), + )}), + (_('Moderation'), {'fields': ( + ('moderation_status', 'moderation_date', 'moderation_by'), + 'moderation_reason', + )}), + ) + readonly_fields = ( + 'parent', 'thread', # no reason to change, and anyway too many objects + 'moderation_date', 'moderation_by', # automatically set at status change + ) + radio_fields = {'moderation_status': admin.VERTICAL} + + def queryset(self, request): + """ + Add a custom select_related() to avoid a bunch of queries for users + in the 'change list' admin view. + + Setting 'list_select_related = True' is not efficient as the default + select_related() does not follow foreign keys that have null=True. + + """ + return super(MessageAdmin, self).queryset(request).select_related('sender', 'recipient') + + @transaction.commit_on_success + def save_model(self, request, obj, form, change): + """ + Add some actions around the save. + + Before the save, adjust some constrained fields. + After the save, update related objects and notify parties if needed. + + """ + obj.clean_moderation(form.initial_status, request.user) + obj.clean_for_visitor() + super(MessageAdmin, self).save_model(request, obj, form, change) + obj.update_parent(form.initial_status) + obj.notify_users(form.initial_status) + +class PendingMessageAdminForm(forms.ModelForm): + class Meta: + model = PendingMessage + class Media: + css = { "all": ("postman/css/admin.css",) } + + def clean(self): + """Set status according to the button used to submit.""" + cleaned_data = super(PendingMessageAdminForm, self).clean() + obj = self.instance + self.initial_status = obj.moderation_status + # look for for button names provided by custom admin/postman/pendingmessage/change_form.html + if '_saveasaccepted' in self.data: + obj.set_accepted() + elif '_saveasrejected' in self.data: + obj.set_rejected() + return cleaned_data + +class PendingMessageAdmin(MessageAdmin): + form = PendingMessageAdminForm + search_fields = () + date_hierarchy = None + actions = None + list_display = ('subject', 'admin_sender', 'admin_recipient', 'sent_at') + list_filter = () + fieldsets = ( + (None, {'fields': ( + 'admin_sender', 'admin_recipient', 'sent_at', + )}), + (_('Message'), {'fields': ( + 'subject', + 'body', + )}), + (_('Moderation'), {'fields': ( + 'moderation_reason', + )}), + ) + readonly_fields = ('admin_sender', 'admin_recipient') + + def has_add_permission(self, request): + "Adding is impossible" + return False + + def has_delete_permission(self, request, obj=None): + "Deleting is impossible" + return False + +admin.site.register(Message, MessageAdmin) +admin.site.register(PendingMessage, PendingMessageAdmin) diff --git a/postman/context_processors.py b/postman/context_processors.py new file mode 100644 index 0000000..b4054cc --- /dev/null +++ b/postman/context_processors.py @@ -0,0 +1,8 @@ +from postman.models import Message + +def inbox(request): + """Provide the count of unread messages for an authenticated user.""" + if request.user.is_authenticated(): + return {'postman_unread_count': Message.objects.inbox_unread_count(request.user)} + else: + return {} diff --git a/postman/fields.py b/postman/fields.py new file mode 100644 index 0000000..c2c70a8 --- /dev/null +++ b/postman/fields.py @@ -0,0 +1,120 @@ +""" +Custom fields. +""" + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.validators import EMPTY_VALUES +from django.forms.fields import CharField +from django.utils.translation import ugettext_lazy as _ + +class BasicCommaSeparatedUserField(CharField): + """ + An internal base class for CommaSeparatedUserField. + + This class is not intended to be used directly in forms. + Use CommaSeparatedUserField instead, + to benefit from the auto-complete fonctionality if available. + + """ + default_error_messages = { + 'unknown': _("Some usernames are unknown or no more active: {users}."), + 'max': _("Ensure this value has at most {limit_value} distinct items (it has {show_value})."), + 'min': _("Ensure this value has at least {limit_value} distinct items (it has {show_value})."), + 'filtered': _("Some usernames are rejected: {users}."), + 'filtered_user': _("{user.username}"), + 'filtered_user_with_reason': _("{user.username} ({reason})"), + } + + def __init__(self, max=None, min=None, user_filter=None, *args, **kwargs): + self.max, self.min, self.user_filter = max, min, user_filter + label = kwargs.get('label') + if isinstance(label, tuple): + self.pluralized_labels = label + kwargs.update(label=label[max == 1]) + super(BasicCommaSeparatedUserField, self).__init__(*args, **kwargs) + + def set_max(self, max): + """Supersede the max value and ajust accordingly the label.""" + pluralized_labels = getattr(self, 'pluralized_labels', None) + if pluralized_labels: + self.label = pluralized_labels[max == 1] + self.max = max + + def to_python(self, value): + """Normalize data to an unordered list of distinct, non empty, whitespace-stripped strings.""" + value = super(BasicCommaSeparatedUserField, self).to_python(value) + if value in EMPTY_VALUES: # Return an empty list if no useful input was given. + return [] + return list(set([name.strip() for name in value.split(',') if name and not name.isspace()])) + + def validate(self, value): + """Check the limits.""" + super(BasicCommaSeparatedUserField, self).validate(value) + if value in EMPTY_VALUES: + return + count = len(value) + if self.max and count > self.max: + raise ValidationError(self.error_messages['max'].format(limit_value=self.max, show_value=count)) + if self.min and count < self.min: + raise ValidationError(self.error_messages['min'].format(limit_value=self.min, show_value=count)) + + def clean(self, value): + """Check names are valid and filter them.""" + names = super(BasicCommaSeparatedUserField, self).clean(value) + if not names: + return [] + users = list(User.objects.filter(is_active=True, username__in=names)) + unknown_names = set(names) ^ set([u.username for u in users]) + errors = [] + if unknown_names: + errors.append(self.error_messages['unknown'].format(users=', '.join(unknown_names))) + if self.user_filter: + filtered_names = [] + for u in users[:]: + try: + reason = self.user_filter(u) + if reason is not None: + users.remove(u) + filtered_names.append( + self.error_messages[ + 'filtered_user_with_reason' if reason else 'filtered_user' + ].format(user=u, reason=reason) + ) + except ValidationError, e: + users.remove(u) + errors.extend(e.messages) + if filtered_names: + errors.append(self.error_messages['filtered'].format(users=', '.join(filtered_names))) + if errors: + raise ValidationError(errors) + return users + +d = getattr(settings, 'POSTMAN_AUTOCOMPLETER_APP', {}) +app_name = d.get('name', 'ajax_select') +field_name = d.get('field', 'AutoCompleteField') +arg_name = d.get('arg_name', 'channel') +arg_default = d.get('arg_default') # the minimum to declare to enable the feature + +if app_name in settings.INSTALLED_APPS and arg_default: + # does something like "from ajax_select.fields import AutoCompleteField" + auto_complete_field = getattr(__import__(app_name + '.fields', globals(), locals(), [field_name]), field_name) + is_autocompleted = True + + class CommaSeparatedUserField(BasicCommaSeparatedUserField, auto_complete_field): + def __init__(self, *args, **kwargs): + if not args and arg_name not in kwargs: + kwargs.update([(arg_name,arg_default)]) + super(CommaSeparatedUserField, self).__init__(*args, **kwargs) + + def set_arg(self, value): + """Same as it is done in ajax_select.fields.py for Fields and Widgets.""" + if hasattr(self, arg_name): + setattr(self, arg_name, value) + if hasattr(self.widget, arg_name): + setattr(self.widget, arg_name, value) + +else: + CommaSeparatedUserField = BasicCommaSeparatedUserField + is_autocompleted = False diff --git a/postman/forms.py b/postman/forms.py new file mode 100644 index 0000000..5b4287d --- /dev/null +++ b/postman/forms.py @@ -0,0 +1,197 @@ +""" +You may define your own custom forms, based or inspired by the following ones. + +Examples of customization: + recipients = CommaSeparatedUserField(label=("Recipients", "Recipient"), + min=2, + max=5, + user_filter=my_user_filter, + channel='my_channel', + ) + can_overwrite_limits = False + exchange_filter = staticmethod(my_exchange_filter) + +""" +from django import forms +from django.conf import settings +from django.contrib.auth.models import User +from django.db import transaction +from django.utils.translation import ugettext, ugettext_lazy as _ + +from postman.fields import CommaSeparatedUserField +from postman.models import Message +from postman.utils import WRAP_WIDTH + +class BaseWriteForm(forms.ModelForm): + """The base class for other forms.""" + class Meta: + model = Message + fields = ('body',) + widgets = { + # for better confort, ensure a 'cols' of at least + # the 'width' of the body quote formatter. + 'body': forms.Textarea(attrs={'cols': WRAP_WIDTH, 'rows': 12}), + } + + error_css_class = 'error' + required_css_class = 'required' + + def __init__(self, *args, **kwargs): + sender = kwargs.pop('sender', None) + exchange_filter = kwargs.pop('exchange_filter', None) + user_filter = kwargs.pop('user_filter', None) + max = kwargs.pop('max', None) + channel = kwargs.pop('channel', None) + super(BaseWriteForm, self).__init__(*args, **kwargs) + + self.instance.sender = sender if (sender and sender.is_authenticated()) else None + if exchange_filter: + self.exchange_filter = exchange_filter + if 'recipients' in self.fields: + if user_filter and hasattr(self.fields['recipients'], 'user_filter'): + self.fields['recipients'].user_filter = user_filter + + if getattr(settings, 'POSTMAN_DISALLOW_MULTIRECIPIENTS', False): + max = 1 + if max is not None and hasattr(self.fields['recipients'], 'set_max') \ + and getattr(self, 'can_overwrite_limits', True): + self.fields['recipients'].set_max(max) + + if channel and hasattr(self.fields['recipients'], 'set_arg'): + self.fields['recipients'].set_arg(channel) + + error_messages = { + 'filtered': _("Writing to some users is not possible: {users}."), + 'filtered_user': _("{user.username}"), + 'filtered_user_with_reason': _("{user.username} ({reason})"), + } + def clean_recipients(self): + """Check no filter prohibit the exchange.""" + recipients = self.cleaned_data['recipients'] + exchange_filter = getattr(self, 'exchange_filter', None) + if exchange_filter: + errors = [] + filtered_names = [] + recipients_list = recipients[:] + for u in recipients_list: + try: + reason = exchange_filter(self.instance.sender, u, recipients_list) + if reason is not None: + recipients.remove(u) + filtered_names.append( + self.error_messages[ + 'filtered_user_with_reason' if reason else 'filtered_user' + ].format(user=u, reason=reason) + ) + except forms.ValidationError, e: + recipients.remove(u) + errors.extend(e.messages) + if filtered_names: + errors.append(self.error_messages['filtered'].format(users=', '.join(filtered_names))) + if errors: + raise forms.ValidationError(errors) + return recipients + + @transaction.commit_on_success + def save(self, recipient=None, parent=None, auto_moderators=[]): + """ + Save as many messages as there are recipients. + + Additional actions: + - If it's a reply, build a conversation + - Call auto-moderators + - Notify parties if needed + + Return False if one of the messages is rejected. + + """ + recipients = self.cleaned_data.get('recipients', []) + if parent and not parent.thread_id: # at the very first reply, make it a conversation + parent.thread = parent + parent.save() + # but delay the setting of parent.replied_at to the moderation step + if parent: + self.instance.parent = parent + self.instance.thread_id = parent.thread_id + initial_moderation = self.instance.get_moderation() + initial_dates = self.instance.get_dates() + initial_status = self.instance.moderation_status + if recipient: + if isinstance(recipient, User) and recipient in recipients: + recipients.remove(recipient) + recipients.insert(0, recipient) + is_successful = True + for r in recipients: + if isinstance(r, User): + self.instance.recipient = r + else: + self.instance.recipient = None + self.instance.email = r + self.instance.pk = None # force_insert=True is not accessible from here + self.instance.auto_moderate(auto_moderators) + self.instance.clean_moderation(initial_status) + self.instance.clean_for_visitor() + m = super(BaseWriteForm, self).save() + if self.instance.is_rejected(): + is_successful = False + self.instance.update_parent(initial_status) + self.instance.notify_users(initial_status) + # some resets for next reuse + if not isinstance(r, User): + self.instance.email = '' + self.instance.set_moderation(*initial_moderation) + self.instance.set_dates(*initial_dates) + return is_successful + +class WriteForm(BaseWriteForm): + """The form for an authenticated user, to compose a message.""" + recipients = CommaSeparatedUserField(label=(_("Recipients"), _("Recipient"))) + + class Meta(BaseWriteForm.Meta): + fields = ('recipients', 'subject', 'body') + +class AnonymousWriteForm(BaseWriteForm): + """The form for an anonymous user, to compose a message.""" + # The 'max' customization should not be permitted here. + # The features available to anonymous users should be kept to the strict minimum. + can_overwrite_limits = False + + email = forms.EmailField(label=_("Email")) + recipients = CommaSeparatedUserField(label=(_("Recipients"), _("Recipient")), max=1) # one recipient is enough + + class Meta(BaseWriteForm.Meta): + fields = ('email', 'recipients', 'subject', 'body') + +class BaseReplyForm(BaseWriteForm): + """The base class for a reply to a message.""" + def __init__(self, *args, **kwargs): + recipient = kwargs.pop('recipient', None) + super(BaseReplyForm, self).__init__(*args, **kwargs) + self.recipient = recipient + + def clean(self): + """Check that the recipient is correctly initialized.""" + if not self.recipient: + raise forms.ValidationError(ugettext("Undefined recipient.")) + return super(BaseReplyForm, self).clean() + + def save(self, *args, **kwargs): + return super(BaseReplyForm, self).save(self.recipient, *args, **kwargs) + +class QuickReplyForm(BaseReplyForm): + """ + The form to use in the view of a message or a conversation, for a quick reply. + + The recipient is imposed and a default value for the subject will be provided. + + """ + pass + +allow_copies = not getattr(settings, 'POSTMAN_DISALLOW_COPIES_ON_REPLY', False) +class FullReplyForm(BaseReplyForm): + """The complete reply form.""" + if allow_copies: + recipients = CommaSeparatedUserField(label=(_("Additional recipients"), _("Additional recipient")), required=False) + + class Meta(BaseReplyForm.Meta): + fields = (['recipients'] if allow_copies else []) + ['subject', 'body'] diff --git a/postman/locale/de/LC_MESSAGES/django.mo b/postman/locale/de/LC_MESSAGES/django.mo index 4545852f31dbdbbaa9fde6926aec891a994ab844..118c8922d980ea9cbf4a9be66b93c84c078c34d8 100644 GIT binary patch literal 4391 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 delta 1388 zcmXxkOGs2v9LMo9K1Lm9(lpB~%Y0_0H}mbnvIk^~AUBqq8XxIsGh@!oB3jHQgu)o= zB4N}b2wW9~xd<YPXwwtIC?bM_9+WPG7Uin%Z@eDv|9;N7_s;*k?wy&^$3^kad1+II z(n4$|=3T~g;bbZ&%H1?$HsU?xmU)33<`t(De2Z>;=Uo5f_yyCs{vCa|fO>8jbJ4|3 zj=0HNshC2{;Kmlz1C>~WE$GE=Y{L_{7pGB)KVl~S#I?ADS(r+1RE-bUU_R2fDMb!b zm2Ah2siUGq4OoVqsDTXPdJLixokw+a(P`VbiS{&V_2y73wTS8{gCrZ!i|eo)bFd9L zOc$q2`Zq_ZXoS7ajR0x}XV8P^kep`1xqr!NPdV-DsKmEWD|R2Ru~%<U0~{flwrT=3 zkqb^cfpI0iLWO6|wDZ7C#~IWNZlh-Y47DP!QNR0!*|>ll<}ar_Okvbpu>jR>!ozq7 zd(cL$_-_yUuf+ejP=G09qiPCK3CmFLLM^Jp4%G7p9gm=X*Mr+Ih?+<OHPEZ5e0NZJ z9-#6*M-6a3oAuvA<qsE%v5_}T9d)CY?l@{sW2g})Pzi1zKQS{{h_k4UUOK)<ZRuCk zfR|9uFQf9iNv47N;#9PE1*pBNKz(MKjrMA_lu*&<S?*j=XL{SW5LyA{&{9_s+A@_2 zLN9c!8kBsZhNvbgi3TFB*{l}5r5ddUqUCELG&_}MqKVKlYUwokQiAa&%TCUX1QSfo zbO&xHv<j<5FQJN7R)xQq=H#cM#%`UQQjfcwx|Yi#v<-F6wPJfQ-OAbL?;kY>{L#VW zDPo^;@3%j=%V!fAbFQ?$@W5GnGHcqd^$b|U(dbyaW%UK3eX;)0!DwB0e8?J$_=DEK zh(9zGt{n?UqCtPi3Pd8Y!H9j?Q=HLQQs3I%&^kNkv0SBr{^1VOQDQnrqk(Y9^v1@Y qK3wTM7EJaYvX6OB+P}Qjvrl|KT=q;(b9x{&>_2Tc<vwBvHU9z6Dvxvk diff --git a/postman/locale/de/LC_MESSAGES/django.po b/postman/locale/de/LC_MESSAGES/django.po index ea7be1a..4c346ce 100644 --- a/postman/locale/de/LC_MESSAGES/django.po +++ b/postman/locale/de/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: django-postman\n" -"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n" -"POT-Creation-Date: 2010-12-24 18:38+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-12-27 14:44+0100\n" "PO-Revision-Date: 2010-12-25 11:36+0000\n" "Last-Translator: psam <maxcom@laposte.net>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply." msgstr "" #: .\admin.py:66 -msgid "The message cannot be replied without being in a thread." +msgid "The message cannot be replied without being in a conversation." msgstr "" #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5 @@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}." msgstr "" #: .\fields.py:23 -msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at most {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:24 -msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at least {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:25 @@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})" msgid "Writing to some users is not possible: {users}." msgstr "" -#: .\forms.py:149 .\forms.py:161 +#: .\forms.py:148 .\forms.py:160 msgid "Recipients" msgstr "" -#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26 +#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26 #: .\templates\postman\reply.html.py:4 msgid "Recipient" msgstr "Empfänger" -#: .\forms.py:160 +#: .\forms.py:159 msgid "Email" msgstr "E-Mail" -#: .\forms.py:176 +#: .\forms.py:175 msgid "Undefined recipient." msgstr "" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipients" msgstr "" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipient" msgstr "" @@ -146,87 +150,87 @@ msgstr "betreff" msgid "body" msgstr "inhalt" -#: .\models.py:199 .\models.py:282 +#: .\models.py:199 .\models.py:281 msgid "sender" msgstr "absender" -#: .\models.py:200 .\models.py:306 +#: .\models.py:200 .\models.py:305 msgid "recipient" msgstr "empfänger" -#: .\models.py:202 +#: .\models.py:201 msgid "visitor" -msgstr "" +msgstr "besucher" -#: .\models.py:203 +#: .\models.py:202 msgid "parent message" msgstr "Übergeordnete nachricht" -#: .\models.py:204 +#: .\models.py:203 msgid "root message" msgstr "" -#: .\models.py:205 +#: .\models.py:204 msgid "sent at" msgstr "gesendet am" -#: .\models.py:206 +#: .\models.py:205 msgid "read at" msgstr "gelesen am" -#: .\models.py:207 +#: .\models.py:206 msgid "replied at" msgstr "beantwortet am" -#: .\models.py:208 +#: .\models.py:207 msgid "archived by sender" -msgstr "" +msgstr "vom absender archiviert" -#: .\models.py:209 +#: .\models.py:208 msgid "archived by recipient" -msgstr "" +msgstr "vom empfänger archiviert" -#: .\models.py:210 +#: .\models.py:209 msgid "deleted by sender at" msgstr "vom absender gelöscht am" -#: .\models.py:211 +#: .\models.py:210 msgid "deleted by recipient at" msgstr "vom empfänger gelöscht am" -#: .\models.py:213 +#: .\models.py:212 msgid "status" msgstr "status" -#: .\models.py:215 +#: .\models.py:214 msgid "moderator" msgstr "" -#: .\models.py:216 +#: .\models.py:215 msgid "moderated at" msgstr "" -#: .\models.py:217 +#: .\models.py:216 msgid "rejection reason" msgstr "" -#: .\models.py:222 +#: .\models.py:221 msgid "message" msgstr "nachricht" -#: .\models.py:223 +#: .\models.py:222 msgid "messages" msgstr "nachrichten" -#: .\models.py:334 +#: .\models.py:333 msgid "Undefined sender." msgstr "" -#: .\models.py:478 +#: .\models.py:473 msgid "pending message" msgstr "" -#: .\models.py:479 +#: .\models.py:474 msgid "pending messages" msgstr "" @@ -258,21 +262,21 @@ msgstr "Nachricht erfolgreich gesendet." msgid "Message rejected for at least one recipient." msgstr "" -#: .\views.py:277 +#: .\views.py:276 msgid "Select at least one object." msgstr "" -#: .\views.py:283 -msgid "Message(s) or thread(s) successfully archived." -msgstr "" +#: .\views.py:282 +msgid "Messages or conversations successfully archived." +msgstr "Nachrichten oder Konversationen erfolgreich archiviert." -#: .\views.py:288 -msgid "Message(s) or thread(s) successfully deleted." -msgstr "" +#: .\views.py:287 +msgid "Messages or conversations successfully deleted." +msgstr "Nachrichten oder Konversationen erfolgreich gelöscht." -#: .\views.py:293 -msgid "Message(s) or thread(s) successfully recovered." -msgstr "" +#: .\views.py:292 +msgid "Messages or conversations successfully recovered." +msgstr "Nachrichten oder Konversationen erfolgreich wiederhergestellt." #: .\management\__init__.py:14 msgid "Message Rejected" @@ -314,10 +318,12 @@ msgstr "" #: .\templates\postman\archives.html.py:3 msgid "Archived Messages" -msgstr "" +msgstr "Archivierte Nachrichten" #: .\templates\postman\archives.html.py:7 -msgid "Messages in this folder will never be removed. You can use this folder for long term storage." +msgid "" +"Messages in this folder will never be removed. You can use this folder for " +"long term storage." msgstr "" #: .\templates\postman\base.html.py:3 @@ -338,7 +344,7 @@ msgstr "Schreiben" #: .\templates\postman\base.html.py:9 msgid "Archives" -msgstr "" +msgstr "Archiven" #: .\templates\postman\base.html.py:10 msgid "Trash" @@ -349,12 +355,12 @@ msgid "Sorry, this page number is invalid." msgstr "Sorry, diese Seite ist ungültig." #: .\templates\postman\base_folder.html.py:12 -msgid "by thread" -msgstr "" +msgid "by conversation" +msgstr "nach Konversation" #: .\templates\postman\base_folder.html.py:13 msgid "by message" -msgstr "" +msgstr "nach Nachricht" #: .\templates\postman\base_folder.html.py:17 #: .\templates\postman\view.html.py:22 @@ -364,7 +370,7 @@ msgstr "Löschen" #: .\templates\postman\base_folder.html.py:18 #: .\templates\postman\view.html.py:23 msgid "Archive" -msgstr "" +msgstr "Archivieren" #: .\templates\postman\base_folder.html.py:19 msgid "Undelete" @@ -400,7 +406,7 @@ msgstr "Senden" #: .\templates\postman\email_user.txt.py:1 msgid "Dear user," -msgstr "" +msgstr "Sehr geehrter Benutzer," #: .\templates\postman\email_user.txt.py:3 #: .\templates\postman\email_visitor.txt.py:3 @@ -422,7 +428,8 @@ msgstr "" #: .\templates\postman\email_visitor.txt.py:10 #, python-format msgid "On %(date)s, you sent a message to the user '%(sender)s'." -msgstr "Am %(date)s, du hast eine Nachricht an den Benutzer '%(sender)s' gesendet." +msgstr "" +"Am %(date)s, du hast eine Nachricht an den Benutzer '%(sender)s' gesendet." #: .\templates\postman\email_user.txt.py:10 msgid "Your correspondent has given you an answer." @@ -436,7 +443,7 @@ msgstr "" #: .\templates\postman\email_user.txt.py:13 #, python-format msgid "You have received a message from the user '%(sender)s'." -msgstr "Du hast eine Nachricht von den Benutzer '%(sender)s' erhalten." +msgstr "Du hast eine Nachricht von den Benutzer '%(sender)s' erhalten." #: .\templates\postman\email_user.txt.py:16 #: .\templates\postman\email_visitor.txt.py:14 @@ -459,7 +466,7 @@ msgstr "" #: .\templates\postman\email_visitor_subject.txt.py:1 #, python-format msgid "Message \"%(subject)s\" on the site %(sitename)s" -msgstr "" +msgstr "Nachricht \"%(subject)s\" auf der site %(sitename)s" #: .\templates\postman\email_visitor.txt.py:1 msgid "Dear visitor," @@ -499,12 +506,14 @@ msgid "Deleted Messages" msgstr "Gelöschte Nachrichten" #: .\templates\postman\trash.html.py:10 -msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." +msgid "" +"Messages in this folder can be removed from time to time. For long term " +"storage, use instead the archive folder." msgstr "" #: .\templates\postman\view.html.py:5 -msgid "Thread" -msgstr "" +msgid "Conversation" +msgstr "Konversation" #: .\templates\postman\view.html.py:13 msgid ":" @@ -514,6 +523,6 @@ msgstr " :" msgid "Back" msgstr "Zurück" -#: .\templatetags\postman_tags.py:34 +#: .\templatetags\postman_tags.py:35 msgid "<me>" -msgstr "<ich>" +msgstr "<Ich>" diff --git a/postman/locale/en/LC_MESSAGES/django.po b/postman/locale/en/LC_MESSAGES/django.po index 2693ee2..48b5dde 100644 --- a/postman/locale/en/LC_MESSAGES/django.po +++ b/postman/locale/en/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2010-12-27 13:35+0100\n" +"POT-Creation-Date: 2010-12-27 14:21+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -261,15 +261,15 @@ msgid "Select at least one object." msgstr "" #: .\views.py:282 -msgid "Message(s) or conversation(s) successfully archived." +msgid "Messages or conversations successfully archived." msgstr "" #: .\views.py:287 -msgid "Message(s) or conversation(s) successfully deleted." +msgid "Messages or conversations successfully deleted." msgstr "" #: .\views.py:292 -msgid "Message(s) or conversation(s) successfully recovered." +msgid "Messages or conversations successfully recovered." msgstr "" #: .\management\__init__.py:14 diff --git a/postman/locale/es/LC_MESSAGES/django.mo b/postman/locale/es/LC_MESSAGES/django.mo index 3c1150b1c971613ac2c137bdcbd1bb4e2fe8f334..9b2a38e3d34cca22b991ccaff54634bf93fec422 100644 GIT binary patch literal 3708 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 delta 1379 zcmX}sOGs2v9LMovj_*fmnf6e}w9<TxrkNN>N|3G0O%_COow?}vm>FiSZD--8MS(E1 zsc0J*QDRWb%7P$@&>kR_L@f-87M4)7==;-Shx<RDd(J&`&bj|{_3xg=^5n-7-xWhS zLhK=4rx|k+FQrpa?)r?`iL=Nf^BlR%3#u)+fSLHl`X0H=XX^@P(Eg4Zw~9shXQMx9 z@_4EN#Z*~Xh8j?1ZNXi%doUl*;V}$j0nT7HKEPspggLl~x%dIK@CtI7A5?|-3w<U@ z#ZwK)p<+{}2sLpjYC|=+0~=5i^r9B*v+V)gO?wcP#RMv2x3ChQqUQaA+R#_ria#-n z^^K2JG+{n!XQilxYpL`mji?`XAips^$YuJhr%)4~L1k(LuX0vXs3Wap5*^i1)C+Xm z_Hj%y#+=|r_M;XUL=8M|+ac5j$8Z~Z){9s|`#S3PcTh)nA9WPZP?=e>{cAWtJBznb zhR^1be@#40hbD@l&UnfWm_|Lnf&A9oM2(w8ZS;xtIcft7sD+no|2I@dR#Ed5=55SV zh8ka;NB(uT^>iG<W2m!Bpi*}kweSpbnR`^)`9stMFHsY}wf)Ps{T=lpYu0oc+HetS zzB1dcPI9BOt3&Oy6ZL1-Oy~$UOEow8nD*I@4pb)mL@A*;bToBD8PQJE6Sc%<*~?8M zp^Y3Ml&K_{`Cqnkr`_qzl@)!><-}$=%#F@>KcTm-A(Rc3CPJBG|Ld7*K)sMY=*^;! zP^Dr6!@ro0^*;@@+ia)MQM3{V38lG((1#QtRH{-F86Cb}CpeaRmoYiFoH?DAN@l+b zjCfwGD-ak8dqb1KG1qI3#)kv3gfkHcjXIIxXj3ej@Ftu{Ae=}{x`|YOPDNI`zqPZg zt#dAs(~?#>F@L+;bo)(D(2aRc9E0KcxrjL(9lzklQ@?Xt{eE}c4SI2U!@;QObs`~m c)bWR0zZ>!5PRL2M=k?7M<UdYJeJZ&24|-{g!vFvP diff --git a/postman/locale/es/LC_MESSAGES/django.po b/postman/locale/es/LC_MESSAGES/django.po index f1eb8f0..8c95cab 100644 --- a/postman/locale/es/LC_MESSAGES/django.po +++ b/postman/locale/es/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: django-postman\n" -"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n" -"POT-Creation-Date: 2010-12-24 18:38+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-12-27 15:16+0100\n" "PO-Revision-Date: 2010-12-25 15:29+0000\n" "Last-Translator: psam <maxcom@laposte.net>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply." msgstr "" #: .\admin.py:66 -msgid "The message cannot be replied without being in a thread." +msgid "The message cannot be replied without being in a conversation." msgstr "" #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5 @@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}." msgstr "" #: .\fields.py:23 -msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at most {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:24 -msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at least {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:25 @@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})" msgid "Writing to some users is not possible: {users}." msgstr "" -#: .\forms.py:149 .\forms.py:161 +#: .\forms.py:148 .\forms.py:160 msgid "Recipients" msgstr "Destinatarios" -#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26 +#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26 #: .\templates\postman\reply.html.py:4 msgid "Recipient" msgstr "Destinatario" -#: .\forms.py:160 +#: .\forms.py:159 msgid "Email" -msgstr "Correo electrónico" +msgstr "Correo" -#: .\forms.py:176 +#: .\forms.py:175 msgid "Undefined recipient." msgstr "" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipients" msgstr "" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipient" msgstr "" @@ -146,87 +150,87 @@ msgstr "asunto" msgid "body" msgstr "contenido" -#: .\models.py:199 .\models.py:282 +#: .\models.py:199 .\models.py:281 msgid "sender" msgstr "emisor" -#: .\models.py:200 .\models.py:306 +#: .\models.py:200 .\models.py:305 msgid "recipient" msgstr "destinatario" -#: .\models.py:202 +#: .\models.py:201 msgid "visitor" msgstr "" -#: .\models.py:203 +#: .\models.py:202 msgid "parent message" msgstr "mensaje padre" -#: .\models.py:204 +#: .\models.py:203 msgid "root message" msgstr "" -#: .\models.py:205 +#: .\models.py:204 msgid "sent at" msgstr "enviado a" -#: .\models.py:206 +#: .\models.py:205 msgid "read at" msgstr "leído a" -#: .\models.py:207 +#: .\models.py:206 msgid "replied at" msgstr "respondido a" -#: .\models.py:208 +#: .\models.py:207 msgid "archived by sender" msgstr "" -#: .\models.py:209 +#: .\models.py:208 msgid "archived by recipient" msgstr "" -#: .\models.py:210 +#: .\models.py:209 msgid "deleted by sender at" msgstr "" -#: .\models.py:211 +#: .\models.py:210 msgid "deleted by recipient at" msgstr "" -#: .\models.py:213 +#: .\models.py:212 msgid "status" msgstr "" -#: .\models.py:215 +#: .\models.py:214 msgid "moderator" msgstr "" -#: .\models.py:216 +#: .\models.py:215 msgid "moderated at" msgstr "" -#: .\models.py:217 +#: .\models.py:216 msgid "rejection reason" msgstr "" -#: .\models.py:222 +#: .\models.py:221 msgid "message" msgstr "mensaje" -#: .\models.py:223 +#: .\models.py:222 msgid "messages" msgstr "mensajes" -#: .\models.py:334 +#: .\models.py:333 msgid "Undefined sender." msgstr "" -#: .\models.py:478 +#: .\models.py:473 msgid "pending message" msgstr "" -#: .\models.py:479 +#: .\models.py:474 msgid "pending messages" msgstr "" @@ -258,21 +262,21 @@ msgstr "Mensaje enviado con éxito." msgid "Message rejected for at least one recipient." msgstr "" -#: .\views.py:277 +#: .\views.py:276 msgid "Select at least one object." msgstr "" -#: .\views.py:283 -msgid "Message(s) or thread(s) successfully archived." -msgstr "" +#: .\views.py:282 +msgid "Messages or conversations successfully archived." +msgstr "Mensajes o conversaciones archivado con éxito." -#: .\views.py:288 -msgid "Message(s) or thread(s) successfully deleted." -msgstr "" +#: .\views.py:287 +msgid "Messages or conversations successfully deleted." +msgstr "Mensajes o conversaciones eliminado con éxito." -#: .\views.py:293 -msgid "Message(s) or thread(s) successfully recovered." -msgstr "" +#: .\views.py:292 +msgid "Messages or conversations successfully recovered." +msgstr "Mensajes o conversaciones recuperado con éxito." #: .\management\__init__.py:14 msgid "Message Rejected" @@ -314,10 +318,12 @@ msgstr "Rechazar" #: .\templates\postman\archives.html.py:3 msgid "Archived Messages" -msgstr "" +msgstr "Mensajes archivados" #: .\templates\postman\archives.html.py:7 -msgid "Messages in this folder will never be removed. You can use this folder for long term storage." +msgid "" +"Messages in this folder will never be removed. You can use this folder for " +"long term storage." msgstr "" #: .\templates\postman\base.html.py:3 @@ -326,7 +332,7 @@ msgstr "" #: .\templates\postman\base.html.py:6 msgid "Inbox" -msgstr "Bandeja de entrada" +msgstr "Recibidos" #: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3 msgid "Sent Messages" @@ -338,7 +344,7 @@ msgstr "Escribe" #: .\templates\postman\base.html.py:9 msgid "Archives" -msgstr "" +msgstr "Archivos" #: .\templates\postman\base.html.py:10 msgid "Trash" @@ -349,8 +355,8 @@ msgid "Sorry, this page number is invalid." msgstr "" #: .\templates\postman\base_folder.html.py:12 -msgid "by thread" -msgstr "" +msgid "by conversation" +msgstr "por conversación" #: .\templates\postman\base_folder.html.py:13 msgid "by message" @@ -364,7 +370,7 @@ msgstr "Eliminar" #: .\templates\postman\base_folder.html.py:18 #: .\templates\postman\view.html.py:23 msgid "Archive" -msgstr "" +msgstr "Archivar" #: .\templates\postman\base_folder.html.py:19 msgid "Undelete" @@ -499,12 +505,14 @@ msgid "Deleted Messages" msgstr "Mensajes eliminados" #: .\templates\postman\trash.html.py:10 -msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." +msgid "" +"Messages in this folder can be removed from time to time. For long term " +"storage, use instead the archive folder." msgstr "" #: .\templates\postman\view.html.py:5 -msgid "Thread" -msgstr "" +msgid "Conversation" +msgstr "Conversación" #: .\templates\postman\view.html.py:13 msgid ":" @@ -514,6 +522,6 @@ msgstr " :" msgid "Back" msgstr "Volver" -#: .\templatetags\postman_tags.py:34 +#: .\templatetags\postman_tags.py:35 msgid "<me>" -msgstr "<mí>" +msgstr "<usuario>" diff --git a/postman/locale/fr/LC_MESSAGES/django.mo b/postman/locale/fr/LC_MESSAGES/django.mo index 1b80e8f607deb71a18380af73d9a7c6eb76bd89e..717f65a143a9d21e96c6078510b394b00dce83f2 100644 GIT binary patch delta 1955 zcmaLXe@NVQ9LMqZrRUwXd3m~APTieut~G7y(Tc52$JWxEjuHLX2&3aXJ;6=A^9)D$ zEshvcQRCZx75Go12Eyp1Sh>JTZ1hjj+8<GjT#N`JqWWXDQP0=z`%g3y_3`<9?*09| zKcDyK^S!IRpZA{K?Q_P=>~OhRKQ3Y$M#E;U8pn2g9z*yZ-i`~n7MC%N7x4h@xx;KH z<}rZhaW5|64lJuMBZck8X6&gj$AUB_8Hk{RN^}a9=nX8x)2O&P^yB+TPFuhS@C$6g z5+;#tSd0Bwic_e0rg1yI=#9@uoZ^bkG0?;Z%XmNjf}3!2WpO9n7^i<2J1~z*{0Ua$ zDr#YoDzkOC9+|>gPz&$Cdh9|iEQN}H!J*Mg<0S6G54?a?RDx<|tHDF4lBIDYzK(jX zfOp~*WDRx=wd1u6Qgw@<D%pVRu>}dShrG|7qcn6@!>E;xqt5mO>L^a4N_`47;Ve>Z zJBxa55fyjA8~+-W@JG}<zoJTi9Ti_fxzt~S^c|}#Hf#ebK_hO!I6mt$>qC`piRG&j zSMWBxfhwWhRqU6cHW2cxL?x=lI&4H$uG9PcX{;o_4S546@BjgM)PomLJNOb+((h3T zS5RmB6RI++s0xHwoyM#132eg0@Hy1NzQPoKivzfqUG|aR-lm~5`5jfFAU8y}v;y_I zbfPNL=Z*KHD)KVEif3>Ncd^ZOoX0p`^{gRSN79RH@JZBsNp#XQvNVq1ci4?PsFNO; z#s_f*J8=oMAb+&@7F3}s@+fM;BdCg{QE@rchVq^-p(^E~;@^r=|3ft97|`v%j-A*U zGvn7B{%L|))XLw%9-K#w|BWhnprQCS)ZoMPAHi+-8ft+bVFxZ^7g{6Lg6>A==KZle z16tXesMq8)a_{V8Z~PnFO#d=AVJTk@TEI5c#BtQb<H#x6IoydCQI{{w!>!ni6xp7_ zHk@*3DBuk0!DZCME2!7(XVlLB!Wag~z7e;gO5BBte;jqm`cd(No*C37oko2>yn=kS z*ev?-=sXRb*@vjWWz+<hy#9*U{|$8~ZlE87TZ)EJiPxddv>vsQd)=A9P{Td6bU^yy zHMw~xYVYSy+b}m0Z1(lIdxBk>)DF5c!A-t#cP@C)cei^j81)UhHKF}}lG;``5psN6 z3O|G<OWdJw#0`YcSEaJC(M&8kdVDgK$tEU-M~`RSW989({~eBHbID{Xn;p!Jj7-G_ zQX{E})WCM@V<<6nt7$HkM+={Y{|J=cw=3RWSg2ktEj&{f@zpVP?%x?>iA*v*Jo)Ln jS@&{nb^R>@vbnLb%<!@QiO8g~6Ny~LiztlO{~7!T@^b#m delta 1997 zcmajfYiyHM9LMp0b=_KJ9jk0GwqDrW1~SKG3ghB55@66_mW&W*B%!HgD~W8hYYj2x ziAIQMOeN0?HL^rrArTGO#E6$^oR=A+2@rxN7&HbF-!P){1sJ2>-}<~FL6@$dbIx<l z|D5wb=jl}6mA;efea?Mm)?aPbg$M9{JdL9`jXQBy$SjBxcn41596X1^_%m+AEj4Cq zu!vRo4L*ctP;tX^%?N3|xD<EHHOB%Jo}-}_UqVeZj+*EgR^of8ey7oopCfbH6t2b# z*nu?!k?XM$GgyHKP;p+wPCV?je^%?1SM((ftvon~tMEFu;PP;JC);r)^~bRXi>Qge z$9k;1tGuuX&ZFLfgs^VZ!na}s2T%*!huX-?4uxeDTznWOy$+Q`(FD!dfRCY4Hil7r z6E*Hrtix$!E#{}99WOw#ZV^-_@5TApjdZns@41tvptH)OR{9L;Y!9N2!bPQe92M{c zl5P7KHSRl9zw=)ECDer1P;vf3r9MEK)V~H*pI@#!7WE#qp(g0Wh1i2n`OF4UsXN2+ zm5FJ*4TB5HDV&3<hfy1-^NgY<YR4w*L}f1FJ>P?2?w{qnhJ&bETtp2#kJ`aSR7!tC zo!M2?+0LLcQ^~57fjU%s40qrP+>S4x7WNaS@d`eHee7}@^V=y3I+K4;DVoO((JgI4 zy)Fq<W(K|X3@Rh9q3+20*o{{`+u3fB`Yzm$*HA~Yhe3B@9u@CdbcQJ$rtm1<z+N06 zPa1Fp*WodI5PwB2s3lf@3s#^qGKvaVz}dTl>gS?1bky@WDpRLW{XdG4|3@f%OM`Cz z!gzV2oyf<=`BH!h)XKlYt#}sI9$8dQc`GiXz8W{-5Z;6Dq89ig_TVMljL~MZwYa<4 z;Z>n<lm@NrL)2^X1#<7~qSyWh-c9{)Y{h1N9JGL5RN#J8;39I0b^+Jm?@0EogTY$h z79`2GAD82+4h40XLJho(3anZ5ngvihZ@{>kkyEfus1&DB{d1^G_B5*h^PaDuF6j}} z@59^3PmE2V9|zA;(3$;!>UbFyV8*LkTe)6?x)Y7)$2QLn)Wj=MXSxoxk%W7wYG{!T zOYa21X7j8=(ccFCwT*plZD6TyxBEa~v!8+1?j8!X_!90rfrQW8>wy^G4Z#im#XK`N z8FYM0O1}gPx7RPuEs2k0<EfFog>*KT9LtRCrOE9M#cuT|6N=vI(d(g@`*n5L9j^YQ z^g(E*s-kOc*Q(OV`WqF_wsbC+97@klm%n+scru$B&J-q(>v6J>PQ`QiRBH0AT&Ml_ mxLkg8G@E(y{|7eRw83~bof}K$v$G>hZ#2ezrGdzwfqwxTDhgx( diff --git a/postman/locale/fr/LC_MESSAGES/django.po b/postman/locale/fr/LC_MESSAGES/django.po index d4b73bf..2a9b082 100644 --- a/postman/locale/fr/LC_MESSAGES/django.po +++ b/postman/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: django-postman 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2010-12-27 13:36+0100\n" +"POT-Creation-Date: 2010-12-27 14:21+0100\n" "PO-Revision-Date: 2010-12-15 17:19+0100\n" "Last-Translator: Patrick Samson <maxcom@laposte.net>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -277,16 +277,16 @@ msgid "Select at least one object." msgstr "Sélectionner au moins un objet." #: .\views.py:282 -msgid "Message(s) or conversation(s) successfully archived." -msgstr "Message(s) ou conversation(s) archivé(s) avec succès." +msgid "Messages or conversations successfully archived." +msgstr "Messages ou conversations archivés avec succès." #: .\views.py:287 -msgid "Message(s) or conversation(s) successfully deleted." -msgstr "Message(s) ou conversation(s) supprimé(s) avec succès." +msgid "Messages or conversations successfully deleted." +msgstr "Messages ou conversations supprimés avec succès." #: .\views.py:292 -msgid "Message(s) or conversation(s) successfully recovered." -msgstr "Message(s) ou conversation(s) restauré(s) avec succès." +msgid "Messages or conversations successfully recovered." +msgstr "Messages ou conversations restaurés avec succès." #: .\management\__init__.py:14 msgid "Message Rejected" diff --git a/postman/locale/it/LC_MESSAGES/django.mo b/postman/locale/it/LC_MESSAGES/django.mo index 92b66dbd7e9b4af1813fbd89cbf4765b1f56ebaf..89383828253a1fe5932d7541f7b2e0ff4f3d49a8 100644 GIT binary patch literal 4649 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 delta 1715 zcmYk+OK4PA0LJk%F?C`R<0C$rCecyjD`qmrq&6mMeN#nI5ehDZ-ppLao5$QR_ePph znMLbHR9Y@7c2O~O;d5iiO5JH!M%@T5L~$XCU^SqqMZy1<IVnBNxxaJHJ@0eoUTAn; zUwAaL^rRtdB-RldN{rc$O{F{tzkOj$4gQWXyo7pn1*vMT;$$4bGQ4i{w{RBu+c+Da zpxV8&enfr+6QlK1Uc^xy&E_#3>yQd2iE(VT<(<}@_!Z>`uo8bn)jNV4@Hnd9JE)00 z#2NS$r{Fs*XM7W*6=_q6s#uL0xDKmuA!_C=sCuhyejRFnO}Gg6AU_lGn1er~`iW5e zp0W8MOp!m2wTy2bkoX4Qptib++VgN5YG4=3u^Tn>0i25k)E1w{X?Pjw!dypw=BCY$ zqT1a@_463DkQZ1`gE12FgEdBNbvOmJ#WnZ`hu?;p*j;+j+5dx+aLnf4B2~<LYe|LO zS=5eHqmG~v8Op4!VE?tktrW-|sI%W~EBt`f<UQ1k52FV76=&iJ`~DoxCqIH3;6AF} zGwj4M)KMk5C>ppERc}ir`>)IMJrC-bG^(L*9Yi(w1=Zjf>P-K%<$s}e?h0z+BiM|8 z+w!-lBmHFabD0I3W9Fk4)L0<VN}>f-(Y1M%D?f}2aTGPN*T~QO%R>W}aYJ<^HK?O% zN4?*Ns_&o{&|?jextPPK1s9Im7pG7&JBwP;MVr5hx(hc@1B{}+13J%di5)~uV0>^? z(4|ypA*wG0EoLIDC6OR<v<0(_#7d%qZnXkqjIVADE>(dINz`g|6#5G(d|71t1Gbv9 zCab&9V9S@FR;pvuUC`>aqs74aH<DUU)P>RxEGH^_P1Nn42=z9hbJN!9!fLCVi2n~8 z$TSmciTd)bBd#FoAG3hCil{HG!s3b9E2D3KJ|u0#*67IO?rEExbdTBMgzoqwh}OiD z@m+2ZI9WFuimx9I%3j85y8O(b=^9M*6<;VB9?ZK=COTjKDs?an`#VypE-&mFNcXs5 zv!BnV`U9sgmFaeJSwGqD2VtL+OL;*s;0Dp@iu!n4qNTl~wSD+n#j04$i(XZ3-@84R z@!T-)Ya&i@N}HX$pE%?NUg(5=-fc3Qom|@O?R89Xidip_wk4rwwkG==ulK)KepFiZ r-SCsD+LC2yKNq^WfzUS@r`N6`>ye|qpF8B{1Lr5t&$-e58TUQ|`<=tU diff --git a/postman/locale/it/LC_MESSAGES/django.po b/postman/locale/it/LC_MESSAGES/django.po index 0fdbf4c..97ba077 100644 --- a/postman/locale/it/LC_MESSAGES/django.po +++ b/postman/locale/it/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# +# msgid "" msgstr "" "Project-Id-Version: django-postman\n" -"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n" -"POT-Creation-Date: 2010-12-24 18:38+0100\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2010-12-27 15:44+0100\n" "PO-Revision-Date: 2010-12-25 14:39+0000\n" "Last-Translator: psam <maxcom@laposte.net>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -58,7 +58,7 @@ msgid "Response date cannot be set without at least one reply." msgstr "" #: .\admin.py:66 -msgid "The message cannot be replied without being in a thread." +msgid "The message cannot be replied without being in a conversation." msgstr "" #: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5 @@ -78,11 +78,15 @@ msgid "Some usernames are unknown or no more active: {users}." msgstr "" #: .\fields.py:23 -msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at most {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:24 -msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})." +msgid "" +"Ensure this value has at least {limit_value} distinct items (it has " +"{show_value})." msgstr "" #: .\fields.py:25 @@ -101,28 +105,28 @@ msgstr "{user.username} ({reason})" msgid "Writing to some users is not possible: {users}." msgstr "Scrivi per alcuni utenti non è possibile: {users}." -#: .\forms.py:149 .\forms.py:161 +#: .\forms.py:148 .\forms.py:160 msgid "Recipients" msgstr "Destinatari" -#: .\forms.py:149 .\forms.py:161 .\templates\postman\base_folder.html.py:26 +#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26 #: .\templates\postman\reply.html.py:4 msgid "Recipient" msgstr "Destinatario" -#: .\forms.py:160 +#: .\forms.py:159 msgid "Email" -msgstr "E-mail" +msgstr "Posta" -#: .\forms.py:176 +#: .\forms.py:175 msgid "Undefined recipient." msgstr "" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipients" msgstr "Altri destinatari" -#: .\forms.py:195 +#: .\forms.py:194 msgid "Additional recipient" msgstr "Ulteriori destinatario" @@ -146,87 +150,87 @@ msgstr "oggetto" msgid "body" msgstr "contenuto" -#: .\models.py:199 .\models.py:282 +#: .\models.py:199 .\models.py:281 msgid "sender" msgstr "mittente" -#: .\models.py:200 .\models.py:306 +#: .\models.py:200 .\models.py:305 msgid "recipient" msgstr "destinatario" -#: .\models.py:202 +#: .\models.py:201 msgid "visitor" msgstr "visitatore" -#: .\models.py:203 +#: .\models.py:202 msgid "parent message" msgstr "" -#: .\models.py:204 +#: .\models.py:203 msgid "root message" msgstr "" -#: .\models.py:205 +#: .\models.py:204 msgid "sent at" msgstr "inviato il" -#: .\models.py:206 +#: .\models.py:205 msgid "read at" msgstr "letto il" -#: .\models.py:207 +#: .\models.py:206 msgid "replied at" msgstr "risposto il" -#: .\models.py:208 +#: .\models.py:207 msgid "archived by sender" -msgstr "" +msgstr "archiviato dal mittente" -#: .\models.py:209 +#: .\models.py:208 msgid "archived by recipient" -msgstr "" +msgstr "archiviato dal destinatario" -#: .\models.py:210 +#: .\models.py:209 msgid "deleted by sender at" msgstr "cancellati dal mittente il" -#: .\models.py:211 +#: .\models.py:210 msgid "deleted by recipient at" msgstr "cancellati dal destinatario il" -#: .\models.py:213 +#: .\models.py:212 msgid "status" msgstr "" -#: .\models.py:215 +#: .\models.py:214 msgid "moderator" msgstr "" -#: .\models.py:216 +#: .\models.py:215 msgid "moderated at" msgstr "" -#: .\models.py:217 +#: .\models.py:216 msgid "rejection reason" msgstr "" -#: .\models.py:222 +#: .\models.py:221 msgid "message" msgstr "messaggio" -#: .\models.py:223 +#: .\models.py:222 msgid "messages" msgstr "messaggi" -#: .\models.py:334 +#: .\models.py:333 msgid "Undefined sender." msgstr "" -#: .\models.py:478 +#: .\models.py:473 msgid "pending message" msgstr "" -#: .\models.py:479 +#: .\models.py:474 msgid "pending messages" msgstr "" @@ -258,21 +262,21 @@ msgstr "Messaggio inviato con successo." msgid "Message rejected for at least one recipient." msgstr "" -#: .\views.py:277 +#: .\views.py:276 msgid "Select at least one object." msgstr "" -#: .\views.py:283 -msgid "Message(s) or thread(s) successfully archived." -msgstr "" +#: .\views.py:282 +msgid "Messages or conversations successfully archived." +msgstr "Messaggi o conversazioni archiviati con successo." -#: .\views.py:288 -msgid "Message(s) or thread(s) successfully deleted." -msgstr "" +#: .\views.py:287 +msgid "Messages or conversations successfully deleted." +msgstr "Messaggi o conversazioni eliminato con successo." -#: .\views.py:293 -msgid "Message(s) or thread(s) successfully recovered." -msgstr "" +#: .\views.py:292 +msgid "Messages or conversations successfully recovered." +msgstr "Messaggi o conversazioni recuperati con successo." #: .\management\__init__.py:14 msgid "Message Rejected" @@ -314,10 +318,12 @@ msgstr "Rifiutare" #: .\templates\postman\archives.html.py:3 msgid "Archived Messages" -msgstr "" +msgstr "Messaggi archiviati" #: .\templates\postman\archives.html.py:7 -msgid "Messages in this folder will never be removed. You can use this folder for long term storage." +msgid "" +"Messages in this folder will never be removed. You can use this folder for " +"long term storage." msgstr "" #: .\templates\postman\base.html.py:3 @@ -326,7 +332,7 @@ msgstr "" #: .\templates\postman\base.html.py:6 msgid "Inbox" -msgstr "" +msgstr "Posta in arrivo" #: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3 msgid "Sent Messages" @@ -338,7 +344,7 @@ msgstr "Scrivi" #: .\templates\postman\base.html.py:9 msgid "Archives" -msgstr "" +msgstr "Archivi" #: .\templates\postman\base.html.py:10 msgid "Trash" @@ -349,8 +355,8 @@ msgid "Sorry, this page number is invalid." msgstr "Spiacenti, questo numero di pagina non valida." #: .\templates\postman\base_folder.html.py:12 -msgid "by thread" -msgstr "di conversazione" +msgid "by conversation" +msgstr "dal conversazione" #: .\templates\postman\base_folder.html.py:13 msgid "by message" @@ -359,12 +365,12 @@ msgstr "dal messaggio" #: .\templates\postman\base_folder.html.py:17 #: .\templates\postman\view.html.py:22 msgid "Delete" -msgstr "Cancella" +msgstr "Elimina" #: .\templates\postman\base_folder.html.py:18 #: .\templates\postman\view.html.py:23 msgid "Archive" -msgstr "" +msgstr "Archivia" #: .\templates\postman\base_folder.html.py:19 msgid "Undelete" @@ -400,7 +406,7 @@ msgstr "Invia" #: .\templates\postman\email_user.txt.py:1 msgid "Dear user," -msgstr "" +msgstr "Caro utente," #: .\templates\postman\email_user.txt.py:3 #: .\templates\postman\email_visitor.txt.py:3 @@ -459,7 +465,7 @@ msgstr "" #: .\templates\postman\email_visitor_subject.txt.py:1 #, python-format msgid "Message \"%(subject)s\" on the site %(sitename)s" -msgstr "" +msgstr "Messaggio \"%(subject)s\" sul sito %(sitename)s" #: .\templates\postman\email_visitor.txt.py:1 msgid "Dear visitor," @@ -496,15 +502,17 @@ msgstr "Spedito" #: .\templates\postman\trash.html.py:3 msgid "Deleted Messages" -msgstr "Messaggi cancellati" +msgstr "Messaggi eliminati" #: .\templates\postman\trash.html.py:10 -msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." +msgid "" +"Messages in this folder can be removed from time to time. For long term " +"storage, use instead the archive folder." msgstr "" #: .\templates\postman\view.html.py:5 -msgid "Thread" -msgstr "" +msgid "Conversation" +msgstr "Conversazione" #: .\templates\postman\view.html.py:13 msgid ":" @@ -514,6 +522,6 @@ msgstr " :" msgid "Back" msgstr "Indietro" -#: .\templatetags\postman_tags.py:34 +#: .\templatetags\postman_tags.py:35 msgid "<me>" -msgstr "<i>" +msgstr "<me>" diff --git a/postman/locale/nl/LC_MESSAGES/django.mo b/postman/locale/nl/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..691eda63187ae3eab19a7cec5be9581ae815fcb4 GIT binary patch literal 3681 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 Q6i_{&Tf^?H?ddT5Kj?{$IRF3v literal 0 HcmV?d00001 diff --git a/postman/locale/nl/LC_MESSAGES/django.po b/postman/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 0000000..8961a22 --- /dev/null +++ b/postman/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,519 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: django-postman\n" +"Report-Msgid-Bugs-To: http://bitbucket.org/psam/django-postman/issues\n" +"POT-Creation-Date: 2010-12-27 14:21+0100\n" +"PO-Revision-Date: 2010-12-27 15:10+0000\n" +"Last-Translator: psam <maxcom@laposte.net>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: nl\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" + +#: .\admin.py:22 +msgid "Sender and Recipient cannot be both undefined." +msgstr "" + +#: .\admin.py:29 +msgid "Visitor's email is in excess." +msgstr "" + +#: .\admin.py:34 +msgid "Visitor's email is missing." +msgstr "" + +#: .\admin.py:40 +msgid "Reading date must be later to sending date." +msgstr "" + +#: .\admin.py:45 +msgid "Deletion date by sender must be later to sending date." +msgstr "" + +#: .\admin.py:50 +msgid "Deletion date by recipient must be later to sending date." +msgstr "" + +#: .\admin.py:58 +msgid "Response date must be later to sending date." +msgstr "" + +#: .\admin.py:60 +msgid "The message cannot be replied without having been read." +msgstr "" + +#: .\admin.py:62 +msgid "Response date must be later to reading date." +msgstr "" + +#: .\admin.py:64 +msgid "Response date cannot be set without at least one reply." +msgstr "" + +#: .\admin.py:66 +msgid "The message cannot be replied without being in a conversation." +msgstr "" + +#: .\admin.py:88 .\admin.py:157 .\templates\postman\view.html.py:5 +msgid "Message" +msgstr "Bericht" + +#: .\admin.py:93 +msgid "Dates" +msgstr "Data" + +#: .\admin.py:98 .\admin.py:161 +msgid "Moderation" +msgstr "" + +#: .\fields.py:22 +msgid "Some usernames are unknown or no more active: {users}." +msgstr "" + +#: .\fields.py:23 +msgid "Ensure this value has at most {limit_value} distinct items (it has {show_value})." +msgstr "" + +#: .\fields.py:24 +msgid "Ensure this value has at least {limit_value} distinct items (it has {show_value})." +msgstr "" + +#: .\fields.py:25 +msgid "Some usernames are rejected: {users}." +msgstr "" + +#: .\fields.py:26 .\forms.py:65 +msgid "{user.username}" +msgstr "{user.username}" + +#: .\fields.py:27 .\forms.py:66 +msgid "{user.username} ({reason})" +msgstr "{user.username} ({reason})" + +#: .\forms.py:64 +msgid "Writing to some users is not possible: {users}." +msgstr "" + +#: .\forms.py:148 .\forms.py:160 +msgid "Recipients" +msgstr "Ontvangers" + +#: .\forms.py:148 .\forms.py:160 .\templates\postman\base_folder.html.py:26 +#: .\templates\postman\reply.html.py:4 +msgid "Recipient" +msgstr "Ontvanger" + +#: .\forms.py:159 +msgid "Email" +msgstr "E-mail" + +#: .\forms.py:175 +msgid "Undefined recipient." +msgstr "" + +#: .\forms.py:194 +msgid "Additional recipients" +msgstr "" + +#: .\forms.py:194 +msgid "Additional recipient" +msgstr "" + +#: .\models.py:19 +msgid "Pending" +msgstr "" + +#: .\models.py:20 +msgid "Accepted" +msgstr "" + +#: .\models.py:21 .\templates\postman\view.html.py:13 +msgid "Rejected" +msgstr "" + +#: .\models.py:197 +msgid "subject" +msgstr "onderwerp" + +#: .\models.py:198 +msgid "body" +msgstr "inhoud" + +#: .\models.py:199 .\models.py:281 +msgid "sender" +msgstr "verstuurder" + +#: .\models.py:200 .\models.py:305 +msgid "recipient" +msgstr "ontvanger" + +#: .\models.py:201 +msgid "visitor" +msgstr "bezoeker" + +#: .\models.py:202 +msgid "parent message" +msgstr "hoofdbericht" + +#: .\models.py:203 +msgid "root message" +msgstr "" + +#: .\models.py:204 +msgid "sent at" +msgstr "verstuurd op" + +#: .\models.py:205 +msgid "read at" +msgstr "gelezen op" + +#: .\models.py:206 +msgid "replied at" +msgstr "beantwoord op" + +#: .\models.py:207 +msgid "archived by sender" +msgstr "" + +#: .\models.py:208 +msgid "archived by recipient" +msgstr "" + +#: .\models.py:209 +msgid "deleted by sender at" +msgstr "" + +#: .\models.py:210 +msgid "deleted by recipient at" +msgstr "" + +#: .\models.py:212 +msgid "status" +msgstr "" + +#: .\models.py:214 +msgid "moderator" +msgstr "" + +#: .\models.py:215 +msgid "moderated at" +msgstr "" + +#: .\models.py:216 +msgid "rejection reason" +msgstr "" + +#: .\models.py:221 +msgid "message" +msgstr "bericht" + +#: .\models.py:222 +msgid "messages" +msgstr "berichten" + +#: .\models.py:333 +msgid "Undefined sender." +msgstr "" + +#: .\models.py:473 +msgid "pending message" +msgstr "" + +#: .\models.py:474 +msgid "pending messages" +msgstr "" + +#: .\utils.py:32 +msgid "> " +msgstr "> " + +#: .\utils.py:48 +msgid "" +"\n" +"\n" +"{sender} wrote:\n" +"{body}\n" +msgstr "" +"\n" +"\n" +"{sender} schreef:\n" +"{body}\n" + +#: .\utils.py:57 +msgid "Re: {subject}" +msgstr "Antw: {subject}" + +#: .\views.py:129 .\views.py:187 +msgid "Message successfully sent." +msgstr "Bericht succesvol verstuurd." + +#: .\views.py:131 .\views.py:189 +msgid "Message rejected for at least one recipient." +msgstr "" + +#: .\views.py:276 +msgid "Select at least one object." +msgstr "" + +#: .\views.py:282 +msgid "Messages or conversations successfully archived." +msgstr "Berichten of conversaties succesvol gearchiveerd." + +#: .\views.py:287 +msgid "Messages or conversations successfully deleted." +msgstr "Berichten of conversaties succesvol verwijderd." + +#: .\views.py:292 +msgid "Messages or conversations successfully recovered." +msgstr "Berichten of conversaties succesvol hersteld." + +#: .\management\__init__.py:14 +msgid "Message Rejected" +msgstr "" + +#: .\management\__init__.py:14 +msgid "Your message has been rejected" +msgstr "" + +#: .\management\__init__.py:15 +msgid "Message Received" +msgstr "Bericht ontvangen" + +#: .\management\__init__.py:15 +msgid "You have received a message" +msgstr "U hebt een bericht ontvangen" + +#: .\management\__init__.py:16 +msgid "Reply Received" +msgstr "Antwoord ontvangen" + +#: .\management\__init__.py:16 +msgid "You have received a reply" +msgstr "U hebt een antwoord ontvangen" + +#: .\templates\admin\postman\pendingmessage\change_form.html.py:17 +msgid "Please correct the error below." +msgid_plural "Please correct the errors below." +msgstr[0] "Herstel de fouten hieronder." +msgstr[1] "Herstel de fout hieronder." + +#: .\templates\admin\postman\pendingmessage\submit_line.html.py:6 +msgid "Accept" +msgstr "" + +#: .\templates\admin\postman\pendingmessage\submit_line.html.py:7 +msgid "Reject" +msgstr "" + +#: .\templates\postman\archives.html.py:3 +msgid "Archived Messages" +msgstr "" + +#: .\templates\postman\archives.html.py:7 +msgid "Messages in this folder will never be removed. You can use this folder for long term storage." +msgstr "" + +#: .\templates\postman\base.html.py:3 +msgid "Messaging" +msgstr "" + +#: .\templates\postman\base.html.py:6 +msgid "Inbox" +msgstr "Postvak In" + +#: .\templates\postman\base.html.py:7 .\templates\postman\sent.html.py:3 +msgid "Sent Messages" +msgstr "Verzonden berichten" + +#: .\templates\postman\base.html.py:8 .\templates\postman\write.html.py:3 +msgid "Write" +msgstr "Schrijven" + +#: .\templates\postman\base.html.py:9 +msgid "Archives" +msgstr "Archieven" + +#: .\templates\postman\base.html.py:10 +msgid "Trash" +msgstr "Prullenbak" + +#: .\templates\postman\base_folder.html.py:9 +msgid "Sorry, this page number is invalid." +msgstr "Sorry, deze pagina is ongeldig." + +#: .\templates\postman\base_folder.html.py:12 +msgid "by conversation" +msgstr "op conversatie" + +#: .\templates\postman\base_folder.html.py:13 +msgid "by message" +msgstr "per bericht" + +#: .\templates\postman\base_folder.html.py:17 +#: .\templates\postman\view.html.py:22 +msgid "Delete" +msgstr "Verwijderen" + +#: .\templates\postman\base_folder.html.py:18 +#: .\templates\postman\view.html.py:23 +msgid "Archive" +msgstr "Archiveren" + +#: .\templates\postman\base_folder.html.py:19 +msgid "Undelete" +msgstr "Herstellen" + +#: .\templates\postman\base_folder.html.py:24 +msgid "Action" +msgstr "Actie" + +#: .\templates\postman\base_folder.html.py:25 +msgid "Sender" +msgstr "Verstuurder" + +#: .\templates\postman\base_folder.html.py:27 +msgid "Subject" +msgstr "Onderwerp" + +#: .\templates\postman\base_folder.html.py:28 +msgid "Date" +msgstr "Datum" + +#: .\templates\postman\base_folder.html.py:43 +msgid "g:i A,M j,n/j/y" +msgstr "G:i,j b,j/n/y" + +#: .\templates\postman\base_folder.html.py:51 +msgid "No messages." +msgstr "Geen berichten." + +#: .\templates\postman\base_write.html.py:20 +msgid "Send" +msgstr "Verzenden" + +#: .\templates\postman\email_user.txt.py:1 +msgid "Dear user," +msgstr "Beste gebruiker," + +#: .\templates\postman\email_user.txt.py:3 +#: .\templates\postman\email_visitor.txt.py:3 +#, python-format +msgid "On %(date)s, you asked to send a message to the user '%(recipient)s'." +msgstr "" + +#: .\templates\postman\email_user.txt.py:5 +#: .\templates\postman\email_visitor.txt.py:5 +msgid "Your message has been rejected by the moderator" +msgstr "" + +#: .\templates\postman\email_user.txt.py:5 +#: .\templates\postman\email_visitor.txt.py:5 +msgid ", for the following reason:" +msgstr "" + +#: .\templates\postman\email_user.txt.py:9 +#: .\templates\postman\email_visitor.txt.py:10 +#, python-format +msgid "On %(date)s, you sent a message to the user '%(sender)s'." +msgstr "" + +#: .\templates\postman\email_user.txt.py:10 +msgid "Your correspondent has given you an answer." +msgstr "" + +#: .\templates\postman\email_user.txt.py:11 +#, python-format +msgid "You have received a copy of a response from the user '%(sender)s'." +msgstr "" + +#: .\templates\postman\email_user.txt.py:13 +#, python-format +msgid "You have received a message from the user '%(sender)s'." +msgstr "" + +#: .\templates\postman\email_user.txt.py:16 +#: .\templates\postman\email_visitor.txt.py:14 +msgid "Thank you again for your interest in our services." +msgstr "" + +#: .\templates\postman\email_user.txt.py:17 +#: .\templates\postman\email_visitor.txt.py:16 +msgid "The site administrator" +msgstr "De sitebeheerder" + +#: .\templates\postman\email_user.txt.py:19 +#: .\templates\postman\email_visitor.txt.py:18 +msgid "" +"Note: This message is issued by an automated system.\n" +"Do not reply, this would not be taken into account." +msgstr "" + +#: .\templates\postman\email_user_subject.txt.py:1 +#: .\templates\postman\email_visitor_subject.txt.py:1 +#, python-format +msgid "Message \"%(subject)s\" on the site %(sitename)s" +msgstr "Bericht \" %(subject)s \" op de site %(sitename)s " + +#: .\templates\postman\email_visitor.txt.py:1 +msgid "Dear visitor," +msgstr "Beste bezoeker," + +#: .\templates\postman\email_visitor.txt.py:8 +msgid "As a reminder, please find below the content of your message." +msgstr "" + +#: .\templates\postman\email_visitor.txt.py:11 +msgid "Please find below the answer from your correspondent." +msgstr "" + +#: .\templates\postman\email_visitor.txt.py:15 +msgid "For more comfort, we encourage you to open an account on the site." +msgstr "" + +#: .\templates\postman\inbox.html.py:3 +msgid "Received Messages" +msgstr "Ontvangen berichten" + +#: .\templates\postman\inbox.html.py:6 +msgid "Received" +msgstr "Ontvangen" + +#: .\templates\postman\reply.html.py:3 .\templates\postman\view.html.py:25 +#: .\templates\postman\view.html.py:28 .\templates\postman\view.html.py:31 +msgid "Reply" +msgstr "Beantwoorden" + +#: .\templates\postman\sent.html.py:6 +msgid "Sent" +msgstr "Verstuurde" + +#: .\templates\postman\trash.html.py:3 +msgid "Deleted Messages" +msgstr "Verwijderde berichten" + +#: .\templates\postman\trash.html.py:10 +msgid "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." +msgstr "" + +#: .\templates\postman\view.html.py:5 +msgid "Conversation" +msgstr "Conversatie" + +#: .\templates\postman\view.html.py:13 +msgid ":" +msgstr " :" + +#: .\templates\postman\view.html.py:20 +msgid "Back" +msgstr "Terug" + +#: .\templatetags\postman_tags.py:35 +msgid "<me>" +msgstr "<mij>" diff --git a/postman/management/__init__.py b/postman/management/__init__.py new file mode 100644 index 0000000..cefbc1c --- /dev/null +++ b/postman/management/__init__.py @@ -0,0 +1,19 @@ +import sys + +from django.conf import settings +from django.db.models import signals +from django.utils.translation import ugettext_noop as _ + +name = getattr(settings, 'POSTMAN_NOTIFIER_APP', 'notification') +if name and name in settings.INSTALLED_APPS: + name = name + '.models' + __import__(name) + notification = sys.modules[name] + + def create_notice_types(*args, **kwargs): + notification.create_notice_type("postman_rejection", _("Message Rejected"), _("Your message has been rejected")) + notification.create_notice_type("postman_message", _("Message Received"), _("You have received a message")) + notification.create_notice_type("postman_reply", _("Reply Received"), _("You have received a reply")) + + signals.post_syncdb.connect(create_notice_types, sender=notification) + diff --git a/postman/management/commands/__init__.py b/postman/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/postman/management/commands/postman_checkup.py b/postman/management/commands/postman_checkup.py new file mode 100644 index 0000000..374076e --- /dev/null +++ b/postman/management/commands/postman_checkup.py @@ -0,0 +1,56 @@ +import datetime + +from django.core.management.base import NoArgsCommand +from django.db.models import Q, F, Count + +from postman.models import Message + +class Command(NoArgsCommand): + help = "Can be run as a cron job or directly to check-up data consistency in the database." + + def handle_noargs(self, **options): + verbose = int(options.get('verbosity')) + if verbose >= 1: + self.stdout.write(datetime.datetime.now().strftime("%H:%M:%S ") + "Checking messages and conversations for inconsistencies...\n") + checks = [ + ("Sender and Recipient cannot be both undefined.", Q(sender__isnull=True, recipient__isnull=True)), + ("Visitor's email is in excess.", Q(sender__isnull=False, recipient__isnull=False) & ~Q(email='')), + ("Visitor's email is missing.", (Q(sender__isnull=True) | Q(recipient__isnull=True)) & Q(email='')), + ("Reading date must be later to sending date.", Q(read_at__lt=F('sent_at'))), + ("Deletion date by sender must be later to sending date.", Q(sender_deleted_at__lt=F('sent_at'))), + ("Deletion date by recipient must be later to sending date.", Q(recipient_deleted_at__lt=F('sent_at'))), + ("Response date must be later to sending date.", Q(replied_at__lt=F('sent_at'))), + ("The message cannot be replied without having been read.", Q(replied_at__isnull=False, read_at__isnull=True)), + ("Response date must be later to reading date.", Q(replied_at__lt=F('read_at'))), + # because of the delay due to the moderation, no constraint between replied_at and recipient_deleted_at + ("Response date cannot be set without at least one reply.", + Q(replied_at__isnull=False), {'cnt': Count('next_messages')}, Q(cnt=0)), + # cnt should filter to allow only accepted replies, but do not know how to specify it + ("The message cannot be replied without being in a conversation.", + Q(replied_at__isnull=False, thread__isnull=True)), + ("The message cannot be a reply without being in a conversation.", + Q(parent__isnull=False, thread__isnull=True)), + ("The reply and its parent are not in a conversation in common.", + Q(parent__isnull=False, thread__isnull=False) & (Q(parent__thread__isnull=True) | ~Q(parent__thread=F('thread')))), + ] + count = 0 + for c in checks: + msgs = Message.objects.filter(c[1]) + if len(c) >= 4: + msgs = msgs.annotate(**c[2]).filter(c[3]) + if msgs: + count += len(msgs) + self.report_errors(c[0], msgs) + if verbose >= 1: + self.stdout.write(datetime.datetime.now().strftime("%H:%M:%S ") + + ("Number of inconsistencies found: {0}. See details on the error stream.\n".format(count) if count + else "All is correct.\n")) + + def report_errors(self, reason, msgs): + self.stderr.write(reason + '\n') + self.stderr.write(" {0:6} {1:5} {2:5} {3:10} {4:6} {5:6} {6:16} {7:16} {8:16}\n".format( + "Id","From","To","Email","Parent","Thread","Sent","Read","Replied")) + for msg in msgs: + self.stderr.write( + " {0.pk:6} {0.sender_id:5} {0.recipient_id:5} {0.email:10.10} {0.parent_id:6} {0.thread_id:6}" + " {0.sent_at!s:16.16} {0.read_at!s:16.16} {0.replied_at!s:16.16}\n".format(msg)) diff --git a/postman/management/commands/postman_cleanup.py b/postman/management/commands/postman_cleanup.py new file mode 100644 index 0000000..194239c --- /dev/null +++ b/postman/management/commands/postman_cleanup.py @@ -0,0 +1,36 @@ +import datetime +from optparse import make_option + +from django.core.management.base import NoArgsCommand +from django.db.models import Max, Count, F, Q + +from postman.models import Message + +class Command(NoArgsCommand): + help = """Can be run as a cron job or directly to clean out old data from the database: + Messages or conversations marked as deleted by both sender and recipient, + more than a minimal number of days ago.""" + option_list = NoArgsCommand.option_list + ( + make_option('-d', '--days', type='int', default=30, + help='The minimal number of days a message is kept marked as deleted, ' + 'before to be considered for real deletion [default: %default]'), + ) + + def handle_noargs(self, **options): + verbose = int(options.get('verbosity')) + days = options.get('days') + date = datetime.date.today() - datetime.timedelta(days=days) + if verbose >= 1: + self.stdout.write("Erase messages and conversations marked as deleted before %s\n" % date) + # for a conversation to be candidate, all messages must satisfy the criteria + tpks = Message.objects.filter(thread__isnull=False).values('thread').annotate( + cnt=Count('pk'), + s_max=Max('sender_deleted_at'), s_cnt=Count('sender_deleted_at'), + r_max=Max('recipient_deleted_at'), r_cnt=Count('recipient_deleted_at') + ).order_by().filter( + s_cnt=F('cnt'), r_cnt=F('cnt'), s_max__lte=date, r_max__lte=date + ).values_list('thread', flat=True) + Message.objects.filter( + Q(thread__in=tpks) | + Q(thread__isnull=True, sender_deleted_at__lte=date, recipient_deleted_at__lte=date) + ).delete() diff --git a/postman/medias/postman/css/admin.css b/postman/medias/postman/css/admin.css new file mode 100644 index 0000000..798f45e --- /dev/null +++ b/postman/medias/postman/css/admin.css @@ -0,0 +1,6 @@ +/* + This stylesheet is dedicated to the admin site. +*/ + +/* Form Fields */ +#id_subject, #id_moderation_reason { width: 50em; } diff --git a/postman/models.py b/postman/models.py new file mode 100644 index 0000000..c413213 --- /dev/null +++ b/postman/models.py @@ -0,0 +1,483 @@ +import datetime +import hashlib + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.text import truncate_words +from django.utils.translation import ugettext, ugettext_lazy as _ + +from postman.urls import OPTION_MESSAGES +from postman.utils import email_visitor, notify_user + +# moderation constants +STATUS_PENDING = 'p' +STATUS_ACCEPTED = 'a' +STATUS_REJECTED = 'r' +STATUS_CHOICES = ( + (STATUS_PENDING, _('Pending')), + (STATUS_ACCEPTED, _('Accepted')), + (STATUS_REJECTED, _('Rejected')), +) +# ordering constants +ORDER_BY_KEY = 'o' # as 'order' +ORDER_BY_FIELDS = { + 'f': 'sender__username', # as 'from' + 't': 'recipient__username', # as 'to' + 's': 'subject', # as 'subject' + 'd': 'sent_at', # as 'date' +} +ORDER_BY_MAPPER = {'sender': 'f', 'recipient': 't', 'subject': 's', 'date': 'd'} # for templatetags usage + +def get_order_by(query_dict): + """ + Return a field name, optionally prefixed for descending order, or None if not found. + + Argument: + ``query_dict``: a dictionary to look for a key dedicated to ordering purpose + + >>> get_order_by({}) + + >>> get_order_by({ORDER_BY_KEY: 'f'}) + 'sender__username' + >>> get_order_by({ORDER_BY_KEY: 'D'}) + '-sent_at' + """ + if ORDER_BY_KEY in query_dict: + code = query_dict[ORDER_BY_KEY] # code may be uppercase or lowercase + order_by_field = ORDER_BY_FIELDS.get(code.lower()) + if order_by_field: + if code.isupper(): + order_by_field = '-' + order_by_field + return order_by_field + +class MessageManager(models.Manager): + """The manager for Message.""" + + @property + def _last_in_thread(self): + """Return the latest message id for each conversation.""" + return self.filter(thread__isnull=False).values('thread').annotate(models.Max('pk'))\ + .values_list('pk__max', flat=True).order_by() + + def _folder(self, related, filters, option=None, order_by=None): + """Base code, in common to the folders.""" + if related: + qs = self.select_related(*related) + else: + qs = self.all() + if order_by: + qs = qs.order_by(order_by) + if isinstance(filters, (list,tuple)): + lookups = models.Q() + for filter in filters: + lookups |= models.Q(**filter) + else: + lookups = models.Q(**filters) + if option == OPTION_MESSAGES: + return qs.filter(lookups) + # Adding a 'count' attribute, to be similar to the by-conversation case, + # should not be necessary. Otherwise add: + # .extra(select={'count': 'SELECT 1'}) + else: + return qs.filter( + models.Q(id__in=self._last_in_thread.filter(lookups)) | models.Q(lookups, thread__isnull=True) + ).extra(select={'count': + 'SELECT COUNT(*) FROM "postman_message" T' + ' WHERE T."thread_id" = "postman_message"."thread_id"' + }) + # For single message, 'count' is returned as 0. Should be acceptable if known. + # If not, replace "COUNT(*)" by "1+COUNT(*)" and add: + # ' AND T."id" <> T."thread_id"' + + def inbox(self, user, related=True, **kwargs): + """ + Return accepted messages received by a user but not marked as archived or deleted. + """ + related = ('sender',) if related else None + filters = { + 'recipient': user, + 'recipient_archived': False, + 'recipient_deleted_at__isnull': True, + 'moderation_status': STATUS_ACCEPTED, + } + return self._folder(related, filters, **kwargs) + + def inbox_unread_count(self, user): + """ + Return the number of unread messages for a user. + + Designed for context_processors.py and templatetags/postman_tags.py + + """ + return self.inbox(user, related=False, option=OPTION_MESSAGES).filter(read_at__isnull=True).count() + + def sent(self, user, **kwargs): + """ + Return all messages sent by a user but not marked as archived or deleted. + """ + related = ('recipient',) + filters = { + 'sender': user, + 'sender_archived': False, + 'sender_deleted_at__isnull': True, + # allow to see pending and rejected messages as well + } + return self._folder(related, filters, **kwargs) + + def archives(self, user, **kwargs): + """ + Return messages belonging to a user and marked as archived. + """ + related = ('sender','recipient') + filters = ({ + 'recipient': user, + 'recipient_archived': True, + 'recipient_deleted_at__isnull': True, + 'moderation_status': STATUS_ACCEPTED, + }, { + 'sender': user, + 'sender_archived': True, + 'sender_deleted_at__isnull': True, + }) + return self._folder(related, filters, **kwargs) + + def trash(self, user, **kwargs): + """ + Return messages belonging to a user and marked as deleted. + """ + related = ('sender','recipient') + filters = ({ + 'recipient': user, + 'recipient_deleted_at__isnull': False, + 'moderation_status': STATUS_ACCEPTED, + }, { + 'sender': user, + 'sender_deleted_at__isnull': False, + }) + return self._folder(related, filters, **kwargs) + + def thread(self, user, filter): + """ + Return message/conversation for display. + """ + return self.select_related('sender','recipient').filter( + filter, + (models.Q(recipient=user) & models.Q(moderation_status=STATUS_ACCEPTED)) | models.Q(sender=user), + ).order_by('sent_at') + + def perms(self, user): + """ + Return a field-lookups filter as a permission controller for a reply request. + + The user must be the recipient of the accepted, non-deleted, message + + """ + return models.Q(recipient=user) & models.Q(moderation_status=STATUS_ACCEPTED) & models.Q(recipient_deleted_at__isnull=True) + + def set_read(self, user, filter): + """ + Set messages as read. + """ + return self.filter( + filter, + recipient=user, + moderation_status=STATUS_ACCEPTED, + read_at__isnull=True, + ).update(read_at=datetime.datetime.now()) + +class Message(models.Model): + """ + A message between a User and another User or an AnonymousUser. + """ + + SUBJECT_MAX_LENGTH = 120 + + subject = models.CharField(_("subject"), max_length=SUBJECT_MAX_LENGTH) + body = models.TextField(_("body"), blank=True) + sender = models.ForeignKey(User, related_name='sent_messages', null=True, blank=True, verbose_name=_("sender")) + recipient = models.ForeignKey(User, related_name='received_messages', null=True, blank=True, verbose_name=_("recipient")) + email = models.EmailField(_("visitor"), blank=True) # instead of either sender or recipient, for an AnonymousUser + parent = models.ForeignKey('self', related_name='next_messages', null=True, blank=True, verbose_name=_("parent message")) + thread = models.ForeignKey('self', related_name='child_messages', null=True, blank=True, verbose_name=_("root message")) + sent_at = models.DateTimeField(_("sent at"), default=datetime.datetime.now) + read_at = models.DateTimeField(_("read at"), null=True, blank=True) + replied_at = models.DateTimeField(_("replied at"), null=True, blank=True) + sender_archived = models.BooleanField(_("archived by sender")) + recipient_archived = models.BooleanField(_("archived by recipient")) + sender_deleted_at = models.DateTimeField(_("deleted by sender at"), null=True, blank=True) + recipient_deleted_at = models.DateTimeField(_("deleted by recipient at"), null=True, blank=True) + # moderation fields + moderation_status = models.CharField(_("status"), max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING) + moderation_by = models.ForeignKey(User, related_name='moderated_messages', + null=True, blank=True, verbose_name=_("moderator")) + moderation_date = models.DateTimeField(_("moderated at"), null=True, blank=True) + moderation_reason = models.CharField(_("rejection reason"), max_length=120, blank=True) + + objects = MessageManager() + + class Meta: + verbose_name = _("message") + verbose_name_plural = _("messages") + ordering = ['-sent_at'] + + def __unicode__(self): + return u"{0}>{1}:{2}".format(self.obfuscated_sender, self.obfuscated_recipient, truncate_words(self.subject,5)) + + @models.permalink + def get_absolute_url(self): + return ('postman_view', [str(self.pk)]) + + def is_pending(self): + """Tell if the message is in the pending state.""" + return self.moderation_status == STATUS_PENDING + def is_rejected(self): + """Tell if the message is in the rejected state.""" + return self.moderation_status == STATUS_REJECTED + def is_accepted(self): + """Tell if the message is in the accepted state.""" + return self.moderation_status == STATUS_ACCEPTED + + @property + def is_new(self): + """Tell if the recipient has not yet read the message.""" + return self.read_at is None + + @property + def is_replied(self): + """Tell if the recipient has written a reply to the message.""" + return self.replied_at is not None + + def _obfuscated_email(self): + """ + Return the email field as obfuscated, to keep it undisclosed. + + Format is: + first 4 characters of the hash email + '..' + last 4 characters of the hash email + '@' + domain without TLD + Example: + foo@domain.com -> 1a2b..e8f9@domain + + """ + email = self.email + digest = hashlib.md5(email + settings.SECRET_KEY).hexdigest() + shrunken_digest = '..'.join((digest[:4], digest[-4:])) # 32 characters is too long and is useless + bits = email.split('@') + if len(bits) <> 2: + return '' + domain = bits[1] + return '@'.join((shrunken_digest, domain.rsplit('.',1)[0])) # leave off the TLD to gain some space + + def admin_sender(self): + """ + Return the sender either as a username or as a plain email. + Designed for the Admin site. + + """ + if self.sender: + return str(self.sender) + else: + return '<{0}>'.format(self.email) + admin_sender.short_description = _("sender") + admin_sender.admin_order_field = 'sender' + + # Give the sender either as a username or as a plain email. + clear_sender = property(admin_sender) + + @property + def obfuscated_sender(self): + """Return the sender either as a username or as an undisclosed email.""" + if self.sender: + return str(self.sender) + else: + return self._obfuscated_email() + + def admin_recipient(self): + """ + Return the recipient either as a username or as a plain email. + Designed for the Admin site. + + """ + if self.recipient: + return str(self.recipient) + else: + return '<{0}>'.format(self.email) + admin_recipient.short_description = _("recipient") + admin_recipient.admin_order_field = 'recipient' + + # Give the recipient either as a username or as a plain email. + clear_recipient = property(admin_recipient) + + @property + def obfuscated_recipient(self): + """Return the recipient either as a username or as an undisclosed email.""" + if self.recipient: + return str(self.recipient) + else: + return self._obfuscated_email() + + def get_replies_count(self): + """Return the number of accepted responses.""" + return self.next_messages.filter(moderation_status=STATUS_ACCEPTED).count() + + def quote(self, format_subject, format_body): + """Return a dictionary of quote values to initiate a reply.""" + return { + 'subject': format_subject(self.subject)[:self.SUBJECT_MAX_LENGTH], + 'body': format_body(self.obfuscated_sender, self.body), + } + + def clean(self): + """Check some validity constraints.""" + if not (self.sender_id or self.email): + raise ValidationError(ugettext("Undefined sender.")) + + def clean_moderation(self, initial_status, user=None): + """Adjust automatically some fields, according to status workflow.""" + if self.moderation_status <> initial_status: + self.moderation_date = datetime.datetime.now() + self.moderation_by = user + if self.is_rejected(): + # even if maybe previously deleted during a temporary 'accepted' stay + self.recipient_deleted_at = datetime.datetime.now() + elif initial_status == STATUS_REJECTED: + # rollback + self.recipient_deleted_at = None + + def clean_for_visitor(self): + """Do some auto-read and auto-delete, because there is no one to do it (no account).""" + if not self.sender_id: + # no need to wait for a final moderation status to mark as deleted + if not self.sender_deleted_at: + self.sender_deleted_at = datetime.datetime.now() + elif not self.recipient_id: + if self.is_accepted(): + if not self.read_at: + self.read_at = datetime.datetime.now() + if not self.recipient_deleted_at: + self.recipient_deleted_at = datetime.datetime.now() + else: + # rollbacks + if self.read_at: + self.read_at = None + # but stay deleted if rejected + if self.is_pending() and self.recipient_deleted_at: + self.recipient_deleted_at = None + + def update_parent(self, initial_status): + """Update the parent to actualize its response state.""" + if self.moderation_status <> initial_status: + parent = self.parent + if self.is_accepted(): + # keep the very first date; no need to do differently + if parent and (not parent.replied_at or self.sent_at < parent.replied_at): + parent.replied_at = self.sent_at + parent.save() + elif initial_status == STATUS_ACCEPTED: + if parent and parent.replied_at == self.sent_at: + # rollback, but there may be some other valid replies + try: + other_date = parent.next_messages\ + .exclude(pk=self.pk).filter(moderation_status=STATUS_ACCEPTED)\ + .values_list('sent_at', flat=True)\ + .order_by('sent_at')[:1].get() + parent.replied_at = other_date + except Message.DoesNotExist: + parent.replied_at = None + parent.save() + + def notify_users(self, initial_status): + """Notify the rejection (to sender) or the acceptance (to recipient) of the message.""" + if initial_status == STATUS_PENDING: + if self.is_rejected(): + (notify_user if self.sender_id else email_visitor)(self, 'rejection') + elif self.is_accepted(): + (notify_user if self.recipient_id else email_visitor)(self, 'acceptance') + + def get_dates(self): + """Get some dates to restore later.""" + return (self.sender_deleted_at, self.recipient_deleted_at, self.read_at) + + def set_dates(self, sender_deleted_at, recipient_deleted_at, read_at): + """Restore some dates.""" + self.sender_deleted_at = sender_deleted_at + self.recipient_deleted_at = recipient_deleted_at + self.read_at = read_at + + def get_moderation(self): + """Get moderation information to restore later.""" + return (self.moderation_status, self.moderation_by_id, self.moderation_date, self.moderation_reason) + + def set_moderation(self, status, by_id, date, reason): + """Restore moderation information.""" + self.moderation_status = status + self.moderation_by_id = by_id + self.moderation_date = date + self.moderation_reason = reason + + def auto_moderate(self, moderators): + """Run a chain of auto-moderators.""" + auto = None + final_reason = '' + percents = [] + reasons = [] + if not isinstance(moderators, (list, tuple)): + moderators = (moderators,) + for moderator in moderators: + rating = moderator(self) + if rating is None: continue + if isinstance(rating, tuple): + percent, reason = rating + else: + percent = rating + reason = getattr(moderator, 'default_reason', '') + if percent is False: percent = 0 + if percent is True: percent = 100 + if not 0 <= percent <= 100: continue + if percent == 0: + auto = False + final_reason = reason + break + elif percent == 100: + auto = True + break + percents.append(percent) + reasons.append(reason) + if auto is None and percents: + average = float(sum(percents)) / len(percents) + final_reason = ', '.join([r for i,r in enumerate(reasons) if r and not r.isspace() and percents[i] < 50]) + auto = average >= 50 + if auto is None: + auto = getattr(settings, 'POSTMAN_AUTO_MODERATE_AS', None) + if auto is True: + self.moderation_status = STATUS_ACCEPTED + elif auto is False: + self.moderation_status = STATUS_REJECTED + self.moderation_reason = final_reason + +class PendingMessageManager(models.Manager): + """The manager for PendingMessage.""" + + def get_query_set(self): + """Filter to get only pending objects.""" + return super(PendingMessageManager, self).get_query_set().filter(moderation_status=STATUS_PENDING) + +class PendingMessage(Message): + """ + A proxy to Message, focused on pending objects to accept or reject. + """ + + objects = PendingMessageManager() + + class Meta: + verbose_name = _("pending message") + verbose_name_plural = _("pending messages") + proxy = True + + def set_accepted(self): + """Set the message as accepted.""" + self.moderation_status = STATUS_ACCEPTED + + def set_rejected(self): + """Set the message as rejected.""" + self.moderation_status = STATUS_REJECTED diff --git a/postman/templates/admin/postman/pendingmessage/change_form.html b/postman/templates/admin/postman/pendingmessage/change_form.html new file mode 100644 index 0000000..dd6e84c --- /dev/null +++ b/postman/templates/admin/postman/pendingmessage/change_form.html @@ -0,0 +1,45 @@ +{% extends "admin/change_form.html" %} +{% comment %} +A copy of contrib/admin/templates/admin/change_form.html, with these changes: +- add the loading of 'postman_admin_modify' template elements set +- call block.super for every inner block +- remplace occurrences of 'submit_row' tag by 'postman_submit_row' +{% endcomment %} +{% load i18n admin_modify postman_admin_modify %} +{% block content %}<div id="content-main"> +{% block object-tools %}{{ block.super }}{% endblock %} +<form {% if has_file_field %}enctype="multipart/form-data" {% endif %}action="{{ form_url }}" method="post" id="{{ opts.module_name }}_form">{% csrf_token %}{% block form_top %}{{ block.super }}{% endblock %} +<div> +{% if is_popup %}<input type="hidden" name="_popup" value="1" />{% endif %} +{% if save_on_top %}{% postman_submit_row %}{% endif %} +{% if errors %} + <p class="errornote"> + {% blocktrans count errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} + </p> + {{ adminform.form.non_field_errors }} +{% endif %} + +{% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" %} +{% endfor %} + +{% block after_field_sets %}{{ block.super }}{% endblock %} + +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} + +{% block after_related_objects %}{{ block.super }}{% endblock %} + +{% postman_submit_row %} + +{% if adminform and add %} + <script type="text/javascript">document.getElementById("{{ adminform.first_field.auto_id }}").focus();</script> +{% endif %} + +{# JavaScript for prepopulated fields #} +{% prepopulated_fields_js %} + +</div> +</form></div> +{% endblock %} \ No newline at end of file diff --git a/postman/templates/admin/postman/pendingmessage/submit_line.html b/postman/templates/admin/postman/pendingmessage/submit_line.html new file mode 100644 index 0000000..db47031 --- /dev/null +++ b/postman/templates/admin/postman/pendingmessage/submit_line.html @@ -0,0 +1,8 @@ +{% comment %} +Another set of buttons in replacement of contrib/admin/templates/admin/submit_line.html +{% endcomment %} +{% load i18n %} +<div class="submit-row"> +<input type="submit" value="{% trans 'Accept' %}" class="default" name="_saveasaccepted" /> +<input type="submit" value="{% trans 'Reject' %}" name="_saveasrejected" /> +</div> diff --git a/postman/templates/autocomplete_postman_multiple.html b/postman/templates/autocomplete_postman_multiple.html new file mode 100644 index 0000000..798c7c4 --- /dev/null +++ b/postman/templates/autocomplete_postman_multiple.html @@ -0,0 +1,13 @@ +{% extends "autocomplete.html" %} +{% block script %} + $('#{{ html_id }}').autocomplete('{{ lookup_url }}', { + width: 320, + formatItem: function(row) { return row[1]; }, + formatResult: function(row) { return row[2]; }, + multiple: true, + dataType: "text" + }) + $('#{{ html_id }}').result(function(event, data, formatted) { + $('#{{ html_id }}').trigger("added"); + }) +{% endblock %} \ No newline at end of file diff --git a/postman/templates/autocomplete_postman_single.html b/postman/templates/autocomplete_postman_single.html new file mode 100644 index 0000000..979e068 --- /dev/null +++ b/postman/templates/autocomplete_postman_single.html @@ -0,0 +1,12 @@ +{% extends "autocomplete.html" %} +{% block script %} + $('#{{ html_id }}').autocomplete('{{ lookup_url }}', { + width: 320, + formatItem: function(row) { return row[1]; }, + formatResult: function(row) { return row[2]; }, + dataType: "text" + }) + $('#{{ html_id }}').result(function(event, data, formatted) { + $('#{{ html_id }}').trigger("added"); + }) +{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/archives.html b/postman/templates/postman/archives.html new file mode 100644 index 0000000..ab3d94a --- /dev/null +++ b/postman/templates/postman/archives.html @@ -0,0 +1,8 @@ +{% extends "postman/base_folder.html" %} +{% load i18n %} +{% block pm_folder_title %}{% trans "Archived Messages" %}{% endblock %} +{% block pm_archive_button %}{% endblock %} +{% block pm_undelete_button %}{% endblock %} +{% block pm_footer_info %} +<p>{% trans "Messages in this folder will never be removed. You can use this folder for long term storage." %}</p> +{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/base.html b/postman/templates/postman/base.html new file mode 100644 index 0000000..e1d80bd --- /dev/null +++ b/postman/templates/postman/base.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% load i18n %}{% load postman_tags %} +{% block title %}{% trans "Messaging" %}{% endblock %} +{% block postman_menu %} +<ul id="postman_menu">{% postman_unread as unread_count %} + <li><a href="{% url postman_inbox %} ">» {% 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 diff --git a/postman/templates/postman/base_folder.html b/postman/templates/postman/base_folder.html new file mode 100644 index 0000000..700faaf --- /dev/null +++ b/postman/templates/postman/base_folder.html @@ -0,0 +1,55 @@ +{% extends "postman/base.html" %} +{% load i18n postman_tags %}{% load pagination_tags %} +{% block content %} +<div id="postman"> +<h1>{% block pm_folder_title %}{% endblock %}</h1> +{% if pm_messages %} +{% autopaginate pm_messages %} +{% if invalid_page %} +<p>{% trans "Sorry, this page number is invalid." %}</p> +{% else %} +{% block pm_by_modes %}<div id="pm_by_modes"> +<span class="pm_by_mode">{% if by_message %}<a href="{{ by_conversation_url }}">{% endif %}{% trans "by conversation" %}{% if by_message %}</a>{% endif %}</span> +<span class="pm_by_mode">{% if by_conversation %}<a href="{{ by_message_url }}">{% endif %}{% trans "by message" %}{% if by_conversation %}</a>{% endif %}</span> +</div>{% endblock pm_by_modes %} +<form action="{% block pm_form_action %}{% endblock %}" method="post">{% csrf_token %} +{% block pm_form_buttons %}<span id="pm_buttons"> +{% block pm_delete_button %}<button type="submit" onclick="this.form.action='{% url postman_delete %}'">{% trans "Delete" %}</button>{% endblock %} +{% block pm_archive_button %}<button type="submit" onclick="this.form.action='{% url postman_archive %}'">{% trans "Archive" %}</button>{% endblock %} +{% block pm_undelete_button %}<button type="submit" onclick="this.form.action='{% url postman_undelete %}'">{% trans "Undelete" %}</button>{% endblock %} +</span>{% endblock %} +<table id="pm_messages"> + <thead> + <tr> + <th>{% trans "Action" %}</th> +{% block pm_sender_header %} <th><a href="{% postman_order_by sender %}">{% trans "Sender" %}</a></th>{% endblock %} +{% block pm_recipient_header %} <th><a href="{% postman_order_by recipient %}">{% trans "Recipient" %}</a></th>{% endblock %} + <th><a href="{% postman_order_by subject %}">{% trans "Subject" %}</a></th> + <th><a href="{% postman_order_by date %}">{% block pm_date %}{% trans "Date" %}{% endblock %}</a></th> + </tr> + </thead> + <tbody>{% for message in pm_messages %} + <tr> + <td><input type="checkbox" {% if by_conversation and message.thread_id %}name="tpks" value="{{ message.thread_id }}"{% else %}name="pks" value="{{ message.pk }}"{% endif %} /></td> +{% block pm_sender_cell %} <td>{{ message.obfuscated_sender|or_me:user }}{% if message.count %} ({{ message.count }}){% endif %}</td>{% endblock %} +{% block pm_recipient_cell %} <td>{{ message.obfuscated_recipient|or_me:user }}{% if message.count %} ({{ message.count }}){% endif %}</td>{% endblock %} + <td>{% if message.is_new %}<strong>{% endif %}{% if message.is_replied %}<em>{% endif %} +{% block pm_subject %} + <a href="{% if by_conversation and message.thread_id %}{% url postman_view_conversation message.thread_id %}{% else %}{{message.get_absolute_url }}{% endif %}?next={{ current_url|urlencode }}"> + {% include "postman/inc_subject_ex.html" %} + </a> +{% endblock %} + {% if message.is_replied %}</em>{% endif %}{% if message.is_new %}</strong>{% endif %}</td> + <td>{{ message.sent_at|compact_date:_("g:i A,M j,n/j/y") }}</td> + </tr>{% endfor %} + </tbody> +</table> +</form> +{% paginate %} +{% endif %} +{% else %} +<p>{% trans "No messages." %}</p> +{% endif %} +{% block pm_footer_info %}{% endblock %} +</div> +{% endblock content %} \ No newline at end of file diff --git a/postman/templates/postman/base_write.html b/postman/templates/postman/base_write.html new file mode 100644 index 0000000..d454e9e --- /dev/null +++ b/postman/templates/postman/base_write.html @@ -0,0 +1,23 @@ +{% extends "postman/base.html" %} +{% load i18n %} +{% block extrahead %}{{ block.super }} +{% if is_autocompleted %} +{# using the available admin jQuery is enough #} +<script type="text/javascript" src="{% load adminmedia %}{% admin_media_prefix %}js/jquery.min.js"></script> +{# <script type="text/javascript" src="{{ MEDIA_URL }}js/jquery.min.js"></script> #} +<script type="text/javascript" src="{{ MEDIA_URL }}js/jquery.autocomplete.min.js"></script> +<link href="{{ MEDIA_URL }}css/jquery.autocomplete.css" type="text/css" media="all" rel="stylesheet" /> +{% endif %} +{% endblock %} +{% block content %} +<div id="postman"> +<h1>{% block pm_write_title %}{% endblock %}</h1> +<form action="{% if next_url %}?next={{ next_url|urlencode }}{% endif %}" method="post">{% csrf_token %} +<table> +{% block pm_write_recipient %}{% endblock %} +{{ form.as_table }} +</table> +<button type="submit">{% trans "Send" %}</button> +</form> +</div> +{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/email_user.txt b/postman/templates/postman/email_user.txt new file mode 100644 index 0000000..c62111b --- /dev/null +++ b/postman/templates/postman/email_user.txt @@ -0,0 +1,20 @@ +{% load i18n %}{% autoescape off %}{% trans "Dear user," %} +{% if action == 'rejection' %} +{% blocktrans with object.sent_at|date:"DATETIME_FORMAT" as date and object.obfuscated_recipient as recipient %}On {{ date }}, you asked to send a message to the user '{{ recipient }}'.{% endblocktrans %} + +{% trans "Your message has been rejected by the moderator" %}{% if object.moderation_reason %}{% trans ", for the following reason:" %} + {{ object.moderation_reason }}{% else %}.{% endif %} + +{% else %}{# 'acceptance' #} +{% if object.parent_id %}{% if object.parent.sender_id == object.recipient_id %}{% blocktrans with object.parent.sent_at|date:"DATETIME_FORMAT" as date and object.obfuscated_sender as sender %}On {{ date }}, you sent a message to the user '{{ sender }}'.{% endblocktrans %} +{% trans "Your correspondent has given you an answer." %} +{% else %}{% blocktrans with object.obfuscated_sender as sender %}You have received a copy of a response from the user '{{ sender }}'.{% endblocktrans %} +{% endif %} +{% else %}{% blocktrans with object.obfuscated_sender as sender %}You have received a message from the user '{{ sender }}'.{% endblocktrans %} +{% endif %} +{% endif %}{# 'acceptance' #} +{% trans "Thank you again for your interest in our services." %} +{% trans "The site administrator" %} + +{% blocktrans %}Note: This message is issued by an automated system. +Do not reply, this would not be taken into account.{% endblocktrans %}{% endautoescape %} \ No newline at end of file diff --git a/postman/templates/postman/email_user_subject.txt b/postman/templates/postman/email_user_subject.txt new file mode 100644 index 0000000..cace60c --- /dev/null +++ b/postman/templates/postman/email_user_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% autoescape off %}{% blocktrans with object.subject as subject and site.name as sitename %}Message "{{ subject }}" on the site {{ sitename }}{% endblocktrans %}{% endautoescape %} \ No newline at end of file diff --git a/postman/templates/postman/email_visitor.txt b/postman/templates/postman/email_visitor.txt new file mode 100644 index 0000000..23e18a9 --- /dev/null +++ b/postman/templates/postman/email_visitor.txt @@ -0,0 +1,22 @@ +{% load i18n %}{% autoescape off %}{% trans "Dear visitor," %} +{% if action == 'rejection' %} +{% blocktrans with object.sent_at|date:"DATETIME_FORMAT" as date and object.recipient as recipient %}On {{ date }}, you asked to send a message to the user '{{ recipient }}'.{% endblocktrans %} + +{% trans "Your message has been rejected by the moderator" %}{% if object.moderation_reason %}{% trans ", for the following reason:" %} + {{ object.moderation_reason }}{% else %}.{% endif %} + +{% trans "As a reminder, please find below the content of your message." %} +{% else %}{# 'acceptance' #} +{% blocktrans with object.parent.sent_at|date:"DATETIME_FORMAT" as date and object.sender as sender %}On {{ date }}, you sent a message to the user '{{ sender }}'.{% endblocktrans %} +{% trans "Please find below the answer from your correspondent." %} +{% endif %} + +{% trans "Thank you again for your interest in our services." %} +{% trans "For more comfort, we encourage you to open an account on the site." %} +{% trans "The site administrator" %} + +{% blocktrans %}Note: This message is issued by an automated system. +Do not reply, this would not be taken into account.{% endblocktrans %} +------------------------------------------------------- +{{ object.body }} +-------------------------------------------------------{% endautoescape %} \ No newline at end of file diff --git a/postman/templates/postman/email_visitor_subject.txt b/postman/templates/postman/email_visitor_subject.txt new file mode 100644 index 0000000..cace60c --- /dev/null +++ b/postman/templates/postman/email_visitor_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% autoescape off %}{% blocktrans with object.subject as subject and site.name as sitename %}Message "{{ subject }}" on the site {{ sitename }}{% endblocktrans %}{% endautoescape %} \ No newline at end of file diff --git a/postman/templates/postman/inbox.html b/postman/templates/postman/inbox.html new file mode 100644 index 0000000..6da9d25 --- /dev/null +++ b/postman/templates/postman/inbox.html @@ -0,0 +1,7 @@ +{% extends "postman/base_folder.html" %} +{% load i18n %} +{% block pm_folder_title %}{% trans "Received Messages" %}{% endblock %} +{% block pm_undelete_button %}{% endblock %} +{% block pm_recipient_header %}{% endblock %} +{% block pm_date %}{% trans "Received" %}{% endblock %} +{% block pm_recipient_cell %}{% endblock %} diff --git a/postman/templates/postman/inc_subject_ex.html b/postman/templates/postman/inc_subject_ex.html new file mode 100644 index 0000000..9ef1c3c --- /dev/null +++ b/postman/templates/postman/inc_subject_ex.html @@ -0,0 +1,22 @@ +{% comment %} +This file is intended to be included, such as in postman/base_folder.html: + {% include "postman/inc_subject_ex.html" %} + +It provides an extended subject, as a replacement to a simple {{ message.subject }} tag. +Enhancements are: +* limit the subject length to a few words +* if there is still room up to a maximum number of words, then add the very first words of the body, + in a grey style. + +Examples: +With a total of at most 12 words, and 5 words of subject. +With body: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod." + +With subject: "a subject of great interest for you": +"a subject of great interest ... - Lorem ipsum dolor sit amet, consectetur ..." + +With subject: "a great interest": +"a great interest - Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed ..." + +{% endcomment %}{% load postman_tags %}{% with message.subject|truncatewords:5 as truncated_subject %}{{ truncated_subject }} +{% with truncated_subject|wordcount as subject_wc %}{% with 12|sub:subject_wc as wc %}{% if message.body and wc > 0 %} - <span style="color: grey">{{ message.body|truncatewords:wc }}</span>{% endif %}{% endwith %}{% endwith %}{% endwith %} \ No newline at end of file diff --git a/postman/templates/postman/reply.html b/postman/templates/postman/reply.html new file mode 100644 index 0000000..1c27076 --- /dev/null +++ b/postman/templates/postman/reply.html @@ -0,0 +1,4 @@ +{% extends "postman/base_write.html" %} +{% load i18n %} +{% block pm_write_title %}{% trans "Reply"%}{% endblock %} +{% block pm_write_recipient %}<tr><th><label>{% trans "Recipient" %}:</label></th><td>{{ recipient }}</td></tr>{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/sent.html b/postman/templates/postman/sent.html new file mode 100644 index 0000000..0480f6a --- /dev/null +++ b/postman/templates/postman/sent.html @@ -0,0 +1,7 @@ +{% extends "postman/base_folder.html" %} +{% load i18n %} +{% block pm_folder_title %}{% trans "Sent Messages" %}{% endblock %} +{% block pm_undelete_button %}{% endblock %} +{% block pm_sender_header %}{% endblock %} +{% block pm_date %}{% trans "Sent" %}{% endblock %} +{% block pm_sender_cell %}{% endblock %} diff --git a/postman/templates/postman/trash.html b/postman/templates/postman/trash.html new file mode 100644 index 0000000..8982ea3 --- /dev/null +++ b/postman/templates/postman/trash.html @@ -0,0 +1,11 @@ +{% extends "postman/base_folder.html" %} +{% load i18n %} +{% block pm_folder_title %}{% trans "Deleted Messages" %}{% endblock %} +{% block pm_delete_button %}{% endblock %} +{% block pm_archive_button %}{% endblock %} +{% block pm_subject %}{# no link #} + {% include "postman/inc_subject_ex.html" %} +{% endblock %} +{% block pm_footer_info %} +<p>{% trans "Messages in this folder can be removed from time to time. For long term storage, use instead the archive folder." %}</p> +{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/view.html b/postman/templates/postman/view.html new file mode 100644 index 0000000..5436208 --- /dev/null +++ b/postman/templates/postman/view.html @@ -0,0 +1,36 @@ +{% extends "postman/base.html" %} +{% load i18n %}{% load postman_tags %} +{% block content %} +<div id="postman"> +<h1>{% if pm_messages|length > 1 %}{% trans "Conversation" %}{% else %}{% trans "Message" %}{% endif %}</h1> +{% for message in pm_messages %} +<div class="pm_message{% if message.is_pending %} pm_pending{% endif %}{% if message.is_rejected %} pm_rejected{% endif %}{% if message.sender == user and message.sender_archived or message.recipient == user and message.recipient_archived %} pm_archived{% endif %}{% if message.sender == user and message.sender_deleted_at or message.recipient == user and message.recipient_deleted_at %} pm_deleted{% endif %}{% if message.recipient == user and not message.read_at %} pm_unread{% endif %}"> + <div class="pm_header"> + <span class="pm_sender">{{ message.obfuscated_sender|or_me:user }}</span> » + <span class="pm_recipient">{{ message.obfuscated_recipient|or_me:user }}</span> | + <span class="pm_date">{{ message.sent_at|date:"DATETIME_FORMAT"}}</span> | + <span class="pm_subject">{{ message.subject }}</span> +{% if message.is_rejected %} <div class="pm_status">{% trans "Rejected" %}{% if message.moderation_reason %}{% trans ":" %} {{ message.moderation_reason }}{% endif %}</div>{% endif %} + </div> + <div class="pm_body">{{ message.body|linebreaksbr }}</div> +</div> +{% if forloop.last %} +<form action="" method="post">{% csrf_token %} +<input type="hidden" {% if message.thread_id %}name="tpks" value="{{ message.thread_id }}"{% else %}name="pks" value="{{ message.pk }}"{% endif %} /> +<a href="{{ next_url }}">{% trans "Back" %}</a> +<span id="pm_buttons"> +<button type="submit" onclick="this.form.action='{% url postman_delete %}?next={{ next_url|urlencode }}'">{% trans "Delete" %}</button> +{% if not archived %}<button type="submit" onclick="this.form.action='{% url postman_archive %}?next={{ next_url|urlencode }}'">{% trans "Archive" %}</button>{% endif %} +</span> +{% if reply_to_pk %}<a href="{% url postman_reply reply_to_pk %}?next={{ next_url|urlencode }}">{% trans "Reply" %}</a>{% endif %} +</form> +{% if reply_to_pk %}<hr /> +<h2>{% trans 'Reply' %}</h2> +<form action="{% url postman_reply reply_to_pk %}?next={{ next_url|urlencode }}" method="post">{% csrf_token %} +<div id="pm_reply">{{ form.body }}</div> +<button type="submit">{% trans 'Reply' %}</button> +</form>{% endif %} +{% endif %} +{% endfor %} +</div> +{% endblock %} \ No newline at end of file diff --git a/postman/templates/postman/write.html b/postman/templates/postman/write.html new file mode 100644 index 0000000..1d52b03 --- /dev/null +++ b/postman/templates/postman/write.html @@ -0,0 +1,3 @@ +{% extends "postman/base_write.html" %} +{% load i18n %} +{% block pm_write_title %}{% trans "Write"%}{% endblock %} \ No newline at end of file diff --git a/postman/templatetags/__init__.py b/postman/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/postman/templatetags/pagination_tags_for_tests.py b/postman/templatetags/pagination_tags_for_tests.py new file mode 100644 index 0000000..014d8a3 --- /dev/null +++ b/postman/templatetags/pagination_tags_for_tests.py @@ -0,0 +1,27 @@ +""" +A mock of django-pagination's pagination_tags.py that do nothing. +Just to avoid failures in template rendering during the test suite, +if the real application is not installed. + +To activate this mock, just rename it to ``pagination_tags.py`` +for the time of the test session. +""" +from django.template import Node, Library + +register = Library() + +class AutoPaginateNode(Node): + def render(self, context): + return u'' + +@register.tag +def autopaginate(parser, token): + return AutoPaginateNode() + +class PaginateNode(Node): + def render(self, context): + return u'' + +@register.tag +def paginate(parser, token): + return PaginateNode() diff --git a/postman/templatetags/postman_admin_modify.py b/postman/templatetags/postman_admin_modify.py new file mode 100644 index 0000000..7c3052a --- /dev/null +++ b/postman/templatetags/postman_admin_modify.py @@ -0,0 +1,13 @@ +""" +Written as in contrib/admin/templatetags/admin_modify.py, +to define a customized version of 'submit_row' tag with a cutomized html template. + +In use in templates/admin/postman/pendingmessage/change_form.html. +""" +from django import template + +register = template.Library() + +@register.inclusion_tag('admin/postman/pendingmessage/submit_line.html') +def postman_submit_row(): + return {} diff --git a/postman/templatetags/postman_tags.py b/postman/templatetags/postman_tags.py new file mode 100644 index 0000000..e15d9a8 --- /dev/null +++ b/postman/templatetags/postman_tags.py @@ -0,0 +1,147 @@ +import datetime + +from django.http import QueryDict +from django.template import Node +from django.template import TemplateSyntaxError +from django.template import Library +from django.template.defaultfilters import date +from django.utils.translation import ugettext_lazy as _ + +from postman.models import ORDER_BY_MAPPER, ORDER_BY_KEY, Message + +register = Library() + +########## +# filters +########## + +@register.filter +def sub(value, arg): + """Subtract the arg from the value.""" + try: + return int(value) - int(arg) + except (ValueError, TypeError): + return value +sub.is_safe = True + +@register.filter +def or_me(value, arg): + """ + Replace the value by a fixed pattern, if it equals the argument. + + Typical usage: sender|or_me:user + + """ + return _('<me>') if str(value) == str(arg) else value + +@register.filter +def compact_date(value, arg): + """ + Output a date as short as possible. + + The argument must provide 3 patterns: for same day, for same year, otherwise + Typical usage: |compact_date:_("G:i,j b,j/n/y") + + """ + bits = arg.split(u',') + if len(bits) < 3: + return value # Invalid arg. + today = datetime.date.today() + return date(value, bits[0] if value.date() == today else bits[1] if value.year == today.year else bits[2]) + +####### +# tags +####### + +class OrderByNode(Node): + "For use in the postman_order_by tag" + def __init__(self, code): + self.code = code + + def render(self, context): + """ + Return a formatted GET query string, as "?order_key=order_val". + + Preserves existing GET's keys, if any, such as a page number. + For that, the view has to provide request.GET in a 'gets' entry of the context. + + """ + if 'gets' in context: + gets = context['gets'].copy() + else: + gets = QueryDict('').copy() + if ORDER_BY_KEY in gets: + code = gets.pop(ORDER_BY_KEY)[0] + else: + code = None + if self.code: + gets[ORDER_BY_KEY] = self.code if self.code <> code else self.code.upper() + return '?'+gets.urlencode() if gets else '' + +class InboxCountNode(Node): + "For use in the postman_unread tag" + def __init__(self, asvar=None): + self.asvar = asvar + + def render(self, context): + """ + Return the count of unread messages for the user found in context, + (may be 0) or an empty string. + """ + try: + user = context['user'] + if user.is_anonymous(): + count = '' + else: + count = Message.objects.inbox_unread_count(user) + except (KeyError, AttributeError): + count = '' + if self.asvar: + context[self.asvar] = count + return '' + return count + +@register.tag +def postman_order_by(parser, token): + """ + Compose a query string to ask for a specific ordering in messages list. + + The unique argument must be one of the keywords of a set defined in the model. + Example:: + + <a href="{% postman_order_by subject %}">...</a> + """ + try: + tag_name, field_name = token.split_contents() + field_code = ORDER_BY_MAPPER[field_name.lower()] + except ValueError: + raise TemplateSyntaxError("'{0}' tag requires a single argument".format(token.contents.split()[0])) + except KeyError: + raise TemplateSyntaxError( + "'{0}' is not a valid argument to '{1}' tag." + " Must be one of: {2}".format(field_name, tag_name, ORDER_BY_MAPPER.keys())) + return OrderByNode(field_code) + +@register.tag +def postman_unread(parser, token): + """ + Give the number of unread messages for a user, + or nothing (an empty string) for an anonymous user. + + Storing the count in a variable for further processing is advised, such as:: + + {% postman_unread as unread_count %} + ... + {% if unread_count %} + You have <strong>{{ unread_count }}</strong> unread messages. + {% endif %} + """ + bits = token.split_contents() + if len(bits) > 1: + if len(bits) != 3: + raise TemplateSyntaxError("'{0}' tag takes no argument or exactly two arguments".format(bits[0])) + if bits[1] != 'as': + raise TemplateSyntaxError("First argument to '{0}' tag must be 'as'".format(bits[0])) + return InboxCountNode(bits[2]) + else: + return InboxCountNode() diff --git a/postman/test_urls.py b/postman/test_urls.py new file mode 100644 index 0000000..1812aa0 --- /dev/null +++ b/postman/test_urls.py @@ -0,0 +1,129 @@ +""" +URLconf for tests.py usage. + +""" +from django.conf import settings +from django.conf.urls.defaults import * +from django.forms import ValidationError +from django.views.generic.simple import redirect_to + +from postman.urls import OPTIONS + +# user_filter function set +def user_filter_reason(user): + if user.username == 'bar': return 'some reason' + return None +def user_filter_no_reason(user): + return '' +def user_filter_false(user): + return False +def user_filter_exception(user): + if user.username == 'bar': raise ValidationError(['first good reason',"anyway, I don't like {0}".format(user.username)]) + return None + +# exchange_filter function set +def exch_filter_reason(sender, recipient, recipients_list): + if recipient.username=='bar': return 'some reason' + return None +def exch_filter_no_reason(sender, recipient, recipients_list): + return '' +def exch_filter_false(sender, recipient, recipients_list): + return False +def exch_filter_exception(sender, recipient, recipients_list): + if recipient.username == 'bar': raise ValidationError(['first good reason',"anyway, I don't like {0}".format(recipient.username)]) + return None + +# auto-moderation function set +def moderate_as_51(message): + return 51 +def moderate_as_48(message): + return (48, "some reason") +moderate_as_48.default_reason = 'some default reason' + +# quote formatters +def format_subject(subject): + return "Re_ " + subject +def format_body(sender, body): + return "{0} _ {1}".format(sender, body) + +postman_patterns = patterns('postman.views', + # Basic set + url(r'^inbox/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', name='postman_inbox'), + url(r'^sent/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', name='postman_sent'), + url(r'^archives/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', name='postman_archives'), + url(r'^trash/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', name='postman_trash'), + url(r'^write/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', name='postman_write'), + url(r'^reply/(?P<message_id>[\d]+)/$', 'reply', name='postman_reply'), + url(r'^view/(?P<message_id>[\d]+)/$', 'view', name='postman_view'), + url(r'^view/t/(?P<thread_id>[\d]+)/$', 'view_conversation', name='postman_view_conversation'), + url(r'^archive/$', 'archive', name='postman_archive'), + url(r'^delete/$', 'delete', name='postman_delete'), + url(r'^undelete/$', 'undelete', name='postman_undelete'), + (r'^$', redirect_to, {'url': 'inbox/'}), + + # Customized set + # 'success_url' + url(r'^write_sent/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'success_url': 'postman_sent'}, name='postman_write_with_success_url_to_sent'), + url(r'^reply_sent/(?P<message_id>[\d]+)/$', 'reply', {'success_url': 'postman_sent'}, name='postman_reply_with_success_url_to_sent'), + url(r'^archive_arch/$', 'archive', {'success_url': 'postman_archives'}, name='postman_archive_with_success_url_to_archives'), + url(r'^delete_arch/$', 'delete', {'success_url': 'postman_archives'}, name='postman_delete_with_success_url_to_archives'), + url(r'^undelete_arch/$', 'undelete', {'success_url': 'postman_archives'}, name='postman_undelete_with_success_url_to_archives'), + # 'max' + url(r'^write_max/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'max': 1}, name='postman_write_with_max'), + url(r'^reply_max/(?P<message_id>[\d]+)/$', 'reply', {'max': 1}, name='postman_reply_with_max'), + # 'user_filter' on write + url(r'^write_user_filter_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_reason}, name='postman_write_with_user_filter_reason'), + url(r'^write_user_filter_no_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_no_reason}, name='postman_write_with_user_filter_no_reason'), + url(r'^write_user_filter_false/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_false}, name='postman_write_with_user_filter_false'), + url(r'^write_user_filter_exception/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'user_filter': user_filter_exception}, name='postman_write_with_user_filter_exception'), + # 'user_filter' on reply + url(r'^reply_user_filter_reason/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_reason}, name='postman_reply_with_user_filter_reason'), + url(r'^reply_user_filter_no_reason/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_no_reason}, name='postman_reply_with_user_filter_no_reason'), + url(r'^reply_user_filter_false/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_false}, name='postman_reply_with_user_filter_false'), + url(r'^reply_user_filter_exception/(?P<message_id>[\d]+)/$', 'reply', {'user_filter': user_filter_exception}, name='postman_reply_with_user_filter_exception'), + # 'exchange_filter' on write + url(r'^write_exch_filter_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_reason}, name='postman_write_with_exch_filter_reason'), + url(r'^write_exch_filter_no_reason/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_no_reason}, name='postman_write_with_exch_filter_no_reason'), + url(r'^write_exch_filter_false/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_false}, name='postman_write_with_exch_filter_false'), + url(r'^write_exch_filter_exception/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'exchange_filter': exch_filter_exception}, name='postman_write_with_exch_filter_exception'), + # 'exchange_filter' on reply + url(r'^reply_exch_filter_reason/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_reason}, name='postman_reply_with_exch_filter_reason'), + url(r'^reply_exch_filter_no_reason/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_no_reason}, name='postman_reply_with_exch_filter_no_reason'), + url(r'^reply_exch_filter_false/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_false}, name='postman_reply_with_exch_filter_false'), + url(r'^reply_exch_filter_exception/(?P<message_id>[\d]+)/$', 'reply', {'exchange_filter': exch_filter_exception}, name='postman_reply_with_exch_filter_exception'), + # 'auto_moderators' + url(r'^write_moderate/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'auto_moderators': (moderate_as_51,moderate_as_48)}, name='postman_write_moderate'), + url(r'^reply_moderate/(?P<message_id>[\d]+)/$', 'reply', {'auto_moderators': (moderate_as_51,moderate_as_48)}, name='postman_reply_moderate'), + # 'formatters' + url(r'^reply_formatters/(?P<message_id>[\d]+)/$', 'reply', {'formatters': (format_subject,format_body)}, name='postman_reply_formatters'), + url(r'^view_formatters/(?P<message_id>[\d]+)/$', 'view', {'formatters': (format_subject,format_body)}, name='postman_view_formatters'), + # auto-complete + url(r'^write_ac/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'autocomplete_channels': ('postman_multiple', None)}, name='postman_write_auto_complete'), + url(r'^reply_ac/(?P<message_id>[\d]+)/$', 'reply', {'autocomplete_channel': 'postman_multiple'}, name='postman_reply_auto_complete'), + # 'template_name' + url(r'^inbox_template/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', {'template_name': 'postman/fake.html'}, name='postman_inbox_template'), + url(r'^sent_template/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', {'template_name': 'postman/fake.html'}, name='postman_sent_template'), + url(r'^archives_template/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', {'template_name': 'postman/fake.html'}, name='postman_archives_template'), + url(r'^trash_template/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', {'template_name': 'postman/fake.html'}, name='postman_trash_template'), + url(r'^write_template/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', {'template_name': 'postman/fake.html'}, name='postman_write_template'), + url(r'^reply_template/(?P<message_id>[\d]+)/$', 'reply', {'template_name': 'postman/fake.html'}, name='postman_reply_template'), + url(r'^view_template/(?P<message_id>[\d]+)/$', 'view', {'template_name': 'postman/fake.html'}, name='postman_view_template'), + url(r'^view_template/t/(?P<thread_id>[\d]+)/$', 'view_conversation', {'template_name': 'postman/fake.html'}, name='postman_view_conversation_template'), +) + +urlpatterns = patterns('', + (r'^accounts/login/$', 'django.contrib.auth.views.login'), # because of the login_required decorator + (r'^messages/', include(postman_patterns)), +) + +# because of fields.py/AutoCompleteWidget/render()/reverse() +if 'ajax_select' in settings.INSTALLED_APPS: + urlpatterns += patterns('', + (r'^ajax_select/', include('ajax_select.urls')), # django-ajax-selects + ) + +# optional +if 'notification' in settings.INSTALLED_APPS: + urlpatterns += patterns('', + (r'^notification/', include('notification.urls')), # django-notification + ) diff --git a/postman/tests.py b/postman/tests.py new file mode 100644 index 0000000..59e3d3b --- /dev/null +++ b/postman/tests.py @@ -0,0 +1,1547 @@ +""" +Test suite. + +- Do not put 'mailer' in INSTALLED_APPS, it disturbs the emails counting. +- Make sure these templates are accessible: + registration/login.html + base.html + 404.html + +To have a fast test session, you can set a minimal configuration as: +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': ':memory:', # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.admin', + # 'pagination', # or use the mock + # 'ajax_select', # is an option + # 'notification', # is an option + 'postman', +) + +""" +import copy +from datetime import datetime, timedelta +import re +import sys +from time import sleep + +from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.models import User, AnonymousUser +from django.core import mail +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse, clear_url_caches, get_resolver, get_urlconf +from django.db.models import Q +from django.http import QueryDict +from django.template import Template, Context, TemplateSyntaxError, TemplateDoesNotExist +from django.test import TestCase + +from postman.fields import CommaSeparatedUserField +# because of reload()'s, do "from postman.forms import xxForm" just before needs +from postman.models import ORDER_BY_KEY, ORDER_BY_MAPPER, Message, PendingMessage,\ + STATUS_PENDING, STATUS_ACCEPTED, STATUS_REJECTED +from postman.urls import OPTION_MESSAGES +from postman.utils import format_body, format_subject, notification + +if not 'pagination' in settings.INSTALLED_APPS: + try: + import postman.templatetags.pagination_tags + except: + sys.exit( + "Some templates need templatetags from the django-pagination application.\n" + "Add it to the INSTALLED_APPS, or allow a mock by renaming\n" + "postman/templatetags/pagination_tags_for_tests.py to pagination_tags.py" + ) + +class GenericTest(TestCase): + """ + Usual generic tests. + """ + def test_version(self): + self.assertEqual(sys.modules['postman'].__version__, "1.0.0") + +class BaseTest(TestCase): + """ + Common configuration and helper functions for all tests. + """ + urls = 'postman.test_urls' + + def setUp(self): + settings.LANGUAGE_CODE = 'en' # do not bother about translation + for a in ( + 'POSTMAN_DISALLOW_ANONYMOUS', + 'POSTMAN_DISALLOW_MULTIRECIPIENTS', + 'POSTMAN_DISALLOW_COPIES_ON_REPLY', + 'POSTMAN_AUTO_MODERATE_AS', + ): + if hasattr(settings, a): + delattr(settings, a) + settings.POSTMAN_NOTIFIER_APP = None + settings.POSTMAN_MAILER_APP = None + settings.POSTMAN_AUTOCOMPLETER_APP = { + 'arg_default': 'postman_single', # no default, mandatory to enable the feature + } + self.reload_modules() + + self.user1 = User.objects.create_user('foo', 'foo@domain.com', 'pass') + self.user2 = User.objects.create_user('bar', 'bar@domain.com', 'pass') + self.user3 = User.objects.create_user('baz', 'baz@domain.com', 'pass') + self.email = 'qux@domain.com' + + def check_now(self, dt): + "Check that a date is now. Well... almost." + delta = dt - datetime.now() + seconds = delta.days * (24*60*60) + delta.seconds + self.assert_(-2 <= seconds <= 2) + + def check_status(self, m, status=STATUS_PENDING, is_new=True, is_replied=False, parent=None, thread=None, + moderation_date=False, moderation_by=None, moderation_reason='', + sender_archived=False, recipient_archived=False, + sender_deleted_at=False, recipient_deleted_at=False): + "Check a bunch of properties of a message." + + self.assertEqual(m.is_pending(), status==STATUS_PENDING) + self.assertEqual(m.is_rejected(), status==STATUS_REJECTED) + self.assertEqual(m.is_accepted(), status==STATUS_ACCEPTED) + self.assertEqual(m.is_new, is_new) + self.assertEqual(m.is_replied, is_replied) + self.check_now(m.sent_at) + self.assertEqual(m.parent, parent) + self.assertEqual(m.thread, thread) + self.assertEqual(m.sender_archived, sender_archived) + self.assertEqual(m.recipient_archived, recipient_archived) + if sender_deleted_at: + if isinstance(sender_deleted_at, datetime): + self.assertEqual(m.sender_deleted_at, sender_deleted_at) + else: + self.assertNotEquals(m.sender_deleted_at, None) + else: + self.assertEqual(m.sender_deleted_at, None) + if recipient_deleted_at: + if isinstance(recipient_deleted_at, datetime): + self.assertEqual(m.recipient_deleted_at, recipient_deleted_at) + else: + self.assertNotEquals(m.recipient_deleted_at, None) + else: + self.assertEqual(m.recipient_deleted_at, None) + if moderation_date: + if isinstance(moderation_date, datetime): + self.assertEqual(m.moderation_date, moderation_date) + else: + self.assertNotEquals(m.moderation_date, None) + else: + self.assertEqual(m.moderation_date, None) + self.assertEqual(m.moderation_by, moderation_by) + self.assertEqual(m.moderation_reason, moderation_reason) + + def create(self, moderation_status=None, *args, **kwargs): + "Create a message." + # need to sleep between creations + # otherwise some sent_at datetimes are equal and ordering predictions are disturbed + sleep(0.03) + if moderation_status: + kwargs.update(moderation_status=moderation_status) + return Message.objects.create(subject='s', *args, **kwargs) + + def create_accepted(self, moderation_status=STATUS_ACCEPTED, *args, **kwargs): + "Create a message as 'accepted'." + return self.create(moderation_status=moderation_status, *args, **kwargs) + + # set of message creations + def c12(self, *args, **kwargs): + return self.create_accepted(sender=self.user1, recipient=self.user2, *args, **kwargs) + def c13(self, *args, **kwargs): + return self.create_accepted(sender=self.user1, recipient=self.user3, *args, **kwargs) + def c21(self, *args, **kwargs): + return self.create_accepted(sender=self.user2, recipient=self.user1, *args, **kwargs) + def c23(self, *args, **kwargs): + return self.create_accepted(sender=self.user2, recipient=self.user3, *args, **kwargs) + def c32(self, *args, **kwargs): + return self.create_accepted(sender=self.user3, recipient=self.user2, *args, **kwargs) + + def reload_modules(self): + "Reload some modules after a change in settings." + clear_url_caches() + try: + reload(sys.modules['postman.forms']) + reload(sys.modules['postman.views']) + reload(sys.modules['postman.urls']) + except KeyError: # happens once at the setUp + pass + reload(get_resolver(get_urlconf()).urlconf_module) + +class ViewTest(BaseTest): + """ + Test the views. + """ + def test_home(self): + response = self.client.get('/messages/') + self.assertRedirects(response, reverse('postman_inbox'), status_code=301, target_status_code=302) + + def check_folder(self, folder): + url = reverse('postman_' + folder, args=[OPTION_MESSAGES]) + template = "postman/{0}.html".format(folder) + # anonymous + response = self.client.get(url) + self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url)) + # authenticated + self.assert_(self.client.login(username='foo', password='pass')) + response = self.client.get(url) + self.assertTemplateUsed(response, template) + url = reverse('postman_' + folder) + response = self.client.get(url) + self.assertTemplateUsed(response, template) + + def test_inbox(self): + self.check_folder('inbox') + + def test_sent(self): + self.check_folder('sent') + + def test_archives(self): + self.check_folder('archives') + + def test_trash(self): + self.check_folder('trash') + + def check_template(self, action, args): + # don't want to bother with additional templates; test only the parameter passing + url = reverse('postman_' + action + '_template', args=args) + self.assertRaises(TemplateDoesNotExist, self.client.get, url) + + def test_template(self): + "Test the 'template_name' parameter." + m1 = self.c12() + m1.read_at, m1.thread = datetime.now(), m1 + m2 = self.c21(parent=m1, thread=m1.thread) + m1.replied_at = m2.sent_at; m1.save() + self.assert_(self.client.login(username='foo', password='pass')) + for actions, args in [ + (('inbox', 'sent', 'archives', 'trash', 'write'), []), + (('view', 'view_conversation'), [1]), + (('reply',), [2]), + ]: + for action in actions: + self.check_template(action, args) + + def test_write_authentication(self): + "Test permission and what template & form are used." + url = reverse('postman_write') + template = "postman/write.html" + # anonymous is allowed + response = self.client.get(url) + self.assertTemplateUsed(response, template) + from postman.forms import AnonymousWriteForm + self.assert_(isinstance(response.context['form'], AnonymousWriteForm)) + # anonymous is not allowed + settings.POSTMAN_DISALLOW_ANONYMOUS = True + self.reload_modules() + response = self.client.get(url) + self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url)) + # authenticated + self.assert_(self.client.login(username='foo', password='pass')) + response = self.client.get(url) + self.assertTemplateUsed(response, template) + from postman.forms import WriteForm + self.assert_(isinstance(response.context['form'], WriteForm)) + + def test_write_recipient(self): + "Test the passing of recipient names in URL." + template = "postman/write.html" + + url = reverse('postman_write', args=['foo']) + response = self.client.get(url) + self.assertContains(response, 'value="foo"') + + url = reverse('postman_write', args=['foo:bar']) + response = self.client.get(url) + self.assertContains(response, 'value="bar, foo"') + + url = reverse('postman_write', args=[':foo::intruder:bar:a-b+c@d.com:foo:']) + response = self.client.get(url) + self.assertContains(response, 'value="bar, foo"') + + def test_write_auto_complete(self): + "Test the 'autocomplete_channels' parameter." + url = reverse('postman_write_auto_complete') + # anonymous + response = self.client.get(url) + f = response.context['form'].fields['recipients'] + if hasattr(f, 'channel'): # app may not be in INSTALLED_APPS + self.assertEqual(f.channel, 'postman_single') + # authenticated + self.assert_(self.client.login(username='foo', password='pass')) + response = self.client.get(url) + f = response.context['form'].fields['recipients'] + if hasattr(f, 'channel'): + self.assertEqual(f.channel, 'postman_multiple') + + def check_init_by_query_string(self, action, args=[]): + template = "postman/{0}.html".format(action) + url = reverse('postman_' + action, args=args) + response = self.client.get(url + '?subject=that%20is%20the%20subject') + self.assertContains(response, 'value="that is the subject"') + response = self.client.get(url + '?body=this%20is%20my%20body') + self.assertContains(response, 'name="body">this is my body') + + def test_write_querystring(self): + "Test the prefilling by query string." + self.check_init_by_query_string('write') + + def check_message(self, m, is_anonymous=False, subject='s', body='b', recipient_username='bar'): + "Check some message properties, status, and that no mail is sent." + self.assertEqual(m.subject, subject) + self.assertEqual(m.body, body) + self.assertEqual(m.email, 'a@b.com' if is_anonymous else '') + self.assertEqual(m.sender, self.user1 if not is_anonymous else None) + self.assertEqual(m.recipient.username, recipient_username) + if is_anonymous: + self.check_status(m, sender_deleted_at=True) + self.assertEqual(len(mail.outbox), 0) + + def check_write_post(self, extra={}, is_anonymous=False): + "Check message generation, redirection, and mandatory fields." + url = reverse('postman_write') + url_with_success_url = reverse('postman_write_with_success_url_to_sent') + data = {'recipients': self.user2.username, 'subject': 's', 'body': 'b'} + data.update(extra) + # default redirect is to the requestor page + response = self.client.post(url, data, HTTP_REFERER=url) + self.assertRedirects(response, url) + self.check_message(Message.objects.get(pk=1), is_anonymous) + # fallback redirect is to inbox. So redirect again when login is required + response = self.client.post(url, data) + self.assertRedirects(response, reverse('postman_inbox'), target_status_code=302 if is_anonymous else 200) + self.check_message(Message.objects.get(pk=2), is_anonymous) + # redirect url may be superseded + response = self.client.post(url_with_success_url, data, HTTP_REFERER=url) + self.assertRedirects(response, reverse('postman_sent'), target_status_code=302 if is_anonymous else 200) + self.check_message(Message.objects.get(pk=3), is_anonymous) + # query string has highest precedence + response = self.client.post(url_with_success_url + '?next=' + url, data, HTTP_REFERER='does not matter') + self.assertRedirects(response, url) + self.check_message(Message.objects.get(pk=4), is_anonymous) + + for f in data.keys(): + if f in ('body',): continue + d = data.copy() + del d[f] + response = self.client.post(url, d, HTTP_REFERER=url) + self.assertFormError(response, 'form', f, 'This field is required.') + + def test_write_post_anonymous(self): + self.check_write_post({'email': 'a@b.com'}, is_anonymous=True) + + def test_write_post_authenticated(self): + self.assert_(self.client.login(username='foo', password='pass')) + self.check_write_post() + + def test_write_post_multirecipient(self): + "Test number of recipients constraint." + url = reverse('postman_write') + data = { + 'email': 'a@b.com', 'subject': 's', 'body': 'b', + 'recipients': '{0}, {1}'.format(self.user2.username, self.user3.username)} + # anonymous + response = self.client.post(url, data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2)) + # authenticated + self.assert_(self.client.login(username='foo', password='pass')) + del data['email'] + response = self.client.post(url, data, HTTP_REFERER=url) + self.assertRedirects(response, url) + self.check_message(Message.objects.get(pk=1)) + self.check_message(Message.objects.get(pk=2), recipient_username='baz') + + url_with_max = reverse('postman_write_with_max') + response = self.client.post(url_with_max, data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2)) + + settings.POSTMAN_DISALLOW_MULTIRECIPIENTS = True + response = self.client.post(url, data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', CommaSeparatedUserField.default_error_messages['max'].format(limit_value=1, show_value=2)) + + def test_write_post_filters(self): + "Test user- and exchange- filters." + url = reverse('postman_write') + data = { + 'subject': 's', 'body': 'b', + 'recipients': '{0}, {1}'.format(self.user2.username, self.user3.username)} + self.assert_(self.client.login(username='foo', password='pass')) + + response = self.client.post(reverse('postman_write_with_user_filter_reason'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar (some reason).") + + response = self.client.post(reverse('postman_write_with_user_filter_no_reason'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.") + + response = self.client.post(reverse('postman_write_with_user_filter_false'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Some usernames are rejected: bar, baz.") + + response = self.client.post(reverse('postman_write_with_user_filter_exception'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"]) + + response = self.client.post(reverse('postman_write_with_exch_filter_reason'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar (some reason).") + + response = self.client.post(reverse('postman_write_with_exch_filter_no_reason'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.") + + response = self.client.post(reverse('postman_write_with_exch_filter_false'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', "Writing to some users is not possible: bar, baz.") + + response = self.client.post(reverse('postman_write_with_exch_filter_exception'), data, HTTP_REFERER=url) + self.assertFormError(response, 'form', 'recipients', ['first good reason',"anyway, I don't like bar"]) + + def test_write_post_moderate(self): + "Test 'auto_moderators' parameter." + url = reverse('postman_write') + data = {'subject': 's', 'body': 'b', 'recipients': self.user2.username} + self.assert_(self.client.login(username='foo', password='pass')) + response = self.client.post(reverse('postman_write_moderate'), data, HTTP_REFERER=url) + self.assertRedirects(response, url) + self.check_status(Message.objects.get(pk=1), status=STATUS_REJECTED, recipient_deleted_at=True, + moderation_date=True, moderation_reason="some reason") + + def test_reply_authentication(self): + "Test permission and what template & form are used." + template = "postman/reply.html" + self.c21(body="this is my body") + url = reverse('postman_reply', args=[1]) + # anonymous + response = self.client.get(url) + self.assertRedirects(response, "{0}?{1}={2}".format(settings.LOGIN_URL, REDIRECT_FIELD_NAME, url)) + # authenticated + self.assert_(self.client.login(username='foo', password='pass')) + response = self.client.get(url) + self.assertTemplateUsed(response, template) + from postman.forms import FullReplyForm + self.assert_(isinstance(response.context['form'], FullReplyForm)) + self.assertContains(response, 'value="Re: s"') + self.assertContains(response, 'name="body">\n\nbar wrote:\n> 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") diff --git a/postman/urls.py b/postman/urls.py new file mode 100644 index 0000000..cd6495e --- /dev/null +++ b/postman/urls.py @@ -0,0 +1,108 @@ +""" +If the default usage of the views suits you, simply use a line like +this one in your root URLconf to set up the default URLs:: + + (r'^messages/', include('postman.urls')), + +Otherwise you may customize the behavior by passing extra parameters. + +Recipients Max +-------------- +Views supporting the parameter are: ``write``, ``reply``. +Example:: + ..., {'max': 3}, name='postman_write'), +See also the ``POSTMAN_DISALLOW_MULTIRECIPIENTS`` setting + +User filter +----------- +Views supporting a user filter are: ``write``, ``reply``. +Example:: + def my_user_filter(user): + if user.get_profile().is_absent: + return "is away" + return None + ... + ..., {'user_filter': my_user_filter}, name='postman_write'), + +function interface: +In: a User instance +Out: None, False, '', 'a reason', or ValidationError + +Exchange filter +--------------- +Views supporting an exchange filter are: ``write``, ``reply``. +Example:: + def my_exchange_filter(sender, recipient, recipients_list): + if recipient.relationships.exists(sender, RelationshipStatus.objects.blocking()): + return "has blacklisted you" + return None + ... + ..., {'exchange_filter': my_exchange_filter}, name='postman_write'), + +function interface: +In: + ``sender``: a User instance + ``recipient``: a User instance + ``recipients_list``: the full list of recipients +Out: None, False, '', 'a reason', or ValidationError + +Auto-complete field +------------------- +Views supporting an auto-complete parameter are: ``write``, ``reply``. +Examples:: + ..., {'autocomplete_channels': (None,'anonymous_ac')}, name='postman_write'), + ..., {'autocomplete_channels': 'write_ac'}, name='postman_write'), + ..., {'autocomplete_channel': 'reply_ac'}, name='postman_reply'), + +Auto moderators +--------------- +Views supporting an ``auto-moderators`` parameter are: ``write``, ``reply``. +Example:: + def mod1(message): + # ... + return None + def mod2(message): + # ... + return None + mod2.default_reason = 'mod2 default reason' + ... + ..., {'auto_moderators': (mod1, mod2)}, name='postman_write'), + ..., {'auto_moderators': mod1}, name='postman_reply'), + +function interface: +In: ``message``: a Message instance +Out: rating or (rating, "reason") + with reting: None, 0 or False, 100 or True, 1..99 + +Others +------ +Refer to documentation. + ..., {'form_classes': (MyCustomWriteForm, MyCustomAnonymousWriteForm)}, name='postman_write'), + ..., {'form_class': MyCustomFullReplyForm}, name='postman_reply'), + ..., {'form_class': MyCustomQuickReplyForm}, name='postman_view'), + ..., {'template_name': 'my_custom_view.html'}, name='postman_view'), + ..., {'success_url': 'postman_inbox'}, name='postman_reply'), + ..., {'formatters': (format_subject,format_body)}, name='postman_reply'), + ..., {'formatters': (format_subject,format_body)}, name='postman_view'), + +""" +from django.conf.urls.defaults import * +from django.views.generic.simple import redirect_to + +OPTION_MESSAGES = 'm' +OPTIONS = OPTION_MESSAGES + +urlpatterns = patterns('postman.views', + url(r'^inbox/(?:(?P<option>'+OPTIONS+')/)?$', 'inbox', name='postman_inbox'), + url(r'^sent/(?:(?P<option>'+OPTIONS+')/)?$', 'sent', name='postman_sent'), + url(r'^archives/(?:(?P<option>'+OPTIONS+')/)?$', 'archives', name='postman_archives'), + url(r'^trash/(?:(?P<option>'+OPTIONS+')/)?$', 'trash', name='postman_trash'), + url(r'^write/(?:(?P<recipients>[\w.@+-:]+)/)?$', 'write', name='postman_write'), + url(r'^reply/(?P<message_id>[\d]+)/$', 'reply', name='postman_reply'), + url(r'^view/(?P<message_id>[\d]+)/$', 'view', name='postman_view'), + url(r'^view/t/(?P<thread_id>[\d]+)/$', 'view_conversation', name='postman_view_conversation'), + url(r'^archive/$', 'archive', name='postman_archive'), + url(r'^delete/$', 'delete', name='postman_delete'), + url(r'^undelete/$', 'undelete', name='postman_undelete'), + (r'^$', redirect_to, {'url': 'inbox/'}), +) diff --git a/postman/utils.py b/postman/utils.py new file mode 100644 index 0000000..d148e61 --- /dev/null +++ b/postman/utils.py @@ -0,0 +1,94 @@ +import re +import sys +from textwrap import TextWrapper + +from django.conf import settings +from django.contrib.sites.models import Site +from django.template.loader import render_to_string +from django.utils.encoding import force_unicode +from django.utils.translation import ugettext, ugettext_lazy as _ + +# make use of a favourite notifier app such as django-notification +# but if not installed or not desired, fallback will be to do basic emailing +name = getattr(settings, 'POSTMAN_NOTIFIER_APP', 'notification') +if name and name in settings.INSTALLED_APPS: + name = name + '.models' + __import__(name) + notification = sys.modules[name] +else: + notification = None + +# give priority to a favourite mailer app such as django-mailer +# but if not installed or not desired, fallback to django.core.mail +name = getattr(settings, 'POSTMAN_MAILER_APP', 'mailer') +if name and name in settings.INSTALLED_APPS: + send_mail = __import__(name, globals(), locals(), ['send_mail']).send_mail +else: + from django.core.mail import send_mail + +# default wrap width; referenced in forms.py +WRAP_WIDTH = 55 + +def format_body(sender, body, indent=_("> "), width=WRAP_WIDTH): + """ + Wrap the text and prepend lines with a prefix. + + The aim is to get lines with at most `width` chars. + But does not wrap if the line is already prefixed. + + Prepends each line with a localized prefix, even empty lines. + Existing line breaks are preserved. + Used for quoting messages in replies. + + """ + indent = force_unicode(indent) # join() doesn't work on lists with lazy translation objects + wrapper = TextWrapper(width=width, initial_indent=indent, subsequent_indent=indent) + # rem: TextWrapper doesn't add the indent on an empty text + quote = '\n'.join([line.startswith(indent) and indent+line or wrapper.fill(line) or indent for line in body.splitlines()]) + return ugettext("\n\n{sender} wrote:\n{body}\n").format(sender=sender, body=quote) + +def format_subject(subject): + """ + Prepend a pattern to the subject, unless already there. + + Matching is case-insensitive. + + """ + str = ugettext("Re: {subject}") + pattern = '^' + str.replace('{subject}', '.*') + '$' + return subject if re.match(pattern, subject, re.IGNORECASE) else str.format(subject=subject) + +def email(subject_template, message_template, recipient_list, object, action=None): + """Compose and send an email.""" + site = Site.objects.get_current() + ctx_dict = {'site': site, 'object': object, 'action': action} + subject = render_to_string(subject_template, ctx_dict) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + message = render_to_string(message_template, ctx_dict) + if settings.DEBUG and getattr(settings, 'DEV_DEBUG', False): + print "email from:", settings.DEFAULT_FROM_EMAIL, " - to:", recipient_list, " - subject:", subject + print message + else: + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True) + +def email_visitor(object, action): + """Email a visitor.""" + email('postman/email_visitor_subject.txt', 'postman/email_visitor.txt', [object.email], object, action) + +def notify_user(object, action): + """Notify a user.""" + if action == 'rejection': + user = object.sender + label = 'postman_rejection' + elif action == 'acceptance': + user = object.recipient + parent = object.parent + label = 'postman_reply' if (parent and parent.sender_id == object.recipient_id) else 'postman_message' + else: + return + if notification: + notification.send(users=[user], label=label, extra_context={'message': object, 'action': action}) + else: + if user.email and user.is_active: + email('postman/email_user_subject.txt', 'postman/email_user.txt', [user.email], object, action) diff --git a/postman/views.py b/postman/views.py new file mode 100644 index 0000000..e999ba0 --- /dev/null +++ b/postman/views.py @@ -0,0 +1,294 @@ +import datetime +import urlparse + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.db.models import Q +from django.http import Http404 +from django.shortcuts import render_to_response, get_object_or_404, redirect +from django.template import RequestContext +from django.utils.translation import ugettext as _ + +from postman.fields import is_autocompleted +from postman.forms import WriteForm, AnonymousWriteForm, QuickReplyForm, FullReplyForm +from postman.models import Message, get_order_by +from postman.urls import OPTION_MESSAGES +from postman.utils import format_subject, format_body + +########## +# Helpers +########## +def _get_referer(request): + """Return the HTTP_REFERER, if existing.""" + if 'HTTP_REFERER' in request.META: + sr = urlparse.urlsplit(request.META['HTTP_REFERER']) + return urlparse.urlunsplit(('','',sr.path,sr.query,sr.fragment)) + +######## +# Views +######## +def _folder(request, folder_name, view_name, option, template_name): + """Code common to the folders.""" + kwargs = {} + if option: + kwargs.update(option=option) + order_by = get_order_by(request.GET) + if order_by: + kwargs.update(order_by=order_by) + msgs = getattr(Message.objects, folder_name)(request.user, **kwargs) + return render_to_response(template_name, { + 'pm_messages': msgs, # avoid 'messages', already used by contrib.messages + 'by_conversation': option is None, + 'by_message': option == OPTION_MESSAGES, + 'by_conversation_url': reverse(view_name), + 'by_message_url': reverse(view_name, args=[OPTION_MESSAGES]), + 'current_url': request.get_full_path(), + 'gets': request.GET, # useful to postman_order_by template tag + }, context_instance=RequestContext(request)) + +@login_required +def inbox(request, option=None, template_name='postman/inbox.html'): + """ + Display the list of received messages for the current user. + + Optional arguments: + ``option``: display option: + OPTION_MESSAGES to view all messages + default to None to view only the last message for each conversation + ``template_name``: the name of the template to use + + """ + return _folder(request, 'inbox', 'postman_inbox', option, template_name) + +@login_required +def sent(request, option=None, template_name='postman/sent.html'): + """ + Display the list of sent messages for the current user. + + Optional arguments: refer to inbox() + + """ + return _folder(request, 'sent', 'postman_sent', option, template_name) + +@login_required +def archives(request, option=None, template_name='postman/archives.html'): + """ + Display the list of archived messages for the current user. + + Optional arguments: refer to inbox() + + """ + return _folder(request, 'archives', 'postman_archives', option, template_name) + +@login_required +def trash(request, option=None, template_name='postman/trash.html'): + """ + Display the list of deleted messages for the current user. + + Optional arguments: refer to inbox() + + """ + return _folder(request, 'trash', 'postman_trash', option, template_name) + +def write(request, recipients=None, form_classes=(WriteForm, AnonymousWriteForm), autocomplete_channels=None, + template_name='postman/write.html', success_url=None, + user_filter=None, exchange_filter=None, max=None, auto_moderators=[]): + """ + Display a form to compose a message. + + Optional arguments: + ``recipients``: a colon-separated list of usernames + ``form_classes``: a 2-tuple of form classes + ``autocomplete_channels``: a channel name or a 2-tuple of names + ``template_name``: the name of the template to use + ``success_url``: where to redirect to after a successful POST + ``user_filter``: a filter for recipients + ``exchange_filter``: a filter for exchanges between a sender and a recipient + ``max``: an upper limit for the recipients number + ``auto_moderators``: a list of auto-moderation functions + + """ + user = request.user + form_class = form_classes[0] if user.is_authenticated() else form_classes[1] + if isinstance(autocomplete_channels, tuple) and len(autocomplete_channels) == 2: + channel = autocomplete_channels[user.is_anonymous()] + else: + channel = autocomplete_channels + next_url = _get_referer(request) + if request.method == 'POST': + form = form_class(request.POST, sender=user, channel=channel, + user_filter=user_filter, + exchange_filter=exchange_filter, + max=max) + if form.is_valid(): + is_successful = form.save(auto_moderators=auto_moderators) + if is_successful: + messages.success(request, _("Message successfully sent."), fail_silently=True) + else: + messages.warning(request, _("Message rejected for at least one recipient."), fail_silently=True) + return redirect(request.GET.get('next', success_url or next_url or 'postman_inbox')) + else: + initial = dict(request.GET.items()) # allow optional initializations by query string + if recipients: + # order_by() is not mandatory, but: a) it doesn't hurt; b) it eases the test suite + # and anyway the original ordering cannot be respected. + usernames = list(User.objects.values_list('username', flat=True).filter( + is_active=True, + username__in=[r.strip() for r in recipients.split(':') if r and not r.isspace()], + ).order_by('username')) + if usernames: + initial.update(recipients=', '.join(usernames)) + form = form_class(initial=initial, channel=channel) + return render_to_response(template_name, { + 'form': form, + 'is_autocompleted': is_autocompleted, + 'next_url': request.GET.get('next', next_url), + }, context_instance=RequestContext(request)) +if getattr(settings, 'POSTMAN_DISALLOW_ANONYMOUS', False): + write = login_required(write) + +@login_required +def reply(request, message_id, form_class=FullReplyForm, formatters=(format_subject,format_body), autocomplete_channel=None, + template_name='postman/reply.html', success_url=None, + user_filter=None, exchange_filter=None, max=None, auto_moderators=[]): + """ + Display a form to compose a reply. + + Optional arguments: + ``form_class``: the form class to use + ``formatters``: a 2-tuple of functions to prefill the subject and body fields + ``autocomplete_channel``: a channel name + ``template_name``: the name of the template to use + ``success_url``: where to redirect to after a successful POST + ``user_filter``: a filter for recipients + ``exchange_filter``: a filter for exchanges between a sender and a recipient + ``max``: an upper limit for the recipients number + ``auto_moderators``: a list of auto-moderation functions + + """ + user = request.user + perms = Message.objects.perms(user) + parent = get_object_or_404(Message, perms, pk=message_id) + initial = parent.quote(*formatters) + next_url = _get_referer(request) + if request.method == 'POST': + post = request.POST.copy() + if 'subject' not in post: # case of the quick reply form + post['subject'] = initial['subject'] + form = form_class(post, sender=user, recipient=parent.sender or parent.email, + channel=autocomplete_channel, + user_filter=user_filter, + exchange_filter=exchange_filter, + max=max) + if form.is_valid(): + is_successful = form.save(parent=parent, auto_moderators=auto_moderators) + if is_successful: + messages.success(request, _("Message successfully sent."), fail_silently=True) + else: + messages.warning(request, _("Message rejected for at least one recipient."), fail_silently=True) + return redirect(request.GET.get('next', success_url or next_url or 'postman_inbox')) + else: + initial.update(request.GET.items()) # allow overwriting of the defaults by query string + form = form_class(initial=initial, channel=autocomplete_channel) + return render_to_response(template_name, { + 'form': form, + 'recipient': parent.obfuscated_sender, + 'is_autocompleted': is_autocompleted, + 'next_url': request.GET.get('next', next_url), + }, context_instance=RequestContext(request)) + +def _view(request, filter, form_class=QuickReplyForm, formatters=(format_subject,format_body), + template_name='postman/view.html'): + """ + Code common to the by-message and by-conversation views. + + Optional arguments: + ``form_class``: the form class to use + ``formatters``: a 2-tuple of functions to prefill the subject and body fields + ``template_name``: the name of the template to use + + """ + user = request.user + msgs = Message.objects.thread(user, filter) + if msgs: + Message.objects.set_read(user, filter) + # are all messages archived ? + for m in msgs: + if not getattr(m, ('sender' if m.sender == user else 'recipient') + '_archived'): + archived = False + break + else: + archived = True + # look for the more recent received message, if any + for m in reversed(msgs): + if m.recipient == user: + received = m + break + else: + received = None + return render_to_response(template_name, { + 'pm_messages': msgs, + 'archived': archived, + 'reply_to_pk': received.pk if received else None, + 'form' : form_class(initial=received.quote(*formatters)) if received else None, + 'next_url': request.GET.get('next', reverse('postman_inbox')), + }, context_instance=RequestContext(request)) + raise Http404 + +@login_required +def view(request, message_id, *args, **kwargs): + """Display one specific message.""" + return _view(request, Q(pk=message_id), *args, **kwargs) + +@login_required +def view_conversation(request, thread_id, *args, **kwargs): + """Display a conversation.""" + return _view(request, Q(thread=thread_id), *args, **kwargs) + +def _update(request, field_bit, success_msg, field_value=None, success_url=None): + """ + Code common to the archive/delete/undelete actions. + + Arguments: + ``field_bit``: a part of the name of the field to update + ``success_msg``: the displayed text in case of success + Optional arguments: + ``field_value``: the value to set in the field + ``success_url``: where to redirect to after a successful POST + + """ + if not request.method == 'POST': + raise Http404 + next_url = _get_referer(request) or 'postman_inbox' + pks = request.POST.getlist('pks') + tpks = request.POST.getlist('tpks') + if pks or tpks: + queryset = Message.objects.filter(Q(pk__in=pks) | Q(thread__in=tpks)) + user = request.user + recipient_rows = queryset.filter(recipient=user).update(**{'recipient_{0}'.format(field_bit): field_value}) + sender_rows = queryset.filter(sender=user).update(**{'sender_{0}'.format(field_bit): field_value}) + if not (recipient_rows or sender_rows): + raise Http404 # abnormal enough, like forged ids + messages.success(request, success_msg, fail_silently=True) + return redirect(request.GET.get('next', success_url or next_url)) + else: + messages.warning(request, _("Select at least one object."), fail_silently=True) + return redirect(next_url) + +@login_required +def archive(request, *args, **kwargs): + """Mark messages/conversations as archived.""" + return _update(request, 'archived', _("Messages or conversations successfully archived."), True, *args, **kwargs) + +@login_required +def delete(request, *args, **kwargs): + """Mark messages/conversations as deleted.""" + return _update(request, 'deleted_at', _("Messages or conversations successfully deleted."), datetime.datetime.now(), *args, **kwargs) + +@login_required +def undelete(request, *args, **kwargs): + """Revert messages/conversations from marked as deleted.""" + return _update(request, 'deleted_at', _("Messages or conversations successfully recovered."), *args, **kwargs) -- 2.39.5