# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework # Copyright © 2010-2016 Lance Edgar # # This file is part of Rattail. # # Rattail is free software: you can redistribute it and/or modify it under the # terms of the GNU Affero General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. # # Rattail is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for # more details. # # You should have received a copy of the GNU Affero General Public License # along with Rattail. If not, see . # ################################################################################ """ Message Views """ from __future__ import unicode_literals, absolute_import import json import pytz from rattail import enum from rattail.db import model from rattail.time import localtime import formalchemy from formalchemy.helpers import text_field from pyramid import httpexceptions from webhelpers.html import tags, HTML from tailbone import forms from tailbone.db import Session from tailbone.views import MasterView class SubjectFieldRenderer(formalchemy.FieldRenderer): def render_readonly(self, **kwargs): subject = self.raw_value if not subject: return '' return tags.link_to(subject, self.request.route_url('messages.view', uuid=self.field.parent.model.uuid)) class SenderFieldRenderer(forms.renderers.UserFieldRenderer): def render_readonly(self, **kwargs): sender = self.raw_value if sender is self.request.user: return 'you' return super(SenderFieldRenderer, self).render_readonly(**kwargs) class RecipientsField(formalchemy.Field): """ Custom field for recipients, used when sending new messages. """ is_collection = True def sync(self): if not self.is_readonly(): message = self.parent.model for uuid in self._deserialize(): user = Session.query(model.User).get(uuid) if user: message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX) class RecipientsFieldRenderer(formalchemy.FieldRenderer): def render(self, **kwargs): uuids = self.value value = ','.join(uuids) if uuids else '' return text_field(self.name, value=value, **kwargs) def deserialize(self): value = self.params.getone(self.name).split(',') value = [uuid.strip() for uuid in value] value = set([uuid for uuid in value if uuid]) return value def render_readonly(self, **kwargs): recipients = self.raw_value if not recipients: return '' recips = [r for r in recipients if r.recipient is not self.request.user] recips = sorted([r.recipient.display_name for r in recips]) if len(recips) < len(recipients): recips.insert(0, 'you') max_display = 5 if len(recips) > max_display: basic = HTML.literal("{}, ".format(', '.join(recips[:max_display-1]))) more = tags.link_to("({} more)".format(len(recips[max_display-1:])), '#', class_='more') everyone = HTML.tag('span', class_='everyone', c=', '.join(recips[max_display-1:])) return basic + more + everyone return ', '.join(recips) class TerseRecipientsFieldRenderer(formalchemy.FieldRenderer): def render_readonly(self, **kwargs): recipients = self.raw_value if not recipients: return '' message = self.field.parent.model recips = [r for r in recipients if r.recipient is not self.request.user] recips = sorted([r.recipient.display_name for r in recips]) if len(recips) < len(recipients) and ( message.sender is not self.request.user or not recips): recips.insert(0, 'you') if len(recips) < 5: return ', '.join(recips) return "{}, ...".format(', '.join(recips[:4])) class MessagesView(MasterView): """ Base class for message views. """ model_class = model.Message editable = False deletable = False checkboxes = True replying = False reply_header_sent_format = '%a %d %b %Y at %I:%M %p' def get_index_title(self): if self.listing: return self.index_title if self.viewing: message = self.get_instance() recipient = self.get_recipient(message) if recipient and recipient.status == enum.MESSAGE_STATUS_ARCHIVE: return "Message Archive" elif not recipient: return "Sent Messages" return "Message Inbox" def get_index_url(self): # not really used, but necessary to make certain other code happy return self.request.route_url('messages.inbox') def index(self): if not self.request.user: raise httpexceptions.HTTPForbidden return super(MessagesView, self).index() def get_instance(self): if not self.request.user: raise httpexceptions.HTTPForbidden message = super(MessagesView, self).get_instance() if not self.associated_with(message): raise httpexceptions.HTTPForbidden return message def associated_with(self, message): if message.sender is self.request.user: return True for recip in message.recipients: if recip.recipient is self.request.user: return True return False def query(self, session): return session.query(model.Message)\ .outerjoin(model.MessageRecipient)\ .filter(model.MessageRecipient.recipient == self.request.user) def configure_grid(self, g): g.joiners['sender'] = lambda q: q.join(model.User, model.User.uuid == model.Message.sender_uuid).outerjoin(model.Person) g.filters['sender'] = g.make_filter('sender', model.Person.display_name, default_active=True, default_verb='contains') g.sorters['sender'] = g.make_sorter(model.Person.display_name) g.filters['subject'].default_active = True g.filters['subject'].default_verb = 'contains' g.default_sortkey = 'sent' g.default_sortdir = 'desc' g.configure( include=[ g.subject.with_renderer(SubjectFieldRenderer), g.sender.with_renderer(SenderFieldRenderer).label("From"), g.recipients.with_renderer(TerseRecipientsFieldRenderer).label("To"), g.sent, ], readonly=True) def row_attrs(self, row, i): recip = self.get_recipient(row) if recip: return {'data-uuid': recip.uuid} return {} def make_form(self, instance, **kwargs): form = super(MessagesView, self).make_form(instance, **kwargs) if self.creating: form.id = 'new-message' form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox')) return form def configure_fieldset(self, fs): # TODO: A fair amount of this still seems hacky... if self.creating: # Must create a new 'sender' field so that we can feed it the # current user as default value, but prevent attaching user to the # (new) underlying message instance...ugh fs.append(formalchemy.Field('sender', value=self.request.user, renderer=forms.renderers.UserFieldRenderer, label="From", readonly=True)) # Sort of the same thing for recipients, although most of that logic is below. fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer)) fs.configure(include=[ fs.sender, fs.recipients, fs.subject, fs.body.textarea(size='50x15'), ]) # We'll assign some properties directly on the new message; # apparently that's safe and won't cause it to be committed. # Notably, we can't assign the sender yet. Also the actual # recipients assignment is handled by that field's sync(). message = fs.model if self.replying: old_message = self.get_instance() message.subject = "Re: {}".format(old_message.subject) message.body = self.get_reply_body(old_message) # Determine an initial set of recipients, based on reply # method. This value will be set to a 'pseudo' field to avoid # touching the new model instance and causing a crap commit. # If replying to all, massage the list a little so that the # current user is not listed, and the sender is listed first. if self.replying == 'all': value = [(r.recipient.uuid, r.recipient.person.display_name) for r in old_message.recipients if self.filter_reply_recipient(r.recipient)] value = dict(value) value.pop(self.request.user.uuid, None) value = sorted(value.iteritems(), key=lambda r: r[1]) value = [r[0] for r in value] if old_message.sender is not self.request.user and old_message.sender.active: value.insert(0, old_message.sender_uuid) fs.recipients.set(value=value) # Just a normal reply, to sender only. elif self.filter_reply_recipient(old_message.sender): fs.recipients.set(value=[old_message.sender.uuid]) # Set focus to message body instead of recipients, when replying. fs.focus = fs.body elif self.viewing: # Viewing an existing message is a heck of a lot easier... fs.configure(include=[ fs.sender.with_renderer(SenderFieldRenderer).label("From"), fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"), fs.sent, fs.subject, ]) def before_create(self, form): """ This is where we must assign the current user as sender for new messages, for now. I'm still not quite happy with this... """ super(MessagesView, self).before_create(form) message = form.fieldset.model message.sender = self.request.user def filter_reply_recipient(self, user): return user.active def get_reply_header(self, message): sent = pytz.utc.localize(message.sent) sent = localtime(self.rattail_config, sent) sent = sent.strftime(self.reply_header_sent_format) return "On {}, {} wrote:".format(sent, message.sender.person.display_name) def get_reply_body(self, message): """ Given an original message, this method should return the default body value for a "reply" message, i.e. with ">" prefixes etc. """ header = self.get_reply_header(message) lines = message.body.split('\n') if lines and lines[0]: lines.insert(0, '') lines = ['', '', '', header] + ["> {}".format(line) for line in lines] return '\n'.join(lines) def get_recipient(self, message): """ Fetch the recipient from the given message, which corresponds to the current (request) user. """ for recipient in message.recipients: if recipient.recipient is self.request.user: return recipient def template_kwargs_create(self, **kwargs): kwargs['available_recipients'] = self.get_available_recipients() kwargs['json'] = json if self.replying: kwargs['original_message'] = self.get_instance() return kwargs def get_available_recipients(self): """ Return the full mapping of recipients which may be included in a message sent by the current user. """ recips = {} users = Session.query(model.User)\ .join(model.Person)\ .filter(model.User.active == True) for user in users: recips[user.uuid] = user.person.display_name return recips def template_kwargs_view(self, **kwargs): message = kwargs['instance'] return {'message': message, 'recipient': self.get_recipient(message)} def reply(self): """ Reply to a message, i.e. create a new one with first sender as recipient. """ self.replying = True return self.create() def reply_all(self): """ Reply-all to a message, i.e. create a new one with all original recipients listed again in the new message. """ self.replying = 'all' return self.create() def move(self): """ Move a message, either to the archive or back to the inbox. """ message = self.get_instance() recipient = self.get_recipient(message) if not recipient: raise httpexceptions.HTTPForbidden dest = self.request.GET.get('dest') if dest not in ('inbox', 'archive'): self.request.session.flash("Sorry, I couldn't make sense out of that request.") return self.redirect(self.request.get_referrer( default=self.request.route_url('messages_inbox'))) new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE if recipient.status != new_status: recipient.status = new_status return self.redirect(self.request.route_url('messages.{}'.format( 'archive' if dest == 'inbox' else 'inbox'))) def move_bulk(self): """ Move messages in bulk, to the archive or back to the inbox. """ dest = self.request.POST.get('destination', 'archive') if self.request.method == 'POST': uuids = self.request.POST.get('uuids', '').split(',') if uuids: new_status = enum.MESSAGE_STATUS_INBOX if dest == 'inbox' else enum.MESSAGE_STATUS_ARCHIVE for uuid in uuids: recip = Session.query(model.MessageRecipient).get(uuid) if uuid else None if recip and recip.recipient is self.request.user: if recip.status != new_status: recip.status = new_status route = 'messages.{}'.format('archive' if dest == 'inbox' else 'inbox') return self.redirect(self.request.route_url(route)) @classmethod def defaults(cls, config): """ Extra default config for message views. """ # reply config.add_route('messages.reply', '/messages/{uuid}/reply') config.add_view(cls, attr='reply', route_name='messages.reply', permission='messages.create') # reply-all config.add_route('messages.reply_all', '/messages/{uuid}/reply-all') config.add_view(cls, attr='reply_all', route_name='messages.reply_all', permission='messages.create') # move (single) config.add_route('messages.move', '/messages/{uuid}/move') config.add_view(cls, attr='move', route_name='messages.move') # move bulk config.add_route('messages.move_bulk', '/messages/move-bulk') config.add_view(cls, attr='move_bulk', route_name='messages.move_bulk') cls._defaults(config) class InboxView(MessagesView): """ Inbox message view. """ url_prefix = '/messages/inbox' grid_key = 'messages.inbox' index_title = "Message Inbox" def get_index_url(self): return self.request.route_url('messages.inbox') def query(self, session): q = super(InboxView, self).query(session) return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_INBOX) class ArchiveView(MessagesView): """ Archived message view. """ url_prefix = '/messages/archive' grid_key = 'messages.archive' index_title = "Message Archive" def get_index_url(self): return self.request.route_url('messages.archive') def query(self, session): q = super(ArchiveView, self).query(session) return q.filter(model.MessageRecipient.status == enum.MESSAGE_STATUS_ARCHIVE) class SentView(MessagesView): """ Sent messages view. """ url_prefix = '/messages/sent' grid_key = 'messages.sent' checkboxes = False index_title = "Sent Messages" def get_index_url(self): return self.request.route_url('messages.sent') def query(self, session): return session.query(model.Message)\ .filter(model.Message.sender == self.request.user) def configure_grid(self, g): super(SentView, self).configure_grid(g) g.filters['sender'].default_active = False def includeme(config): config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") # inbox config.add_route('messages.inbox', '/messages/inbox/') config.add_view(InboxView, attr='index', route_name='messages.inbox', permission='messages.list') # archive config.add_route('messages.archive', '/messages/archive/') config.add_view(ArchiveView, attr='index', route_name='messages.archive', permission='messages.list') # sent config.add_route('messages.sent', '/messages/sent/') config.add_view(SentView, attr='index', route_name='messages.sent', permission='messages.list') MessagesView.defaults(config)