From: Matthijs Kooijman Date: Mon, 15 Jun 2009 10:01:42 +0000 (+0200) Subject: Merge branch 'production' X-Git-Url: https://git.stderr.nl/gitweb?p=matthijs%2Fprojects%2Fxerxes.git;a=commitdiff_plain;h=884bf66b55546555bfd3df318b9318e8358b02cf;hp=86b65f4079fac913522a51968bd0ef73c903427a Merge branch 'production' * production: Remove SQL password from import.py. Add code to support disabling new influences. Remove \r's. Only notify a player of finished influence changes. --- diff --git a/influences/admin.py b/influences/admin.py index 8abe6cc..487b237 100644 --- a/influences/admin.py +++ b/influences/admin.py @@ -18,9 +18,9 @@ class CharacterAdmin(admin.ModelAdmin): admin.site.register(Character, CharacterAdmin) class InfluenceAdmin(admin.ModelAdmin): - list_filter=('character', 'status', 'longterm', 'todo') - search_fields=('character', 'summary', 'description', 'contact') - list_display=('character', 'contact', 'summary', 'longterm', 'status') + list_filter=('initiator', 'status', 'longterm', 'todo') + search_fields=('initiator', 'summary', 'description', 'contact') + list_display=('initiator', 'summary', 'longterm', 'status') class Media: js = ('base/js/yahoo-dom-event.js', 'base/js/logger-debug.js') diff --git a/influences/forms.py b/influences/forms.py index a060368..db825cc 100644 --- a/influences/forms.py +++ b/influences/forms.py @@ -1,7 +1,9 @@ from django.forms.fields import CharField, BooleanField from django.forms.widgets import Textarea +from django.forms.models import ModelMultipleChoiceField from threadedcomments.forms import ThreadedCommentForm from xerxes.tools.forms import ContextModelForm +from xerxes.tools.widgets import DropDownMultiple from models import Influence, Character # @@ -66,12 +68,17 @@ def _get_influence_comment_form(allow_markup, allow_public, allow_private): raise Exception("Unsupported configuration") class InfluenceForm(ContextModelForm): + # Manually define this field so we can select the DropDownMultiple + # widget. However, we leave the queryset empty, which characters can + # be selected depends on the logged in user and should be set by + # setting the choices property in the view. + other_characters = ModelMultipleChoiceField(queryset=Character.objects.none(), widget=DropDownMultiple) class Meta: model = Influence - fields = ('character', 'contact', 'summary', 'description') + fields = ('initiator', 'summary', 'other_characters', 'other_contacts', 'description') class CharacterForm(ContextModelForm): class Meta: model = Character - fields = ('name') + fields = ('name', 'type') diff --git a/influences/models.py b/influences/models.py index 6464e68..4c14f7c 100644 --- a/influences/models.py +++ b/influences/models.py @@ -5,18 +5,31 @@ from django.utils.text import normalize_newlines from django.utils.translation import ugettext_lazy as _ from threadedcomments.models import ThreadedComment from xerxes.tools.text import rewrap +from string import strip # Create your models here. class Character(models.Model): + NEW = 'N' + APPROVED = 'A' STATUS_CHOICES = ( - ('N', _('New')), - ('A', _('Approved')), + (NEW, _('New')), + (APPROVED, _('Approved')), + ) + PLAYER = 'P' + NPC = 'N' + CONTACT = 'C' + TYPE_CHOICES = ( + (PLAYER, _('Player')), + (NPC, _('NPC')), + (CONTACT, _('Contact')), ) created = models.DateField(auto_now_add=1, verbose_name = _("Creation time")) modified = models.DateField(auto_now=1, verbose_name = _("Modification time")) name = models.CharField(max_length=255, verbose_name = _("Name")) - status = models.CharField(max_length=2, choices=STATUS_CHOICES, default='N', verbose_name = _("Status")) + status = models.CharField(max_length=2, choices=STATUS_CHOICES, default=NEW, verbose_name = _("Status")) player = models.ForeignKey(User, verbose_name = _("Player")) + contacts = models.ManyToManyField('self', blank = True) + type = models.CharField(max_length=2, choices=TYPE_CHOICES, verbose_name=_("Type")) def __unicode__(self): return self.name @@ -29,21 +42,26 @@ class Character(models.Model): verbose_name_plural = _("Characters") class Influence(models.Model): + NEW = 'N' + DISCUSSING = 'U' + PROCESSING = 'P' + DONE = 'D' STATUS_CHOICES = ( - ('N', _('New')), - ('U', _('Under discussion')), - ('P', _('Processing')), - ('D', _('Done')), + (NEW, _('New')), + (DISCUSSING, _('Under discussion')), + (PROCESSING, _('Processing')), + (DONE, _('Done')), ) created = models.DateField(auto_now_add=1, verbose_name = _("Creation time")) modified = models.DateField(auto_now=1, verbose_name = _("Modification time")) - character = models.ForeignKey(Character, verbose_name = _("Character")) - contact = models.CharField(max_length=255, verbose_name = _("Contact Name")) + initiator = models.ForeignKey(Character, verbose_name = _("Initiator"), related_name='initiated_influences') + other_contacts = models.CharField(max_length=255, blank = True, verbose_name = _("Other Contacts")) + other_characters = models.ManyToManyField(Character, blank = True, verbose_name = _("Involved characters"), related_name='influences_involved_in') summary = models.CharField(max_length=255, verbose_name = _("Summary")) description = models.TextField(verbose_name = _("Description")) todo = models.TextField(blank=True, verbose_name = _("Todo")) - status = models.CharField(max_length=1, choices=STATUS_CHOICES, default='N', verbose_name = _("Status")) + status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=NEW, verbose_name = _("Status")) longterm = models.BooleanField(default=False, verbose_name = _("Long term")) result = models.TextField(blank=True,verbose_name = _("Result")) @@ -85,6 +103,31 @@ class Influence(models.Model): prefix=prefix) return comments + @property + def involved(self): + """ Returns the Characters and contacts (strings) involved """ + chars = list(self.other_characters.all()) + if (self.other_contacts): + chars.extend(map(strip,self.other_contacts.split(','))) + return chars + + @property + def related_players(self): + """ Returns all players to this Influence (ie, the players of the + initiator or involved characters). Returns a dict where the + players (User objects) are keys and a list of Character objects + for which this player is related is the value. + """ + players = {self.initiator.player : [self.initiator]} + for char in self.other_characters.all(): + # Add this character to the player's list of characters for + # this Influence, creating a new list if this is the first + # character. + chars = players.get(char.player, []) + chars.append(char) + players[char.player] = chars + return players + class Meta: verbose_name = _("Influence") verbose_name_plural = _("Influences") diff --git a/influences/notify.py b/influences/notify.py index daffadf..6aae6f2 100644 --- a/influences/notify.py +++ b/influences/notify.py @@ -17,6 +17,8 @@ signals.post_save.connect(character_saved, sender=Character) def influence_saved(**kwargs): instance = kwargs['instance'] created = kwargs['created'] + recipients = ['lextalionis@evolution-events.nl'] + recipients.extend(instance.related_players.keys()) if (not settings.DEBUG): recipients = ['lextalionis@evolution-events.nl'] if instance.status == 'D': @@ -39,7 +41,7 @@ def comment_saved(**kwargs): if isinstance(object, Influence): recipients = ['lextalionis@evolution-events.nl'] if comment.is_public: - recipients.append(object.character.player) + recipients.extend(object.related_players.keys()) notify( recipients, diff --git a/influences/views.py b/influences/views.py index 7f7b262..8b2961b 100644 --- a/influences/views.py +++ b/influences/views.py @@ -14,27 +14,40 @@ from threadedcomments.views import free_comment, _preview from xerxes.influences.models import Character from xerxes.influences.models import Influence from forms import get_influence_comment_form, InfluenceForm, CharacterForm +from xerxes.tools.misc import make_choices @login_required def add_influence(request, character_id=None): initial = {} # Get the current user's characters - chars = request.user.character_set.all() + my_chars = request.user.character_set.all().filter(type__in=[Character.PLAYER, Character.NPC]) + # Get all chars + all_chars = Character.objects.all().filter(type__in=[Character.PLAYER, Character.NPC]) # If a character_id was specified in the url, or there is only one # character, preselect it. if (character_id): - initial['character'] = character_id - elif (chars.count() == 1): - initial['character'] = chars[0].id - + initial['initiator'] = character_id + elif (my_chars.count() == 1): + initial['initiator'] = my_chars[0].id f = InfluenceForm(request=request, initial=initial) # Only allow characters of the current user. Putting this here also # ensures that a form will not validate when any other choice was # selected (perhaps through URL crafting). - f.fields['character']._set_queryset(chars) + f.fields['initiator']._set_queryset(my_chars) + + # List the contacts of each of the current users characters, as well + # as all other (non-contact) characters as choices for the + # other_characters field. + char_choices = [ + ("Contacts of %s" % c, make_choices(c.contacts.all())) + for c in my_chars + if c.contacts.all() + ] + char_choices.append(('All player characters', make_choices(all_chars))) + f.fields['other_characters'].choices = char_choices if (f.is_valid()): # The form was submitted, let's save it. @@ -59,7 +72,7 @@ def add_character(request): def index(request): # Only show this player's characters and influences characters = request.user.character_set.all() - influences = Influence.objects.filter(character__player=request.user) + influences = Influence.objects.filter(initiator__player=request.user) return render_to_response('influences/index.html', {'characters' : characters, 'influences' : influences}, RequestContext(request)) # @@ -85,9 +98,9 @@ def character_detail(request, object_id): @login_required def influence_list(request): - # Only show this player's influences - os = Influence.objects.filter(character__player=request.user) - return render_to_response('influences/influence_list.html', {'object_list' : os}, RequestContext(request)) + # Only show the influences related to this player's characters + characters = request.user.character_set.all() + return render_to_response('influences/influence_list.html', {'characters' : characters}, RequestContext(request)) def influence_comment_preview(request, context_processors, extra_context, **kwargs): # Use a custom template @@ -101,8 +114,8 @@ def influence_detail(request, object_id): o = Influence.objects.get(pk=object_id) # Don't show other player's influences - if (not request.user.is_staff and o.character.player != request.user): - return HttpResponseForbidden("Forbidden -- Trying to view influences of somebody else's character") + if (not request.user.is_staff and not request.user in o.related_players): + return HttpResponseForbidden("Forbidden -- Trying to view influences you are not involved in.") # Show all comments to staff, but only public comments to other # users diff --git a/media/base/js/jquery-1.3.1.js b/media/base/js/jquery-1.3.1.js new file mode 100644 index 0000000..94e9c17 --- /dev/null +++ b/media/base/js/jquery-1.3.1.js @@ -0,0 +1,4241 @@ +/*! + * jQuery JavaScript Library v1.3.1 + * http://jquery.com/ + * + * Copyright (c) 2009 John Resig + * Dual licensed under the MIT and GPL licenses. + * http://docs.jquery.com/License + * + * Date: 2009-01-21 20:42:16 -0500 (Wed, 21 Jan 2009) + * Revision: 6158 + */ +(function(){ + +var + // Will speed up references to window, and allows munging its name. + window = this, + // Will speed up references to undefined, and allows munging its name. + undefined, + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + // Map over the $ in case of overwrite + _$ = window.$, + + jQuery = window.jQuery = window.$ = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context ); + }, + + // A simple way to check for HTML strings or ID strings + // (both of which we optimize for) + quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#([\w-]+)$/, + // Is it a simple selector + isSimple = /^.[^:#\[\.,]*$/; + +jQuery.fn = jQuery.prototype = { + init: function( selector, context ) { + // Make sure that a selection was provided + selector = selector || document; + + // Handle $(DOMElement) + if ( selector.nodeType ) { + this[0] = selector; + this.length = 1; + this.context = selector; + return this; + } + // Handle HTML strings + if ( typeof selector === "string" ) { + // Are we dealing with HTML string or an ID? + var match = quickExpr.exec( selector ); + + // Verify a match, and that no context was specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) + selector = jQuery.clean( [ match[1] ], context ); + + // HANDLE: $("#id") + else { + var elem = document.getElementById( match[3] ); + + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem && elem.id != match[3] ) + return jQuery().find( selector ); + + // Otherwise, we inject the element directly into the jQuery object + var ret = jQuery( elem || [] ); + ret.context = document; + ret.selector = selector; + return ret; + } + + // HANDLE: $(expr, [context]) + // (which is just equivalent to: $(content).find(expr) + } else + return jQuery( context ).find( selector ); + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) + return jQuery( document ).ready( selector ); + + // Make sure that old selector state is passed along + if ( selector.selector && selector.context ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return this.setArray(jQuery.makeArray(selector)); + }, + + // Start with an empty selector + selector: "", + + // The current version of jQuery being used + jquery: "1.3.1", + + // The number of elements contained in the matched element set + size: function() { + return this.length; + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num === undefined ? + + // Return a 'clean' array + jQuery.makeArray( this ) : + + // Return just the object + this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems, name, selector ) { + // Build a new jQuery matched element set + var ret = jQuery( elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + ret.context = this.context; + + if ( name === "find" ) + ret.selector = this.selector + (this.selector ? " " : "") + selector; + else if ( name ) + ret.selector = this.selector + "." + name + "(" + selector + ")"; + + // Return the newly-formed element set + return ret; + }, + + // Force the current matched set of elements to become + // the specified array of elements (destroying the stack in the process) + // You should use pushStack() in order to do this, but maintain the stack + setArray: function( elems ) { + // Resetting the length to 0, then using the native Array push + // is a super-fast way to populate an object with array-like properties + this.length = 0; + Array.prototype.push.apply( this, elems ); + + return this; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem && elem.jquery ? elem[0] : elem + , this ); + }, + + attr: function( name, value, type ) { + var options = name; + + // Look for the case where we're accessing a style value + if ( typeof name === "string" ) + if ( value === undefined ) + return this[0] && jQuery[ type || "attr" ]( this[0], name ); + + else { + options = {}; + options[ name ] = value; + } + + // Check to see if we're setting style values + return this.each(function(i){ + // Set all the styles + for ( name in options ) + jQuery.attr( + type ? + this.style : + this, + name, jQuery.prop( this, options[ name ], type, i, name ) + ); + }); + }, + + css: function( key, value ) { + // ignore negative width and height values + if ( (key == 'width' || key == 'height') && parseFloat(value) < 0 ) + value = undefined; + return this.attr( key, value, "curCSS" ); + }, + + text: function( text ) { + if ( typeof text !== "object" && text != null ) + return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); + + var ret = ""; + + jQuery.each( text || this, function(){ + jQuery.each( this.childNodes, function(){ + if ( this.nodeType != 8 ) + ret += this.nodeType != 1 ? + this.nodeValue : + jQuery.fn.text( [ this ] ); + }); + }); + + return ret; + }, + + wrapAll: function( html ) { + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).clone(); + + if ( this[0].parentNode ) + wrap.insertBefore( this[0] ); + + wrap.map(function(){ + var elem = this; + + while ( elem.firstChild ) + elem = elem.firstChild; + + return elem; + }).append(this); + } + + return this; + }, + + wrapInner: function( html ) { + return this.each(function(){ + jQuery( this ).contents().wrapAll( html ); + }); + }, + + wrap: function( html ) { + return this.each(function(){ + jQuery( this ).wrapAll( html ); + }); + }, + + append: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.appendChild( elem ); + }); + }, + + prepend: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.insertBefore( elem, this.firstChild ); + }); + }, + + before: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this ); + }); + }, + + after: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this.nextSibling ); + }); + }, + + end: function() { + return this.prevObject || jQuery( [] ); + }, + + // For internal use only. + // Behaves like an Array's .push method, not like a jQuery method. + push: [].push, + + find: function( selector ) { + if ( this.length === 1 && !/,/.test(selector) ) { + var ret = this.pushStack( [], "find", selector ); + ret.length = 0; + jQuery.find( selector, this[0], ret ); + return ret; + } else { + var elems = jQuery.map(this, function(elem){ + return jQuery.find( selector, elem ); + }); + + return this.pushStack( /[^+>] [^+>]/.test( selector ) ? + jQuery.unique( elems ) : + elems, "find", selector ); + } + }, + + clone: function( events ) { + // Do the clone + var ret = this.map(function(){ + if ( !jQuery.support.noCloneEvent && !jQuery.isXMLDoc(this) ) { + // IE copies events bound via attachEvent when + // using cloneNode. Calling detachEvent on the + // clone will also remove the events from the orignal + // In order to get around this, we use innerHTML. + // Unfortunately, this means some modifications to + // attributes in IE that are actually only stored + // as properties will not be copied (such as the + // the name attribute on an input). + var clone = this.cloneNode(true), + container = document.createElement("div"); + container.appendChild(clone); + return jQuery.clean([container.innerHTML])[0]; + } else + return this.cloneNode(true); + }); + + // Need to set the expando to null on the cloned set if it exists + // removeData doesn't work here, IE removes it from the original as well + // this is primarily for IE but the data expando shouldn't be copied over in any browser + var clone = ret.find("*").andSelf().each(function(){ + if ( this[ expando ] !== undefined ) + this[ expando ] = null; + }); + + // Copy the events from the original to the clone + if ( events === true ) + this.find("*").andSelf().each(function(i){ + if (this.nodeType == 3) + return; + var events = jQuery.data( this, "events" ); + + for ( var type in events ) + for ( var handler in events[ type ] ) + jQuery.event.add( clone[ i ], type, events[ type ][ handler ], events[ type ][ handler ].data ); + }); + + // Return the cloned set + return ret; + }, + + filter: function( selector ) { + return this.pushStack( + jQuery.isFunction( selector ) && + jQuery.grep(this, function(elem, i){ + return selector.call( elem, i ); + }) || + + jQuery.multiFilter( selector, jQuery.grep(this, function(elem){ + return elem.nodeType === 1; + }) ), "filter", selector ); + }, + + closest: function( selector ) { + var pos = jQuery.expr.match.POS.test( selector ) ? jQuery(selector) : null; + + return this.map(function(){ + var cur = this; + while ( cur && cur.ownerDocument ) { + if ( pos ? pos.index(cur) > -1 : jQuery(cur).is(selector) ) + return cur; + cur = cur.parentNode; + } + }); + }, + + not: function( selector ) { + if ( typeof selector === "string" ) + // test special case where just one selector is passed in + if ( isSimple.test( selector ) ) + return this.pushStack( jQuery.multiFilter( selector, this, true ), "not", selector ); + else + selector = jQuery.multiFilter( selector, this ); + + var isArrayLike = selector.length && selector[selector.length - 1] !== undefined && !selector.nodeType; + return this.filter(function() { + return isArrayLike ? jQuery.inArray( this, selector ) < 0 : this != selector; + }); + }, + + add: function( selector ) { + return this.pushStack( jQuery.unique( jQuery.merge( + this.get(), + typeof selector === "string" ? + jQuery( selector ) : + jQuery.makeArray( selector ) + ))); + }, + + is: function( selector ) { + return !!selector && jQuery.multiFilter( selector, this ).length > 0; + }, + + hasClass: function( selector ) { + return !!selector && this.is( "." + selector ); + }, + + val: function( value ) { + if ( value === undefined ) { + var elem = this[0]; + + if ( elem ) { + if( jQuery.nodeName( elem, 'option' ) ) + return (elem.attributes.value || {}).specified ? elem.value : elem.text; + + // We need to handle select boxes special + if ( jQuery.nodeName( elem, "select" ) ) { + var index = elem.selectedIndex, + values = [], + options = elem.options, + one = elem.type == "select-one"; + + // Nothing was selected + if ( index < 0 ) + return null; + + // Loop through all the selected options + for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) { + var option = options[ i ]; + + if ( option.selected ) { + // Get the specifc value for the option + value = jQuery(option).val(); + + // We don't need an array for one selects + if ( one ) + return value; + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + } + + // Everything else, we just grab the value + return (elem.value || "").replace(/\r/g, ""); + + } + + return undefined; + } + + if ( typeof value === "number" ) + value += ''; + + return this.each(function(){ + if ( this.nodeType != 1 ) + return; + + if ( jQuery.isArray(value) && /radio|checkbox/.test( this.type ) ) + this.checked = (jQuery.inArray(this.value, value) >= 0 || + jQuery.inArray(this.name, value) >= 0); + + else if ( jQuery.nodeName( this, "select" ) ) { + var values = jQuery.makeArray(value); + + jQuery( "option", this ).each(function(){ + this.selected = (jQuery.inArray( this.value, values ) >= 0 || + jQuery.inArray( this.text, values ) >= 0); + }); + + if ( !values.length ) + this.selectedIndex = -1; + + } else + this.value = value; + }); + }, + + html: function( value ) { + return value === undefined ? + (this[0] ? + this[0].innerHTML : + null) : + this.empty().append( value ); + }, + + replaceWith: function( value ) { + return this.after( value ).remove(); + }, + + eq: function( i ) { + return this.slice( i, +i + 1 ); + }, + + slice: function() { + return this.pushStack( Array.prototype.slice.apply( this, arguments ), + "slice", Array.prototype.slice.call(arguments).join(",") ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function(elem, i){ + return callback.call( elem, i, elem ); + })); + }, + + andSelf: function() { + return this.add( this.prevObject ); + }, + + domManip: function( args, table, callback ) { + if ( this[0] ) { + var fragment = (this[0].ownerDocument || this[0]).createDocumentFragment(), + scripts = jQuery.clean( args, (this[0].ownerDocument || this[0]), fragment ), + first = fragment.firstChild, + extra = this.length > 1 ? fragment.cloneNode(true) : fragment; + + if ( first ) + for ( var i = 0, l = this.length; i < l; i++ ) + callback.call( root(this[i], first), i > 0 ? extra.cloneNode(true) : fragment ); + + if ( scripts ) + jQuery.each( scripts, evalScript ); + } + + return this; + + function root( elem, cur ) { + return table && jQuery.nodeName(elem, "table") && jQuery.nodeName(cur, "tr") ? + (elem.getElementsByTagName("tbody")[0] || + elem.appendChild(elem.ownerDocument.createElement("tbody"))) : + elem; + } + } +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +function evalScript( i, elem ) { + if ( elem.src ) + jQuery.ajax({ + url: elem.src, + async: false, + dataType: "script" + }); + + else + jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" ); + + if ( elem.parentNode ) + elem.parentNode.removeChild( elem ); +} + +function now(){ + return +new Date; +} + +jQuery.extend = jQuery.fn.extend = function() { + // copy reference to target object + var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) + target = {}; + + // extend jQuery itself if only one argument is passed + if ( length == i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) + // Extend the base object + for ( var name in options ) { + var src = target[ name ], copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) + continue; + + // Recurse if we're merging object values + if ( deep && copy && typeof copy === "object" && !copy.nodeType ) + target[ name ] = jQuery.extend( deep, + // Never move original objects, clone them + src || ( copy.length != null ? [ ] : { } ) + , copy ); + + // Don't bring in undefined values + else if ( copy !== undefined ) + target[ name ] = copy; + + } + + // Return the modified object + return target; +}; + +// exclude the following css properties to add px +var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i, + // cache defaultView + defaultView = document.defaultView || {}, + toString = Object.prototype.toString; + +jQuery.extend({ + noConflict: function( deep ) { + window.$ = _$; + + if ( deep ) + window.jQuery = _jQuery; + + return jQuery; + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return toString.call(obj) === "[object Function]"; + }, + + isArray: function( obj ) { + return toString.call(obj) === "[object Array]"; + }, + + // check if an element is in a (or is an) XML document + isXMLDoc: function( elem ) { + return elem.nodeType === 9 && elem.documentElement.nodeName !== "HTML" || + !!elem.ownerDocument && jQuery.isXMLDoc( elem.ownerDocument ); + }, + + // Evalulates a script in a global context + globalEval: function( data ) { + data = jQuery.trim( data ); + + if ( data ) { + // Inspired by code by Andrea Giammarchi + // http://webreflection.blogspot.com/2007/08/global-scope-evaluation-and-dom.html + var head = document.getElementsByTagName("head")[0] || document.documentElement, + script = document.createElement("script"); + + script.type = "text/javascript"; + if ( jQuery.support.scriptEval ) + script.appendChild( document.createTextNode( data ) ); + else + script.text = data; + + // Use insertBefore instead of appendChild to circumvent an IE6 bug. + // This arises when a base node is used (#2709). + head.insertBefore( script, head.firstChild ); + head.removeChild( script ); + } + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase(); + }, + + // args is for internal usage only + each: function( object, callback, args ) { + var name, i = 0, length = object.length; + + if ( args ) { + if ( length === undefined ) { + for ( name in object ) + if ( callback.apply( object[ name ], args ) === false ) + break; + } else + for ( ; i < length; ) + if ( callback.apply( object[ i++ ], args ) === false ) + break; + + // A special, fast, case for the most common use of each + } else { + if ( length === undefined ) { + for ( name in object ) + if ( callback.call( object[ name ], name, object[ name ] ) === false ) + break; + } else + for ( var value = object[0]; + i < length && callback.call( value, i, value ) !== false; value = object[++i] ){} + } + + return object; + }, + + prop: function( elem, value, type, i, name ) { + // Handle executable functions + if ( jQuery.isFunction( value ) ) + value = value.call( elem, i ); + + // Handle passing in a number to a CSS property + return typeof value === "number" && type == "curCSS" && !exclude.test( name ) ? + value + "px" : + value; + }, + + className: { + // internal only, use addClass("class") + add: function( elem, classNames ) { + jQuery.each((classNames || "").split(/\s+/), function(i, className){ + if ( elem.nodeType == 1 && !jQuery.className.has( elem.className, className ) ) + elem.className += (elem.className ? " " : "") + className; + }); + }, + + // internal only, use removeClass("class") + remove: function( elem, classNames ) { + if (elem.nodeType == 1) + elem.className = classNames !== undefined ? + jQuery.grep(elem.className.split(/\s+/), function(className){ + return !jQuery.className.has( classNames, className ); + }).join(" ") : + ""; + }, + + // internal only, use hasClass("class") + has: function( elem, className ) { + return elem && jQuery.inArray( className, (elem.className || elem).toString().split(/\s+/) ) > -1; + } + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations + swap: function( elem, options, callback ) { + var old = {}; + // Remember the old values, and insert the new ones + for ( var name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + callback.call( elem ); + + // Revert the old values + for ( var name in options ) + elem.style[ name ] = old[ name ]; + }, + + css: function( elem, name, force ) { + if ( name == "width" || name == "height" ) { + var val, props = { position: "absolute", visibility: "hidden", display:"block" }, which = name == "width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ]; + + function getWH() { + val = name == "width" ? elem.offsetWidth : elem.offsetHeight; + var padding = 0, border = 0; + jQuery.each( which, function() { + padding += parseFloat(jQuery.curCSS( elem, "padding" + this, true)) || 0; + border += parseFloat(jQuery.curCSS( elem, "border" + this + "Width", true)) || 0; + }); + val -= Math.round(padding + border); + } + + if ( jQuery(elem).is(":visible") ) + getWH(); + else + jQuery.swap( elem, props, getWH ); + + return Math.max(0, val); + } + + return jQuery.curCSS( elem, name, force ); + }, + + curCSS: function( elem, name, force ) { + var ret, style = elem.style; + + // We need to handle opacity special in IE + if ( name == "opacity" && !jQuery.support.opacity ) { + ret = jQuery.attr( style, "opacity" ); + + return ret == "" ? + "1" : + ret; + } + + // Make sure we're using the right name for getting the float value + if ( name.match( /float/i ) ) + name = styleFloat; + + if ( !force && style && style[ name ] ) + ret = style[ name ]; + + else if ( defaultView.getComputedStyle ) { + + // Only "float" is needed here + if ( name.match( /float/i ) ) + name = "float"; + + name = name.replace( /([A-Z])/g, "-$1" ).toLowerCase(); + + var computedStyle = defaultView.getComputedStyle( elem, null ); + + if ( computedStyle ) + ret = computedStyle.getPropertyValue( name ); + + // We should always get a number back from opacity + if ( name == "opacity" && ret == "" ) + ret = "1"; + + } else if ( elem.currentStyle ) { + var camelCase = name.replace(/\-(\w)/g, function(all, letter){ + return letter.toUpperCase(); + }); + + ret = elem.currentStyle[ name ] || elem.currentStyle[ camelCase ]; + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + if ( !/^\d+(px)?$/i.test( ret ) && /^\d/.test( ret ) ) { + // Remember the original values + var left = style.left, rsLeft = elem.runtimeStyle.left; + + // Put in the new values to get a computed value out + elem.runtimeStyle.left = elem.currentStyle.left; + style.left = ret || 0; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + elem.runtimeStyle.left = rsLeft; + } + } + + return ret; + }, + + clean: function( elems, context, fragment ) { + context = context || document; + + // !context.createElement fails in IE with an error but returns typeof 'object' + if ( typeof context.createElement === "undefined" ) + context = context.ownerDocument || context[0] && context[0].ownerDocument || document; + + // If a single string is passed in and it's a single tag + // just do a createElement and skip the rest + if ( !fragment && elems.length === 1 && typeof elems[0] === "string" ) { + var match = /^<(\w+)\s*\/?>$/.exec(elems[0]); + if ( match ) + return [ context.createElement( match[1] ) ]; + } + + var ret = [], scripts = [], div = context.createElement("div"); + + jQuery.each(elems, function(i, elem){ + if ( typeof elem === "number" ) + elem += ''; + + if ( !elem ) + return; + + // Convert html string into DOM nodes + if ( typeof elem === "string" ) { + // Fix "XHTML"-style tags in all browsers + elem = elem.replace(/(<(\w+)[^>]*?)\/>/g, function(all, front, tag){ + return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area|embed)$/i) ? + all : + front + ">"; + }); + + // Trim whitespace, otherwise indexOf won't work as expected + var tags = jQuery.trim( elem ).toLowerCase(); + + var wrap = + // option or optgroup + !tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + tags.match(/^<(thead|tbody|tfoot|colg|cap)/) && + [ 1, "", "
" ] || + + !tags.indexOf("", "" ] || + + // matched above + (!tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + // IE can't serialize and #} + Xerxes diff --git a/templates/influences/character_detail_block.html b/templates/influences/character_detail_block.html index 6c0a67b..ae325f2 100644 --- a/templates/influences/character_detail_block.html +++ b/templates/influences/character_detail_block.html @@ -5,11 +5,26 @@ {% else %} {% trans "This character is approved by the SLs" %} {% endifequal %} -

{% blocktrans with object.name as name %}Influences for {{ name }}{% endblocktrans %}

-
    -{% for influence in object.influence_set.all %} -
  • {{ influence }}
  • -{% endfor %} -
+ +{% if object.initiated_influences.all or object.influences_involved_in.all %} + {% if object.initiated_influences.all %} +

{% blocktrans with object.name as name %}Influences initiated by {{ name }}{% endblocktrans %}

+
    + {% for influence in object.initiated_influences.all %} +
  • {{ influence }}
  • + {% endfor %} +
+ {% endif %} + {% if object.influences_involved_in.all %} +

{% blocktrans with object.name as name %}Influences {{ name }} is involved in{% endblocktrans %}

+
    + {% for influence in object.influences_involved_in.all %} +
  • {{ influence }}
  • + {% endfor %} +
+ {% endif %} +{% else %} +

{% trans "No influences yet." %}

+{% endif %} {% trans "Submit influence" %} diff --git a/templates/influences/email/character_changed.html b/templates/influences/email/character_changed.html index c858119..524f64d 100644 --- a/templates/influences/email/character_changed.html +++ b/templates/influences/email/character_changed.html @@ -9,8 +9,8 @@ Subject: {% blocktrans %}Character "{{ character }}" created.{% endblocktrans %} Subject: {% blocktrans %}Character "{{ character }}" was changed.{% endblocktrans %} {% endif%} \\ -{% if recipients.0.first_name %} -{% blocktrans with recipients.0.first_name as name %}Hello {{ name }}{% endblocktrans %}, +{% if first_name %} +{% blocktrans %}Hello {{ first_name }}{% endblocktrans %}, {% else %} {% trans "L.S." %}, {% endif %} diff --git a/templates/influences/email/influence_changed.html b/templates/influences/email/influence_changed.html index 197aef5..982bf1e 100644 --- a/templates/influences/email/influence_changed.html +++ b/templates/influences/email/influence_changed.html @@ -1,5 +1,6 @@ {% load gapless %}{% gapless %} {% load i18n %} +{% load list %} {% autoescape off %} From: Xerxes (Evolution Events) X-Mailer: Xerxes @@ -9,23 +10,25 @@ Subject: {% blocktrans %}Influence "{{ influence }}" submitted.{% endblocktrans Subject: {% blocktrans %}Influence "{{ influence }}" was changed.{% endblocktrans %} {% endif%} \\ -{% if recipients.0.first_name %} -{% blocktrans with recipients.0.first_name as name %}Hello {{ name }}{% endblocktrans %}, +{% if first_name %} +{% blocktrans %}Hello {{ first_name }}{% endblocktrans %}, {% else %} {% trans "L.S." %}, {% endif %} \\ {% filter wordwrap:72 %} {% blocktrans with influence.created|date:"j F Y" as creation_date %} -You have submitted an influence on {{ creation_date }}. +You are involved in this influence, submitted on {{ creation_date }}. {% endblocktrans %} {% if not created %} {% blocktrans %}The influence has been modified. The current status is{%endblocktrans %}:{% else %}{% blocktrans %}You submitted{% endblocktrans%}: {% endif %} {% endfilter %} \\ -{% filter ljust:20%}{% trans "Character" %}:{%endfilter%}{{ influence.character }} -{% filter ljust:20%}{% trans "Contact" %}:{%endfilter%}{{ influence.contact }} +{% filter ljust:20%}{% trans "Iniator" %}:{%endfilter%}{{ influence.initiator }} +{% if influence.involved %} +{% filter ljust:20%}{% trans "Involved" %}:{%endfilter%}{{ influence.involved|natural_list }} +{% endif %} {% filter ljust:20%}{% trans "Summary" %}:{%endfilter%}{{ influence.summary }} {% filter ljust:20%}{% trans "Status" %}:{%endfilter%}{{ influence.get_status_display }} {% if influence.longterm %} diff --git a/templates/influences/email/influence_comment_added.html b/templates/influences/email/influence_comment_added.html index 8a2d4a4..d52cce2 100644 --- a/templates/influences/email/influence_comment_added.html +++ b/templates/influences/email/influence_comment_added.html @@ -5,15 +5,15 @@ From: Xerxes (Evolution Events) X-Mailer: Xerxes Subject: {% blocktrans %}Comment added to influence "{{ influence }}".{% endblocktrans %} \\ -{% if recipients.0.first_name %} -{% blocktrans with recipients.0.first_name as name %}Hello {{ name }}{% endblocktrans %}, +{% if first_name %} +{% blocktrans %}Hello {{ first_name }}{% endblocktrans %}, {% else %} {% trans "L.S." %}, {% endif %} \\ {% filter wordwrap:72 %} {% blocktrans %} -{{commenter}} has just commented the following on your influence: +{{commenter}} has just commented the following on an influence you are involved in: {% endblocktrans %} \\ {{comment.comment}} diff --git a/templates/influences/index.html b/templates/influences/index.html index 5f53b5e..a6a3998 100644 --- a/templates/influences/index.html +++ b/templates/influences/index.html @@ -40,9 +40,7 @@ single page, but I'll add that if that would help.

{% endwith %} {% if characters %} -{% with influences as object_list %} {% include "influences/influence_list_block.html" %} -{% endwith %} {% endif %} {% endblock %} diff --git a/templates/influences/influence_detail.html b/templates/influences/influence_detail.html index 8bddb9c..12ec468 100644 --- a/templates/influences/influence_detail.html +++ b/templates/influences/influence_detail.html @@ -1,16 +1,32 @@ {% extends "base/base.html" %} {% load i18n %} +{% load list %} +{% load misc %} {% block content %}

{{ object.summary }}

- - + +{% if object.involved %} + +{% endif %} {% if object.longterm %} {% endif %}
{% trans "Contact" %}:{{ object.contact }}
{% trans "Character" %}:{{ object.character }}
{% trans "Iniator" %}:{{ object.initiator }}
{% trans "Involved" %}: +{{ object.involved|list_or_value }}
{% trans "Long term" %}:{{ object.longterm|yesno|capfirst }}

{{ object.description }}

+{# Show all related players, except for the current user #} +{% with object.related_players|remove_item:user as players %} + {% if players %} + {% trans "Note: This influence (and its comments) can also be viewed by:" %} +
    + {% for player, chars in players.items %} +
  • {{ player }} ({% trans "player of" %} {{ chars|natural_list }})
  • + {% endfor %} +
+ {% endif %} +{% endwith %} {% if object.result %}

{% trans "Result" %}

{{ object.result }}

diff --git a/templates/influences/influence_list_block.html b/templates/influences/influence_list_block.html index e59216f..4d74e40 100644 --- a/templates/influences/influence_list_block.html +++ b/templates/influences/influence_list_block.html @@ -1,14 +1,36 @@ {% load i18n %} +{# Note that this template looks quite like character_detail_block, it is #} +{# still different enough to not try and factor out the common parts #} +{# currently... #} +

{% trans "Your influences" %}

-{% if object_list %} - +{% if characters %} + {% for character in characters %} +

{{ character.name }}

+ {% if character.initiated_influences.all or character.influences_involved_in.all %} + {% if character.initiated_influences.all %} +

{% blocktrans with character.name as name %}Influences initiated by {{ name }}:{% endblocktrans %}

+ + {% endif %} + {% if character.influences_involved_in.all %} +

{% blocktrans with character.name as name %}Influences {{ name }} is involved in:{% endblocktrans %}

+ + {% endif %} + {% else %} +

{% trans "No influences yet." %}

+ {% endif %} + {% endfor %} {% else %} -

{% trans "No influences yet." %}

+

{% trans "No characters. Add a character first, so you can submit your influences." %}

{% endif %}

{% trans "Submit influence" %}

diff --git a/tools/misc.py b/tools/misc.py index 1259ab7..ddc6cee 100644 --- a/tools/misc.py +++ b/tools/misc.py @@ -22,9 +22,38 @@ prints it to stdout and raises it again. def log_error(func): def show(*args, **kwargs): try: - func(*args, **kwargs) + return func(*args, **kwargs) except Exception, e: import traceback traceback.print_exc() raise e return show + +def make_choices(objects): + """ + Transforms a list (or iteratable) of model objects to a list + suitable to be used as a list of choices in form widgets like + Select. + + This fullfills a similar (but simpler) function as + django.forms.models.ModelChoiceIterator, but that one requires a + FormField and is not public. + """ + return [(o.pk, o) for o in objects] + +def filter_choices(choices, filter): + """ + Returns the given choices list with only the choices with the names + in filter left. For example, when a model defines + A = 'A' + B = 'B' + FOO_CHOICES = ( + (A, "Foo A"), + (B, "Foo B") + ) + + you can later define a modelfield using + + foo = ChoiceField(choices=filter_choices(Foo.FOO_CHOICES, [Foo.A])) + """ + return [(name, value) for (name, value) in choices if name in filter] diff --git a/tools/notify.py b/tools/notify.py index bc0b4d8..0c8f969 100644 --- a/tools/notify.py +++ b/tools/notify.py @@ -8,51 +8,69 @@ Notify someone about something. """ def notify(recipients, template, context = {}): recipients = make_iter(recipients) - addresses = []; + # Keep a dict of address -> (firstname, lastname) + to = {}; for r in recipients: if (isinstance(r, User)): - addresses.append(r.email) + to[r.email] = (r.first_name, r.last_name) elif (isinstance(r, Group)): - addresses += [m.email for m in r.user_set.all()] + to.update([(m.email, (m.first_name, m.last_name)) for m in r.user_set.all()]) else: # Assume it is an email address - addresses.append(r) - # TODO: Make addresses unique - - context['recipients'] = recipients - context['addresses'] = addresses - - rendered = loader.render_to_string(template, context) - (headers, body) = rendered.split('\n\n', 1) - - # Turn the headers into a dict so EmailMessage can turn them into a - # string again. Bit pointless, but it works. - # Perhaps we should just use python email stuff directly. OTOH, we - # still always need to parse for the From header. - - headers_dict = {} - # If no From header is present, let EmailMessage do the default - # thing - from_email = None - for header in headers.split('\n'): - (field, value) = header.split(':') - if (field == 'From'): - from_email = value - elif (field == 'Subject'): - subject = value - else: - # Don't put From and Subject in the dict, else they'll be - # present twice. - headers_dict[field] = value - - msg = EmailMessage( - # Only setting the From address through headers won't set the - # envelope address right. - from_email = from_email, - subject = subject, - body = body, - to = addresses, - headers = headers_dict - ) - msg.send() + to[r] = None + + for (address, name) in to.items(): + if name is None: + name = (None, None) + + context['first_name'] = name[0] + context['last_name'] = name[1] + + rendered = loader.render_to_string(template, context) + (headers, body) = rendered.split('\n\n', 1) + + # Turn the headers into a dict so EmailMessage can turn them into a + # string again. Bit pointless, but it works. + # Perhaps we should just use python email stuff directly. OTOH, we + # still always need to parse for the From header. + + headers_dict = {} + # If no From header is present, let EmailMessage do the default + # thing + from_email = None + subject = '' + for header in headers.split('\n'): + (field, value) = header.split(':') + if (field == 'From'): + from_email = value + elif (field == 'Subject'): + subject = value + else: + # Don't put From and Subject in the dict, else they'll be + # present twice. + headers_dict[field] = value + + msg = EmailMessage( + # Only setting the From address through headers won't set the + # envelope address right. + from_email = from_email, + subject = subject, + body = body, + to = [make_rfc822_recipient(address, name)], + headers = headers_dict + ) + msg.send() + +def make_rfc822_recipient(address, name): + """ + Creates a rfc 822 style recipient: Firstname Lastname + + Takes an address and a tuple with firstname and lastname. The tuple can + also be None when no name is known. + """ + if name: + return "%s %s <%s>" % (name[0], name[1], address) + else: + return address + # vim: set sts=4 sw=4 expandtab: diff --git a/tools/templatetags/list.py b/tools/templatetags/list.py new file mode 100644 index 0000000..87d7976 --- /dev/null +++ b/tools/templatetags/list.py @@ -0,0 +1,54 @@ +from django import template +from django.template.defaultfilters import unordered_list +from django.utils.safestring import mark_safe +from django.utils.encoding import force_unicode +from django.utils.translation import ugettext as _ + +""" + Template tags and filters for working with lists. +""" + +register = template.Library() + +@register.filter(name='list_or_value') +def list_or_value(list, autoescape=None): + """ + Turn a list into a simple string or unordered list. + + If the list is empty, returns an empty string. + If the list contains one element, returns just that element. + If the list contains more elements, return an ordered list with + those elements (Just like the builtin unordered_list, but with the +
    tags). + """ + if len(list) == 0: + return '' + elif len(list) == 1: + return list[0] + else: + return mark_safe('
      ' + unordered_list(list, autoescape=autoescape) + '
    ') +list_or_value.needs_autoescape = True + +@register.filter(name='natural_list') +def natural_list(list): + """ + Turns the list into a natural list, using comma's and "and" for + joining the terms. The result is somewhat localized (but probably + insufficient for language that use completely different + interpunction for lists). + """ + if len(list) == 0: + return '' + res = '' + for item in list[0:-1]: + if res: + res += ', ' + res += force_unicode(item) + + if res: + res += ' %s ' % _('and') + + res += force_unicode(list[-1]) + + return res +natural_list.is_safe = True diff --git a/tools/templatetags/misc.py b/tools/templatetags/misc.py new file mode 100644 index 0000000..2579295 --- /dev/null +++ b/tools/templatetags/misc.py @@ -0,0 +1,20 @@ +from django import template + +""" + Miscellaneous template tags and filters. +""" + +register = template.Library() +@register.filter(name='remove_item') +def remove_item(container, item): + """ + Removes the given user from the filtered list or dict. + """ + if (item in container): + if isinstance(container, list): + container.remove(item) + elif isinstance(container, dict): + container.pop(item) + return container + +# vim: set sts=4 sw=4 expandtab: diff --git a/tools/widgets/__init__.py b/tools/widgets/__init__.py new file mode 100644 index 0000000..1608809 --- /dev/null +++ b/tools/widgets/__init__.py @@ -0,0 +1 @@ +from dropdownmultiple import DropDownMultiple diff --git a/tools/widgets/dropdownmultiple.py b/tools/widgets/dropdownmultiple.py new file mode 100644 index 0000000..10c7a2b --- /dev/null +++ b/tools/widgets/dropdownmultiple.py @@ -0,0 +1,109 @@ +# Code taken from http://www.djangosnippets.org/snippets/747/ +# -*- coding: utf-8 -*- +from django.forms import widgets +from django.utils.safestring import mark_safe +from django.utils.datastructures import MultiValueDict +from django.forms.util import flatatt + +TPL_OPTION = """""" + +TPL_SELECT = """ + +""" + +TPL_SCRIPT = """ + +""" + +TPL_FULL = """ + +""" + +class DropDownMultiple(widgets.SelectMultiple): + def __init__(self, attrs=None, choices=()): + super(DropDownMultiple, self).__init__(attrs=attrs, choices=choices) + + def render(self, name, value, attrs=None, choices=()): + # Always render both the default SelectMultiple and our + # javascript assisted version. This allows for graceful + # degradation when javascript is not available or enabled + # (together with the javascript code above). + nonjs_output = super(DropDownMultiple, self).render(name, value, attrs=attrs, choices=choices) + js_output = self.render_js(name, value, attrs=attrs, choices=choices) + + return nonjs_output + js_output + + def render_js(self, name, value, attrs=None, choices=()): + if value is None: value = [] + final_attrs = self.build_attrs(attrs, name=name) + + # Pop id + id = final_attrs['id'] + del final_attrs['id'] + + # Insert blank value. We insert this in self.choices, because + # render_options merges self.choices with its choices argument + # (in that order) and we want to have the empty option at the + # top. + old_choices = self.choices + self.choices = [('','---')] + list(self.choices) + + # Build values + items = [] + for val in value: + opts = self.render_options(choices, [val]) + + items.append(TPL_SELECT %{'attrs': flatatt(final_attrs), 'opts': opts}) + + # Build blank value + opts = self.render_options(choices, ['']) + items.append(TPL_SELECT %{'attrs': flatatt(final_attrs), 'opts': opts}) + + script = TPL_SCRIPT %{'id': id} + output = TPL_FULL %{'id': id, 'values': '\n'.join(items), 'script': script} + + # Restore the original choices + self.choices = old_choices + + return mark_safe(output) + + def value_from_datadict(self, *args, **kwargs): + # Let our parent take care of this, but filter out the empty + # value (which is usually present from the last unused + # dropdown). + values = super(DropDownMultiple, self).value_from_datadict(*args, **kwargs) + return [i for i in values if i]