Add initial reply / reply-all support for messages.

This commit is contained in:
Lance Edgar 2016-02-10 21:59:13 -06:00
parent 415fc439b7
commit 46923d40da
5 changed files with 154 additions and 34 deletions

View file

@ -21,9 +21,9 @@
% endif % endif
</div> </div>
% if not _focus_rendered and (fieldset.focus == field or fieldset.focus is True): % if not _focus_rendered and (fieldset.focus is True or fieldset.focus is field):
% if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True): % if not field.is_readonly() and getattr(field.renderer, 'needs_focus', True):
<script language="javascript" type="text/javascript"> <script type="text/javascript">
$(function() { $(function() {
% if hasattr(field.renderer, 'focus_name'): % if hasattr(field.renderer, 'focus_name'):
$('#${field.renderer.focus_name}').focus(); $('#${field.renderer.focus_name}').focus();

View file

@ -6,12 +6,20 @@
${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))}
<script type="text/javascript"> <script type="text/javascript">
% if initial_recipient_names is not Undefined:
var initial_recipients = {
% for i, (uuid, name) in enumerate(initial_recipient_names.iteritems(), 1):
'${uuid}': "${name.replace('"', '\\""')|n}"${',' if i < len(initial_recipient_names) else ''}
% endfor
};
% endif
$(function() { $(function() {
var recipients = $('.recipients ul'); var recipients = $('.recipients input');
recipients.tagit({ recipients.tagit({
fieldName: 'Message--recipients',
autocomplete: { autocomplete: {
delay: 0, delay: 0,
minLength: 2, minLength: 2,
@ -29,7 +37,11 @@
var label = ui.tagLabel.split(','); var label = ui.tagLabel.split(',');
var value = label.shift(); var value = label.shift();
$(ui.tag).find('.tagit-hidden-field').val(value); $(ui.tag).find('.tagit-hidden-field').val(value);
$(ui.tag).find('.tagit-label').text(label.join()); if (label.length) {
$(ui.tag).find('.tagit-label').text(label.join());
} else {
$(ui.tag).find('.tagit-label').text(initial_recipients[value]);
}
}, },
removeConfirmation: true removeConfirmation: true
}); });
@ -41,7 +53,8 @@
</script> </script>
<style type="text/css"> <style type="text/css">
.field-wrapper.subject .field input[type="text"] { .field-wrapper.subject .field input[type="text"],
.field-wrapper.body .field textarea {
width: 99%; width: 99%;
} }

View file

@ -11,7 +11,7 @@
border-top: 1px solid black; border-top: 1px solid black;
border-bottom: 1px solid black; border-bottom: 1px solid black;
margin-bottom: 15px; margin-bottom: 15px;
padding-top: 15px; white-space: pre-line;
} }
.message-body p { .message-body p {
margin-bottom: 15px; margin-bottom: 15px;
@ -43,6 +43,8 @@
<%def name="message_tools()"> <%def name="message_tools()">
% if recipient: % if recipient:
<div class="message-tools"> <div class="message-tools">
${h.link_to("Reply", url('messages.reply', uuid=instance.uuid), class_='button')}
${h.link_to("Reply to All", url('messages.reply_all', uuid=instance.uuid), class_='button')}
% if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX: % if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX:
${h.link_to("Move to Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive', class_='button')} ${h.link_to("Move to Archive", url('messages.move', uuid=instance.uuid) + '?dest=archive', class_='button')}
% else: % else:

View file

@ -32,8 +32,8 @@ from sqlalchemy import orm
from edbob.util import prettify from edbob.util import prettify
import formalchemy import formalchemy
from pyramid import httpexceptions
from pyramid.renderers import get_renderer, render_to_response from pyramid.renderers import get_renderer, render_to_response
from pyramid.httpexceptions import HTTPException, HTTPFound, HTTPNotFound
from tailbone import forms from tailbone import forms
from tailbone.views import View from tailbone.views import View
@ -107,9 +107,9 @@ class MasterView(View):
form.save() form.save()
instance = form.fieldset.model instance = form.fieldset.model
self.after_create(instance) self.after_create(instance)
self.request.session.flash("{0} {1} has been created.".format( self.request.session.flash("{} {} has been created.".format(
self.get_model_title(), instance)) self.get_model_title(), instance))
return HTTPFound(location=self.get_action_url('view', instance)) return self.redirect(self.get_action_url('view', instance))
return self.render_to_response('create', {'form': form}) return self.render_to_response('create', {'form': form})
def view(self): def view(self):
@ -136,7 +136,7 @@ class MasterView(View):
self.after_edit(instance) self.after_edit(instance)
self.request.session.flash("{0} {1} has been updated.".format( self.request.session.flash("{0} {1} has been updated.".format(
self.get_model_title(), self.get_instance_title(instance))) self.get_model_title(), self.get_instance_title(instance)))
return HTTPFound(location=self.get_action_url('view', instance)) return self.redirect(self.get_action_url('view', instance))
return self.render_to_response('edit', {'instance': instance, return self.render_to_response('edit', {'instance': instance,
'instance_title': self.get_instance_title(instance), 'instance_title': self.get_instance_title(instance),
'form': form}) 'form': form})
@ -160,7 +160,7 @@ class MasterView(View):
# Let derived classes prep for (or cancel) deletion. # Let derived classes prep for (or cancel) deletion.
result = self.before_delete(instance) result = self.before_delete(instance)
if isinstance(result, HTTPException): if isinstance(result, httpexceptions.HTTPException):
return result return result
self.delete_instance(instance) self.delete_instance(instance)
@ -308,7 +308,7 @@ class MasterView(View):
""" """
Convenience method to return a HTTP 302 response. Convenience method to return a HTTP 302 response.
""" """
return HTTPFound(location=url) return httpexceptions.HTTPFound(location=url)
############################## ##############################
# Grid Stuff # Grid Stuff
@ -500,7 +500,7 @@ class MasterView(View):
key = self.request.matchdict[self.get_model_key()] key = self.request.matchdict[self.get_model_key()]
instance = self.Session.query(self.model_class).get(key) instance = self.Session.query(self.model_class).get(key)
if not instance: if not instance:
raise HTTPNotFound() raise httpexceptions.HTTPNotFound()
return instance return instance
def get_instance_title(self, instance): def get_instance_title(self, instance):
@ -605,6 +605,13 @@ class MasterView(View):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
"""
Provide default configuration for a master view.
"""
return cls._defaults(config)
@classmethod
def _defaults(cls, config):
""" """
Provide default configuration for a master view. Provide default configuration for a master view.
""" """

View file

@ -26,11 +26,14 @@ Message Views
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import pytz
from rattail import enum from rattail import enum
from rattail.db import model from rattail.db import model
from rattail.time import localtime
import formalchemy import formalchemy
from formalchemy.helpers import hidden_field from formalchemy.helpers import text_field
from pyramid import httpexceptions from pyramid import httpexceptions
from webhelpers.html import tags, HTML from webhelpers.html import tags, HTML
@ -66,7 +69,7 @@ class RecipientsField(formalchemy.Field):
def sync(self): def sync(self):
if not self.is_readonly(): if not self.is_readonly():
message = self.parent.model message = self.parent.model
for uuid in set(self._deserialize()): for uuid in self._deserialize():
user = Session.query(model.User).get(uuid) user = Session.query(model.User).get(uuid)
if user: if user:
message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX) message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX)
@ -75,7 +78,15 @@ class RecipientsField(formalchemy.Field):
class RecipientsFieldRenderer(formalchemy.FieldRenderer): class RecipientsFieldRenderer(formalchemy.FieldRenderer):
def render(self, **kwargs): def render(self, **kwargs):
return HTML.tag('ul') uuids = self.value
value = ','.join(uuids) if uuids else ''
return text_field(self.name, value=value, maxlength=self.length, **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): def render_readonly(self, **kwargs):
recipients = self.raw_value recipients = self.raw_value
@ -109,6 +120,8 @@ class MessagesView(MasterView):
editable = False editable = False
deletable = False deletable = False
checkboxes = True checkboxes = True
replying = False
reply_header_sent_format = '%a %d %b %Y at %I:%M %p'
def get_index_url(self): def get_index_url(self):
# not really used, but necessary to make certain other code happy # not really used, but necessary to make certain other code happy
@ -168,39 +181,108 @@ class MessagesView(MasterView):
return {'data-uuid': recip.uuid} return {'data-uuid': recip.uuid}
return {} return {}
def make_form(self, instance, **kwargs):
form = super(MessagesView, self).make_form(instance, **kwargs)
if self.creating:
form.cancel_url = self.request.get_referrer(default=self.request.route_url('messages.inbox'))
return form
def configure_fieldset(self, fs): def configure_fieldset(self, fs):
fs.sender.set(label="From")
if self.creating: if self.creating:
fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer)) fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer))
fs.configure(include=[ fs.configure(include=[
fs.sender.with_renderer(forms.renderers.UserFieldRenderer).readonly(),
fs.recipients, fs.recipients,
fs.subject, fs.subject,
fs.body.textarea(size='50x10'), fs.body.textarea(size='50x15'),
]) ])
else: message = fs.model
message.sender = self.request.user
if self.replying:
old_message = self.get_instance()
message.subject = "Re: {}".format(old_message.subject)
message.body = self.get_reply_body(old_message)
if self.replying == 'all':
value = [(r.recipient_uuid, r.recipient.person.display_name)
for r in old_message.recipients
if r.recipient.active]
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)
else:
fs.recipients.set(value=[old_message.sender_uuid])
fs.focus = fs.body
elif self.viewing:
fs.configure(include=[ fs.configure(include=[
fs.sender.with_renderer(SenderFieldRenderer).label("From"), fs.sender.with_renderer(SenderFieldRenderer),
fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"), fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"),
fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)), fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)),
fs.subject, fs.subject,
fs.body.textarea(size='50x10'),
]) ])
if self.viewing:
del fs.body # ..really?
def before_create(self, form): def get_reply_header(self, message):
message = form.fieldset.model sent = pytz.utc.localize(message.sent)
message.sender = self.request.user 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): def get_recipient(self, message):
for recip in message.recipients: for recip in message.recipients:
if recip.recipient is self.request.user: if recip.recipient is self.request.user:
return recip return recip
def template_kwargs_create(self, **kwargs):
if self.replying:
message = self.get_instance()
kwargs['original_message'] = message
names = {message.sender_uuid: message.sender.person.display_name}
if self.replying == 'all':
for recip in message.recipients:
if recip.recipient is not message.sender:
names[recip.recipient_uuid] = recip.recipient.person.display_name
kwargs['initial_recipient_names'] = names
return kwargs
def template_kwargs_view(self, **kwargs): def template_kwargs_view(self, **kwargs):
message = kwargs['instance'] message = kwargs['instance']
return {'message': message, return {'message': message,
'recipient': self.get_recipient(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): def move(self):
""" """
Move a message, either to the archive or back to the inbox. Move a message, either to the archive or back to the inbox.
@ -243,6 +325,30 @@ class MessagesView(MasterView):
route = 'messages.{}'.format('archive' if dest == 'inbox' else 'inbox') route = 'messages.{}'.format('archive' if dest == 'inbox' else 'inbox')
return self.redirect(self.request.route_url(route)) 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')
# reply-all
config.add_route('messages.reply_all', '/messages/{uuid}/reply-all')
config.add_view(cls, attr='reply_all', route_name='messages.reply_all')
# 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): class InboxView(MessagesView):
""" """
@ -324,14 +430,6 @@ def includeme(config):
config.add_view(ArchiveView, attr='index', route_name='messages.archive', config.add_view(ArchiveView, attr='index', route_name='messages.archive',
permission='messages.list') permission='messages.list')
# move (single)
config.add_route('messages.move', '/messages/{uuid}/move')
config.add_view(MessagesView, attr='move', route_name='messages.move')
# move bulk
config.add_route('messages.move_bulk', '/messages/move-bulk')
config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk')
# sent # sent
config.add_route('messages.sent', '/messages/sent/') config.add_route('messages.sent', '/messages/sent/')
config.add_view(SentView, attr='index', route_name='messages.sent', config.add_view(SentView, attr='index', route_name='messages.sent',