Add support for sending new messages.

This commit is contained in:
Lance Edgar 2016-02-05 14:42:56 -06:00
parent 9d802d8f25
commit 687b83e2e1
9 changed files with 228 additions and 18 deletions

View file

@ -0,0 +1,69 @@
ul.tagit {
padding: 1px 5px;
overflow: auto;
margin-left: inherit; /* usually we don't want the regular ul margins. */
margin-right: inherit;
}
ul.tagit li {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit li.tagit-choice {
position: relative;
line-height: inherit;
}
input.tagit-hidden-field {
display: none;
}
ul.tagit li.tagit-choice-read-only {
padding: .2em .5em .2em .5em;
}
ul.tagit li.tagit-choice-editable {
padding: .2em 18px .2em .5em;
}
ul.tagit li.tagit-new {
padding: .25em 4px .25em 0;
}
ul.tagit li.tagit-choice a.tagit-label {
cursor: pointer;
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
cursor: pointer;
position: absolute;
right: .1em;
top: 50%;
margin-top: -8px;
line-height: 17px;
}
/* used for some custom themes that don't need image icons */
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: none;
}
ul.tagit li.tagit-choice input {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit input[type="text"] {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
border: none;
margin: 0;
padding: 0;
width: inherit;
background-color: inherit;
outline: none;
}

17
tailbone/static/js/lib/tag-it.min.js vendored Normal file
View file

@ -0,0 +1,17 @@
(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);

View file

@ -0,0 +1,59 @@
## -*- coding: utf-8 -*-
<%inherit file="/master/create.mako" />
<%def name="head_tags()">
${parent.head_tags()}
${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">
$(function() {
var recipients = $('.recipients ul');
recipients.tagit({
fieldName: 'Message--recipients',
autocomplete: {
delay: 0,
minLength: 2,
autoFocus: true,
source: function(request, response) {
$.get('${url('messages.recipients')}', {term: request.term}, response);
},
select: function(event, ui) {
recipients.tagit('createTag', ui.item.value + ',' + ui.item.label);
// Preventing the tag input to be updated with the chosen value.
return false;
}
},
beforeTagAdded: function(event, ui) {
var label = ui.tagLabel.split(',');
var value = label.shift();
$(ui.tag).find('.tagit-hidden-field').val(value);
$(ui.tag).find('.tagit-label').text(label.join());
},
removeConfirmation: true
});
// set focus to recipients field
recipients.data('ui-tagit').tagInput.focus();
});
</script>
<style type="text/css">
.field-wrapper.subject .field input[type="text"] {
width: 99%;
}
</style>
</%def>
<%def name="context_menu_items()">
% if request.has_perm('messages.list'):
<li>${h.link_to("Go to my Message Inbox", url('messages.inbox'))}</li>
<li>${h.link_to("Go to my Message Archive", url('messages.archive'))}</li>
<li>${h.link_to("Go to my Sent Messages", url('messages.sent'))}</li>
% endif
</%def>
${parent.body()}

View file

@ -58,4 +58,10 @@
## </style> ## </style>
</%def> </%def>
<%def name="context_menu_items()">
% if request.has_perm('messages.create'):
<li>${h.link_to("Send a new Message", url('messages.create'))}</li>
% endif
</%def>
${parent.body()} ${parent.body()}

View file

@ -1,5 +1,5 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8 -*-
<%inherit file="/master/index.mako" /> <%inherit file="/messages/index.mako" />
<%def name="title()">Sent Messages</%def> <%def name="title()">Sent Messages</%def>

View file

@ -2,6 +2,9 @@
<%inherit file="/master/view.mako" /> <%inherit file="/master/view.mako" />
<%def name="context_menu_items()"> <%def name="context_menu_items()">
% if request.has_perm('messages.create'):
<li>${h.link_to("Send a new Message", url('messages.create'))}</li>
% endif
% if recipient: % if recipient:
% if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX: % if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX:
<li>${h.link_to("Back to Message Inbox", url('messages.inbox'))}</li> <li>${h.link_to("Back to Message Inbox", url('messages.inbox'))}</li>

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2014 Lance Edgar # Copyright © 2010-2016 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -20,11 +20,12 @@
# along with Rattail. If not, see <http://www.gnu.org/licenses/>. # along with Rattail. If not, see <http://www.gnu.org/licenses/>.
# #
################################################################################ ################################################################################
""" """
Autocomplete View Autocomplete View
""" """
from __future__ import unicode_literals, absolute_import
from tailbone.views.core import View from tailbone.views.core import View
from tailbone.db import Session from tailbone.db import Session
@ -73,4 +74,4 @@ class AutocompleteView(View):
if not term: if not term:
return [] return []
results = self.query(term).all() results = self.query(term).all()
return [{u'label': self.display(x), u'value': self.value(x)} for x in results] return [{'label': self.display(x), 'value': self.value(x)} for x in results]

View file

@ -103,6 +103,7 @@ class MasterView(View):
form = self.make_form(self.model_class) form = self.make_form(self.model_class)
if self.request.method == 'POST': if self.request.method == 'POST':
if form.validate(): if form.validate():
self.before_create(form)
form.save() form.save()
instance = form.fieldset.model instance = form.fieldset.model
self.after_create(instance) self.after_create(instance)
@ -549,6 +550,12 @@ class MasterView(View):
""" """
fieldset.configure() fieldset.configure()
def before_create(self, form):
"""
Event hook, called just after the form to create a new instance has
been validated, but prior to the form itself being saved.
"""
def after_create(self, instance): def after_create(self, instance):
""" """
Event hook, called just after a new instance is saved. Event hook, called just after a new instance is saved.

View file

@ -30,12 +30,13 @@ from rattail import enum
from rattail.db import model from rattail.db import model
import formalchemy import formalchemy
from formalchemy.helpers import hidden_field
from pyramid import httpexceptions from pyramid import httpexceptions
from webhelpers.html import tags from webhelpers.html import tags, HTML
from tailbone import forms from tailbone import forms
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView from tailbone.views import MasterView, AutocompleteView
class SubjectFieldRenderer(formalchemy.FieldRenderer): class SubjectFieldRenderer(formalchemy.FieldRenderer):
@ -56,13 +57,31 @@ class SenderFieldRenderer(forms.renderers.UserFieldRenderer):
return super(SenderFieldRenderer, self).render_readonly(**kwargs) 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 set(self._deserialize()):
user = Session.query(model.User).get(uuid)
if user:
message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX)
class RecipientsFieldRenderer(formalchemy.FieldRenderer): class RecipientsFieldRenderer(formalchemy.FieldRenderer):
def render(self, **kwargs):
return HTML.tag('ul')
def render_readonly(self, **kwargs): def render_readonly(self, **kwargs):
recipients = self.raw_value recipients = self.raw_value
if not recipients: if not recipients:
return '' return ''
recips = filter(lambda r: r.recipient is not self.request.user, recipients) recips = [r for r in recipients if r.recipient is not self.request.user]
recips = sorted([r.recipient.display_name for r in recips]) recips = sorted([r.recipient.display_name for r in recips])
if len(recips) < len(recipients): if len(recips) < len(recipients):
recips.insert(0, 'you') recips.insert(0, 'you')
@ -87,7 +106,6 @@ class MessagesView(MasterView):
Base class for message views. Base class for message views.
""" """
model_class = model.Message model_class = model.Message
creatable = False
editable = False editable = False
deletable = False deletable = False
checkboxes = True checkboxes = True
@ -151,16 +169,27 @@ class MessagesView(MasterView):
return {} return {}
def configure_fieldset(self, fs): def configure_fieldset(self, fs):
fs.configure( if self.creating:
include=[ fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer))
fs.configure(include=[
fs.recipients,
fs.subject, fs.subject,
fs.body.textarea(size='50x10'),
])
else:
fs.configure(include=[
fs.sender.with_renderer(SenderFieldRenderer).label("From"), fs.sender.with_renderer(SenderFieldRenderer).label("From"),
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.body, fs.subject,
fs.body.textarea(size='50x10'),
]) ])
if self.viewing: if self.viewing:
del fs.body del fs.body # ..really?
def before_create(self, form):
message = form.fieldset.model
message.sender = self.request.user
def get_recipient(self, message): def get_recipient(self, message):
for recip in message.recipients: for recip in message.recipients:
@ -265,6 +294,22 @@ class SentView(MessagesView):
g.filters['sender'].default_active = False g.filters['sender'].default_active = False
class RecipientsAutocomplete(AutocompleteView):
"""
Autocomplete AJAX view for the recipients field when sending a new message.
"""
def query(self, term):
return Session.query(model.User)\
.join(model.Person)\
.filter(model.User.active == True)\
.filter(model.Person.display_name.ilike('%{}%'.format(term)))\
.order_by(model.Person.display_name)
def display(self, user):
return user.person.display_name
def includeme(config): def includeme(config):
config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages")
@ -279,6 +324,10 @@ 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 # move bulk
config.add_route('messages.move_bulk', '/messages/move-bulk') config.add_route('messages.move_bulk', '/messages/move-bulk')
config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk') config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk')
@ -288,10 +337,9 @@ def includeme(config):
config.add_view(SentView, attr='index', route_name='messages.sent', config.add_view(SentView, attr='index', route_name='messages.sent',
permission='messages.list') permission='messages.list')
# view # recipients autocomplete
config.add_route('messages.view', '/messages/{uuid}') config.add_route('messages.recipients', '/messages/recipients')
config.add_view(MessagesView, attr='view', route_name='messages.view') config.add_view(RecipientsAutocomplete, route_name='messages.recipients',
renderer='json', permission='messages.create')
# move (single) MessagesView.defaults(config)
config.add_route('messages.move', '/messages/{uuid}/move')
config.add_view(MessagesView, attr='move', route_name='messages.move')