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
</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):
<script language="javascript" type="text/javascript">
<script type="text/javascript">
$(function() {
% if hasattr(field.renderer, 'focus_name'):
$('#${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.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))}
<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() {
var recipients = $('.recipients ul');
var recipients = $('.recipients input');
recipients.tagit({
fieldName: 'Message--recipients',
autocomplete: {
delay: 0,
minLength: 2,
@ -29,7 +37,11 @@
var label = ui.tagLabel.split(',');
var value = label.shift();
$(ui.tag).find('.tagit-hidden-field').val(value);
if (label.length) {
$(ui.tag).find('.tagit-label').text(label.join());
} else {
$(ui.tag).find('.tagit-label').text(initial_recipients[value]);
}
},
removeConfirmation: true
});
@ -41,7 +53,8 @@
</script>
<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%;
}

View file

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

View file

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

View file

@ -26,11 +26,14 @@ Message Views
from __future__ import unicode_literals, absolute_import
import pytz
from rattail import enum
from rattail.db import model
from rattail.time import localtime
import formalchemy
from formalchemy.helpers import hidden_field
from formalchemy.helpers import text_field
from pyramid import httpexceptions
from webhelpers.html import tags, HTML
@ -66,7 +69,7 @@ class RecipientsField(formalchemy.Field):
def sync(self):
if not self.is_readonly():
message = self.parent.model
for uuid in set(self._deserialize()):
for uuid in self._deserialize():
user = Session.query(model.User).get(uuid)
if user:
message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX)
@ -75,7 +78,15 @@ class RecipientsField(formalchemy.Field):
class RecipientsFieldRenderer(formalchemy.FieldRenderer):
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):
recipients = self.raw_value
@ -109,6 +120,8 @@ class MessagesView(MasterView):
editable = False
deletable = False
checkboxes = True
replying = False
reply_header_sent_format = '%a %d %b %Y at %I:%M %p'
def get_index_url(self):
# not really used, but necessary to make certain other code happy
@ -168,39 +181,108 @@ class MessagesView(MasterView):
return {'data-uuid': recip.uuid}
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):
fs.sender.set(label="From")
if self.creating:
fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer))
fs.configure(include=[
fs.sender.with_renderer(forms.renderers.UserFieldRenderer).readonly(),
fs.recipients,
fs.subject,
fs.body.textarea(size='50x10'),
fs.body.textarea(size='50x15'),
])
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.sender.with_renderer(SenderFieldRenderer).label("From"),
fs.sender.with_renderer(SenderFieldRenderer),
fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"),
fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)),
fs.subject,
fs.body.textarea(size='50x10'),
])
if self.viewing:
del fs.body # ..really?
def before_create(self, form):
message = form.fieldset.model
message.sender = self.request.user
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):
for recip in message.recipients:
if recip.recipient is self.request.user:
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):
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.
@ -243,6 +325,30 @@ class MessagesView(MasterView):
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')
# 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):
"""
@ -324,14 +430,6 @@ def includeme(config):
config.add_view(ArchiveView, attr='index', route_name='messages.archive',
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
config.add_route('messages.sent', '/messages/sent/')
config.add_view(SentView, attr='index', route_name='messages.sent',