=0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var l=h[1];g=n.attrHandle[l]?n.attrHandle[l](g):g[l]!=null?g[l]:g.getAttribute(l);l=g+"";var m=h[2];h=h[4];return g==null?m==="!=":m===
+"="?l===h:m==="*="?l.indexOf(h)>=0:m==="~="?(" "+l+" ").indexOf(h)>=0:!h?l&&g!==false:m==="!="?l!==h:m==="^="?l.indexOf(h)===0:m==="$="?l.substr(l.length-h.length)===h:m==="|="?l===h||l.substr(0,h.length+1)===h+"-":false},POS:function(g,h,l,m){var q=n.setFilters[h[2]];if(q)return q(g,l,h,m)}}},r=n.match.POS;for(var u in n.match){n.match[u]=new RegExp(n.match[u].source+/(?![^\[]*\])(?![^\(]*\))/.source);n.leftMatch[u]=new RegExp(/(^(?:.|\r|\n)*?)/.source+n.match[u].source.replace(/\\(\d+)/g,function(g,
+h){return"\\"+(h-0+1)}))}var z=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g};try{Array.prototype.slice.call(s.documentElement.childNodes,0)}catch(C){z=function(g,h){h=h||[];if(j.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var l=0,m=g.length;l ";var l=s.documentElement;l.insertBefore(g,l.firstChild);if(s.getElementById(h)){n.find.ID=function(m,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(m[1]))?q.id===m[1]||typeof q.getAttributeNode!=="undefined"&&
+q.getAttributeNode("id").nodeValue===m[1]?[q]:w:[]};n.filter.ID=function(m,q){var p=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&p&&p.nodeValue===q}}l.removeChild(g);l=g=null})();(function(){var g=s.createElement("div");g.appendChild(s.createComment(""));if(g.getElementsByTagName("*").length>0)n.find.TAG=function(h,l){l=l.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var m=0;l[m];m++)l[m].nodeType===1&&h.push(l[m]);l=h}return l};g.innerHTML=" ";
+if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")n.attrHandle.href=function(h){return h.getAttribute("href",2)};g=null})();s.querySelectorAll&&function(){var g=k,h=s.createElement("div");h.innerHTML="
";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){k=function(m,q,p,v){q=q||s;if(!v&&q.nodeType===9&&!x(q))try{return z(q.querySelectorAll(m),p)}catch(t){}return g(m,q,p,v)};for(var l in g)k[l]=g[l];h=null}}();
+(function(){var g=s.createElement("div");g.innerHTML="
";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){n.order.splice(1,0,"CLASS");n.find.CLASS=function(h,l,m){if(typeof l.getElementsByClassName!=="undefined"&&!m)return l.getElementsByClassName(h[1])};g=null}}})();var E=s.compareDocumentPosition?function(g,h){return!!(g.compareDocumentPosition(h)&16)}:
+function(g,h){return g!==h&&(g.contains?g.contains(h):true)},x=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},ga=function(g,h){var l=[],m="",q;for(h=h.nodeType?[h]:h;q=n.match.PSEUDO.exec(g);){m+=q[0];g=g.replace(n.match.PSEUDO,"")}g=n.relative[g]?g+"*":g;q=0;for(var p=h.length;q=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f0)for(var j=d;j0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,j=
+{},i;if(f&&a.length){e=0;for(var o=a.length;e-1:c(f).is(e)){d.push({selector:i,elem:f});delete j[i]}}f=f.parentNode}}return d}var k=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(n,r){for(;r&&r.ownerDocument&&r!==b;){if(k?k.index(r)>-1:c(r).is(a))return r;r=r.parentNode}return null})},index:function(a){if(!a||typeof a===
+"string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(),a);return this.pushStack(qa(a[0])||qa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode",
+d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")?
+a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);eb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e):e;if((this.length>1||gb.test(f))&&fb.test(a))e=e.reverse();return this.pushStack(e,a,R.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===w||a.nodeType!==1||!c(a).is(d));){a.nodeType===
+1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var Ja=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ka=/(<([\w:]+)[^>]*?)\/>/g,hb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,La=/<([\w:]+)/,ib=/"+d+">"},F={option:[1,""," "],legend:[1,""," "],thead:[1,""],tr:[2,""],td:[3,""],col:[2,""],area:[1,""," "],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div","
"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d=
+c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==w)return this.empty().append((this[0]&&this[0].ownerDocument||s).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this},
+wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})},
+prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,
+this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,f;(f=this[d])!=null;d++)if(!a||c.filter(a,[f]).length){if(!b&&f.nodeType===1){c.cleanData(f.getElementsByTagName("*"));c.cleanData([f])}f.parentNode&&f.parentNode.removeChild(f)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild);
+return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Ja,"").replace(/=([^="'>\s]+\/)>/g,'="$1">').replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){ra(this,b);ra(this.find("*"),b.find("*"))}return b},html:function(a){if(a===w)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Ja,
+""):null;else if(typeof a==="string"&&!ta.test(a)&&(c.support.leadingWhitespace||!V.test(a))&&!F[(La.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Ka,Ma);try{for(var b=0,d=this.length;b0||e.cacheable||this.length>1?k.cloneNode(true):k)}o.length&&c.each(o,Qa)}return this}});c.fragments={};c.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var f=[];d=c(d);var e=this.length===1&&this[0].parentNode;if(e&&e.nodeType===11&&e.childNodes.length===1&&d.length===1){d[b](this[0]);
+return this}else{e=0;for(var j=d.length;e0?this.clone(true):this).get();c.fn[b].apply(c(d[e]),i);f=f.concat(i)}return this.pushStack(f,a,d.selector)}}});c.extend({clean:function(a,b,d,f){b=b||s;if(typeof b.createElement==="undefined")b=b.ownerDocument||b[0]&&b[0].ownerDocument||s;for(var e=[],j=0,i;(i=a[j])!=null;j++){if(typeof i==="number")i+="";if(i){if(typeof i==="string"&&!jb.test(i))i=b.createTextNode(i);else if(typeof i==="string"){i=i.replace(Ka,Ma);var o=(La.exec(i)||["",
+""])[1].toLowerCase(),k=F[o]||F._default,n=k[0],r=b.createElement("div");for(r.innerHTML=k[1]+i+k[2];n--;)r=r.lastChild;if(!c.support.tbody){n=ib.test(i);o=o==="table"&&!n?r.firstChild&&r.firstChild.childNodes:k[1]===""&&!n?r.childNodes:[];for(k=o.length-1;k>=0;--k)c.nodeName(o[k],"tbody")&&!o[k].childNodes.length&&o[k].parentNode.removeChild(o[k])}!c.support.leadingWhitespace&&V.test(i)&&r.insertBefore(b.createTextNode(V.exec(i)[0]),r.firstChild);i=r.childNodes}if(i.nodeType)e.push(i);else e=
+c.merge(e,i)}}if(d)for(j=0;e[j];j++)if(f&&c.nodeName(e[j],"script")&&(!e[j].type||e[j].type.toLowerCase()==="text/javascript"))f.push(e[j].parentNode?e[j].parentNode.removeChild(e[j]):e[j]);else{e[j].nodeType===1&&e.splice.apply(e,[j+1,0].concat(c.makeArray(e[j].getElementsByTagName("script"))));d.appendChild(e[j])}return e},cleanData:function(a){for(var b,d,f=c.cache,e=c.event.special,j=c.support.deleteExpando,i=0,o;(o=a[i])!=null;i++)if(d=o[c.expando]){b=f[d];if(b.events)for(var k in b.events)e[k]?
+c.event.remove(o,k):Ca(o,k,b.handle);if(j)delete o[c.expando];else o.removeAttribute&&o.removeAttribute(c.expando);delete f[d]}}});var kb=/z-?index|font-?weight|opacity|zoom|line-?height/i,Na=/alpha\([^)]*\)/,Oa=/opacity=([^)]*)/,ha=/float/i,ia=/-([a-z])/ig,lb=/([A-Z])/g,mb=/^-?\d+(?:px)?$/i,nb=/^-?\d/,ob={position:"absolute",visibility:"hidden",display:"block"},pb=["Left","Right"],qb=["Top","Bottom"],rb=s.defaultView&&s.defaultView.getComputedStyle,Pa=c.support.cssFloat?"cssFloat":"styleFloat",ja=
+function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){return X(this,a,b,true,function(d,f,e){if(e===w)return c.curCSS(d,f);if(typeof e==="number"&&!kb.test(f))e+="px";c.style(d,f,e)})};c.extend({style:function(a,b,d){if(!a||a.nodeType===3||a.nodeType===8)return w;if((b==="width"||b==="height")&&parseFloat(d)<0)d=w;var f=a.style||a,e=d!==w;if(!c.support.opacity&&b==="opacity"){if(e){f.zoom=1;b=parseInt(d,10)+""==="NaN"?"":"alpha(opacity="+d*100+")";a=f.filter||c.curCSS(a,"filter")||"";f.filter=
+Na.test(a)?a.replace(Na,b):b}return f.filter&&f.filter.indexOf("opacity=")>=0?parseFloat(Oa.exec(f.filter)[1])/100+"":""}if(ha.test(b))b=Pa;b=b.replace(ia,ja);if(e)f[b]=d;return f[b]},css:function(a,b,d,f){if(b==="width"||b==="height"){var e,j=b==="width"?pb:qb;function i(){e=b==="width"?a.offsetWidth:a.offsetHeight;f!=="border"&&c.each(j,function(){f||(e-=parseFloat(c.curCSS(a,"padding"+this,true))||0);if(f==="margin")e+=parseFloat(c.curCSS(a,"margin"+this,true))||0;else e-=parseFloat(c.curCSS(a,
+"border"+this+"Width",true))||0})}a.offsetWidth!==0?i():c.swap(a,ob,i);return Math.max(0,Math.round(e))}return c.curCSS(a,b,d)},curCSS:function(a,b,d){var f,e=a.style;if(!c.support.opacity&&b==="opacity"&&a.currentStyle){f=Oa.test(a.currentStyle.filter||"")?parseFloat(RegExp.$1)/100+"":"";return f===""?"1":f}if(ha.test(b))b=Pa;if(!d&&e&&e[b])f=e[b];else if(rb){if(ha.test(b))b="float";b=b.replace(lb,"-$1").toLowerCase();e=a.ownerDocument.defaultView;if(!e)return null;if(a=e.getComputedStyle(a,null))f=
+a.getPropertyValue(b);if(b==="opacity"&&f==="")f="1"}else if(a.currentStyle){d=b.replace(ia,ja);f=a.currentStyle[b]||a.currentStyle[d];if(!mb.test(f)&&nb.test(f)){b=e.left;var j=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;e.left=d==="fontSize"?"1em":f||0;f=e.pixelLeft+"px";e.left=b;a.runtimeStyle.left=j}}return f},swap:function(a,b,d){var f={};for(var e in b){f[e]=a.style[e];a.style[e]=b[e]}d.call(a);for(e in b)a.style[e]=f[e]}});if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b=
+a.offsetWidth,d=a.offsetHeight,f=a.nodeName.toLowerCase()==="tr";return b===0&&d===0&&!f?true:b>0&&d>0&&!f?false:c.curCSS(a,"display")==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var sb=J(),tb=/
+
+
-
+
@@ -61,8 +73,8 @@
@@ -71,7 +83,8 @@
- ${list_of_attachments(attachments, add_button_title=_("Attach another file"))}
+
+
@@ -80,9 +93,10 @@
-
- File $attachment.filename, ${sizeinfo(attachment.size)}
- (added by ${authorinfo(attachment.author)}, ${dateinfo(attachment.date)} ago)
+
+ File $attachment.filename,
+ ${pretty_size(attachment.size)}
+ (added by ${authorinfo(attachment.author)}, ${dateinfo(attachment.date)} ago)
@@ -94,7 +108,7 @@
- ${preview_file(preview)}
+
diff -Nru trac-0.11.7/trac/templates/author_or_creator.rss trac-0.12.1~ppa2/trac/templates/author_or_creator.rss
--- trac-0.11.7/trac/templates/author_or_creator.rss 1970-01-01 01:00:00.000000000 +0100
+++ trac-0.12.1~ppa2/trac/templates/author_or_creator.rss 2010-02-24 01:25:29.000000000 +0000
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+ ${format_author(author)}
+ ${format_author(author)}
+
+
+
+
diff -Nru trac-0.11.7/trac/templates/diff_div.html trac-0.12.1~ppa2/trac/templates/diff_div.html
--- trac-0.11.7/trac/templates/diff_div.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/diff_div.html 2010-04-23 14:27:24.000000000 +0100
@@ -1,5 +1,5 @@
+
diff -Nru trac-0.11.7/trac/templates/diff_view.html trac-0.12.1~ppa2/trac/templates/diff_view.html
--- trac-0.11.7/trac/templates/diff_view.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/diff_view.html 2010-05-01 17:01:31.000000000 +0100
@@ -3,9 +3,9 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-
$title
@@ -14,37 +14,45 @@
-
- There was an internal error in Trac. It is recommended
- that you inform your local
- Trac
- administrator and give him all the information he needs to
- reproduce the issue.
+
+ There was an internal error in Trac.
+ It is recommended that you notify your local
+
+ Trac administrator with the information needed to
+ reproduce the issue.
- To that end, you could ${create_ticket()} a ticket.
+ To that end, you could ${create_ticket()} a ticket.
The action that triggered the error was:
${req.method}: ${req.path_info}
@@ -118,28 +126,36 @@
This is probably a local installation issue.
-
+
You should ${create_ticket()} a ticket at the admin Trac to report
the issue.
-
+
Found a bug in Trac?
If you think this should work and you can reproduce the problem,
- you should consider reporting this to the Trac team.
- Before you do that, though, please first try
- searching
+ you should consider creating a bug report.
+
+
+ Note that the ${faulty_plugins[0].name} plugin seems to be involved.
+
+
+ Note that the following plugins seem to be involved:
+ ${', '.join([p.name for p in faulty_plugins])}
+
+ Please report this issue to the plugin maintainer.
+
+ Before you do that, though, please first try
+ searching
for similar issues , as it is quite likely that this problem
has been reported before. For questions about installation
- and configuration of Trac, please try the
+ and configuration of Trac or its plugins, please try the
mailing list
- instead of filing a ticket.
+ instead of creating a ticket.
-
- Otherwise, please ${create_ticket(True)} a new ticket at
- the Trac project site, where you can describe the problem and
- explain how to reproduce it.
+
+ Otherwise, please ${create_ticket(tracker)} a new bug report
+ describing the problem and explain how to reproduce it.
Python Traceback
@@ -148,17 +164,21 @@
- File "${frame.filename}", line ${frame.lineno + 1}, in ${frame.function}
+ File "${frame.filename}", line ${frame.lineno + 1}, in ${frame.function}
${frame.line.lstrip()}
${traceback}
-
+
System Information:
-
- $name:
+
+ $name
$value
+
+ Enabled Plugins:
+
+
diff -Nru trac-0.11.7/trac/templates/history_view.html trac-0.12.1~ppa2/trac/templates/history_view.html
--- trac-0.11.7/trac/templates/history_view.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/history_view.html 2010-05-01 17:01:31.000000000 +0100
@@ -3,21 +3,23 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+ xmlns:xi="http://www.w3.org/2001/XInclude"
+ xmlns:i18n="http://genshi.edgewall.org/i18n">
-
$title
+
-
Change History for ${name_of(resource)}
+
Change History for ${name or name_of(resource)}
-
-
+
+
+
@@ -38,17 +40,19 @@
checked="${idx == 0 or None}" />
- $item.version
+ $item.version
${dateinfo(item.date)}
- ${authorinfo(item.author)}
+ ${authorinfo(item.author)}
-
+
diff -Nru trac-0.11.7/trac/templates/layout.html trac-0.12.1~ppa2/trac/templates/layout.html
--- trac-0.11.7/trac/templates/layout.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/layout.html 2010-03-07 09:14:01.000000000 +0000
@@ -10,15 +10,23 @@
${title} – ${project.name or 'Trac'}
+
+
-
+
+ href="${href.search('opensearch')}"
+ title="${_('Search %(project)s', project=project.name)}"/>
+
${Markup('<!--[if lt IE 7]>')}
diff -Nru trac-0.11.7/trac/templates/list_of_attachments.html trac-0.12.1~ppa2/trac/templates/list_of_attachments.html
--- trac-0.11.7/trac/templates/list_of_attachments.html 1970-01-01 01:00:00.000000000 +0100
+++ trac-0.12.1~ppa2/trac/templates/list_of_attachments.html 2010-03-07 09:14:01.000000000 +0000
@@ -0,0 +1,55 @@
+
+
+
+
+ $attachment.filename
+
+ (${pretty_size(attachment.size)} ) -
+ added by ${authorinfo(attachment.author)} ${dateinfo(attachment.date)} ago.
+
+
+
+
+
+ Attachments
+
+
+
+ ${show_one_attachment(attachment)}
+ ${wiki_to_oneliner(context, attachment.description)}
+
+
+
+
+
+ Attachments
+
+
+
+ ${show_one_attachment(attachment)}
+
+ ${wiki_to_oneliner(context, attachment.description)}
+
+
+
+
+
+
+
+
+
diff -Nru trac-0.11.7/trac/templates/macros.html trac-0.12.1~ppa2/trac/templates/macros.html
--- trac-0.11.7/trac/templates/macros.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/macros.html 2010-03-07 09:14:01.000000000 +0000
@@ -1,61 +1,25 @@
+ xmlns:py="http://genshi.edgewall.org/"
+ xmlns:xi="http://www.w3.org/2001/XInclude"
+ xmlns:i18n="http://genshi.edgewall.org/i18n"
+ py:strip="">
+
+
-
-
${cnt == 1 and ('1 '+noun) or '%d %ss' % (cnt, noun)}
-
-
${
+ ${
pretty_size(size)
}
-
- ${
- author and format_author(author) or 'anonymous'
- } anonymous
-
-
-
- $part.name /
- @
- $rev
-
-
-
-
-
- View differences
-
- inline
- side by side
-
-
- Show
- lines around each change
-
-
- Ignore:
-
-
- Blank lines
-
-
-
- Case changes
-
-
-
- White space changes
-
-
-
-
-
-
- ${preview.rendered}
-
-
- (The file is empty)
-
-
- HTML preview not available ,
-
-
- since the file size exceeds $preview.max_file_size bytes.
-
-
- since no preview renderer could handle it.
-
-
- Try downloading the file instead.
-
-
-
- HTML preview not available .
- To view, download the file.
-
- ${plural(len(preview.errors), 'issue')} found:
-
-
${renderer.__class__.__name__}
-
$err
-
-
-
+
-
-
- $attachment.filename
-
- (${sizeinfo(attachment.size)}) - added by ${authorinfo(attachment.author)}
- ${dateinfo(attachment.date)} ago.
-
-
-
-
- Attachments
-
-
-
- ${show_one_attachment(attachment)}
- ${wiki_to_oneliner(context, attachment.description)}
-
-
-
-
-
- Attachments
-
-
-
- ${show_one_attachment(attachment)}
-
- ${wiki_to_oneliner(context, attachment.description)}
-
-
-
- ${attach_file_form(alist, add_button_title)}
-
-
-
-
+
+
-
-
-
-
-
-
+
-
- ${percent is None and '%d%%' % stats.done_percent or percent}
-
-
-
- ${interval.title.capitalize()} ${stats.unit}s:
- ${interval.title.capitalize()} ${stats.unit}s:
-
-
- ${interval.count}
- ${interval.count}
-
-
- / Total ${stats.unit}s:
- ${sum([x.count for x in stats.intervals], 0)}
-
-
+
+
diff -Nru trac-0.11.7/trac/templates/macros.rss trac-0.12.1~ppa2/trac/templates/macros.rss
--- trac-0.11.7/trac/templates/macros.rss 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/macros.rss 2010-02-24 01:25:29.000000000 +0000
@@ -1,5 +1,6 @@
-
-
-
-
- ${format_author(author)}
- ${format_author(author)}
-
-
-
+
diff -Nru trac-0.11.7/trac/templates/preview_file.html trac-0.12.1~ppa2/trac/templates/preview_file.html
--- trac-0.11.7/trac/templates/preview_file.html 1970-01-01 01:00:00.000000000 +0100
+++ trac-0.12.1~ppa2/trac/templates/preview_file.html 2010-05-01 17:01:31.000000000 +0100
@@ -0,0 +1,29 @@
+
+
+ ${preview.rendered}
+
+
+ (The file is empty)
+
+
+
+
+ HTML preview not available , since the file size exceeds $preview.max_file_size bytes.
+
+
+ HTML preview not available , since no preview renderer could handle it.
+
+
+ Try downloading the file instead.
+
+
+
diff -Nru trac-0.11.7/trac/templates/progress_bar.html trac-0.12.1~ppa2/trac/templates/progress_bar.html
--- trac-0.11.7/trac/templates/progress_bar.html 1970-01-01 01:00:00.000000000 +0100
+++ trac-0.12.1~ppa2/trac/templates/progress_bar.html 2010-04-20 22:17:52.000000000 +0100
@@ -0,0 +1,53 @@
+
+
+
+
+
+ ${percent is None and '%d%%' % stats.done_percent or percent}
+
+
+ Number of ${stats.unit}:
+
+
+
+
+ ${interval.title}:
+ ${interval.count}
+
+
+
+
+
+ Total:
+ ${stats.count}
+
+
+
+
diff -Nru trac-0.11.7/trac/templates/theme.html trac-0.12.1~ppa2/trac/templates/theme.html
--- trac-0.11.7/trac/templates/theme.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/templates/theme.html 2010-03-22 21:58:31.000000000 +0000
@@ -22,10 +22,10 @@
${project.name}
-
+
Search:
-
+
${navigation('metanav')}
@@ -33,6 +33,8 @@
${navigation('mainnav')}
+
+
+
Warning:
diff -Nru trac-0.11.7/trac/test.py trac-0.12.1~ppa2/trac/test.py
--- trac-0.11.7/trac/test.py 2010-03-09 22:49:39.000000000 +0000
+++ trac-0.12.1~ppa2/trac/test.py 2010-04-23 14:23:43.000000000 +0100
@@ -17,20 +17,25 @@
# Author: Jonas Borgström
# Christopher Lenz
+import doctest
import os
import unittest
import sys
-import pkg_resources
-from fnmatch import fnmatch
+
+try:
+ from babel import Locale
+except ImportError:
+ Locale = None
from trac.config import Configuration
-from trac.core import Component, ComponentManager, ExtensionPoint
+from trac.core import Component, ComponentManager
from trac.env import Environment
from trac.db.api import _parse_db_str, DatabaseManager
from trac.db.sqlite_backend import SQLiteConnection
import trac.db.postgres_backend
import trac.db.mysql_backend
from trac.ticket.default_workflow import load_workflow_config_snippet
+from trac.util import translation
def Mock(bases=(), *initargs, **kw):
@@ -85,7 +90,7 @@
bases = (bases,)
cls = type('Mock', bases, {})
mock = cls(*initargs)
- for k,v in kw.items():
+ for k, v in kw.items():
setattr(mock, k, v)
return mock
@@ -93,6 +98,9 @@
class MockPerm(object):
"""Fake permission class. Necessary as Mock can not be used with operator
overloading."""
+
+ username = ''
+
def has_permission(self, action, realm_or_resource=None, id=False,
version=False):
return True
@@ -127,16 +135,10 @@
for test in self._tests:
if hasattr(test, 'setFixture'):
test.setFixture(self.fixture)
- for test in self._tests: # Content of unittest.TestSuite.run()
- if result.shouldStop: # copied here for Python 2.3 compatibility
- break
- test(result)
+ unittest.TestSuite.run(self, result)
self.tearDown()
return result
- def __call__(self, *args, **kwds): # Python 2.3 compatibility
- return self.run(*args, **kwds)
-
class TestCaseSetup(unittest.TestCase):
def setFixture(self, fixture):
@@ -233,8 +235,7 @@
"""
ComponentManager.__init__(self)
Component.__init__(self)
- self.enabled_components = enable or ['trac.*']
- self.systeminfo = [('Python', sys.version)]
+ self.systeminfo = []
import trac
self.path = os.path.dirname(trac.__file__)
@@ -249,6 +250,11 @@
load_workflow_config_snippet(self.config, 'basic-workflow.ini')
self.config.set('logging', 'log_level', 'DEBUG')
self.config.set('logging', 'log_type', 'stderr')
+ if enable is not None:
+ self.config.set('components', 'trac.*', 'disabled')
+ for name_or_class in enable or ():
+ config_key = self._component_name(name_or_class)
+ self.config.set('components', config_key, 'enabled')
# -- logging
from trac.log import logger_factory
@@ -267,15 +273,7 @@
self.abs_href = Href('http://example.org/trac.cgi')
self.known_users = []
-
- def is_component_enabled(self, cls):
- for component in self.enabled_components:
- if component is cls:
- return True
- if isinstance(component, basestring) and \
- fnmatch(cls.__module__ + '.' + cls.__name__, component):
- return True
- return False
+ translation.activate(Locale and Locale('en', 'US'))
def get_db_cnx(self, destroying=False):
if self.db:
@@ -368,7 +366,7 @@
for t in tables:
cursor.execute('DROP TABLE IF EXISTS `%s`' % t)
db.commit()
- except Exception, e:
+ except Exception:
db.rollback()
def get_known_users(self, cnx=None):
@@ -380,7 +378,6 @@
Returns the fully-qualified path, or None.
"""
- import os
exec_suffix = os.name == 'nt' and '.exe' or ''
for p in ["."] + os.environ['PATH'].split(os.pathsep):
@@ -403,6 +400,7 @@
import trac.versioncontrol.web_ui.tests
import trac.web.tests
import trac.wiki.tests
+ import tracopt.mimeview.tests
suite = unittest.TestSuite()
suite.addTest(trac.tests.basicSuite())
@@ -417,19 +415,12 @@
suite.addTest(trac.versioncontrol.web_ui.tests.suite())
suite.addTest(trac.web.tests.suite())
suite.addTest(trac.wiki.tests.suite())
+ suite.addTest(tracopt.mimeview.tests.suite())
+ suite.addTest(doctest.DocTestSuite(sys.modules[__name__]))
return suite
if __name__ == '__main__':
- import doctest, sys
- doctest.testmod(sys.modules[__name__])
-
- # Clean up after doctest or spambayes gets unhappy
- try:
- del __builtins__._
- except NameError:
- pass
-
#FIXME: this is a bit inelegant
if '--skip-functional-tests' in sys.argv:
sys.argv.remove('--skip-functional-tests')
diff -Nru trac-0.11.7/trac/tests/attachment.py trac-0.12.1~ppa2/trac/tests/attachment.py
--- trac-0.11.7/trac/tests/attachment.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/attachment.py 2010-03-17 19:57:05.000000000 +0000
@@ -1,18 +1,15 @@
# -*- coding: utf-8 -*-
-import os
+import os.path
import shutil
+from StringIO import StringIO
import tempfile
import unittest
-import time
-from trac.attachment import Attachment, AttachmentModule, \
- LegacyAttachmentPolicy
+from trac.attachment import Attachment
from trac.core import Component, implements
-from trac.log import logger_factory
from trac.perm import IPermissionPolicy, PermissionCache
-from trac.test import EnvironmentStub, Mock
-from trac.wiki.formatter import Formatter
+from trac.test import EnvironmentStub
class TicketOnlyViewsTicket(Component):
@@ -74,9 +71,9 @@
def test_insert(self):
attachment = Attachment(self.env, 'ticket', 42)
- attachment.insert('foo.txt', tempfile.TemporaryFile(), 0, 1)
+ attachment.insert('foo.txt', StringIO(''), 0, 1)
attachment = Attachment(self.env, 'ticket', 42)
- attachment.insert('bar.jpg', tempfile.TemporaryFile(), 0, 2)
+ attachment.insert('bar.jpg', StringIO(''), 0, 2)
attachments = Attachment.select(self.env, 'ticket', 42)
self.assertEqual('foo.txt', attachments.next().filename)
@@ -85,22 +82,22 @@
def test_insert_unique(self):
attachment = Attachment(self.env, 'ticket', 42)
- attachment.insert('foo.txt', tempfile.TemporaryFile(), 0)
+ attachment.insert('foo.txt', StringIO(''), 0)
self.assertEqual('foo.txt', attachment.filename)
attachment = Attachment(self.env, 'ticket', 42)
- attachment.insert('foo.txt', tempfile.TemporaryFile(), 0)
+ attachment.insert('foo.txt', StringIO(''), 0)
self.assertEqual('foo.2.txt', attachment.filename)
def test_insert_outside_attachments_dir(self):
attachment = Attachment(self.env, '../../../../../sth/private', 42)
self.assertRaises(AssertionError, attachment.insert, 'foo.txt',
- tempfile.TemporaryFile(), 0)
+ StringIO(''), 0)
def test_delete(self):
attachment1 = Attachment(self.env, 'wiki', 'SomePage')
- attachment1.insert('foo.txt', tempfile.TemporaryFile(), 0)
+ attachment1.insert('foo.txt', StringIO(''), 0)
attachment2 = Attachment(self.env, 'wiki', 'SomePage')
- attachment2.insert('bar.jpg', tempfile.TemporaryFile(), 0)
+ attachment2.insert('bar.jpg', StringIO(''), 0)
attachments = Attachment.select(self.env, 'wiki', 'SomePage')
self.assertEqual(2, len(list(attachments)))
@@ -120,11 +117,37 @@
doesn't exist for some reason.
"""
attachment = Attachment(self.env, 'wiki', 'SomePage')
- attachment.insert('foo.txt', tempfile.TemporaryFile(), 0)
+ attachment.insert('foo.txt', StringIO(''), 0)
os.unlink(attachment.path)
attachment.delete()
+ def test_reparent(self):
+ attachment1 = Attachment(self.env, 'wiki', 'SomePage')
+ attachment1.insert('foo.txt', StringIO(''), 0)
+ path1 = attachment1.path
+ attachment2 = Attachment(self.env, 'wiki', 'SomePage')
+ attachment2.insert('bar.jpg', StringIO(''), 0)
+
+ attachments = Attachment.select(self.env, 'wiki', 'SomePage')
+ self.assertEqual(2, len(list(attachments)))
+ attachments = Attachment.select(self.env, 'ticket', 123)
+ self.assertEqual(0, len(list(attachments)))
+ assert os.path.exists(path1) and os.path.exists(attachment2.path)
+
+ attachment1.reparent('ticket', 123)
+ self.assertEqual('ticket', attachment1.parent_realm)
+ self.assertEqual('ticket', attachment1.resource.parent.realm)
+ self.assertEqual('123', attachment1.parent_id)
+ self.assertEqual('123', attachment1.resource.parent.id)
+
+ attachments = Attachment.select(self.env, 'wiki', 'SomePage')
+ self.assertEqual(1, len(list(attachments)))
+ attachments = Attachment.select(self.env, 'ticket', 123)
+ self.assertEqual(1, len(list(attachments)))
+ assert not os.path.exists(path1) and os.path.exists(attachment1.path)
+ assert os.path.exists(attachment2.path)
+
def test_legacy_permission_on_parent(self):
"""Ensure that legacy action tests are done on parent. As
`ATTACHMENT_VIEW` maps to `TICKET_VIEW`, the `TICKET_VIEW` is tested
diff -Nru trac-0.11.7/trac/tests/config.py trac-0.12.1~ppa2/trac/tests/config.py
--- trac-0.11.7/trac/tests/config.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/config.py 2010-03-18 02:23:37.000000000 +0000
@@ -13,22 +13,20 @@
# history and logs, available at http://trac.edgewall.org/log/.
import os
-from StringIO import StringIO
import tempfile
import time
import unittest
from trac.config import *
from trac.test import Configuration
+from trac.util import create_file
class ConfigurationTestCase(unittest.TestCase):
- if not hasattr(unittest.TestCase, "assertTrue"):
- assertTrue = unittest.TestCase.failUnless # Python 2.3 compatibility
-
def setUp(self):
- self.filename = os.path.join(tempfile.gettempdir(), 'trac-test.ini')
+ tmpdir = os.path.realpath(tempfile.gettempdir())
+ self.filename = os.path.join(tmpdir, 'trac-test.ini')
self._write([])
self._orig_registry = Option.registry
Option.registry = {}
@@ -70,7 +68,8 @@
def test_default_int(self):
config = self._read()
- self.assertRaises(ConfigurationError, config.getint, 'a', 'option', 'b')
+ self.assertRaises(ConfigurationError,
+ config.getint, 'a', 'option', 'b')
self.assertEquals(0, config.getint('a', 'option'))
self.assertEquals(1, config.getint('a', 'option', '1'))
self.assertEquals(1, config.getint('a', 'option', 1))
@@ -80,6 +79,20 @@
self.assertEquals(2, config.getint('a', 'option'))
+ def test_default_float(self):
+ config = self._read()
+ self.assertRaises(ConfigurationError,
+ config.getfloat, 'a', 'option', 'b')
+ self.assertEquals(0.0, config.getfloat('a', 'option'))
+ self.assertEquals(1.2, config.getfloat('a', 'option', '1.2'))
+ self.assertEquals(1.2, config.getfloat('a', 'option', 1.2))
+ self.assertEquals(1.0, config.getfloat('a', 'option', 1))
+
+ class Foo(object):
+ option_a = Option('a', 'option', '2.5')
+
+ self.assertEquals(2.5, config.getfloat('a', 'option'))
+
def test_default_path(self):
config = self._read()
class Foo(object):
@@ -126,6 +139,16 @@
self.assertEquals(25, config.getint('b', 'option2', 25))
self.assertEquals(25, config.getint('b', 'option2', '25'))
+ def test_read_and_getfloat(self):
+ self._write(['[a]', 'option = 42.5'])
+ config = self._read()
+ self.assertEquals(42.5, config.getfloat('a', 'option'))
+ self.assertEquals(42.5, config.getfloat('a', 'option', 25.3))
+ self.assertEquals(0, config.getfloat('b', 'option2'))
+ self.assertEquals(25.3, config.getfloat('b', 'option2', 25.3))
+ self.assertEquals(25.0, config.getfloat('b', 'option2', 25))
+ self.assertEquals(25.3, config.getfloat('b', 'option2', '25.3'))
+
def test_read_and_getlist(self):
self._write(['[a]', 'option = foo, bar, baz'])
config = self._read()
@@ -151,15 +174,34 @@
self.assertEquals(['', 'bar', 'baz'],
config.getlist('a', 'option', keep_empty=True))
+ def test_read_and_choice(self):
+ self._write(['[a]', 'option = 2', 'invalid = d'])
+ config = self._read()
+
+ class Foo(object):
+ option = ChoiceOption('a', 'option', ['Item1', 2, '3'])
+ other = ChoiceOption('a', 'other', [1, 2, 3])
+ invalid = ChoiceOption('a', 'invalid', ['a', 'b', 'c'])
+
+ def __init__(self):
+ self.config = config
+
+ foo = Foo()
+ self.assertEquals('2', foo.option)
+ self.assertEquals('1', foo.other)
+ self.assertRaises(ConfigurationError, getattr, foo, 'invalid')
+
def test_getpath(self):
+ base = os.path.dirname(self.filename)
config = self._read()
- config.set('a', 'path_a', '/somewhere/file.txt')
- config.set('a', 'path_b', 'file.txt')
- config.set('a', 'path_c', './file.txt')
- self.assertEquals('/somewhere/file.txt', os.path.splitdrive(
- config.getpath('a', 'path_a'))[1].replace('\\', '/'))
- self.assertNotEquals('file.txt', config.getpath('a', 'path_b'))
- self.assertEquals(config.getpath('a', 'path_b'),
+ config.set('a', 'path_a', os.path.join(base, 'here', 'absolute.txt'))
+ config.set('a', 'path_b', 'thisdir.txt')
+ config.set('a', 'path_c', os.path.join(os.pardir, 'parentdir.txt'))
+ self.assertEquals(os.path.join(base, 'here', 'absolute.txt'),
+ config.getpath('a', 'path_a'))
+ self.assertEquals(os.path.join(base, 'thisdir.txt'),
+ config.getpath('a', 'path_b'))
+ self.assertEquals(os.path.join(os.path.dirname(base), 'parentdir.txt'),
config.getpath('a', 'path_c'))
def test_set_and_save(self):
@@ -335,6 +377,42 @@
self.assertEqual(True, 'a' in config)
self._test_with_inherit(testcb)
+ def test_inherit_multiple(self):
+ class Foo(object):
+ option_b = Option('b', 'option2', 'default')
+ base = os.path.dirname(self.filename)
+ relsite1 = os.path.join('sub1', 'trac-site1.ini')
+ site1 = os.path.join(base, relsite1)
+ relsite2 = os.path.join('sub2', 'trac-site2.ini')
+ site2 = os.path.join(base, relsite2)
+ os.mkdir(os.path.dirname(site1))
+ create_file(site1, '[a]\noption1 = x\n'
+ '[c]\noption = 1\npath1 = site1\n')
+ try:
+ os.mkdir(os.path.dirname(site2))
+ create_file(site2, '[b]\noption2 = y\n'
+ '[c]\noption = 2\npath2 = site2\n')
+ try:
+ self._write(['[inherit]',
+ 'file = %s, %s' % (relsite1, relsite2)])
+ config = self._read()
+ self.assertEqual('x', config.get('a', 'option1'))
+ self.assertEqual('y', config.get('b', 'option2'))
+ self.assertEqual('1', config.get('c', 'option'))
+ self.assertEqual(os.path.join(base, 'site1'),
+ config.getpath('c', 'path1'))
+ self.assertEqual(os.path.join(base, 'site2'),
+ config.getpath('c', 'path2'))
+ self.assertEqual('',
+ config.getpath('c', 'path3'))
+ self.assertEqual(os.path.join(base, 'site4'),
+ config.getpath('c', 'path4', 'site4'))
+ finally:
+ os.remove(site2)
+ os.rmdir(os.path.dirname(site2))
+ finally:
+ os.remove(site1)
+ os.rmdir(os.path.dirname(site1))
def _test_with_inherit(self, testcb):
sitename = os.path.join(tempfile.gettempdir(), 'trac-site.ini')
diff -Nru trac-0.11.7/trac/tests/contentgen.py trac-0.12.1~ppa2/trac/tests/contentgen.py
--- trac-0.11.7/trac/tests/contentgen.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/contentgen.py 2010-03-01 09:28:24.000000000 +0000
@@ -38,18 +38,18 @@
def random_sentence(word_count=None):
if word_count == None:
- word_count = random.randint(1,20)
- words = [random.choice(all_words) for x in range(word_count)]
+ word_count = random.randint(1, 20)
+ words = [random_word() for x in range(word_count)]
return '%s.' % ' '.join(words)
def random_paragraph(sentence_count=None):
if sentence_count == None:
- sentence_count = random.randint(1,10)
- sentences = [random_sentence(random.randint(2,15)) for x in range(sentence_count)]
+ sentence_count = random.randint(1, 10)
+ sentences = [random_sentence(random.randint(2, 15)) for x in range(sentence_count)]
return ' '.join(sentences)
def random_page(paragraph_count=None):
if paragraph_count == None:
- paragraph_count = random.randint(1,10)
- paragraphs = [random_paragraph(random.randint(1,5)) for x in range(paragraph_count)]
+ paragraph_count = random.randint(1, 10)
+ paragraphs = [random_paragraph(random.randint(1, 5)) for x in range(paragraph_count)]
return '\r\n\r\n'.join(paragraphs)
diff -Nru trac-0.11.7/trac/tests/core.py trac-0.12.1~ppa2/trac/tests/core.py
--- trac-0.11.7/trac/tests/core.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/core.py 2009-10-29 18:37:45.000000000 +0000
@@ -24,6 +24,11 @@
"""Dummy function."""
+class IOtherTest(Interface):
+ def other_test():
+ """Other dummy function."""
+
+
class ComponentTestCase(unittest.TestCase):
def setUp(self):
@@ -66,7 +71,8 @@
Make sure the component manager refuses to manage classes not derived
from `Component`.
"""
- class NoComponent(object): pass
+ class NoComponent(object):
+ pass
self.assertRaises(TracError, self.compmgr.__getitem__, NoComponent)
def test_component_registration(self):
@@ -127,22 +133,30 @@
"""
try:
implements()
- self.fail('Expected AssertionError')
except AssertionError:
pass
+ else:
+ self.fail('Expected AssertionError')
- def test_implements_called_twice(self):
+ def test_implements_multiple(self):
"""
- Verify that calling implements() twice in a class definition raises an
- `AssertionError`.
+ Verify that a component "implementing" an interface more than once
+ (e.g. through inheritance) is not called more than once from an
+ extension point.
"""
- try:
- class ComponentA(Component):
- implements()
- implements()
- self.fail('Expected AssertionError')
- except AssertionError:
- pass
+ log = []
+ class Parent(Component):
+ abstract = True
+ implements(ITest)
+ class Child(Parent):
+ implements(ITest)
+ def test(self):
+ log.append("call")
+ class Other(Component):
+ tests = ExtensionPoint(ITest)
+ for test in Other(self.compmgr).tests:
+ test.test()
+ self.assertEqual(["call"], log)
def test_attribute_access(self):
"""
@@ -195,7 +209,8 @@
tests = ExtensionPoint(ITest)
class ComponentB(Component):
implements(ITest)
- def test(self): return 'x'
+ def test(self):
+ return 'x'
tests = iter(ComponentA(self.compmgr).tests)
self.assertEquals('x', tests.next().test())
self.assertRaises(StopIteration, tests.next)
@@ -209,14 +224,14 @@
tests = ExtensionPoint(ITest)
class ComponentB(Component):
implements(ITest)
- def test(self): return 'x'
+ def test(self):
+ return 'x'
class ComponentC(Component):
implements(ITest)
- def test(self): return 'y'
- tests = iter(ComponentA(self.compmgr).tests)
- self.assertEquals('x', tests.next().test())
- self.assertEquals('y', tests.next().test())
- self.assertRaises(StopIteration, tests.next)
+ def test(self):
+ return 'y'
+ results = [test.test() for test in ComponentA(self.compmgr).tests]
+ self.assertEquals(['x', 'y'], sorted(results))
def test_inherited_extension_point(self):
"""
@@ -228,7 +243,8 @@
pass
class ExtendingComponent(Component):
implements(ITest)
- def test(self): return 'x'
+ def test(self):
+ return 'x'
tests = iter(ConcreteComponent(self.compmgr).tests)
self.assertEquals('x', tests.next().test())
self.assertRaises(StopIteration, tests.next)
@@ -244,8 +260,25 @@
class ConcreteComponent(BaseComponent):
pass
from trac.core import ComponentMeta
- assert ConcreteComponent in ComponentMeta._registry[ITest]
+ assert ConcreteComponent in ComponentMeta._registry.get(ITest, [])
+ def test_inherited_implements_multilevel(self):
+ """
+ Verify that extension point interfaces are inherited for more than
+ one level of inheritance.
+ """
+ class BaseComponent(Component):
+ implements(ITest)
+ abstract = True
+ class ChildComponent(BaseComponent):
+ implements(IOtherTest)
+ abstract = True
+ class ConcreteComponent(ChildComponent):
+ pass
+ from trac.core import ComponentMeta
+ assert ConcreteComponent in ComponentMeta._registry.get(ITest, [])
+ assert ConcreteComponent in ComponentMeta._registry.get(IOtherTest, [])
+
def test_component_manager_component(self):
"""
Verify that a component manager can itself be a component with its own
@@ -259,7 +292,8 @@
self.foo, self.bar = foo, bar
class Extender(Component):
implements(ITest)
- def test(self): return 'x'
+ def test(self):
+ return 'x'
mgr = ManagerComponent('Test', 42)
assert id(mgr) == id(mgr[ManagerComponent])
tests = iter(mgr.tests)
diff -Nru trac-0.11.7/trac/tests/env.py trac-0.12.1~ppa2/trac/tests/env.py
--- trac-0.11.7/trac/tests/env.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/env.py 2009-10-29 18:37:45.000000000 +0000
@@ -1,5 +1,4 @@
from trac import db_default
-from trac.db import sqlite_backend
from trac.env import Environment
import os.path
@@ -36,7 +35,7 @@
('joe', 1, 'email', 'joe@example.com'),
('jane', 1, 'name', 'Jane')])
users = {}
- for username,name,email in self.env.get_known_users(self.db):
+ for username, name, email in self.env.get_known_users(self.db):
users[username] = (name, email)
assert not users.has_key('anonymous')
diff -Nru trac-0.11.7/trac/tests/figleaf-exclude trac-0.12.1~ppa2/trac/tests/figleaf-exclude
--- trac-0.11.7/trac/tests/figleaf-exclude 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/figleaf-exclude 2010-04-23 14:23:43.000000000 +0100
@@ -7,4 +7,4 @@
genshi/
pygments/
.*.html
-/tests/
+\\lib\\
\ No newline at end of file
diff -Nru trac-0.11.7/trac/tests/functional/better_twill.py trac-0.12.1~ppa2/trac/tests/functional/better_twill.py
--- trac-0.11.7/trac/tests/functional/better_twill.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/better_twill.py 2010-03-23 08:55:39.000000000 +0000
@@ -41,11 +41,21 @@
# #5497). Therefore we turn it off here.
twill.commands.config('use_tidy', '0')
+ # We use a transparent proxy to access the global browser object through
+ # twill.get_browser(), as the browser can be destroyed by browser_reset()
+ # (see #7472).
+ class _BrowserProxy(object):
+ def __getattribute__(self, name):
+ return getattr(twill.get_browser(), name)
+
+ def __setattr__(self, name, value):
+ setattr(twill.get_browser(), name, value)
+
# setup short names to reduce typing
# This twill browser (and the tc commands that use it) are essentially
# global, and not tied to our test fixture.
tc = twill.commands
- b = twill.get_browser()
+ b = _BrowserProxy()
# Setup XHTML validation for all retrieved pages
try:
@@ -91,7 +101,12 @@
return
etree.clear_error_log()
try:
- doc = etree.parse(StringIO(page), base_url=b.get_url())
+ # lxml will try to convert the URL to unicode by itself,
+ # this won't work for non-ascii URLs, so help him
+ url = b.get_url()
+ if isinstance(url, str):
+ url = unicode(url, 'latin1')
+ etree.parse(StringIO(page), base_url=url)
except etree.XMLSyntaxError, e:
raise twill.errors.TwillAssertionError(
_format_error_log(page, e.error_log))
diff -Nru trac-0.11.7/trac/tests/functional/__init__.py trac-0.12.1~ppa2/trac/tests/functional/__init__.py
--- trac-0.11.7/trac/tests/functional/__init__.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/__init__.py 2009-11-30 20:08:10.000000000 +0000
@@ -78,7 +78,6 @@
from trac.tests.contentgen import random_sentence, random_page, random_word, \
random_unique_camel
-from trac.util.compat import sorted, reversed
from trac.test import TestSetup, TestCaseSetup
internal_error = 'Trac detected an internal error:'
diff -Nru trac-0.11.7/trac/tests/functional/svntestenv.py trac-0.12.1~ppa2/trac/tests/functional/svntestenv.py
--- trac-0.11.7/trac/tests/functional/svntestenv.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/svntestenv.py 2009-10-03 14:57:00.000000000 +0100
@@ -65,8 +65,10 @@
f.write(data)
f.close()
self.call_in_workdir(['svn', 'add', filename])
+ environ = os.environ.copy()
+ environ['LC_ALL'] = 'C' # Force English messages in svn
output = self.call_in_workdir(['svn', '--username=admin', 'commit', '-m',
- 'Add %s' % filename, filename])
+ 'Add %s' % filename, filename], environ=environ)
try:
revision = re.search(r'Committed revision ([0-9]+)\.',
output).group(1)
diff -Nru trac-0.11.7/trac/tests/functional/testcases.py trac-0.12.1~ppa2/trac/tests/functional/testcases.py
--- trac-0.11.7/trac/tests/functional/testcases.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/testcases.py 2010-03-21 10:29:47.000000000 +0000
@@ -1,9 +1,7 @@
+# -*- encoding: utf-8 -*-
#!/usr/bin/python
import os
-from subprocess import call
-from tempfile import mkdtemp
from trac.tests.functional import *
-from trac.util.datefmt import format_date, utc
class RegressionTestRev6017(FunctionalTwillTestCaseSetup):
@@ -154,6 +152,31 @@
tc.notfind('Second Attachment')
+class ErrorPageValidation(FunctionalTwillTestCaseSetup):
+ def runTest(self):
+ """Validate the error page"""
+ url = self._tester.url + '/wiki/WikiStart'
+ tc.go(url + '?version=bug')
+ tc.url(url)
+ tc.find('Trac detected an internal error:')
+
+
+class RegressionTestTicket3663(FunctionalTwillTestCaseSetup):
+ def runTest(self):
+ """Regression test for non-UTF-8 PATH_INFO (#3663)
+
+ Verify that URLs not encoded with UTF-8 are reported as invalid.
+ """
+ # invalid PATH_INFO
+ self._tester.go_to_wiki(u'été'.encode('latin1'))
+ tc.code(404)
+ tc.find('Invalid URL encoding')
+ # invalid SCRIPT_NAME
+ tc.go(u'été'.encode('latin1'))
+ tc.code(404)
+ tc.find('Invalid URL encoding')
+
+
def functionalSuite():
suite = FunctionalTestSuite()
return suite
@@ -168,6 +191,8 @@
suite.addTest(RegressionTestTicket3833c())
suite.addTest(RegressionTestTicket5572())
suite.addTest(RegressionTestTicket7209())
+ suite.addTest(ErrorPageValidation())
+ suite.addTest(RegressionTestTicket3663())
import trac.versioncontrol.tests
trac.versioncontrol.tests.functionalSuite(suite)
diff -Nru trac-0.11.7/trac/tests/functional/testenv.py trac-0.12.1~ppa2/trac/tests/functional/testenv.py
--- trac-0.11.7/trac/tests/functional/testenv.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/testenv.py 2010-04-23 17:00:31.000000000 +0100
@@ -13,26 +13,26 @@
import locale
from subprocess import call, Popen, PIPE, STDOUT
-from trac.db.api import _parse_db_str
from trac.env import open_environment
from trac.test import EnvironmentStub, get_dburi
from trac.tests.functional.compat import rmtree, close_fds
from trac.tests.functional import logfile
from trac.tests.functional.better_twill import tc, ConnectError
-from trac.db.mysql_backend import MySQLConnection
-from trac.db.postgres_backend import PostgreSQLConnection
from trac.util.compat import close_fds
-# TODO: refactor to support testing multiple frontends, backends (and maybe
-# repositories and authentication).
-# Frontends:
-# tracd, ap2+mod_python, ap2+mod_wsgi, ap2+mod_fastcgi, ap2+cgi,
-# lighty+fastcgi, lighty+cgi, cherrypy+wsgi
-# Backends:
-# sqlite2+pysqlite, sqlite3+pysqlite2, postgres python bindings #1,
-# postgres python bindings #2, mysql with server v4, mysql with server v5
-# (those need to test search escaping, among many other things like long
-# paths in browser and unicode chars being allowed/translating...)
+# TODO: refactor to support testing multiple frontends, backends
+# (and maybe repositories and authentication).
+#
+# Frontends::
+# tracd, ap2+mod_python, ap2+mod_wsgi, ap2+mod_fastcgi, ap2+cgi,
+# lighty+fastcgi, lighty+cgi, cherrypy+wsgi
+#
+# Backends::
+# sqlite2+pysqlite, sqlite3+pysqlite2, postgres python bindings #1,
+# postgres python bindings #2, mysql with server v4, mysql with server v5
+# (those need to test search escaping, among many other things like long
+# paths in browser and unicode chars being allowed/translating...)
+
class FunctionalTestEnvironment(object):
"""Common location for convenience functions that work with the test
environment on Trac. Subclass this and override some methods if you are
@@ -155,14 +155,19 @@
"""Starts the webserver, and waits for it to come up."""
if 'FIGLEAF' in os.environ:
exe = os.environ['FIGLEAF']
+ if ' ' in exe: # e.g. 'coverage run'
+ args = exe.split()
+ else:
+ args = [exe]
else:
- exe = sys.executable
+ args = [sys.executable]
options = ["--port=%s" % self.port, "-s", "--hostname=127.0.0.1",
"--basic-auth=trac,%s," % self.htpasswd]
if 'TRAC_TEST_TRACD_OPTIONS' in os.environ:
options += os.environ['TRAC_TEST_TRACD_OPTIONS'].split()
- server = Popen([exe, os.path.join(self.trac_src, 'trac', 'web',
- 'standalone.py')] + options + [self.tracdir],
+ args.append(os.path.join(self.trac_src, 'trac', 'web',
+ 'standalone.py'))
+ server = Popen(args + options + [self.tracdir],
stdout=logfile, stderr=logfile,
close_fds=close_fds,
cwd=self.command_cwd,
@@ -182,11 +187,14 @@
tc.url(self.url)
def stop(self):
- """Stops the webserver, if running"""
+ """Stops the webserver, if running
+
+ FIXME: probably needs a nicer way to exit for coverage to work
+ """
if self.pid:
if os.name == 'nt':
# Untested
- call(["taskkill", "/f", "/pid", str(self.pid)],
+ res = call(["taskkill", "/f", "/pid", str(self.pid)],
stdin=PIPE, stdout=PIPE, stderr=PIPE)
else:
os.kill(self.pid, signal.SIGINT)
@@ -209,9 +217,9 @@
"""Default to no repository"""
return "''" # needed for Python 2.3 and 2.4 on win32
- def call_in_workdir(self, args):
+ def call_in_workdir(self, args, environ=None):
proc = Popen(args, stdout=PIPE, stderr=logfile,
- close_fds=close_fds, cwd=self.work_dir())
+ close_fds=close_fds, cwd=self.work_dir(), env=environ)
(data, _) = proc.communicate()
if proc.wait():
raise Exception('Unable to run command %s in %s' %
diff -Nru trac-0.11.7/trac/tests/functional/tester.py trac-0.12.1~ppa2/trac/tests/functional/tester.py
--- trac-0.11.7/trac/tests/functional/tester.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/functional/tester.py 2010-03-17 19:57:05.000000000 +0000
@@ -3,17 +3,10 @@
working with a Trac environment to make test cases more succinct.
"""
-import os
-import re
-from datetime import datetime, timedelta
-from subprocess import call, Popen, PIPE
-from tempfile import mkdtemp
-
-from trac.tests.functional import internal_error, logfile, close_fds, rmtree
+from trac.tests.functional import internal_error
from trac.tests.functional.better_twill import tc, b
from trac.tests.contentgen import random_page, random_sentence, random_word, \
random_unique_camel
-from trac.util.datefmt import format_date, utc
from trac.util.text import unicode_quote
try:
@@ -183,6 +176,7 @@
tc.formvalue('attachment', 'replace', True)
tc.submit()
tc.url(self.url + '/attachment/ticket/%s/$' % ticketid)
+ return tempfilename
def clone_ticket(self, ticketid):
"""Create a clone of the given ticket id using the clone button."""
@@ -205,7 +199,7 @@
page_url = self.url + "/wiki/" + page
tc.go(page_url)
tc.url(page_url)
- tc.find("Describe %s here." % page)
+ tc.find("The page %s does not exist." % page)
tc.formvalue('modifypage', 'action', 'edit')
tc.submit()
tc.url(page_url + '\\?action=edit')
@@ -239,15 +233,14 @@
tc.formvalue('attachment', 'description', random_sentence())
tc.submit()
tc.url(self.url + '/attachment/wiki/%s/$' % name)
+ return tempfilename
def create_milestone(self, name=None, due=None):
"""Creates the specified milestone, with a random name if none is
provided. Returns the name of the milestone.
"""
- find = False
if name == None:
name = random_unique_camel()
- find = True
milestone_url = self.url + "/admin/ticket/milestones"
tc.go(milestone_url)
tc.url(milestone_url)
diff -Nru trac-0.11.7/trac/tests/__init__.py trac-0.12.1~ppa2/trac/tests/__init__.py
--- trac-0.11.7/trac/tests/__init__.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/__init__.py 2009-10-29 18:37:45.000000000 +0000
@@ -1,4 +1,3 @@
-import doctest
import unittest
from trac.tests import attachment, config, core, env, perm, resource, \
diff -Nru trac-0.11.7/trac/tests/notification.py trac-0.12.1~ppa2/trac/tests/notification.py
--- trac-0.11.7/trac/tests/notification.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/notification.py 2009-10-29 18:37:45.000000000 +0000
@@ -110,8 +110,8 @@
ST_QUIT = 5
def __init__(self, socket, impl):
- self.impl = impl;
- self.socket = socket;
+ self.impl = impl
+ self.socket = socket
self.state = SMTPServerEngine.ST_INIT
def chug(self):
@@ -130,7 +130,7 @@
# this out.
while not completeLine:
try:
- lump = self.socket.recv(1024);
+ lump = self.socket.recv(1024)
if len(lump):
data += lump
if (len(data) >= 2) and data[-2:] == '\r\n':
@@ -319,7 +319,7 @@
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect(('127.0.0.1', self.port))
- r = s.send("QUIT\r\n");
+ r = s.send("QUIT\r\n")
except socket.error:
pass
s.close()
@@ -415,7 +415,7 @@
headers[lh][-1] = headers[lh][-1] + val
else:
# splits header name from value
- (h,v) = line.split(':',1)
+ (h, v) = line.split(':', 1)
val = decode_header(v.strip())
if headers.has_key(h):
if isinstance(headers[h], tuple):
diff -Nru trac-0.11.7/trac/tests/perm.py trac-0.12.1~ppa2/trac/tests/perm.py
--- trac-0.11.7/trac/tests/perm.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/perm.py 2009-08-30 09:55:02.000000000 +0100
@@ -1,7 +1,6 @@
from trac import perm
from trac.core import *
from trac.test import EnvironmentStub
-from trac.util.compat import sorted, set
import unittest
@@ -89,8 +88,6 @@
class PermissionSystemTestCase(unittest.TestCase):
def setUp(self):
- from trac.core import ComponentMeta
-
self.env = EnvironmentStub(enable=[perm.PermissionSystem,
perm.DefaultPermissionStore,
TestPermissionRequestor])
diff -Nru trac-0.11.7/trac/tests/wikisyntax.py trac-0.12.1~ppa2/trac/tests/wikisyntax.py
--- trac-0.11.7/trac/tests/wikisyntax.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/tests/wikisyntax.py 2009-11-30 20:08:10.000000000 +0000
@@ -11,7 +11,7 @@
from trac.web.href import Href
from trac.wiki.tests import formatter
-SEARCH_TEST_CASES="""
+SEARCH_TEST_CASES = """
============================== search: link resolver
search:foo
search:"foo bar"
@@ -40,7 +40,7 @@
------------------------------
"""
-ATTACHMENT_TEST_CASES="""
+ATTACHMENT_TEST_CASES = """
============================== attachment: link resolver (deprecated)
attachment:wiki:WikiStart:file.txt (deprecated)
attachment:ticket:123:file.txt (deprecated)
diff -Nru trac-0.11.7/trac/ticket/admin.py trac-0.12.1~ppa2/trac/ticket/admin.py
--- trac-0.11.7/trac/ticket/admin.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/admin.py 2010-05-01 17:01:31.000000000 +0100
@@ -13,29 +13,39 @@
from datetime import datetime
-from trac.admin import IAdminPanelProvider
+from trac.admin import *
from trac.core import *
from trac.perm import PermissionSystem
from trac.resource import ResourceNotFound
from trac.ticket import model
+from trac.util import getuser
from trac.util.datefmt import utc, parse_date, get_date_format_hint, \
- get_datetime_format_hint
-from trac.util.text import exception_to_unicode
-from trac.util.translation import _
-from trac.web.chrome import add_link, add_notice, add_script, add_warning
+ get_datetime_format_hint, format_date, \
+ format_datetime
+from trac.util.text import print_table, printout, exception_to_unicode
+from trac.util.translation import _, N_, gettext
+from trac.web.chrome import Chrome, add_notice, add_warning
class TicketAdminPanel(Component):
- implements(IAdminPanelProvider)
+ implements(IAdminPanelProvider, IAdminCommandProvider)
abstract = True
+ _label = (N_('(Undefined)'), N_('(Undefined)'))
+
+ # i18n note: use gettext() whenever refering to the above as text labels,
+ # and don't use it whenever using them as field names (after
+ # a call to `.lower()`)
+
+
# IAdminPanelProvider methods
def get_admin_panels(self, req):
if 'TICKET_ADMIN' in req.perm:
- yield ('ticket', 'Ticket System', self._type, self._label[1])
+ yield ('ticket', _('Ticket System'), self._type,
+ gettext(self._label[1]))
def render_admin_panel(self, req, cat, page, version):
req.perm.require('TICKET_ADMIN')
@@ -63,7 +73,7 @@
class ComponentAdminPanel(TicketAdminPanel):
_type = 'components'
- _label = ('Component', 'Components')
+ _label = (N_('Component'), N_('Components'))
# TicketAdminPanel methods
@@ -82,7 +92,7 @@
elif req.args.get('cancel'):
req.redirect(req.href.admin(cat, page))
- add_script(req, 'common/js/wikitoolbar.js')
+ Chrome(self.env).add_wiki_toolbars(req)
data = {'view': 'detail', 'component': comp}
else:
@@ -92,7 +102,7 @@
if req.args.get('add') and req.args.get('name'):
name = req.args.get('name')
try:
- model.Component(self.env, name=name)
+ comp = model.Component(self.env, name=name)
except ResourceNotFound:
comp = model.Component(self.env)
comp.name = name
@@ -103,7 +113,10 @@
'added.', name=name))
req.redirect(req.href.admin(cat, page))
else:
- raise TracError(_('Component %s already exists.') % name)
+ if comp.name is None:
+ raise TracError(_('Invalid component name.'))
+ raise TracError(_('Component %(name)s already exists.',
+ name=name))
# Remove components
elif req.args.get('remove'):
@@ -112,11 +125,11 @@
raise TracError(_('No component selected'))
if not isinstance(sel, list):
sel = [sel]
- db = self.env.get_db_cnx()
- for name in sel:
- comp = model.Component(self.env, name, db=db)
- comp.delete(db=db)
- db.commit()
+ @self.env.with_transaction()
+ def do_remove(db):
+ for name in sel:
+ comp = model.Component(self.env, name, db=db)
+ comp.delete()
add_notice(req, _('The selected components have been '
'removed.'))
req.redirect(req.href.admin(cat, page))
@@ -148,11 +161,84 @@
return 'admin_components.html', data
+ # IAdminCommandProvider methods
+
+ def get_admin_commands(self):
+ yield ('component list', '',
+ 'Show available components',
+ None, self._do_list)
+ yield ('component add', ' ',
+ 'Add a new component',
+ self._complete_add, self._do_add)
+ yield ('component rename', ' ',
+ 'Rename a component',
+ self._complete_remove_rename, self._do_rename)
+ yield ('component remove', '',
+ 'Remove/uninstall a component',
+ self._complete_remove_rename, self._do_remove)
+ yield ('component chown', ' ',
+ 'Change component ownership',
+ self._complete_chown, self._do_chown)
+
+ def get_component_list(self):
+ return [c.name for c in model.Component.select(self.env)]
+
+ def get_user_list(self):
+ db = self.env.get_db_cnx()
+ cursor = db.cursor()
+ cursor.execute("SELECT DISTINCT username FROM permission")
+ return [row[0] for row in cursor]
+
+ def _complete_add(self, args):
+ if len(args) == 2:
+ return self.get_user_list()
+
+ def _complete_remove_rename(self, args):
+ if len(args) == 1:
+ return self.get_component_list()
+
+ def _complete_chown(self, args):
+ if len(args) == 1:
+ return self.get_component_list()
+ elif len(args) == 2:
+ return self.get_user_list()
+
+ def _do_list(self):
+ print_table([(c.name, c.owner)
+ for c in model.Component.select(self.env)],
+ [_('Name'), _('Owner')])
+
+ def _do_add(self, name, owner):
+ component = model.Component(self.env)
+ component.name = name
+ component.owner = owner
+ component.insert()
+
+ def _do_rename(self, name, newname):
+ @self.env.with_transaction()
+ def do_rename(db):
+ component = model.Component(self.env, name, db=db)
+ component.name = newname
+ component.update()
+
+ def _do_remove(self, name):
+ @self.env.with_transaction()
+ def do_remove(db):
+ component = model.Component(self.env, name, db=db)
+ component.delete()
+
+ def _do_chown(self, name, owner):
+ @self.env.with_transaction()
+ def do_chown(db):
+ component = model.Component(self.env, name, db=db)
+ component.owner = owner
+ component.update()
+
class MilestoneAdminPanel(TicketAdminPanel):
_type = 'milestones'
- _label = ('Milestone', 'Milestones')
+ _label = (N_('Milestone'), N_('Milestones'))
# IAdminPanelProvider methods
@@ -191,7 +277,7 @@
elif req.args.get('cancel'):
req.redirect(req.href.admin(cat, page))
- add_script(req, 'common/js/wikitoolbar.js')
+ Chrome(self.env).add_wiki_toolbars(req)
data = {'view': 'detail', 'milestone': mil}
else:
@@ -202,7 +288,7 @@
req.perm.require('MILESTONE_CREATE')
name = req.args.get('name')
try:
- model.Milestone(self.env, name=name)
+ mil = model.Milestone(self.env, name=name)
except ResourceNotFound:
mil = model.Milestone(self.env)
mil.name = name
@@ -214,7 +300,10 @@
'added.', name=name))
req.redirect(req.href.admin(cat, page))
else:
- raise TracError(_('Milestone %s already exists.') % name)
+ if mil.name is None:
+ raise TracError(_('Invalid milestone name.'))
+ raise TracError(_('Milestone %(name)s already exists.',
+ name=name))
# Remove milestone
elif req.args.get('remove'):
@@ -224,11 +313,11 @@
raise TracError(_('No milestone selected'))
if not isinstance(sel, list):
sel = [sel]
- db = self.env.get_db_cnx()
- for name in sel:
- mil = model.Milestone(self.env, name, db=db)
- mil.delete(db=db, author=req.authname)
- db.commit()
+ @self.env.with_transaction()
+ def do_remove(db):
+ for name in sel:
+ mil = model.Milestone(self.env, name, db=db)
+ mil.delete(author=req.authname)
add_notice(req, _('The selected milestones have been '
'removed.'))
req.redirect(req.href.admin(cat, page))
@@ -261,11 +350,94 @@
})
return 'admin_milestones.html', data
+ # IAdminCommandProvider methods
+
+ def get_admin_commands(self):
+ yield ('milestone list', '',
+ 'Show milestones',
+ None, self._do_list)
+ yield ('milestone add', ' [due]',
+ 'Add milestone',
+ None, self._do_add)
+ yield ('milestone rename', ' ',
+ 'Rename milestone',
+ self._complete_name, self._do_rename)
+ yield ('milestone due', ' ',
+ """Set milestone due date
+
+ The date must be specified in the "%s" format.
+ Alternatively, "now" can be used to set the due date to the
+ current time. To remove the due date from a milestone, specify
+ an empty string ("").
+ """ % console_date_format_hint,
+ self._complete_name, self._do_due)
+ yield ('milestone completed', ' ',
+ """Set milestone complete date
+
+ The date must be specified in the "%s" format.
+ Alternatively, "now" can be used to set the completion date to
+ the current time. To remove the completion date from a
+ milestone, specify an empty string ("").
+ """ % console_date_format_hint,
+ self._complete_name, self._do_completed)
+ yield ('milestone remove', '',
+ 'Remove milestone',
+ self._complete_name, self._do_remove)
+
+ def get_milestone_list(self):
+ return [m.name for m in model.Milestone.select(self.env)]
+
+ def _complete_name(self, args):
+ if len(args) == 1:
+ return self.get_milestone_list()
+
+ def _do_list(self):
+ print_table([(m.name, m.due and
+ format_date(m.due, console_date_format),
+ m.completed and
+ format_datetime(m.completed, console_datetime_format))
+ for m in model.Milestone.select(self.env)],
+ [_('Name'), _('Due'), _('Completed')])
+
+ def _do_add(self, name, due=None):
+ milestone = model.Milestone(self.env)
+ milestone.name = name
+ if due is not None:
+ milestone.due = parse_date(due)
+ milestone.insert()
+
+ def _do_rename(self, name, newname):
+ @self.env.with_transaction()
+ def do_rename(db):
+ milestone = model.Milestone(self.env, name, db=db)
+ milestone.name = newname
+ milestone.update()
+
+ def _do_due(self, name, due):
+ @self.env.with_transaction()
+ def do_due(db):
+ milestone = model.Milestone(self.env, name, db=db)
+ milestone.due = due and parse_date(due)
+ milestone.update()
+
+ def _do_completed(self, name, completed):
+ @self.env.with_transaction()
+ def do_completed(db):
+ milestone = model.Milestone(self.env, name, db=db)
+ milestone.completed = completed and parse_date(completed)
+ milestone.update()
+
+ def _do_remove(self, name):
+ @self.env.with_transaction()
+ def do_remove(db):
+ milestone = model.Milestone(self.env, name, db=db)
+ milestone.delete(author=getuser())
+
class VersionAdminPanel(TicketAdminPanel):
_type = 'versions'
- _label = ('Version', 'Versions')
+ _label = (N_('Version'), N_('Versions'))
# TicketAdminPanel methods
@@ -287,7 +459,7 @@
elif req.args.get('cancel'):
req.redirect(req.href.admin(cat, page))
- add_script(req, 'common/js/wikitoolbar.js')
+ Chrome(self.env).add_wiki_toolbars(req)
data = {'view': 'detail', 'version': ver}
else:
@@ -297,7 +469,7 @@
if req.args.get('add') and req.args.get('name'):
name = req.args.get('name')
try:
- model.Version(self.env, name=name)
+ ver = model.Version(self.env, name=name)
except ResourceNotFound:
ver = model.Version(self.env)
ver.name = name
@@ -309,7 +481,10 @@
'added.', name=name))
req.redirect(req.href.admin(cat, page))
else:
- raise TracError(_('Version %s already exists.') % name)
+ if ver.name is None:
+ raise TracError(_('Invalid version name.'))
+ raise TracError(_('Version %(name)s already exists.',
+ name=name))
# Remove versions
elif req.args.get('remove'):
@@ -318,11 +493,11 @@
raise TracError(_('No version selected'))
if not isinstance(sel, list):
sel = [sel]
- db = self.env.get_db_cnx()
- for name in sel:
- ver = model.Version(self.env, name, db=db)
- ver.delete(db=db)
- db.commit()
+ @self.env.with_transaction()
+ def do_remove(db):
+ for name in sel:
+ ver = model.Version(self.env, name, db=db)
+ ver.delete()
add_notice(req, _('The selected versions have been '
'removed.'))
req.redirect(req.href.admin(cat, page))
@@ -345,20 +520,84 @@
})
return 'admin_versions.html', data
+ # IAdminCommandProvider methods
+
+ def get_admin_commands(self):
+ yield ('version list', '',
+ 'Show versions',
+ None, self._do_list)
+ yield ('version add', ' [time]',
+ 'Add version',
+ None, self._do_add)
+ yield ('version rename', ' ',
+ 'Rename version',
+ self._complete_name, self._do_rename)
+ yield ('version time', ' ',
+ """Set version date
+
+ The must be specified in the "%s" format. Alternatively,
+ "now" can be used to set the version date to the current time.
+ To remove the date from a version, specify an empty string
+ ("").
+ """ % console_date_format_hint,
+ self._complete_name, self._do_time)
+ yield ('version remove', '',
+ 'Remove version',
+ self._complete_name, self._do_remove)
+
+ def get_version_list(self):
+ return [v.name for v in model.Version.select(self.env)]
+
+ def _complete_name(self, args):
+ if len(args) == 1:
+ return self.get_version_list()
+
+ def _do_list(self):
+ print_table([(v.name,
+ v.time and format_date(v.time, console_date_format))
+ for v in model.Version.select(self.env)],
+ [_('Name'), _('Time')])
+
+ def _do_add(self, name, time=None):
+ version = model.Version(self.env)
+ version.name = name
+ if time is not None:
+ version.time = time and parse_date(time)
+ version.insert()
+
+ def _do_rename(self, name, newname):
+ @self.env.with_transaction()
+ def do_rename(db):
+ version = model.Version(self.env, name, db=db)
+ version.name = newname
+ version.update()
+
+ def _do_time(self, name, time):
+ @self.env.with_transaction()
+ def do_time(db):
+ version = model.Version(self.env, name, db=db)
+ version.time = time and parse_date(time)
+ version.update()
+
+ def _do_remove(self, name):
+ @self.env.with_transaction()
+ def do_remove(db):
+ version = model.Version(self.env, name, db=db)
+ version.delete()
+
class AbstractEnumAdminPanel(TicketAdminPanel):
- implements(IAdminPanelProvider)
+
abstract = True
_type = 'unknown'
_enum_cls = None
- _label = ('(Undefined)', '(Undefined)')
# TicketAdminPanel methods
def _render_admin_panel(self, req, cat, page, path_info):
- data = {'label_singular': self._label[0],
- 'label_plural': self._label[1]}
+ label = [gettext(each) for each in self._label]
+ data = {'label_singular': label[0], 'label_plural': label[1]}
# Detail view?
if path_info:
@@ -380,19 +619,22 @@
if req.args.get('add') and req.args.get('name'):
name = req.args.get('name')
try:
- self._enum_cls(self.env, name=name)
+ enum = self._enum_cls(self.env, name=name)
except:
enum = self._enum_cls(self.env)
enum.name = name
enum.insert()
- add_notice(req, _('The %(field)s "%(name)s" has been '
- 'added.',
- field=self._label[0].lower(),
- name=name))
+ add_notice(req, _('The %(field)s value "%(name)s" has '
+ 'been added.',
+ field=label[0], name=name))
req.redirect(req.href.admin(cat, page))
else:
- raise TracError(_('%s %s already exists') % (self._type.title(), name))
-
+ if enum.name is None:
+ raise TracError(_('Invalid %(type)s value.',
+ type=label[0]))
+ raise TracError(_('%(type)s value "%(name)s" already '
+ 'exists', type=label[0], name=name))
+
# Remove enums
elif req.args.get('remove'):
sel = req.args.get('sel')
@@ -400,19 +642,18 @@
raise TracError(_('No %s selected') % self._type)
if not isinstance(sel, list):
sel = [sel]
- db = self.env.get_db_cnx()
- for name in sel:
- enum = self._enum_cls(self.env, name, db=db)
- enum.delete(db=db)
- db.commit()
- add_notice(req, _('The selected %(fields)s have been '
- 'removed.',
- fields=self._label[1].lower()))
+ @self.env.with_transaction()
+ def do_remove(db):
+ for name in sel:
+ enum = self._enum_cls(self.env, name, db=db)
+ enum.delete()
+ add_notice(req, _('The selected %(field)s values have '
+ 'been removed.', field=label[0]))
req.redirect(req.href.admin(cat, page))
- # Appy changes
+ # Apply changes
elif req.args.get('apply'):
- changed = False
+ changed = [False]
# Set default value
name = req.args.get('default')
@@ -423,7 +664,7 @@
name)
try:
self.config.save()
- changed = True
+ changed[0] = True
except Exception, e:
self.log.error('Error writing to trac.ini: %s',
exception_to_unicode(e))
@@ -441,16 +682,16 @@
values = dict([(val, True) for val in order.values()])
if len(order) != len(values):
raise TracError(_('Order numbers must be unique'))
- db = self.env.get_db_cnx()
- for enum in self._enum_cls.select(self.env, db=db):
- new_value = order[enum.value]
- if new_value != enum.value:
- enum.value = new_value
- enum.update(db=db)
- changed = True
- db.commit()
+ @self.env.with_transaction()
+ def do_change(db):
+ for enum in self._enum_cls.select(self.env, db=db):
+ new_value = order[enum.value]
+ if new_value != enum.value:
+ enum.value = new_value
+ enum.update()
+ changed[0] = True
- if changed:
+ if changed[0]:
add_notice(req, _('Your changes have been saved.'))
req.redirect(req.href.admin(cat, page))
@@ -458,26 +699,143 @@
default=default, view='list'))
return 'admin_enums.html', data
+ # IAdminCommandProvider methods
+
+ _command_help = {
+ 'list': 'Show possible ticket %s',
+ 'add': 'Add a %s value option',
+ 'change': 'Change a %s value',
+ 'remove': 'Remove a %s value',
+ 'order': 'Move a %s value up or down in the list',
+ }
+
+ def get_admin_commands(self):
+ enum_type = getattr(self, '_command_type', self._type)
+ label = tuple(each.lower() for each in self._label)
+ yield ('%s list' % enum_type, '',
+ self._command_help['list'] % label[1],
+ None, self._do_list)
+ yield ('%s add' % enum_type, '',
+ self._command_help['add'] % label[0],
+ None, self._do_add)
+ yield ('%s change' % enum_type, ' ',
+ self._command_help['change'] % label[0],
+ self._complete_change_remove, self._do_change)
+ yield ('%s remove' % enum_type, '',
+ self._command_help['remove'] % label[0],
+ self._complete_change_remove, self._do_remove)
+ yield ('%s order' % enum_type, ' up|down',
+ self._command_help['order'] % label[0],
+ self._complete_order, self._do_order)
+
+ def get_enum_list(self):
+ return [e.name for e in self._enum_cls.select(self.env)]
+
+ def _complete_change_remove(self, args):
+ if len(args) == 1:
+ return self.get_enum_list()
+
+ def _complete_order(self, args):
+ if len(args) == 1:
+ return self.get_enum_list()
+ elif len(args) == 2:
+ return ['up', 'down']
+
+ def _do_list(self):
+ print_table([(e.name,) for e in self._enum_cls.select(self.env)],
+ [_('Possible Values')])
+
+ def _do_add(self, name):
+ enum = self._enum_cls(self.env)
+ enum.name = name
+ enum.insert()
+
+ def _do_change(self, name, newname):
+ @self.env.with_transaction()
+ def do_change(db):
+ enum = self._enum_cls(self.env, name, db=db)
+ enum.name = newname
+ enum.update()
+
+ def _do_remove(self, value):
+ @self.env.with_transaction()
+ def do_remove(db):
+ enum = self._enum_cls(self.env, value, db=db)
+ enum.delete()
+
+ def _do_order(self, name, up_down):
+ if up_down not in ('up', 'down'):
+ raise AdminCommandError(_("Invalid up/down value: %(value)s",
+ value=up_down))
+ direction = up_down == 'up' and -1 or 1
+ db = self.env.get_db_cnx()
+ enum1 = self._enum_cls(self.env, name, db=db)
+ enum1.value = int(float(enum1.value) + direction)
+ for enum2 in self._enum_cls.select(self.env, db=db):
+ if int(float(enum2.value)) == enum1.value:
+ enum2.value = int(float(enum2.value) - direction)
+ break
+ else:
+ return
+ @self.env.with_transaction()
+ def do_order(db):
+ enum1.update()
+ enum2.update()
+
class PriorityAdminPanel(AbstractEnumAdminPanel):
_type = 'priority'
_enum_cls = model.Priority
- _label = ('Priority', 'Priorities')
+ _label = (N_('Priority'), N_('Priorities'))
class ResolutionAdminPanel(AbstractEnumAdminPanel):
_type = 'resolution'
_enum_cls = model.Resolution
- _label = ('Resolution', 'Resolutions')
+ _label = (N_('Resolution'), N_('Resolutions'))
class SeverityAdminPanel(AbstractEnumAdminPanel):
_type = 'severity'
_enum_cls = model.Severity
- _label = ('Severity', 'Severities')
+ _label = (N_('Severity'), N_('Severities'))
class TicketTypeAdminPanel(AbstractEnumAdminPanel):
_type = 'type'
_enum_cls = model.Type
- _label = ('Ticket Type', 'Ticket Types')
+ _label = (N_('Ticket Type'), N_('Ticket Types'))
+
+ _command_type = 'ticket_type'
+ _command_help = {
+ 'list': 'Show possible %s',
+ 'add': 'Add a %s',
+ 'change': 'Change a %s',
+ 'remove': 'Remove a %s',
+ 'order': 'Move a %s up or down in the list',
+ }
+
+
+class TicketAdmin(Component):
+ """trac-admin command provider for ticket administration."""
+
+ implements(IAdminCommandProvider)
+
+ # IAdminCommandProvider methods
+
+ def get_admin_commands(self):
+ yield ('ticket remove', '',
+ 'Remove ticket',
+ None, self._do_remove)
+
+ def _do_remove(self, number):
+ try:
+ number = int(number)
+ except ValueError:
+ raise AdminCommandError(_(' must be a number'))
+ @self.env.with_transaction()
+ def do_remove(db):
+ ticket = model.Ticket(self.env, number, db=db)
+ ticket.delete()
+ printout(_('Ticket #%(num)s and all associated data removed.',
+ num=number))
diff -Nru trac-0.11.7/trac/ticket/api.py trac-0.12.1~ppa2/trac/ticket/api.py
--- trac-0.11.7/trac/ticket/api.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/api.py 2010-04-05 14:16:58.000000000 +0100
@@ -14,24 +14,19 @@
#
# Author: Jonas Borgström
+import copy
import re
-from datetime import datetime
-try:
- import threading
-except ImportError:
- import dummy_threading as threading
from genshi.builder import tag
+from trac.cache import cached
from trac.config import *
from trac.core import *
from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem
from trac.resource import IResourceManager
from trac.util import Ranges
-from trac.util.compat import set, sorted
-from trac.util.datefmt import utc
-from trac.util.text import shorten_line, obfuscate_email_address
-from trac.util.translation import _
+from trac.util.text import shorten_line
+from trac.util.translation import _, N_, gettext
from trac.wiki import IWikiSyntaxProvider, WikiParser
@@ -143,10 +138,31 @@
ticket. Therefore, a return value of `[]` means everything is OK."""
+class IMilestoneChangeListener(Interface):
+ """Extension point interface for components that require notification
+ when milestones are created, modified, or deleted."""
+
+ def milestone_created(milestone):
+ """Called when a milestone is created."""
+
+ def milestone_changed(milestone, old_values):
+ """Called when a milestone is modified.
+
+ `old_values` is a dictionary containing the previous values of the
+ milestone properties that changed. Currently those properties can be
+ 'name', 'due', 'completed', or 'description'.
+ """
+
+ def milestone_deleted(milestone):
+ """Called when a milestone is deleted."""
+
+
class TicketSystem(Component):
implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager)
change_listeners = ExtensionPoint(ITicketChangeListener)
+ milestone_change_listeners = ExtensionPoint(IMilestoneChangeListener)
+
action_controllers = OrderedExtensionsOption('ticket', 'workflow',
ITicketActionController, default='ConfigurableTicketWorkflow',
include_missing=False,
@@ -160,13 +176,46 @@
[TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]
(''since 0.9'').""")
- _fields = None
- _custom_fields = None
+ default_version = Option('ticket', 'default_version', '',
+ """Default version for newly created tickets.""")
+
+ default_type = Option('ticket', 'default_type', 'defect',
+ """Default type for newly created tickets (''since 0.9'').""")
+
+ default_priority = Option('ticket', 'default_priority', 'major',
+ """Default priority for newly created tickets.""")
+
+ default_milestone = Option('ticket', 'default_milestone', '',
+ """Default milestone for newly created tickets.""")
+
+ default_component = Option('ticket', 'default_component', '',
+ """Default component for newly created tickets.""")
+
+ default_severity = Option('ticket', 'default_severity', '',
+ """Default severity for newly created tickets.""")
+
+ default_summary = Option('ticket', 'default_summary', '',
+ """Default summary (title) for newly created tickets.""")
+
+ default_description = Option('ticket', 'default_description', '',
+ """Default description for newly created tickets.""")
+
+ default_keywords = Option('ticket', 'default_keywords', '',
+ """Default keywords for newly created tickets.""")
+
+ default_owner = Option('ticket', 'default_owner', '',
+ """Default owner for newly created tickets.""")
+
+ default_cc = Option('ticket', 'default_cc', '',
+ """Default cc: list for newly created tickets.""")
+
+ default_resolution = Option('ticket', 'default_resolution', 'fixed',
+ """Default resolution for resolving (closing) tickets
+ (''since 0.11'').""")
def __init__(self):
self.log.debug('action controllers for ticket workflow: %r' %
[c.__class__.__name__ for c in self.action_controllers])
- self._fields_lock = threading.RLock()
# Public API
@@ -193,65 +242,69 @@
valid_states.update(controller.get_all_status())
return sorted(valid_states)
+ def get_ticket_field_labels(self):
+ """Produce a (name,label) mapping from `get_ticket_fields`."""
+ return dict((f['name'], f['label']) for f in
+ TicketSystem(self.env).get_ticket_fields())
+
def get_ticket_fields(self):
- """Returns the list of fields available for tickets."""
- # This is now cached - as it makes quite a number of things faster,
- # e.g. #6436
- if self._fields is None:
- self._fields_lock.acquire()
- try:
- if self._fields is None: # double-check (race after 1st check)
- self._fields = self._get_ticket_fields()
- finally:
- self._fields_lock.release()
- return [f.copy() for f in self._fields]
+ """Returns list of fields available for tickets.
+
+ Each field is a dict with at least the 'name', 'label' (localized)
+ and 'type' keys.
+ It may in addition contain the 'custom' key, the 'optional' and the
+ 'options' keys. When present 'custom' and 'optional' are always `True`.
+ """
+ fields = copy.deepcopy(self.fields)
+ label = 'label' # workaround gettext extraction bug
+ for f in fields:
+ f[label] = gettext(f[label])
+ return fields
def reset_ticket_fields(self):
- self._fields_lock.acquire()
- try:
- self._fields = None
- self.config.touch() # brute force approach for now
- finally:
- self._fields_lock.release()
+ """Invalidate ticket field cache."""
+ del self.fields
- def _get_ticket_fields(self):
+ @cached
+ def fields(self, db):
+ """Return the list of fields available for tickets."""
from trac.ticket import model
- db = self.env.get_db_cnx()
fields = []
# Basic text fields
- for name in ('summary', 'reporter'):
- field = {'name': name, 'type': 'text', 'label': name.title()}
- fields.append(field)
+ fields.append({'name': 'summary', 'type': 'text',
+ 'label': N_('Summary')})
+ fields.append({'name': 'reporter', 'type': 'text',
+ 'label': N_('Reporter')})
# Owner field, by default text but can be changed dynamically
# into a drop-down depending on configuration (restrict_owner=true)
- field = {'name': 'owner', 'label': 'Owner'}
+ field = {'name': 'owner', 'label': N_('Owner')}
field['type'] = 'text'
fields.append(field)
# Description
fields.append({'name': 'description', 'type': 'textarea',
- 'label': 'Description'})
+ 'label': N_('Description')})
# Default select and radio fields
- selects = [('type', model.Type),
- ('status', model.Status),
- ('priority', model.Priority),
- ('milestone', model.Milestone),
- ('component', model.Component),
- ('version', model.Version),
- ('severity', model.Severity),
- ('resolution', model.Resolution)]
- for name, cls in selects:
+ selects = [('type', N_('Type'), model.Type),
+ ('status', N_('Status'), model.Status),
+ ('priority', N_('Priority'), model.Priority),
+ ('milestone', N_('Milestone'), model.Milestone),
+ ('component', N_('Component'), model.Component),
+ ('version', N_('Version'), model.Version),
+ ('severity', N_('Severity'), model.Severity),
+ ('resolution', N_('Resolution'), model.Resolution)]
+ for name, label, cls in selects:
options = [val.name for val in cls.select(self.env, db=db)]
if not options:
# Fields without possible values are treated as if they didn't
# exist
continue
- field = {'name': name, 'type': 'select', 'label': name.title(),
- 'value': self.config.get('ticket', 'default_' + name),
+ field = {'name': name, 'type': 'select', 'label': label,
+ 'value': getattr(self, 'default_' + name, ''),
'options': options}
if name in ('status', 'resolution'):
field['type'] = 'radio'
@@ -261,9 +314,15 @@
fields.append(field)
# Advanced text fields
- for name in ('keywords', 'cc', ):
- field = {'name': name, 'type': 'text', 'label': name.title()}
- fields.append(field)
+ fields.append({'name': 'keywords', 'type': 'text',
+ 'label': N_('Keywords')})
+ fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')})
+
+ # Date/time fields
+ fields.append({'name': 'time', 'type': 'time',
+ 'label': N_('Created')})
+ fields.append({'name': 'changetime', 'type': 'time',
+ 'label': N_('Modified')})
for field in self.get_custom_fields():
if field['name'] in [f['name'] for f in fields]:
@@ -285,19 +344,14 @@
reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
'col', 'row', 'format', 'max', 'page', 'verbose',
- 'comment']
+ 'comment', 'or']
def get_custom_fields(self):
- if self._custom_fields is None:
- self._fields_lock.acquire()
- try:
- if self._custom_fields is None: # double-check
- self._custom_fields = self._get_custom_fields()
- finally:
- self._fields_lock.release()
- return [f.copy() for f in self._custom_fields]
+ return copy.deepcopy(self.custom_fields)
- def _get_custom_fields(self):
+ @cached
+ def custom_fields(self, db):
+ """Return the list of custom ticket fields available for tickets."""
fields = []
config = self.config['ticket-custom']
for name in [option for option, value in config.options()
@@ -325,6 +379,12 @@
fields.sort(lambda x, y: cmp(x['order'], y['order']))
return fields
+ def get_field_synonyms(self):
+ """Return a mapping from field name synonyms to field names.
+ The synonyms are supposed to be more intuitive for custom queries."""
+ # i18n TODO - translated keys
+ return {'created': 'time', 'modified': 'changetime'}
+
def eventually_restrict_owner(self, field, ticket=None):
"""Restrict given owner field to be a list of users having
the TICKET_MODIFY permission (for the given ticket)
@@ -347,10 +407,12 @@
def get_permission_actions(self):
return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION',
+ 'TICKET_EDIT_COMMENT',
('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
'TICKET_VIEW', 'TICKET_EDIT_CC',
- 'TICKET_EDIT_DESCRIPTION'])]
+ 'TICKET_EDIT_DESCRIPTION',
+ 'TICKET_EDIT_COMMENT'])]
# IWikiSyntaxProvider methods
diff -Nru trac-0.11.7/trac/ticket/default_workflow.py trac-0.12.1~ppa2/trac/ticket/default_workflow.py
--- trac-0.11.7/trac/ticket/default_workflow.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/default_workflow.py 2010-04-23 11:29:22.000000000 +0100
@@ -25,8 +25,8 @@
from trac.env import IEnvironmentSetupParticipant
from trac.config import Configuration
from trac.ticket.api import ITicketActionController, TicketSystem
-from trac.util.compat import set
-from trac.util.translation import _
+from trac.ticket.model import Resolution
+from trac.util.translation import _, tag_
# -- Utilities for the ConfigurableTicketWorkflow
@@ -98,8 +98,10 @@
class ConfigurableTicketWorkflow(Component):
"""Ticket action controller which provides actions according to a
- workflow defined in the TracIni configuration file, inside the
- [ticket-workflow] section.
+ workflow defined in trac.ini.
+
+ The workflow is idefined in the `[ticket-workflow]` section of the
+ [wiki:TracIni#ticket-workflow-section trac.ini] configuration file.
"""
def __init__(self, *args, **kwargs):
@@ -171,30 +173,31 @@
# once and get really confused.
status = ticket._old.get('status', ticket['status']) or 'new'
+ ticket_perm = req.perm(ticket.resource)
allowed_actions = []
for action_name, action_info in self.actions.items():
oldstates = action_info['oldstates']
if oldstates == ['*'] or status in oldstates:
# This action is valid in this state. Check permissions.
- allowed = 0
required_perms = action_info['permissions']
- if required_perms:
- for permission in required_perms:
- if permission in req.perm(ticket.resource):
- allowed = 1
- break
- else:
- allowed = 1
- if allowed:
+ if self._is_action_allowed(ticket_perm, required_perms):
allowed_actions.append((action_info['default'],
action_name))
if not (status in ['new', 'closed'] or \
status in TicketSystem(self.env).get_all_status()) \
- and 'TICKET_ADMIN' in req.perm(ticket.resource):
+ and 'TICKET_ADMIN' in ticket_perm:
# State no longer exists - add a 'reset' action if admin.
allowed_actions.append((0, '_reset'))
return allowed_actions
+ def _is_action_allowed(self, ticket_perm, required_perms):
+ if not required_perms:
+ return True
+ for permission in required_perms:
+ if permission in ticket_perm:
+ return True
+ return False
+
def get_all_status(self):
"""Return a list of all states described by the configuration.
@@ -207,7 +210,6 @@
return all_status
def render_ticket_action_control(self, req, ticket, action):
- from trac.ticket import model
self.log.debug('render_ticket_action_control: action "%s"' % action)
@@ -239,27 +241,31 @@
if owners == None:
owner = req.args.get(id, req.authname)
- control.append(tag(['to ', tag.input(type='text', id=id,
- name=id, value=owner)]))
- hints.append(_("The owner will change from %(current_owner)s",
+ control.append(tag_('to %(owner)s',
+ owner=tag.input(type='text', id=id,
+ name=id, value=owner)))
+ hints.append(_("The owner will be changed from "
+ "%(current_owner)s",
current_owner=current_owner))
elif len(owners) == 1:
- control.append(tag('to %s ' % owners[0]))
+ control.append(tag_('to %(owner)s ', owner=owners[0]))
if ticket['owner'] != owners[0]:
- hints.append(_("The owner will change from "
+ hints.append(_("The owner will be changed from "
"%(current_owner)s to %(selected_owner)s",
current_owner=current_owner,
selected_owner=owners[0]))
else:
- control.append(tag([_("to "), tag.select(
- [tag.option(x, selected=(x == selected_owner or None))
+ control.append(tag_('to %(owner)s', owner=tag.select(
+ [tag.option(x, value=x,
+ selected=(x == selected_owner or None))
for x in owners],
- id=id, name=id)]))
- hints.append(_("The owner will change from %(current_owner)s",
+ id=id, name=id)))
+ hints.append(_("The owner will be changed from "
+ "%(current_owner)s",
current_owner=current_owner))
if 'set_owner_to_self' in operations and \
ticket._old.get('owner', ticket['owner']) != req.authname:
- hints.append(_("The owner will change from %(current_owner)s "
+ hints.append(_("The owner will be changed from %(current_owner)s "
"to %(authname)s", current_owner=current_owner,
authname=req.authname))
if 'set_resolution' in operations:
@@ -267,33 +273,36 @@
resolutions = [x.strip() for x in
this_action['set_resolution'].split(',')]
else:
- resolutions = [val.name for val in
- model.Resolution.select(self.env)]
+ resolutions = [val.name for val in Resolution.select(self.env)]
if not resolutions:
raise TracError(_("Your workflow attempts to set a resolution "
"but none is defined (configuration issue, "
"please contact your Trac admin)."))
if len(resolutions) == 1:
- control.append(tag('as %s' % resolutions[0]))
- hints.append(_("The resolution will be set to %s") %
- resolutions[0])
+ control.append(tag_('as %(resolution)s',
+ resolution=resolutions[0]))
+ hints.append(_("The resolution will be set to %(name)s",
+ name=resolutions[0]))
else:
id = 'action_%s_resolve_resolution' % action
- selected_option = req.args.get(id,
- self.config.get('ticket', 'default_resolution'))
- control.append(tag(['as ', tag.select(
- [tag.option(x, selected=(x == selected_option or None))
+ selected_option = req.args.get(id,
+ TicketSystem(self.env).default_resolution)
+ control.append(tag_('as %(resolution)s',
+ resolution=tag.select(
+ [tag.option(x, value=x,
+ selected=(x == selected_option or None))
for x in resolutions],
- id=id, name=id)]))
+ id=id, name=id)))
hints.append(_("The resolution will be set"))
if 'del_resolution' in operations:
hints.append(_("The resolution will be deleted"))
if 'leave_status' in operations:
- control.append('as %s ' % ticket._old.get('status',
- ticket['status']))
+ control.append(_('as %(status)s ',
+ status= ticket._old.get('status',
+ ticket['status'])))
else:
if status != '*':
- hints.append(_("Next status will be '%s'") % status)
+ hints.append(_("Next status will be '%(name)s'", name=status))
return (this_action['name'], tag(*control), '. '.join(hints))
def get_ticket_changes(self, req, ticket, action):
@@ -314,7 +323,7 @@
for operation in this_action['operations']:
if operation == 'reset_workflow':
updated['status'] = 'new'
- if operation == 'del_owner':
+ elif operation == 'del_owner':
updated['owner'] = ''
elif operation == 'set_owner':
newowner = req.args.get('action_%s_reassign_owner' % action,
@@ -326,8 +335,7 @@
updated['owner'] = newowner
elif operation == 'set_owner_to_self':
updated['owner'] = req.authname
-
- if operation == 'del_resolution':
+ elif operation == 'del_resolution':
updated['resolution'] = ''
elif operation == 'set_resolution':
newresolution = req.args.get('action_%s_resolve_resolution' % \
diff -Nru trac-0.11.7/trac/ticket/model.py trac-0.12.1~ppa2/trac/ticket/model.py
--- trac-0.11.7/trac/ticket/model.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/model.py 2010-04-28 22:18:37.000000000 +0100
@@ -18,16 +18,15 @@
# Christopher Lenz
import re
-import sys
-import time
from datetime import date, datetime
from trac.attachment import Attachment
from trac.core import TracError
from trac.resource import Resource, ResourceNotFound
from trac.ticket.api import TicketSystem
-from trac.util import embedded_numbers, partition, sorted
-from trac.util.datefmt import utc, utcmax, to_timestamp
+from trac.util import embedded_numbers, partition
+from trac.util.text import empty
+from trac.util.datefmt import from_utimestamp, to_utimestamp, utc, utcmax
from trac.util.translation import _
__all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
@@ -37,30 +36,34 @@
class Ticket(object):
# Fields that must not be modified directly by the user
- protected_fields = ('resolution', 'status')
-
- id_is_valid = staticmethod(lambda num: 0 < int(num) <= 1L << 31)
+ protected_fields = ('resolution', 'status', 'time', 'changetime')
+ @staticmethod
+ def id_is_valid(num):
+ return 0 < int(num) <= 1L << 31
+
+ # 0.11 compatibility
+ time_created = property(lambda self: self.values.get('time'))
+ time_changed = property(lambda self: self.values.get('changetime'))
+
def __init__(self, env, tkt_id=None, db=None, version=None):
self.env = env
+ if tkt_id is not None:
+ tkt_id = int(tkt_id)
self.resource = Resource('ticket', tkt_id, version)
self.fields = TicketSystem(self.env).get_ticket_fields()
+ self.time_fields = [f['name'] for f in self.fields
+ if f['type'] == 'time']
self.values = {}
if tkt_id is not None:
self._fetch_ticket(tkt_id, db)
else:
self._init_defaults(db)
- self.id = self.time_created = self.time_changed = None
+ self.id = None
self._old = {}
def _get_db(self, db):
- return db or self.env.get_db_cnx()
-
- def _get_db_for_write(self, db):
- if db:
- return (db, False)
- else:
- return (self.env.get_db_cnx(), True)
+ return db or self.env.get_read_db()
exists = property(fget=lambda self: self.id is not None)
@@ -92,35 +95,43 @@
db = self._get_db(db)
# Fetch the standard ticket fields
- std_fields = [f['name'] for f in self.fields if not f.get('custom')]
+ std_fields = [f['name'] for f in self.fields
+ if not f.get('custom')]
cursor = db.cursor()
- cursor.execute("SELECT %s,time,changetime FROM ticket WHERE id=%%s"
+ cursor.execute("SELECT %s FROM ticket WHERE id=%%s"
% ','.join(std_fields), (tkt_id,))
row = cursor.fetchone()
if not row:
- raise ResourceNotFound('Ticket %s does not exist.' % tkt_id,
- 'Invalid Ticket Number')
+ raise ResourceNotFound(_('Ticket %(id)s does not exist.',
+ id=tkt_id), _('Invalid ticket number'))
self.id = tkt_id
- for i in range(len(std_fields)):
- if row[i] is not None:
- self.values[std_fields[i]] = row[i]
- self.time_created = datetime.fromtimestamp(row[len(std_fields)], utc)
- self.time_changed = datetime.fromtimestamp(row[len(std_fields) + 1], utc)
+ for i, field in enumerate(std_fields):
+ value = row[i]
+ if field in self.time_fields:
+ self.values[field] = from_utimestamp(value)
+ elif value is None:
+ self.values[field] = empty
+ else:
+ self.values[field] = value
# Fetch custom fields if available
custom_fields = [f['name'] for f in self.fields if f.get('custom')]
cursor.execute("SELECT name,value FROM ticket_custom WHERE ticket=%s",
(tkt_id,))
for name, value in cursor:
- if name in custom_fields and value is not None:
- self.values[name] = value
+ if name in custom_fields:
+ if value is None:
+ self.values[name] = empty
+ else:
+ self.values[name] = value
def __getitem__(self, name):
return self.values.get(name)
def __setitem__(self, name, value):
- """Log ticket modifications so the table ticket_change can be updated"""
+ """Log ticket modifications so the table ticket_change can be updated
+ """
if name in self.values and self.values[name] == value:
return
if name not in self._old: # Changed field
@@ -136,15 +147,17 @@
self.values[name] = value
def get_value_or_default(self, name):
- """Return the value of a field or the default value if it is
- undefined"""
+ """Return the value of a field or the default value if it is undefined
+ """
try:
- return self.values[name]
- except KeyError:
+ value = self.values[name]
+ if value is not empty:
+ return value
field = [field for field in self.fields if field['name'] == name]
if field:
- return field[0].get('value')
- return None
+ return field[0].get('value', '')
+ except KeyError:
+ pass
def populate(self, values):
"""Populate the ticket with 'suitable' values from a dictionary"""
@@ -159,16 +172,16 @@
self[name[9:]] = '0'
def insert(self, when=None, db=None):
- """Add ticket to database"""
+ """Add ticket to database.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert not self.exists, 'Cannot insert an existing ticket'
- db, handle_ta = self._get_db_for_write(db)
# Add a timestamp
if when is None:
when = datetime.now(utc)
- self.time_created = self.time_changed = when
-
- cursor = db.cursor()
+ self.values['time'] = self.values['changetime'] = when
# The owner field defaults to the component owner
if self.values.get('component') and not self.values.get('owner'):
@@ -176,13 +189,17 @@
component = Component(self.env, self['component'], db=db)
if component.owner:
self['owner'] = component.owner
- except ResourceNotFound, e:
+ except ResourceNotFound:
# No such component exists
pass
+ # Perform type conversions
+ values = dict(self.values)
+ for field in self.time_fields:
+ if field in values:
+ values[field] = to_utimestamp(values[field])
+
# Insert ticket record
- created = to_timestamp(self.time_created)
- changed = to_timestamp(self.time_changed)
std_fields = []
custom_fields = []
for f in self.fields:
@@ -192,22 +209,25 @@
custom_fields.append(fname)
else:
std_fields.append(fname)
- cursor.execute("INSERT INTO ticket (%s,time,changetime) VALUES (%s)"
- % (','.join(std_fields),
- ','.join(['%s'] * (len(std_fields) + 2))),
- [self[name] for name in std_fields] + [created, changed])
- tkt_id = db.get_last_id(cursor, 'ticket')
-
- # Insert custom fields
- if custom_fields:
- cursor.executemany("INSERT INTO ticket_custom (ticket,name,value) "
- "VALUES (%s,%s,%s)", [(tkt_id, name, self[name])
- for name in custom_fields])
- if handle_ta:
- db.commit()
- self.id = tkt_id
- self.resource = self.resource(id=tkt_id)
+ tkt_id = [None]
+ @self.env.with_transaction(db)
+ def do_insert(db):
+ cursor = db.cursor()
+ cursor.execute("INSERT INTO ticket (%s) VALUES (%s)"
+ % (','.join(std_fields),
+ ','.join(['%s'] * len(std_fields))),
+ [values[name] for name in std_fields])
+ tkt_id[0] = db.get_last_id(cursor, 'ticket')
+
+ # Insert custom fields
+ if custom_fields:
+ cursor.executemany("""
+ INSERT INTO ticket_custom (ticket,name,value) VALUES (%s,%s,%s)
+ """, [(tkt_id[0], name, self[name]) for name in custom_fields])
+
+ self.id = tkt_id[0]
+ self.resource = self.resource(id=tkt_id[0])
self._old = {}
for listener in TicketSystem(self.env).change_listeners:
@@ -215,40 +235,40 @@
return self.id
- def save_changes(self, author, comment, when=None, db=None, cnum=''):
+ def save_changes(self, author=None, comment=None, when=None, db=None, cnum=''):
"""
Store ticket changes in the database. The ticket must already exist in
the database. Returns False if there were no changes to save, True
otherwise.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
"""
assert self.exists, 'Cannot update a new ticket'
if not self._old and not comment:
return False # Not modified
- db, handle_ta = self._get_db_for_write(db)
- cursor = db.cursor()
if when is None:
when = datetime.now(utc)
- when_ts = to_timestamp(when)
+ when_ts = to_utimestamp(when)
if 'component' in self.values:
- # If the component is changed on a 'new' ticket then owner field
- # is updated accordingly. (#623).
+ # If the component is changed on a 'new' ticket
+ # then owner field is updated accordingly. (#623).
if self.values.get('status') == 'new' \
and 'component' in self._old \
and 'owner' not in self._old:
try:
- old_comp = Component(self.env, self._old['component'], db)
+ old_comp = Component(self.env, self._old['component'])
old_owner = old_comp.owner or ''
current_owner = self.values.get('owner') or ''
if old_owner == current_owner:
- new_comp = Component(self.env, self['component'], db)
+ new_comp = Component(self.env, self['component'])
if new_comp.owner:
self['owner'] = new_comp.owner
- except TracError, e:
- # If the old component has been removed from the database we
- # just leave the owner as is.
+ except TracError:
+ # If the old component has been removed from the database
+ # we just leave the owner as is.
pass
# Fix up cc list separators and remove duplicates
@@ -259,41 +279,75 @@
cclist.append(cc)
self.values['cc'] = ', '.join(cclist)
- custom_fields = [f['name'] for f in self.fields if f.get('custom')]
- for name in self._old.keys():
- if name in custom_fields:
- cursor.execute("SELECT * FROM ticket_custom "
- "WHERE ticket=%s and name=%s", (self.id, name))
- if cursor.fetchone():
- cursor.execute("UPDATE ticket_custom SET value=%s "
- "WHERE ticket=%s AND name=%s",
- (self[name], self.id, name))
- else:
- cursor.execute("INSERT INTO ticket_custom (ticket,name,"
- "value) VALUES(%s,%s,%s)",
- (self.id, name, self[name]))
- else:
- cursor.execute("UPDATE ticket SET %s=%%s WHERE id=%%s" % name,
- (self[name], self.id))
- cursor.execute("INSERT INTO ticket_change "
- "(ticket,time,author,field,oldvalue,newvalue) "
- "VALUES (%s, %s, %s, %s, %s, %s)",
- (self.id, when_ts, author, name, self._old[name],
- self[name]))
- # always save comment, even if empty (numbering support for timeline)
- cursor.execute("INSERT INTO ticket_change "
- "(ticket,time,author,field,oldvalue,newvalue) "
- "VALUES (%s,%s,%s,'comment',%s,%s)",
- (self.id, when_ts, author, cnum, comment))
+ @self.env.with_transaction(db)
+ def do_save(db):
+ cursor = db.cursor()
- cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
- (when_ts, self.id))
+ # find cnum if it isn't provided
+ comment_num = cnum
+ if not comment_num:
+ num = 0
+ cursor.execute("""
+ SELECT DISTINCT tc1.time,COALESCE(tc2.oldvalue,'')
+ FROM ticket_change AS tc1
+ LEFT OUTER JOIN
+ (SELECT time,oldvalue FROM ticket_change
+ WHERE field='comment') AS tc2
+ ON (tc1.time = tc2.time)
+ WHERE ticket=%s ORDER BY tc1.time DESC
+ """, (self.id,))
+ for ts, old in cursor:
+ # Use oldvalue if available, else count edits
+ try:
+ num += int(old.rsplit('.', 1)[-1])
+ break
+ except ValueError:
+ num += 1
+ comment_num = str(num + 1)
+
+ # store fields
+ custom_fields = [f['name'] for f in self.fields if f.get('custom')]
+
+ for name in self._old.keys():
+ if name in custom_fields:
+ cursor.execute("""
+ SELECT * FROM ticket_custom
+ WHERE ticket=%s and name=%s
+ """, (self.id, name))
+ if cursor.fetchone():
+ cursor.execute("""
+ UPDATE ticket_custom SET value=%s
+ WHERE ticket=%s AND name=%s
+ """, (self[name], self.id, name))
+ else:
+ cursor.execute("""
+ INSERT INTO ticket_custom (ticket,name,value)
+ VALUES(%s,%s,%s)
+ """, (self.id, name, self[name]))
+ else:
+ cursor.execute("UPDATE ticket SET %s=%%s WHERE id=%%s"
+ % name, (self[name], self.id))
+ cursor.execute("""
+ INSERT INTO ticket_change
+ (ticket,time,author,field,oldvalue,newvalue)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ """, (self.id, when_ts, author, name, self._old[name],
+ self[name]))
+
+ # always save comment, even if empty
+ # (numbering support for timeline)
+ cursor.execute("""
+ INSERT INTO ticket_change
+ (ticket,time,author,field,oldvalue,newvalue)
+ VALUES (%s,%s,%s,'comment',%s,%s)
+ """, (self.id, when_ts, author, comment_num, comment))
+
+ cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
+ (when_ts, self.id))
- if handle_ta:
- db.commit()
old_values = self._old
self._old = {}
- self.time_changed = when
+ self.values['changetime'] = when
for listener in TicketSystem(self.env).change_listeners:
listener.ticket_changed(self, comment, author, old_values)
@@ -309,54 +363,279 @@
"""
db = self._get_db(db)
cursor = db.cursor()
- when_ts = when and to_timestamp(when) or 0
+ sid = str(self.id)
+ when_ts = to_utimestamp(when)
if when_ts:
- cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
- "FROM ticket_change WHERE ticket=%s AND time=%s "
- "UNION "
- "SELECT time,author,'attachment',null,filename,0 "
- "FROM attachment WHERE id=%s AND time=%s "
- "UNION "
- "SELECT time,author,'comment',null,description,0 "
- "FROM attachment WHERE id=%s AND time=%s "
- "ORDER BY time",
- (self.id, when_ts, str(self.id), when_ts,
- str(self.id), when_ts))
+ cursor.execute("""
+ SELECT time,author,field,oldvalue,newvalue, 1 AS permanent
+ FROM ticket_change WHERE ticket=%s AND time=%s
+ UNION
+ SELECT time,author,'attachment',null,filename, 0 AS permanent
+ FROM attachment WHERE id=%s AND time=%s
+ UNION
+ SELECT time,author,'comment',null,description, 0 AS permanent
+ FROM attachment WHERE id=%s AND time=%s
+ ORDER BY time,permanent,author
+ """, (self.id, when_ts, sid, when_ts, sid, when_ts))
else:
- cursor.execute("SELECT time,author,field,oldvalue,newvalue,1 "
- "FROM ticket_change WHERE ticket=%s "
- "UNION "
- "SELECT time,author,'attachment',null,filename,0 "
- "FROM attachment WHERE id=%s "
- "UNION "
- "SELECT time,author,'comment',null,description,0 "
- "FROM attachment WHERE id=%s "
- "ORDER BY time", (self.id, str(self.id),
- str(self.id)))
+ cursor.execute("""
+ SELECT time,author,field,oldvalue,newvalue, 1 AS permanent
+ FROM ticket_change WHERE ticket=%s
+ UNION
+ SELECT time,author,'attachment',null,filename, 0 AS permanent
+ FROM attachment WHERE id=%s
+ UNION
+ SELECT time,author,'comment',null,description, 0 AS permanent
+ FROM attachment WHERE id=%s
+ ORDER BY time,permanent,author
+ """, (self.id, sid, sid))
log = []
for t, author, field, oldvalue, newvalue, permanent in cursor:
- log.append((datetime.fromtimestamp(int(t), utc), author, field,
- oldvalue or '', newvalue or '', permanent))
+ log.append((from_utimestamp(t), author, field,
+ oldvalue or '', newvalue or '', permanent))
return log
def delete(self, db=None):
- db, handle_ta = self._get_db_for_write(db)
- Attachment.delete_all(self.env, 'ticket', self.id, db)
- cursor = db.cursor()
- cursor.execute("DELETE FROM ticket WHERE id=%s", (self.id,))
- cursor.execute("DELETE FROM ticket_change WHERE ticket=%s", (self.id,))
- cursor.execute("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,))
-
- if handle_ta:
- db.commit()
+ """Delete the ticket.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
+ @self.env.with_transaction(db)
+ def do_delete(db):
+ Attachment.delete_all(self.env, 'ticket', self.id, db)
+ cursor = db.cursor()
+ cursor.execute("DELETE FROM ticket WHERE id=%s", (self.id,))
+ cursor.execute("DELETE FROM ticket_change WHERE ticket=%s",
+ (self.id,))
+ cursor.execute("DELETE FROM ticket_custom WHERE ticket=%s",
+ (self.id,))
for listener in TicketSystem(self.env).change_listeners:
listener.ticket_deleted(self)
+ def get_change(self, cnum, db=None):
+ """Return a ticket change by its number."""
+ db = self._get_db(db)
+ cursor = db.cursor()
+ row = self._find_change(cnum, db)
+ if row:
+ ts, author, comment = row
+ cursor.execute("""
+ SELECT field,author,oldvalue,newvalue
+ FROM ticket_change WHERE ticket=%s AND time=%s
+ """, (self.id, ts))
+ fields = {}
+ change = {'date': from_utimestamp(ts),
+ 'author': author, 'fields': fields}
+ for field, author, old, new in cursor:
+ fields[field] = {'author': author, 'old': old, 'new': new}
+ return change
+
+ def delete_change(self, cnum):
+ """Delete a ticket change."""
+ @self.env.with_transaction()
+ def do_delete(db):
+ cursor = db.cursor()
+ row = self._find_change(cnum, db)
+ if not row:
+ return
+ ts = row[0]
+
+ custom_fields = set(f['name'] for f in self.fields
+ if f.get('custom'))
+
+ # Find modified fields and their previous value
+ cursor.execute("""
+ SELECT field, oldvalue, newvalue FROM ticket_change
+ WHERE ticket=%s AND time=%s
+ """, (self.id, ts))
+ fields = [(field, old, new) for field, old, new in cursor
+ if field != 'comment' and not field.startswith('_')]
+ for field, oldvalue, newvalue in fields:
+ # Find the next change
+ cursor.execute("""
+ SELECT time FROM ticket_change
+ WHERE ticket=%s AND time>%s AND field=%s
+ LIMIT 1
+ """, (self.id, ts, field))
+ for next_ts, in cursor:
+ # Modify the old value of the next change if it is equal
+ # to the new value of the deleted change
+ cursor.execute("""
+ UPDATE ticket_change SET oldvalue=%s
+ WHERE ticket=%s AND time=%s AND field=%s
+ AND oldvalue=%s
+ """, (oldvalue, self.id, next_ts, field, newvalue))
+ break
+ else:
+ # No next change, edit ticket field
+ if field in custom_fields:
+ cursor.execute("""
+ UPDATE ticket_custom SET value=%s
+ WHERE ticket=%s AND name=%s
+ """, (oldvalue, self.id, field))
+ else:
+ cursor.execute("""
+ UPDATE ticket SET %s=%%s WHERE id=%%s
+ """ % field, (oldvalue, self.id))
+
+ # Delete the change
+ cursor.execute("""
+ DELETE FROM ticket_change WHERE ticket=%s AND time=%s
+ """, (self.id, ts))
+
+ # Fix the last modification time
+ cursor.execute("""
+ UPDATE ticket SET changetime=(
+ SELECT time FROM ticket_change WHERE ticket=%s
+ ORDER BY time DESC LIMIT 1)
+ WHERE id=%s
+ """, (self.id, self.id))
+
+ self._fetch_ticket(self.id)
+
+ def modify_comment(self, cdate, author, comment, when=None):
+ """Modify a ticket comment specified by its date, while keeping a
+ history of edits.
+ """
+ ts = to_utimestamp(cdate)
+ if when is None:
+ when = datetime.now(utc)
+ when_ts = to_utimestamp(when)
+
+ @self.env.with_transaction()
+ def do_modify(db):
+ cursor = db.cursor()
+ # Find the current value of the comment
+ cursor.execute("""
+ SELECT newvalue FROM ticket_change
+ WHERE ticket=%s AND time=%s AND field='comment'
+ """, (self.id, ts))
+ old_comment = False
+ for old_comment, in cursor:
+ break
+ if comment == (old_comment or ''):
+ return
+
+ # Comment history is stored in fields named "_comment%d"
+ # Find the next edit number
+ cursor.execute("""
+ SELECT field FROM ticket_change
+ WHERE ticket=%%s AND time=%%s AND field %s
+ """ % db.like(), (self.id, ts,
+ db.like_escape('_comment') + '%'))
+ fields = list(cursor)
+ rev = fields and max(int(field[8:]) for field, in fields) + 1 or 0
+ cursor.execute("""
+ INSERT INTO ticket_change
+ (ticket,time,author,field,oldvalue,newvalue)
+ VALUES (%s,%s,%s,%s,%s,%s)
+ """, (self.id, ts, author, '_comment%d' % rev,
+ old_comment or '', str(when_ts)))
+ if old_comment is False:
+ # There was no comment field, add one, find the original author
+ # in one of the other changed fields
+ cursor.execute("""
+ SELECT author FROM ticket_change
+ WHERE ticket=%%s AND time=%%s AND NOT field %s
+ LIMIT 1
+ """ % db.like(), (self.id, ts, db.like_escape('_') + '%'))
+ old_author = None
+ for old_author, in cursor:
+ break
+ cursor.execute("""
+ INSERT INTO ticket_change
+ (ticket,time,author,field,oldvalue,newvalue)
+ VALUES (%s,%s,%s,'comment','',%s)
+ """, (self.id, ts, old_author, comment))
+ else:
+ cursor.execute("""
+ UPDATE ticket_change SET newvalue=%s
+ WHERE ticket=%s AND time=%s AND
+ field='comment'
+ """, (comment, self.id, ts))
+
+ def get_comment_history(self, cnum, db=None):
+ db = self._get_db(db)
+ history = []
+ cursor = db.cursor()
+ row = self._find_change(cnum, db)
+ if row:
+ ts0, author0, last_comment = row
+ # Get all fields of the form "_comment%d"
+ cursor.execute("""
+ SELECT field,author,oldvalue,newvalue
+ FROM ticket_change
+ WHERE ticket=%%s AND time=%%s AND field %s
+ """ % db.like(), (self.id, ts0,
+ db.like_escape('_comment') + '%'))
+ rows = sorted((int(field[8:]), author, old, new)
+ for field, author, old, new in cursor)
+ for rev, author, comment, ts in rows:
+ history.append((rev, from_utimestamp(long(ts0)), author0,
+ comment))
+ ts0, author0 = ts, author
+ history.sort()
+ rev = history and (history[-1][0] + 1) or 0
+ history.append((rev, from_utimestamp(long(ts0)), author0,
+ last_comment))
+ return history
+
+ def _find_change(self, cnum, db):
+ """Find a comment by its number."""
+ scnum = str(cnum)
+ cursor = db.cursor()
+ cursor.execute("""
+ SELECT time,author,newvalue FROM ticket_change
+ WHERE ticket=%%s AND field='comment'
+ AND (oldvalue=%%s OR oldvalue %s)
+ """ % db.like(), (self.id, scnum,
+ '%' + db.like_escape('.' + scnum)))
+ for row in cursor:
+ return row
+
+ # Fallback when comment number is not available in oldvalue
+ num = 0
+ cursor.execute("""
+ SELECT DISTINCT tc1.time,COALESCE(tc2.oldvalue,''),
+ tc2.author,COALESCE(tc2.newvalue,'')
+ FROM ticket_change AS tc1
+ LEFT OUTER JOIN
+ (SELECT time,author,oldvalue,newvalue
+ FROM ticket_change
+ WHERE field='comment') AS tc2
+ ON (tc1.time = tc2.time)
+ WHERE ticket=%s
+ ORDER BY tc1.time
+ """, (self.id,))
+ for ts, old, author, comment in cursor:
+ # Use oldvalue if available, else count edits
+ try:
+ num = int(old.rsplit('.', 1)[-1])
+ except ValueError:
+ num += 1
+ if num == cnum:
+ break
+ else:
+ return
+
+ # Find author if NULL
+ if author is None:
+ cursor.execute("""
+ SELECT author FROM ticket_change
+ WHERE ticket=%%s AND time=%%s AND NOT field %s
+ LIMIT 1
+ """ % db.like(), (self.id, ts, db.like_escape('_') + '%'))
+ for author, in cursor:
+ break
+ return (ts, author, comment)
+
def simplify_whitespace(name):
"""Strip spaces and remove duplicate spaces within names"""
- return ' '.join(name.split())
+ if name:
+ return ' '.join(name.split())
+ return name
class AbstractEnum(object):
@@ -368,17 +647,15 @@
self.ticket_col = self.type
self.env = env
if name:
- name = simplify_whitespace(name)
- if name:
if not db:
- db = self.env.get_db_cnx()
+ db = self.env.get_read_db()
cursor = db.cursor()
cursor.execute("SELECT value FROM enum WHERE type=%s AND name=%s",
(self.type, name))
row = cursor.fetchone()
if not row:
raise ResourceNotFound(_('%(type)s %(name)s does not exist.',
- type=self.type, name=name))
+ type=self.type, name=name))
self.value = self._old_value = row[0]
self.name = self._old_name = name
else:
@@ -388,98 +665,100 @@
exists = property(fget=lambda self: self._old_value is not None)
def delete(self, db=None):
+ """Delete the enum value.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot delete non-existent %s' % self.type
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
- cursor = db.cursor()
- self.env.log.info('Deleting %s %s' % (self.type, self.name))
- cursor.execute("DELETE FROM enum WHERE type=%s AND value=%s",
- (self.type, self._old_value))
- # Re-order any enums that have higher value than deleted (close gap)
- for enum in list(self.select(self.env)):
- try:
- if int(enum.value) > int(self._old_value):
- enum.value = unicode(int(enum.value) - 1)
- enum.update(db=db)
- except ValueError:
- pass # Ignore cast error for this non-essential operation
-
- if handle_ta:
- db.commit()
+ @self.env.with_transaction(db)
+ def do_delete(db):
+ cursor = db.cursor()
+ self.env.log.info('Deleting %s %s' % (self.type, self.name))
+ cursor.execute("DELETE FROM enum WHERE type=%s AND value=%s",
+ (self.type, self._old_value))
+ # Re-order any enums that have higher value than deleted
+ # (close gap)
+ for enum in list(self.select(self.env, db)):
+ try:
+ if int(enum.value) > int(self._old_value):
+ enum.value = unicode(int(enum.value) - 1)
+ enum.update()
+ except ValueError:
+ pass # Ignore cast error for this non-essential operation
+ TicketSystem(self.env).reset_ticket_fields()
self.value = self._old_value = None
self.name = self._old_name = None
- TicketSystem(self.env).reset_ticket_fields()
def insert(self, db=None):
+ """Add a new enum value.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert not self.exists, 'Cannot insert existing %s' % self.type
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot create %s with no name' % self.type
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
+ if not self.name:
+ raise TracError(_('Invalid %(type)s name.', type=self.type))
- cursor = db.cursor()
- self.env.log.debug("Creating new %s '%s'" % (self.type, self.name))
- if not self.value:
- cursor.execute(("SELECT COALESCE(MAX(%s),0) FROM enum "
- "WHERE type=%%s") % db.cast('value', 'int'),
- (self.type,))
- self.value = int(float(cursor.fetchone()[0])) + 1
- cursor.execute("INSERT INTO enum (type,name,value) VALUES (%s,%s,%s)",
- (self.type, self.name, self.value))
+ @self.env.with_transaction(db)
+ def do_insert(db):
+ cursor = db.cursor()
+ self.env.log.debug("Creating new %s '%s'" % (self.type, self.name))
+ if not self.value:
+ cursor.execute("""
+ SELECT COALESCE(MAX(%s),0) FROM enum WHERE type=%%s
+ """ % db.cast('value', 'int'), (self.type,))
+ self.value = int(float(cursor.fetchone()[0])) + 1
+ cursor.execute("INSERT INTO enum (type,name,value) "
+ "VALUES (%s,%s,%s)",
+ (self.type, self.name, self.value))
+ TicketSystem(self.env).reset_ticket_fields()
- if handle_ta:
- db.commit()
self._old_name = self.name
self._old_value = self.value
- TicketSystem(self.env).reset_ticket_fields()
def update(self, db=None):
+ """Update the enum value.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot update non-existent %s' % self.type
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot update %s with no name' % self.type
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
+ if not self.name:
+ raise TracError(_('Invalid %(type)s name.', type=self.type))
- cursor = db.cursor()
- self.env.log.info('Updating %s "%s"' % (self.type, self.name))
- cursor.execute("UPDATE enum SET name=%s,value=%s "
- "WHERE type=%s AND name=%s",
- (self.name, self.value, self.type, self._old_name))
- if self.name != self._old_name:
- # Update tickets
- cursor.execute("UPDATE ticket SET %s=%%s WHERE %s=%%s" %
- (self.ticket_col, self.ticket_col),
- (self.name, self._old_name))
+ @self.env.with_transaction(db)
+ def do_update(db):
+ cursor = db.cursor()
+ self.env.log.info('Updating %s "%s"' % (self.type, self.name))
+ cursor.execute("""
+ UPDATE enum SET name=%s,value=%s
+ WHERE type=%s AND name=%s
+ """, (self.name, self.value, self.type, self._old_name))
+ if self.name != self._old_name:
+ # Update tickets
+ cursor.execute("UPDATE ticket SET %s=%%s WHERE %s=%%s" %
+ (self.ticket_col, self.ticket_col),
+ (self.name, self._old_name))
+ TicketSystem(self.env).reset_ticket_fields()
- if handle_ta:
- db.commit()
self._old_name = self.name
self._old_value = self.value
- TicketSystem(self.env).reset_ticket_fields()
+ @classmethod
def select(cls, env, db=None):
if not db:
- db = env.get_db_cnx()
+ db = env.get_read_db()
cursor = db.cursor()
- cursor.execute("SELECT name,value FROM enum WHERE type=%s "
- "ORDER BY " + db.cast('value', 'int'),
- (cls.type,))
+ cursor.execute("""
+ SELECT name,value FROM enum WHERE type=%s
+ ORDER BY
+ """ + db.cast('value', 'int'), (cls.type,))
for name, value in cursor:
obj = cls(env)
obj.name = obj._old_name = name
obj.value = obj._old_value = value
yield obj
- select = classmethod(select)
class Type(AbstractEnum):
@@ -490,12 +769,13 @@
class Status(object):
def __init__(self, env):
self.env = env
+
+ @classmethod
def select(cls, env, db=None):
for state in TicketSystem(env).get_all_status():
status = cls(env)
status.name = state
yield status
- select = classmethod(select)
class Resolution(AbstractEnum):
@@ -515,17 +795,16 @@
def __init__(self, env, name=None, db=None):
self.env = env
if name:
- name = simplify_whitespace(name)
- if name:
if not db:
- db = self.env.get_db_cnx()
+ db = self.env.get_read_db()
cursor = db.cursor()
- cursor.execute("SELECT owner,description FROM component "
- "WHERE name=%s", (name,))
+ cursor.execute("""
+ SELECT owner,description FROM component WHERE name=%s
+ """, (name,))
row = cursor.fetchone()
if not row:
raise ResourceNotFound(_('Component %(name)s does not exist.',
- name=name))
+ name=name))
self.name = self._old_name = name
self.owner = row[0] or None
self.description = row[1] or ''
@@ -537,83 +816,81 @@
exists = property(fget=lambda self: self._old_name is not None)
def delete(self, db=None):
+ """Delete the component.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot delete non-existent component'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
-
- cursor = db.cursor()
- self.env.log.info('Deleting component %s' % self.name)
- cursor.execute("DELETE FROM component WHERE name=%s", (self.name,))
- self.name = self._old_name = None
-
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_delete(db):
+ cursor = db.cursor()
+ self.env.log.info('Deleting component %s' % self.name)
+ cursor.execute("DELETE FROM component WHERE name=%s", (self.name,))
+ self.name = self._old_name = None
+ TicketSystem(self.env).reset_ticket_fields()
def insert(self, db=None):
+ """Insert a new component.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert not self.exists, 'Cannot insert existing component'
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot create component with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
-
- cursor = db.cursor()
- self.env.log.debug("Creating new component '%s'" % self.name)
- cursor.execute("INSERT INTO component (name,owner,description) "
- "VALUES (%s,%s,%s)",
- (self.name, self.owner, self.description))
- self._old_name = self.name
+ if not self.name:
+ raise TracError(_('Invalid component name.'))
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_insert(db):
+ cursor = db.cursor()
+ self.env.log.debug("Creating new component '%s'" % self.name)
+ cursor.execute("""
+ INSERT INTO component (name,owner,description)
+ VALUES (%s,%s,%s)
+ """, (self.name, self.owner, self.description))
+ self._old_name = self.name
+ TicketSystem(self.env).reset_ticket_fields()
def update(self, db=None):
+ """Update the component.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot update non-existent component'
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot update component with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
-
- cursor = db.cursor()
- self.env.log.info('Updating component "%s"' % self.name)
- cursor.execute("UPDATE component SET name=%s,owner=%s,description=%s "
- "WHERE name=%s",
- (self.name, self.owner, self.description,
- self._old_name))
- if self.name != self._old_name:
- # Update tickets
- cursor.execute("UPDATE ticket SET component=%s WHERE component=%s",
- (self.name, self._old_name))
- self._old_name = self.name
+ if not self.name:
+ raise TracError(_('Invalid component name.'))
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_update(db):
+ cursor = db.cursor()
+ self.env.log.info('Updating component "%s"' % self.name)
+ cursor.execute("""
+ UPDATE component SET name=%s,owner=%s, description=%s
+ WHERE name=%s
+ """, (self.name, self.owner, self.description, self._old_name))
+ if self.name != self._old_name:
+ # Update tickets
+ cursor.execute("""
+ UPDATE ticket SET component=%s WHERE component=%s
+ """, (self.name, self._old_name))
+ self._old_name = self.name
+ TicketSystem(self.env).reset_ticket_fields()
+ @classmethod
def select(cls, env, db=None):
if not db:
- db = env.get_db_cnx()
+ db = env.get_read_db()
cursor = db.cursor()
- cursor.execute("SELECT name,owner,description FROM component "
- "ORDER BY name")
+ cursor.execute("""
+ SELECT name,owner,description FROM component ORDER BY name
+ """)
for name, owner, description in cursor:
component = cls(env)
component.name = component._old_name = name
component.owner = owner or None
component.description = description or ''
yield component
- select = classmethod(select)
class Milestone(object):
@@ -622,11 +899,11 @@
self.env = env
if name:
self._fetch(name, db)
- self._old_name = name
else:
- self.name = self._old_name = None
+ self.name = None
self.due = self.completed = None
self.description = ''
+ self._to_old()
def _get_resource(self):
return Resource('milestone', self.name) ### .version !!!
@@ -634,105 +911,122 @@
def _fetch(self, name, db=None):
if not db:
- db = self.env.get_db_cnx()
+ db = self.env.get_read_db()
cursor = db.cursor()
- cursor.execute("SELECT name,due,completed,description "
- "FROM milestone WHERE name=%s", (name,))
+ cursor.execute("""
+ SELECT name,due,completed,description
+ FROM milestone WHERE name=%s
+ """, (name,))
row = cursor.fetchone()
if not row:
- raise ResourceNotFound('Milestone %s does not exist.' % name,
- 'Invalid Milestone Name')
+ raise ResourceNotFound(_('Milestone %(name)s does not exist.',
+ name=name), _('Invalid milestone name'))
self._from_database(row)
- exists = property(fget=lambda self: self._old_name is not None)
+ exists = property(fget=lambda self: self._old['name'] is not None)
is_completed = property(fget=lambda self: self.completed is not None)
is_late = property(fget=lambda self: self.due and \
self.due.date() < date.today())
def _from_database(self, row):
name, due, completed, description = row
- self.name = self._old_name = name
- self.due = due and datetime.fromtimestamp(int(due), utc) or None
- self.completed = completed and \
- datetime.fromtimestamp(int(completed), utc) or None
+ self.name = name
+ self.due = due and from_utimestamp(due) or None
+ self.completed = completed and from_utimestamp(completed) or None
self.description = description or ''
+ self._to_old()
- def delete(self, retarget_to=None, author=None, db=None):
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
+ def _to_old(self):
+ self._old = {'name': self.name, 'due': self.due,
+ 'completed': self.completed,
+ 'description': self.description}
- cursor = db.cursor()
- self.env.log.info('Deleting milestone %s' % self.name)
- cursor.execute("DELETE FROM milestone WHERE name=%s", (self.name,))
+ def delete(self, retarget_to=None, author=None, db=None):
+ """Delete the milestone.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
+ @self.env.with_transaction(db)
+ def do_delete(db):
+ cursor = db.cursor()
+ self.env.log.info('Deleting milestone %s' % self.name)
+ cursor.execute("DELETE FROM milestone WHERE name=%s", (self.name,))
- # Retarget/reset tickets associated with this milestone
- now = datetime.now(utc)
- cursor.execute("SELECT id FROM ticket WHERE milestone=%s", (self.name,))
- tkt_ids = [int(row[0]) for row in cursor]
- for tkt_id in tkt_ids:
- ticket = Ticket(self.env, tkt_id, db)
- ticket['milestone'] = retarget_to
- ticket.save_changes(author, 'Milestone %s deleted' % self.name,
- now, db=db)
- self.name = self._old_name = None
+ # Retarget/reset tickets associated with this milestone
+ now = datetime.now(utc)
+ cursor.execute("SELECT id FROM ticket WHERE milestone=%s",
+ (self.name,))
+ tkt_ids = [int(row[0]) for row in cursor]
+ for tkt_id in tkt_ids:
+ ticket = Ticket(self.env, tkt_id, db)
+ ticket['milestone'] = retarget_to
+ ticket.save_changes(author, 'Milestone %s deleted' % self.name,
+ now)
+ self._old['name'] = None
+ TicketSystem(self.env).reset_ticket_fields()
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ for listener in TicketSystem(self.env).milestone_change_listeners:
+ listener.milestone_deleted(self)
def insert(self, db=None):
- assert self.name, 'Cannot create milestone with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
-
+ """Insert a new milestone.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
self.name = simplify_whitespace(self.name)
- cursor = db.cursor()
- self.env.log.debug("Creating new milestone '%s'" % self.name)
- cursor.execute("INSERT INTO milestone (name,due,completed,description) "
- "VALUES (%s,%s,%s,%s)",
- (self.name, to_timestamp(self.due), to_timestamp(self.completed),
- self.description))
- self._old_name = self.name
+ if not self.name:
+ raise TracError(_('Invalid milestone name.'))
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_insert(db):
+ cursor = db.cursor()
+ self.env.log.debug("Creating new milestone '%s'" % self.name)
+ cursor.execute("""
+ INSERT INTO milestone (name,due,completed,description)
+ VALUES (%s,%s,%s,%s)
+ """, (self.name, to_utimestamp(self.due),
+ to_utimestamp(self.completed), self.description))
+ self._to_old()
+ TicketSystem(self.env).reset_ticket_fields()
- def update(self, db=None):
- assert self.name, 'Cannot update milestone with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
+ for listener in TicketSystem(self.env).milestone_change_listeners:
+ listener.milestone_created(self)
+ def update(self, db=None):
+ """Update the milestone.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
self.name = simplify_whitespace(self.name)
- cursor = db.cursor()
- self.env.log.info('Updating milestone "%s"' % self.name)
- cursor.execute("UPDATE milestone SET name=%s,due=%s,"
- "completed=%s,description=%s WHERE name=%s",
- (self.name, to_timestamp(self.due), to_timestamp(self.completed),
- self.description,
- self._old_name))
- self.env.log.info('Updating milestone field of all tickets '
- 'associated with milestone "%s"' % self.name)
- cursor.execute("UPDATE ticket SET milestone=%s WHERE milestone=%s",
- (self.name, self._old_name))
- self._old_name = self.name
+ if not self.name:
+ raise TracError(_('Invalid milestone name.'))
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_update(db):
+ cursor = db.cursor()
+ self.env.log.info('Updating milestone "%s"' % self.name)
+ cursor.execute("""
+ UPDATE milestone
+ SET name=%s,due=%s, completed=%s,description=%s WHERE name=%s
+ """, (self.name, to_utimestamp(self.due),
+ to_utimestamp(self.completed),
+ self.description, self._old['name']))
+ self.env.log.info('Updating milestone field of all tickets '
+ 'associated with milestone "%s"' % self.name)
+ cursor.execute("UPDATE ticket SET milestone=%s WHERE milestone=%s",
+ (self.name, self._old['name']))
+ TicketSystem(self.env).reset_ticket_fields()
+
+ old_values = dict((k, v) for k, v in self._old.iteritems()
+ if getattr(self, k) != v)
+ self._to_old()
+ for listener in TicketSystem(self.env).milestone_change_listeners:
+ listener.milestone_changed(self, old_values)
+ @classmethod
def select(cls, env, include_completed=True, db=None):
if not db:
- db = env.get_db_cnx()
+ db = env.get_read_db()
sql = "SELECT name,due,completed,description FROM milestone "
if not include_completed:
sql += "WHERE COALESCE(completed,0)=0 "
@@ -748,7 +1042,6 @@
m.due or utcmax,
embedded_numbers(m.name))
return sorted(milestones, key=milestone_order)
- select = classmethod(select)
def group_milestones(milestones, include_completed):
@@ -774,16 +1067,17 @@
self.env = env
if name:
if not db:
- db = self.env.get_db_cnx()
+ db = self.env.get_read_db()
cursor = db.cursor()
- cursor.execute("SELECT time,description FROM version "
- "WHERE name=%s", (name,))
+ cursor.execute("""
+ SELECT time,description FROM version WHERE name=%s
+ """, (name,))
row = cursor.fetchone()
if not row:
raise ResourceNotFound(_('Version %(name)s does not exist.',
- name=name))
+ name=name))
self.name = self._old_name = name
- self.time = row[0] and datetime.fromtimestamp(int(row[0]), utc) or None
+ self.time = row[0] and from_utimestamp(row[0]) or None
self.description = row[1] or ''
else:
self.name = self._old_name = None
@@ -793,83 +1087,78 @@
exists = property(fget=lambda self: self._old_name is not None)
def delete(self, db=None):
+ """Delete the version.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot delete non-existent version'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
- cursor = db.cursor()
- self.env.log.info('Deleting version %s' % self.name)
- cursor.execute("DELETE FROM version WHERE name=%s", (self.name,))
-
- self.name = self._old_name = None
-
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_delete(db):
+ cursor = db.cursor()
+ self.env.log.info('Deleting version %s' % self.name)
+ cursor.execute("DELETE FROM version WHERE name=%s", (self.name,))
+ self.name = self._old_name = None
+ TicketSystem(self.env).reset_ticket_fields()
def insert(self, db=None):
+ """Insert a new version.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert not self.exists, 'Cannot insert existing version'
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot create version with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
-
- cursor = db.cursor()
- self.env.log.debug("Creating new version '%s'" % self.name)
- cursor.execute("INSERT INTO version (name,time,description) "
- "VALUES (%s,%s,%s)",
- (self.name, to_timestamp(self.time), self.description))
- self._old_name = self.name
+ if not self.name:
+ raise TracError(_('Invalid version name.'))
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_insert(db):
+ cursor = db.cursor()
+ self.env.log.debug("Creating new version '%s'" % self.name)
+ cursor.execute("""
+ INSERT INTO version (name,time,description) VALUES (%s,%s,%s)
+ """, (self.name, to_utimestamp(self.time), self.description))
+ self._old_name = self.name
+ TicketSystem(self.env).reset_ticket_fields()
def update(self, db=None):
+ """Update the version.
+
+ The `db` argument is deprecated in favor of `with_transaction()`.
+ """
assert self.exists, 'Cannot update non-existent version'
self.name = simplify_whitespace(self.name)
- assert self.name, 'Cannot update version with no name'
- if not db:
- db = self.env.get_db_cnx()
- handle_ta = True
- else:
- handle_ta = False
+ if not self.name:
+ raise TracError(_('Invalid version name.'))
- cursor = db.cursor()
- self.env.log.info('Updating version "%s"' % self.name)
- cursor.execute("UPDATE version SET name=%s,time=%s,description=%s "
- "WHERE name=%s",
- (self.name, to_timestamp(self.time), self.description,
- self._old_name))
- if self.name != self._old_name:
- # Update tickets
- cursor.execute("UPDATE ticket SET version=%s WHERE version=%s",
- (self.name, self._old_name))
- self._old_name = self.name
-
- if handle_ta:
- db.commit()
- TicketSystem(self.env).reset_ticket_fields()
+ @self.env.with_transaction(db)
+ def do_update(db):
+ cursor = db.cursor()
+ self.env.log.info('Updating version "%s"' % self.name)
+ cursor.execute("""
+ UPDATE version SET name=%s,time=%s,description=%s WHERE name=%s
+ """, (self.name, to_utimestamp(self.time),
+ self.description, self._old_name))
+ if self.name != self._old_name:
+ # Update tickets
+ cursor.execute("UPDATE ticket SET version=%s WHERE version=%s",
+ (self.name, self._old_name))
+ self._old_name = self.name
+ TicketSystem(self.env).reset_ticket_fields()
+ @classmethod
def select(cls, env, db=None):
if not db:
- db = env.get_db_cnx()
+ db = env.get_read_db()
cursor = db.cursor()
cursor.execute("SELECT name,time,description FROM version")
versions = []
for name, time, description in cursor:
version = cls(env)
version.name = version._old_name = name
- version.time = time and datetime.fromtimestamp(int(time), utc) or None
+ version.time = time and from_utimestamp(time) or None
version.description = description or ''
versions.append(version)
def version_order(v):
return (v.time or utcmax, embedded_numbers(v.name))
return sorted(versions, key=version_order, reverse=True)
- select = classmethod(select)
diff -Nru trac-0.11.7/trac/ticket/notification.py trac-0.12.1~ppa2/trac/ticket/notification.py
--- trac-0.11.7/trac/ticket/notification.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/notification.py 2010-04-05 20:05:28.000000000 +0100
@@ -16,15 +16,16 @@
# Author: Daniel Lundin
#
-from trac import __version__
from trac.core import *
from trac.config import *
from trac.notification import NotifyEmail
+from trac.ticket.api import TicketSystem
from trac.util import md5
-from trac.util.datefmt import to_timestamp
-from trac.util.text import CRLF, wrap, to_unicode, obfuscate_email_address
+from trac.util.datefmt import to_utimestamp
+from trac.util.text import CRLF, wrap, obfuscate_email_address
+from trac.util.translation import deactivate, reactivate
-from genshi.template.text import TextTemplate
+from genshi.template.text import NewTextTemplate
class TicketNotificationSystem(Component):
@@ -66,6 +67,17 @@
self.prev_cc = []
def notify(self, ticket, newticket=True, modtime=None):
+ """Send ticket change notification e-mail (untranslated)"""
+ t = deactivate()
+ translated_fields = ticket.fields
+ try:
+ ticket.fields = TicketSystem(self.env).get_ticket_fields()
+ self._notify(ticket, newticket, modtime)
+ finally:
+ ticket.fields = translated_fields
+ reactivate(t)
+
+ def _notify(self, ticket, newticket=True, modtime=None):
self.ticket = ticket
self.modtime = modtime
self.newticket = newticket
@@ -132,12 +144,14 @@
changes_body += ' * %s: %s%s' % (field, chg, CRLF)
if newv:
change_data[field] = {'oldvalue': old, 'newvalue': new}
-
- self.ticket['description'] = wrap(
- self.ticket.values.get('description', ''), self.COLS,
+
+ ticket_values = ticket.values.copy()
+ ticket_values['id'] = ticket.id
+ ticket_values['description'] = wrap(
+ ticket_values.get('description', ''), self.COLS,
initial_indent=' ', subsequent_indent=' ', linesep=CRLF)
- self.ticket['new'] = self.newticket
- self.ticket['link'] = link
+ ticket_values['new'] = self.newticket
+ ticket_values['link'] = link
subject = self.format_subj(summary)
if not self.newticket:
@@ -146,7 +160,7 @@
'ticket_props': self.format_props(),
'ticket_body_hdr': self.format_hdr(),
'subject': subject,
- 'ticket': ticket.values,
+ 'ticket': ticket_values,
'changes_body': changes_body,
'changes_descr': changes_descr,
'change': change_data
@@ -155,7 +169,8 @@
def format_props(self):
tkt = self.ticket
- fields = [f for f in tkt.fields if f['name'] not in ('summary', 'cc')]
+ fields = [f for f in tkt.fields
+ if f['name'] not in ('summary', 'cc', 'time', 'changetime')]
width = [0, 0, 0, 0]
i = 0
for f in [f['name'] for f in fields if f['type'] != 'textarea']:
@@ -170,8 +185,8 @@
if len(fval) > width[idx + 1]:
width[idx + 1] = len(fval)
i += 1
- format = ('%%%is: %%-%is | ' % (width[0], width[1]),
- ' %%%is: %%-%is%s' % (width[2], width[3], CRLF))
+ format = (u'%%%is: %%-%is | ' % (width[0], width[1]),
+ u' %%%is: %%-%is%s' % (width[2], width[3], CRLF))
l = (width[0] + width[1] + 5)
sep = l * '-' + '+' + (self.COLS - l) * '-'
txt = sep + CRLF
@@ -185,9 +200,11 @@
if fname in ['owner', 'reporter']:
fval = obfuscate_email_address(fval)
if f['type'] == 'textarea' or '\n' in unicode(fval):
- big.append((fname.capitalize(), CRLF.join(fval.splitlines())))
+ big.append((f['label'], CRLF.join(fval.splitlines())))
else:
- txt += format[i % 2] % (fname.capitalize(), fval)
+ # Note: f['label'] is a Babel's LazyObject, make sure its
+ # __str__ method won't be called.
+ txt += format[i % 2] % (f['label'], unicode(fval))
i += 1
if i % 2:
txt += CRLF
@@ -216,11 +233,11 @@
def format_subj(self, summary):
template = self.config.get('notification','ticket_subject_template')
- template = TextTemplate(template.encode('utf8'))
+ template = NewTextTemplate(template.encode('utf8'))
prefix = self.config.get('notification', 'smtp_subject_prefix')
if prefix == '__default__':
- prefix = '[%s]' % self.config.get('project', 'name')
+ prefix = '[%s]' % self.env.project_name
data = {
'prefix': prefix,
@@ -260,7 +277,7 @@
if notify_updater:
cursor.execute("SELECT DISTINCT author,ticket FROM ticket_change "
"WHERE ticket=%s", (tktid,))
- for author,ticket in cursor:
+ for author, ticket in cursor:
torecipients.append(author)
# Suppress the updater from the recipients
@@ -290,8 +307,8 @@
def get_message_id(self, rcpt, modtime=None):
"""Generate a predictable, but sufficiently unique message ID."""
- s = '%s.%08d.%d.%s' % (self.config.get('project', 'url'),
- int(self.ticket.id), to_timestamp(modtime),
+ s = '%s.%08d.%d.%s' % (self.env.project_url.encode('utf-8'),
+ int(self.ticket.id), to_utimestamp(modtime),
rcpt.encode('ascii', 'ignore'))
dig = md5(s).hexdigest()
host = self.from_email[self.from_email.find('@') + 1:]
@@ -303,7 +320,7 @@
hdrs = {}
hdrs['Message-ID'] = self.get_message_id(dest, self.modtime)
hdrs['X-Trac-Ticket-ID'] = str(self.ticket.id)
- hdrs['X-Trac-Ticket-URL'] = self.ticket['link']
+ hdrs['X-Trac-Ticket-URL'] = self.data['ticket']['link']
if not self.newticket:
msgid = self.get_message_id(dest)
hdrs['In-Reply-To'] = msgid
diff -Nru trac-0.11.7/trac/ticket/query.py trac-0.12.1~ppa2/trac/ticket/query.py
--- trac-0.11.7/trac/ticket/query.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/query.py 2010-02-22 11:04:30.000000000 +0000
@@ -16,6 +16,7 @@
# Author: Christopher Lenz
import csv
+from itertools import groupby
from math import ceil
from datetime import datetime, timedelta
import re
@@ -30,33 +31,45 @@
from trac.resource import Resource
from trac.ticket.api import TicketSystem
from trac.util import Ranges
-from trac.util.compat import groupby, set
-from trac.util.datefmt import to_timestamp, utc
+from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \
+ to_timestamp, to_utimestamp, utc
from trac.util.presentation import Paginator
-from trac.util.text import shorten_line
-from trac.util.translation import _
-from trac.web import parse_query_string, IRequestHandler
+from trac.util.text import empty, shorten_line, unicode_unquote
+from trac.util.translation import _, tag_
+from trac.web import arg_list_to_args, parse_arg_list, IRequestHandler
from trac.web.href import Href
from trac.web.chrome import add_ctxtnav, add_link, add_script, add_stylesheet, \
- INavigationContributor, Chrome
+ add_warning, INavigationContributor, Chrome
-from trac.wiki.api import IWikiSyntaxProvider, parse_args
+from trac.wiki.api import IWikiSyntaxProvider
from trac.wiki.macros import WikiMacroBase # TODO: should be moved in .api
-class QuerySyntaxError(Exception):
+class QuerySyntaxError(TracError):
"""Exception raised when a ticket query cannot be parsed from a string."""
+class QueryValueError(TracError):
+ """Exception raised when a ticket query has bad constraint values."""
+ def __init__(self, errors):
+ TracError.__init__(self, _('Invalid query constraint value'))
+ self.errors = errors
+
+
class Query(object):
substitutions = ['$USER']
+ clause_re = re.compile(r'(?P\d+)_(?P.+)$')
def __init__(self, env, report=None, constraints=None, cols=None,
order=None, desc=0, group=None, groupdesc=0, verbose=0,
rows=None, page=None, max=None, format=None):
self.env = env
self.id = report # if not None, it's the corresponding saved query
- self.constraints = constraints or {}
- self.order = order
+ constraints = constraints or []
+ if isinstance(constraints, dict):
+ constraints = [constraints]
+ self.constraints = constraints
+ synonyms = TicketSystem(self.env).get_field_synonyms()
+ self.order = synonyms.get(order, order) # 0.11 compatibility
self.desc = desc
self.group = group
self.groupdesc = groupdesc
@@ -101,34 +114,47 @@
if verbose and 'description' not in rows: # 0.10 compatibility
rows.append('description')
self.fields = TicketSystem(self.env).get_ticket_fields()
- field_names = [f['name'] for f in self.fields]
+ self.time_fields = set(f['name'] for f in self.fields
+ if f['type'] == 'time')
+ field_names = set(f['name'] for f in self.fields)
self.cols = [c for c in cols or [] if c in field_names or
- c in ('id', 'time', 'changetime')]
+ c == 'id']
self.rows = [c for c in rows if c in field_names]
if self.order != 'id' and self.order not in field_names:
- # TODO: fix after adding time/changetime to the api.py
- if order == 'created':
- order = 'time'
- elif order == 'modified':
- order = 'changetime'
- if order in ('time', 'changetime'):
- self.order = order
- else:
- self.order = 'priority'
+ self.order = 'priority'
if self.group not in field_names:
self.group = None
+ constraint_cols = {}
+ for clause in self.constraints:
+ for k, v in clause.iteritems():
+ constraint_cols.setdefault(k, []).append(v)
+ self.constraint_cols = constraint_cols
+
+ _clause_splitter = re.compile(r'(?=%%s AND %s<%%s)" % (neg and 'NOT ' or '',
+ col_cast, col_cast),
+ (start, end))
+ elif start is not None:
+ return ("%s%s>=%%s" % (neg and 'NOT ' or '', col_cast),
+ (start, ))
+ elif end is not None:
+ return ("%s%s<%%s" % (neg and 'NOT ' or '', col_cast),
+ (end, ))
+ else:
+ return None
+
+ if mode == '~' and name == 'keywords':
+ words = value.split()
+ clauses, args = [], []
+ for word in words:
+ cneg = ''
+ if word.startswith('-'):
+ cneg = 'NOT '
+ word = word[1:]
+ if not word:
+ continue
+ clauses.append("COALESCE(%s,'') %s%s" % (col, cneg,
+ db.like()))
+ args.append('%' + db.like_escape(word) + '%')
+ if not clauses:
+ return None
+ return ((neg and 'NOT ' or '')
+ + '(' + ' AND '.join(clauses) + ')', args)
+
if mode == '':
- return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''),
- value)
+ return ("COALESCE(%s,'')%s=%%s" % (col, neg and '!' or ''),
+ (value, ))
+
if not value:
return None
- db = self.env.get_db_cnx()
value = db.like_escape(value)
if mode == '~':
value = '%' + value + '%'
@@ -448,94 +534,101 @@
value = value + '%'
elif mode == '$':
value = '%' + value
- return ("COALESCE(%s,'') %s%s" % (name, neg and 'NOT ' or '',
+ return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '',
db.like()),
- value)
+ (value, ))
- clauses = []
- args = []
- for k, v in self.constraints.items():
- if req:
- v = [val.replace('$USER', req.authname) for val in v]
- # Determine the match mode of the constraint (contains,
- # starts-with, negation, etc.)
- neg = v[0].startswith('!')
- mode = ''
- if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'):
- mode = v[0][neg]
+ def get_clause_sql(constraints):
+ db = self.env.get_db_cnx()
+ clauses = []
+ for k, v in constraints.iteritems():
+ if req:
+ v = [val.replace('$USER', req.authname) for val in v]
+ # Determine the match mode of the constraint (contains,
+ # starts-with, negation, etc.)
+ neg = v[0].startswith('!')
+ mode = ''
+ if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'):
+ mode = v[0][neg]
- # Special case id ranges
- if k == 'id':
- ranges = Ranges()
- for r in v:
- r = r.replace('!', '')
- ranges.appendrange(r)
- ids = []
- id_clauses = []
- for a,b in ranges.pairs:
- if a == b:
- ids.append(str(a))
+ # Special case id ranges
+ if k == 'id':
+ ranges = Ranges()
+ for r in v:
+ r = r.replace('!', '')
+ try:
+ ranges.appendrange(r)
+ except Exception:
+ errors.append(_('Invalid ticket id list: '
+ '%(value)s', value=r))
+ ids = []
+ id_clauses = []
+ for a, b in ranges.pairs:
+ if a == b:
+ ids.append(str(a))
+ else:
+ id_clauses.append('id BETWEEN %s AND %s')
+ args.append(a)
+ args.append(b)
+ if ids:
+ id_clauses.append('id IN (%s)' % (','.join(ids)))
+ if id_clauses:
+ clauses.append('%s(%s)' % (neg and 'NOT ' or '',
+ ' OR '.join(id_clauses)))
+ # Special case for exact matches on multiple values
+ elif not mode and len(v) > 1 and k not in self.time_fields:
+ if k not in custom_fields:
+ col = 't.' + k
else:
- id_clauses.append('id BETWEEN %s AND %s')
- args.append(a)
- args.append(b)
- if ids:
- id_clauses.append('id IN (%s)' % (','.join(ids)))
- if id_clauses:
- clauses.append('%s(%s)' % (neg and 'NOT ' or '',
- ' OR '.join(id_clauses)))
- # Special case for exact matches on multiple values
- elif not mode and len(v) > 1:
- if k not in custom_fields:
- col = 't.' + k
- else:
- col = k + '.value'
- clauses.append("COALESCE(%s,'') %sIN (%s)"
- % (col, neg and 'NOT ' or '',
- ','.join(['%s' for val in v])))
- args += [val[neg:] for val in v]
- elif len(v) > 1:
- constraint_sql = filter(None,
- [get_constraint_sql(k, val, mode, neg)
- for val in v])
- if not constraint_sql:
- continue
- if neg:
- clauses.append("(" + " AND ".join(
- [item[0] for item in constraint_sql]) + ")")
- else:
- clauses.append("(" + " OR ".join(
- [item[0] for item in constraint_sql]) + ")")
- args += [item[1] for item in constraint_sql]
- elif len(v) == 1:
- constraint_sql = get_constraint_sql(k, v[0], mode, neg)
- if constraint_sql:
- clauses.append(constraint_sql[0])
- args.append(constraint_sql[1])
+ col = '%s.value' % db.quote(k)
+ clauses.append("COALESCE(%s,'') %sIN (%s)"
+ % (col, neg and 'NOT ' or '',
+ ','.join(['%s' for val in v])))
+ args.extend([val[neg:] for val in v])
+ elif v:
+ constraint_sql = [get_constraint_sql(k, val, mode, neg)
+ for val in v]
+ constraint_sql = filter(None, constraint_sql)
+ if not constraint_sql:
+ continue
+ if neg:
+ clauses.append("(" + " AND ".join(
+ [item[0] for item in constraint_sql]) + ")")
+ else:
+ clauses.append("(" + " OR ".join(
+ [item[0] for item in constraint_sql]) + ")")
+ for item in constraint_sql:
+ args.extend(item[1])
+ return " AND ".join(clauses)
- clauses = filter(None, clauses)
+ args = []
+ errors = []
+ clauses = filter(None, (get_clause_sql(c) for c in self.constraints))
if clauses:
sql.append("\nWHERE ")
- sql.append(" AND ".join(clauses))
+ sql.append(" OR ".join('(%s)' % c for c in clauses))
if cached_ids:
sql.append(" OR ")
- sql.append("id in (%s)" % (','.join(
- [str(id) for id in cached_ids])))
+ sql.append("id in (%s)" %
+ (','.join([str(id) for id in cached_ids])))
sql.append("\nORDER BY ")
order_cols = [(self.order, self.desc)]
if self.group and self.group != self.order:
order_cols.insert(0, (self.group, self.groupdesc))
+
for name, desc in order_cols:
- if name in custom_fields or name in enum_columns:
+ if name in enum_columns:
col = name + '.value'
+ elif name in custom_fields:
+ col = '%s.value' % db.quote(name)
else:
col = 't.' + name
desc = desc and ' DESC' or ''
# FIXME: This is a somewhat ugly hack. Can we also have the
# column type for this? If it's an integer, we do first
# one, if text, we do 'else'
- if name in ('id', 'time', 'changetime'):
+ if name == 'id' or name in self.time_fields:
sql.append("COALESCE(%s,0)=0%s," % (col, desc))
else:
sql.append("COALESCE(%s,'')=''%s," % (col, desc))
@@ -558,51 +651,12 @@
if self.order != 'id':
sql.append(",t.id")
+ if errors:
+ raise QueryValueError(errors)
return "".join(sql), args
- def template_data(self, context, tickets, orig_list=None, orig_time=None,
- req=None):
- constraints = {}
- for k, v in self.constraints.items():
- constraint = {'values': [], 'mode': ''}
- for val in v:
- neg = val.startswith('!')
- if neg:
- val = val[1:]
- mode = ''
- if val[:1] in ('~', '^', '$') \
- and not val in self.substitutions:
- mode, val = val[:1], val[1:]
- constraint['mode'] = (neg and '!' or '') + mode
- constraint['values'].append(val)
- constraints[k] = constraint
-
- cols = self.get_columns()
- labels = dict([(f['name'], f['label']) for f in self.fields])
- wikify = set([f['name'] for f in self.fields
- if f['type'] == 'text' and f.get('format') == 'wiki'])
-
- # TODO: remove after adding time/changetime to the api.py
- labels['changetime'] = _('Modified')
- labels['time'] = _('Created')
-
- headers = [{
- 'name': col, 'label': labels.get(col, _('Ticket')),
- 'wikify': col in wikify,
- 'href': self.get_href(context.href, order=col,
- desc=(col == self.order and not self.desc))
- } for col in cols]
-
- fields = {}
- for field in self.fields:
- if field['name'] == 'owner' and field['type'] == 'select':
- # Make $USER work when restrict_owner = true
- field['options'].insert(0, '$USER')
- field_data = {}
- field_data.update(field)
- del field_data['name']
- fields[field['name']] = field_data
-
+ @staticmethod
+ def get_modes():
modes = {}
modes['text'] = [
{'name': _("contains"), 'value': "~"},
@@ -610,7 +664,7 @@
{'name': _("begins with"), 'value': "^"},
{'name': _("ends with"), 'value': "$"},
{'name': _("is"), 'value': ""},
- {'name': _("is not"), 'value': "!"}
+ {'name': _("is not"), 'value': "!"},
]
modes['textarea'] = [
{'name': _("contains"), 'value': "~"},
@@ -618,8 +672,54 @@
]
modes['select'] = [
{'name': _("is"), 'value': ""},
- {'name': _("is not"), 'value': "!"}
+ {'name': _("is not"), 'value': "!"},
+ ]
+ modes['id'] = [
+ {'name': _("is"), 'value': ""},
+ {'name': _("is not"), 'value': "!"},
]
+ return modes
+
+ def template_data(self, context, tickets, orig_list=None, orig_time=None,
+ req=None):
+ clauses = []
+ for clause in self.constraints:
+ constraints = {}
+ for k, v in clause.items():
+ constraint = {'values': [], 'mode': ''}
+ for val in v:
+ neg = val.startswith('!')
+ if neg:
+ val = val[1:]
+ mode = ''
+ if val[:1] in ('~', '^', '$') \
+ and not val in self.substitutions:
+ mode, val = val[:1], val[1:]
+ constraint['mode'] = (neg and '!' or '') + mode
+ constraint['values'].append(val)
+ constraints[k] = constraint
+ clauses.append(constraints)
+
+ cols = self.get_columns()
+ labels = TicketSystem(self.env).get_ticket_field_labels()
+ wikify = set(f['name'] for f in self.fields
+ if f['type'] == 'text' and f.get('format') == 'wiki')
+
+ headers = [{
+ 'name': col, 'label': labels.get(col, _('Ticket')),
+ 'wikify': col in wikify,
+ 'href': self.get_href(context.href, order=col,
+ desc=(col == self.order and not self.desc))
+ } for col in cols]
+
+ fields = {'id': {'type': 'id', 'label': _("Ticket")}}
+ for field in self.fields:
+ name = field['name']
+ if name == 'owner' and field['type'] == 'select':
+ # Make $USER work when restrict_owner = true
+ field = field.copy()
+ field['options'].insert(0, '$USER')
+ fields[name] = field
groups = {}
groupsequence = []
@@ -685,11 +785,10 @@
'context': context,
'col': cols,
'row': self.rows,
- 'constraints': constraints,
- 'labels': labels,
+ 'clauses': clauses,
'headers': headers,
'fields': fields,
- 'modes': modes,
+ 'modes': self.get_modes(),
'tickets': tickets,
'groups': groupsequence or [(None, tickets)],
'last_group_is_partial': last_group_is_partial,
@@ -774,23 +873,21 @@
self.log.debug('QueryModule: Using default query: %s', str(qstring))
if qstring.startswith('?'):
- ticket_fields = [f['name'] for f in
- TicketSystem(self.env).get_ticket_fields()]
- ticket_fields.append('id')
- args = parse_query_string(qstring[1:])
- constraints = dict([(k, args.getlist(k)) for k in args
- if k in ticket_fields])
+ arg_list = parse_arg_list(qstring[1:])
+ args = arg_list_to_args(arg_list)
+ constraints = self._get_constraints(arg_list=arg_list)
else:
constraints = Query.from_string(self.env, qstring).constraints
# Substitute $USER, or ensure no field constraints that depend
# on $USER are used if we have no username.
- for field, vals in constraints.items():
- for (i, val) in enumerate(vals):
- if user:
- vals[i] = val.replace('$USER', user)
- elif val.endswith('$USER'):
- del constraints[field]
- break
+ for clause in constraints:
+ for field, vals in clause.items():
+ for (i, val) in enumerate(vals):
+ if user:
+ vals[i] = val.replace('$USER', user)
+ elif val.endswith('$USER'):
+ del clause[field]
+ break
cols = args.get('col')
if isinstance(cols, basestring):
@@ -837,43 +934,89 @@
# Internal methods
- def _get_constraints(self, req):
- constraints = {}
- ticket_fields = [f['name'] for f in
- TicketSystem(self.env).get_ticket_fields()]
- ticket_fields.append('id')
-
- # For clients without JavaScript, we remove constraints here if
- # requested
- remove_constraints = {}
- to_remove = [k[10:] for k in req.args.keys()
- if k.startswith('rm_filter_')]
- if to_remove: # either empty or containing a single element
- match = re.match(r'(\w+?)_(\d+)$', to_remove[0])
- if match:
- remove_constraints[match.group(1)] = int(match.group(2))
- else:
- remove_constraints[to_remove[0]] = -1
+ remove_re = re.compile(r'rm_filter_\d+_(.+)_(\d+)$')
+ add_re = re.compile(r'add_(\d+)$')
- for field in [k for k in req.args.keys() if k in ticket_fields]:
- vals = req.args[field]
- if not isinstance(vals, (list, tuple)):
- vals = [vals]
- if vals:
- mode = req.args.get(field + '_mode')
- if mode:
- vals = [mode + x for x in vals]
- if field in remove_constraints:
- idx = remove_constraints[field]
- if idx >= 0:
- del vals[idx]
- if not vals:
- continue
+ def _get_constraints(self, req=None, arg_list=[]):
+ fields = TicketSystem(self.env).get_ticket_fields()
+ synonyms = TicketSystem(self.env).get_field_synonyms()
+ fields = dict((f['name'], f) for f in fields)
+ fields['id'] = {'type': 'id'}
+ fields.update((k, fields[v]) for k, v in synonyms.iteritems())
+
+ clauses = []
+ if req is not None:
+ # For clients without JavaScript, we remove constraints here if
+ # requested
+ remove_constraints = {}
+ for k in req.args:
+ match = self.remove_re.match(k)
+ if match:
+ field = match.group(1)
+ if fields[field]['type'] == 'radio':
+ index = -1
else:
- continue
- constraints[field] = vals
-
- return constraints
+ index = int(match.group(2))
+ remove_constraints[k[10:match.end(1)]] = index
+
+ # Get constraints from form fields, and add a constraint if
+ # requested for clients without JavaScript
+ add_num = None
+ constraints = {}
+ for k, vals in req.args.iteritems():
+ match = self.add_re.match(k)
+ if match:
+ add_num = match.group(1)
+ continue
+ match = Query.clause_re.match(k)
+ if not match:
+ continue
+ field = match.group('field')
+ clause_num = int(match.group('clause'))
+ if field not in fields:
+ continue
+ if not isinstance(vals, (list, tuple)):
+ vals = [vals]
+ if vals:
+ mode = req.args.get(k + '_mode')
+ if mode:
+ vals = [mode + x for x in vals]
+ if fields[field]['type'] == 'time':
+ ends = req.args.getlist(k + '_end')
+ if ends:
+ vals = [start + ';' + end
+ for (start, end) in zip(vals, ends)]
+ if k in remove_constraints:
+ idx = remove_constraints[k]
+ if idx >= 0:
+ del vals[idx]
+ if not vals:
+ continue
+ else:
+ continue
+ field = synonyms.get(field, field)
+ clause = constraints.setdefault(clause_num, {})
+ clause.setdefault(field, []).extend(vals)
+ if add_num is not None:
+ field = req.args.get('add_filter_' + add_num,
+ req.args.get('add_clause_' + add_num))
+ if field:
+ clause = constraints.setdefault(int(add_num), {})
+ modes = Query.get_modes().get(fields[field]['type'])
+ mode = modes and modes[0]['value'] or ''
+ clause.setdefault(field, []).append(mode)
+ clauses.extend(each[1] for each in sorted(constraints.iteritems()))
+
+ # Get constraints from query string
+ clauses.append({})
+ for field, val in arg_list or req.arg_list:
+ if field == "or":
+ clauses.append({})
+ elif field in fields:
+ clauses[-1].setdefault(field, []).append(val)
+ clauses = filter(None, clauses)
+
+ return clauses
def display_html(self, req, query):
db = self.env.get_db_cnx()
@@ -884,18 +1027,23 @@
query_time = int(req.session.get('query_time', 0))
query_time = datetime.fromtimestamp(query_time, utc)
query_constraints = unicode(query.constraints)
- if query_constraints != req.session.get('query_constraints') \
- or query_time < orig_time - timedelta(hours=1):
- tickets = query.execute(req, db)
- # New or outdated query, (re-)initialize session vars
- req.session['query_constraints'] = query_constraints
- req.session['query_tickets'] = ' '.join([str(t['id'])
- for t in tickets])
- else:
- orig_list = [int(id) for id
- in req.session.get('query_tickets', '').split()]
- tickets = query.execute(req, db, orig_list)
- orig_time = query_time
+ try:
+ if query_constraints != req.session.get('query_constraints') \
+ or query_time < orig_time - timedelta(hours=1):
+ tickets = query.execute(req, db)
+ # New or outdated query, (re-)initialize session vars
+ req.session['query_constraints'] = query_constraints
+ req.session['query_tickets'] = ' '.join([str(t['id'])
+ for t in tickets])
+ else:
+ orig_list = [int(id) for id
+ in req.session.get('query_tickets', '').split()]
+ tickets = query.execute(req, db, orig_list)
+ orig_time = query_time
+ except QueryValueError, e:
+ tickets = []
+ for error in e.errors:
+ add_warning(req, error)
context = Context.from_request(req, 'query')
owner_field = [f for f in query.fields if f['name'] == 'owner']
@@ -903,16 +1051,6 @@
TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
data = query.template_data(context, tickets, orig_list, orig_time, req)
- # For clients without JavaScript, we add a new constraint here if
- # requested
- constraints = data['constraints']
- if 'add' in req.args:
- field = req.args.get('add_filter')
- if field:
- constraint = constraints.setdefault(field, {})
- constraint.setdefault('values', []).append('')
- # FIXME: '' not always correct (e.g. checkboxes)
-
req.session['query_href'] = query.get_href(context.href)
req.session['query_time'] = to_timestamp(orig_time)
req.session['query_tickets'] = ' '.join([str(t['id'])
@@ -969,24 +1107,21 @@
if col in ('cc', 'reporter'):
value = Chrome(self.env).format_emails(context(ticket),
value)
+ elif col in query.time_fields:
+ value = format_datetime(value, tzinfo=req.tz)
values.append(unicode(value).encode('utf-8'))
writer.writerow(values)
return (content.getvalue(), '%s;charset=utf-8' % mimetype)
def export_rss(self, req, query):
+ context = Context.from_request(req, 'query', absurls=True)
+ query_href = query.get_href(context.href)
if 'description' not in query.rows:
query.rows.append('description')
db = self.env.get_db_cnx()
results = query.execute(req, db)
- query_href = req.abs_href.query(group=query.group,
- groupdesc=(query.groupdesc and 1
- or None),
- row=query.rows,
- page=req.args.get('page'),
- max=req.args.get('max'),
- **query.constraints)
data = {
- 'context': Context.from_request(req, 'query', absurls=True),
+ 'context': context,
'results': results,
'query_href': query_href
}
@@ -1013,11 +1148,12 @@
href=query.get_href(formatter.context.href),
class_='query')
except QuerySyntaxError, e:
- return tag.em(_('[Error: %(error)s]', error=e), class_='error')
+ return tag.em(_('[Error: %(error)s]', error=unicode(e)),
+ class_='error')
class TicketQueryMacro(WikiMacroBase):
- """Macro that lists tickets that match certain criteria.
+ """Wiki macro listing tickets that match certain criteria.
This macro accepts a comma-separated list of keyed parameters,
in the form "key=value".
@@ -1025,8 +1161,12 @@
If the key is the name of a field, the value must use the syntax
of a filter specifier as defined in TracQuery#QueryLanguage.
Note that this is ''not'' the same as the simplified URL syntax
- used for `query:` links starting with a `?` character.
+ used for `query:` links starting with a `?` character. Commas (`,`)
+ can be included in field values by escaping them with a backslash (`\`).
+ Groups of field constraints to be OR-ed together can be separated by a
+ litteral `or` argument.
+
In addition to filters, several other named parameters can be used
to control how the results are presented. All of them are optional.
@@ -1062,19 +1202,38 @@
The `rows` parameter can be used to specify which field(s) should
be viewed as a row, e.g. `rows=description|summary`
- For compatibility with Trac 0.10, if there's a second positional parameter
+ For compatibility with Trac 0.10, if there's a last positional parameter
given to the macro, it will be used to specify the `format`.
Also, using "&" as a field separator still works (except for `order`)
but is deprecated.
"""
- def expand_macro(self, formatter, name, content):
- req = formatter.req
- query_string = ''
- argv, kwargs = parse_args(content, strict=False)
+ _comma_splitter = re.compile(r'(? 0 and not 'format' in kwargs: # 0.10 compatibility hack
kwargs['format'] = argv[0]
-
if 'order' not in kwargs:
kwargs['order'] = 'id'
if 'max' not in kwargs:
@@ -1082,11 +1241,23 @@
format = kwargs.pop('format', 'list').strip().lower()
if format in ('list', 'compact'): # we need 'status' and 'summary'
- kwargs['col'] = '|'.join(['status', 'summary',
- kwargs.get('col', '')])
+ if 'col' in kwargs:
+ kwargs['col'] = 'status|summary|' + kwargs['col']
+ else:
+ kwargs['col'] = 'status|summary'
- query_string = '&'.join(['%s=%s' % item
- for item in kwargs.iteritems()])
+ query_string = '&or&'.join('&'.join('%s=%s' % item
+ for item in clause.iteritems())
+ for clause in clauses)
+ return query_string, kwargs, format
+
+ def expand_macro(self, formatter, name, content):
+ req = formatter.req
+ query_string, kwargs, format = self.parse_args(content)
+ if query_string:
+ query_string += '&'
+ query_string += '&'.join('%s=%s' % item
+ for item in kwargs.iteritems())
query = Query.from_string(self.env, query_string)
if format == 'count':
@@ -1131,7 +1302,8 @@
"%(query)s", groupvalue=v, groupname=query.group,
query=q.to_string())
# produce the href for the query corresponding to the group
- q.constraints[str(query.group)] = v
+ for constraint in q.constraints:
+ constraint[str(query.group)] = v
q.order = order
href = q.get_href(formatter.context)
groups.append((v, [t for t in g], href, title))
@@ -1150,8 +1322,10 @@
else:
if query.group:
return tag.div(
- [(tag.p(tag.a(query.group, ' ', v, href=href,
- class_='query', title=title)),
+ [(tag.p(tag_('%(groupvalue)s %(groupname)s tickets:',
+ groupvalue=tag.a(v, href=href, class_='query',
+ title=title),
+ groupname=query.group)),
tag.dl([(tag.dt(ticket_anchor(t)),
tag.dd(t['summary'])) for t in g],
class_='wiki compact'))
diff -Nru trac-0.11.7/trac/ticket/report.py trac-0.12.1~ppa2/trac/ticket/report.py
--- trac-0.11.7/trac/ticket/report.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/report.py 2010-04-16 09:54:28.000000000 +0100
@@ -28,14 +28,16 @@
from trac.mimeview import Context
from trac.perm import IPermissionRequestor
from trac.resource import Resource, ResourceNotFound
-from trac.util import sorted
-from trac.util.datefmt import format_datetime, format_time
+from trac.ticket.api import TicketSystem
+from trac.util import as_int
+from trac.util.datefmt import format_datetime, format_time, from_utimestamp
from trac.util.presentation import Paginator
-from trac.util.text import to_unicode, unicode_urlencode
+from trac.util.text import to_unicode
from trac.util.translation import _
from trac.web.api import IRequestHandler, RequestDone
-from trac.web.chrome import add_ctxtnav, add_link, add_notice, \
- add_stylesheet, INavigationContributor, Chrome
+from trac.web.chrome import add_ctxtnav, add_link, add_notice, add_script, \
+ add_stylesheet, add_warning, \
+ INavigationContributor, Chrome
from trac.wiki import IWikiSyntaxProvider, WikiParser
@@ -93,26 +95,29 @@
id = int(req.args.get('id', -1))
action = req.args.get('action', 'view')
- db = self.env.get_db_cnx()
-
data = {}
if req.method == 'POST':
if action == 'new':
- self._do_create(req, db)
+ self._do_create(req)
elif action == 'delete':
- self._do_delete(req, db, id)
+ self._do_delete(req, id)
elif action == 'edit':
- self._do_save(req, db, id)
+ self._do_save(req, id)
elif action in ('copy', 'edit', 'new'):
template = 'report_edit.html'
- data = self._render_editor(req, db, id, action=='copy')
+ data = self._render_editor(req, id, action=='copy')
+ Chrome(self.env).add_wiki_toolbars(req)
elif action == 'delete':
template = 'report_delete.html'
- data = self._render_confirm_delete(req, db, id)
+ data = self._render_confirm_delete(req, id)
+ elif id == -1:
+ template, data, content_type = self._render_list(req)
+ if content_type: # i.e. alternate format
+ return template, data, content_type
else:
- template, data, content_type = self._render_view(req, db, id)
+ template, data, content_type = self._render_view(req, id)
if content_type: # i.e. alternate format
- return template, data, content_type
+ return template, data, content_type
if id != -1 or action == 'new':
add_ctxtnav(req, _('Available Reports'), href=req.href.report())
@@ -135,7 +140,7 @@
# Internal methods
- def _do_create(self, req, db):
+ def _do_create(self, req):
req.perm.require('REPORT_CREATE')
if 'cancel' in req.args:
@@ -144,27 +149,30 @@
title = req.args.get('title', '')
query = req.args.get('query', '')
description = req.args.get('description', '')
- cursor = db.cursor()
- cursor.execute("INSERT INTO report (title,query,description) "
- "VALUES (%s,%s,%s)", (title, query, description))
- id = db.get_last_id(cursor, 'report')
- db.commit()
+ report_id = [ None ]
+ @self.env.with_transaction()
+ def do_create(db):
+ cursor = db.cursor()
+ cursor.execute("INSERT INTO report (title,query,description) "
+ "VALUES (%s,%s,%s)", (title, query, description))
+ report_id[0] = db.get_last_id(cursor, 'report')
add_notice(req, _('The report has been created.'))
- req.redirect(req.href.report(id))
+ req.redirect(req.href.report(report_id[0]))
- def _do_delete(self, req, db, id):
+ def _do_delete(self, req, id):
req.perm.require('REPORT_DELETE')
if 'cancel' in req.args:
req.redirect(req.href.report(id))
- cursor = db.cursor()
- cursor.execute("DELETE FROM report WHERE id=%s", (id,))
- db.commit()
+ @self.env.with_transaction()
+ def do_delete(db):
+ cursor = db.cursor()
+ cursor.execute("DELETE FROM report WHERE id=%s", (id,))
add_notice(req, _('The report {%(id)d} has been deleted.', id=id))
req.redirect(req.href.report())
- def _do_save(self, req, db, id):
+ def _do_save(self, req, id):
"""Save report changes to the database"""
req.perm.require('REPORT_MODIFY')
@@ -172,16 +180,19 @@
title = req.args.get('title', '')
query = req.args.get('query', '')
description = req.args.get('description', '')
- cursor = db.cursor()
- cursor.execute("UPDATE report SET title=%s,query=%s,description=%s "
- "WHERE id=%s", (title, query, description, id))
- db.commit()
+ @self.env.with_transaction()
+ def do_save(db):
+ cursor = db.cursor()
+ cursor.execute("UPDATE report "
+ "SET title=%s,query=%s,description=%s "
+ "WHERE id=%s", (title, query, description, id))
add_notice(req, _('Your changes have been saved.'))
req.redirect(req.href.report(id))
- def _render_confirm_delete(self, req, db, id):
+ def _render_confirm_delete(self, req, id):
req.perm.require('REPORT_DELETE')
+ db = self.env.get_db_cnx()
cursor = db.cursor()
cursor.execute("SELECT title FROM report WHERE id=%s", (id,))
for title, in cursor:
@@ -190,19 +201,20 @@
'action': 'delete',
'report': {'id': id, 'title': title}}
else:
- raise TracError(_('Report %(num)s does not exist.', num=id),
+ raise TracError(_('Report {%(num)s} does not exist.', num=id),
_('Invalid Report Number'))
- def _render_editor(self, req, db, id, copy):
+ def _render_editor(self, req, id, copy):
if id != -1:
req.perm.require('REPORT_MODIFY')
+ db = self.env.get_db_cnx()
cursor = db.cursor()
cursor.execute("SELECT title,description,query FROM report "
"WHERE id=%s", (id,))
for title, description, query in cursor:
break
else:
- raise TracError(_('Report %(num)s does not exist.', num=id),
+ raise TracError(_('Report {%(num)s} does not exist.', num=id),
_('Invalid Report Number'))
else:
req.perm.require('REPORT_CREATE')
@@ -228,31 +240,67 @@
'sql': query, 'description': description}
return data
- def _render_view(self, req, db, id):
+ def _render_list(self, req):
+ """Render the list of available reports."""
+ sort = req.args.get('sort', 'report')
+ asc = bool(int(req.args.get('asc', 1)))
+ format = req.args.get('format')
+
+ db = self.env.get_db_cnx()
+ cursor = db.cursor()
+ cursor.execute("SELECT id, title FROM report ORDER BY %s%s"
+ % (sort == 'title' and 'title' or 'id',
+ not asc and ' DESC' or ''))
+ rows = list(cursor)
+
+ if format == 'rss':
+ data = {'rows': rows}
+ return 'report_list.rss', data, 'application/rss+xml'
+ elif format == 'csv':
+ self._send_csv(req, ['report', 'title'], rows, mimetype='text/csv',
+ filename='reports.csv')
+ elif format == 'tab':
+ self._send_csv(req, ['report', 'title'], rows, '\t',
+ mimetype='text/tab-separated-values',
+ filename='reports.tsv')
+
+ def report_href(**kwargs):
+ return req.href.report(sort=req.args.get('sort'),
+ asc=asc and '1' or '0', **kwargs)
+
+ add_link(req, 'alternate',
+ report_href(format='rss'),
+ _('RSS Feed'), 'application/rss+xml', 'rss')
+ add_link(req, 'alternate', report_href(format='csv'),
+ _('Comma-delimited Text'), 'text/plain')
+ add_link(req, 'alternate', report_href(format='tab'),
+ _('Tab-delimited Text'), 'text/plain')
+
+ reports = [(id, title, 'REPORT_MODIFY' in req.perm('report', id),
+ 'REPORT_DELETE' in req.perm('report', id))
+ for id, title in rows]
+ data = {'reports': reports, 'sort': sort, 'asc': asc}
+
+ return 'report_list.html', data, None
+
+ def _render_view(self, req, id):
"""Retrieve the report results and pre-process them for rendering."""
+ db = self.env.get_db_cnx()
+ cursor = db.cursor()
+ cursor.execute("SELECT title,query,description from report "
+ "WHERE id=%s", (id,))
+ for title, sql, description in cursor:
+ break
+ else:
+ raise ResourceNotFound(
+ _('Report {%(num)s} does not exist.', num=id),
+ _('Invalid Report Number'))
+
try:
args = self.get_var_args(req)
- except ValueError,e:
+ except ValueError, e:
raise TracError(_('Report failed: %(error)s', error=e))
- if id == -1:
- # If no particular report was requested, display
- # a list of available reports instead
- title = _('Available Reports')
- sql = ("SELECT id AS report, title, 'report' as _realm "
- "FROM report ORDER BY report")
- description = _('This is a list of available reports.')
- else:
- cursor = db.cursor()
- cursor.execute("SELECT title,query,description from report "
- "WHERE id=%s", (id,))
- for title, sql, description in cursor:
- break
- else:
- raise ResourceNotFound(
- _('Report %(num)s does not exist.', num=id),
- _('Invalid Report Number'))
-
# If this is a saved custom query. redirect to the query module
#
# A saved query is either an URL query (?... or query:?...),
@@ -287,27 +335,50 @@
if format == 'sql':
self._send_sql(req, id, title, description, sql)
- if id > 0:
- title = '{%i} %s' % (id, title)
+ title = '{%i} %s' % (id, title)
report_resource = Resource('report', id)
req.perm.require('REPORT_VIEW', report_resource)
context = Context.from_request(req, report_resource)
+
+ page = int(req.args.get('page', '1'))
+ default_max = {'rss': self.items_per_page_rss,
+ 'csv': 0, 'tab': 0}.get(format, self.items_per_page)
+ max = req.args.get('max')
+ limit = as_int(max, default_max, min=0) # explict max takes precedence
+ offset = (page - 1) * limit
+
+ sort_col = req.args.get('sort', '')
+ asc = req.args.get('asc', 1)
+ asc = bool(int(asc)) # string '0' or '1' to int/boolean
+
+ def report_href(**kwargs):
+ """Generate links to this report preserving user variables,
+ and sorting and paging variables.
+ """
+ params = args.copy()
+ if sort_col:
+ params['sort'] = sort_col
+ params['page'] = page
+ if max:
+ params['max'] = max
+ params.update(kwargs)
+ params['asc'] = params.get('asc', asc) and '1' or '0'
+ return req.href.report(id, params)
+
data = {'action': 'view',
'report': {'id': id, 'resource': report_resource},
'context': context,
'title': title, 'description': description,
- 'args': args, 'message': None, 'paginator':None}
-
- page = int(req.args.get('page', '1'))
- limit = {'rss': self.items_per_page_rss,
- 'csv': 0, 'tab': 0}.get(format, self.items_per_page)
- offset = (page - 1) * limit
- user = req.args.get('USER', None)
+ 'max': limit, 'args': args, 'show_args_form': False,
+ 'message': None, 'paginator': None,
+ 'report_href': report_href
+ }
try:
- cols, results, num_items = self.execute_paginated_report(
- req, db, id, sql, args, limit, offset)
+ cols, results, num_items, missing_args = \
+ self.execute_paginated_report(req, db, id, sql, args, limit,
+ offset)
results = [list(row) for row in results]
numrows = len(results)
@@ -316,27 +387,23 @@
data['message'] = _('Report execution failed: %(error)s',
error=to_unicode(e))
return 'report_view.html', data, None
+
paginator = None
- if id != -1 and limit > 0:
- asc = req.args.get('asc', None)
- sort_col = req.args.get('sort', None)
+ if limit > 0:
paginator = Paginator(results, page - 1, limit, num_items)
data['paginator'] = paginator
if paginator.has_next_page:
- next_href = req.href.report(id, asc=asc, sort=sort_col,
- page=page + 1, **args)
- add_link(req, 'next', next_href, _('Next Page'))
+ add_link(req, 'next', report_href(page=page + 1),
+ _('Next Page'))
if paginator.has_previous_page:
- prev_href = req.href.report(id, asc=asc, sort=sort_col,
- page=page - 1, **args)
- add_link(req, 'prev', prev_href, _('Previous Page'))
+ add_link(req, 'prev', report_href(page=page - 1),
+ _('Previous Page'))
pagedata = []
shown_pages = paginator.get_shown_pages(21)
for p in shown_pages:
- pagedata.append([req.href.report(id, asc=asc, sort=sort_col,
- page=p, **args),
- None, str(p), _('Page %(num)d', num=p)])
+ pagedata.append([report_href(page=p), None, str(p),
+ _('Page %(num)d', num=p)])
fields = ['href', 'class', 'string', 'title']
paginator.shown_pages = [dict(zip(fields, p)) for p in pagedata]
paginator.current_page = {'href': None, 'class': 'current',
@@ -344,18 +411,21 @@
'title': None}
numrows = paginator.num_items
- sort_col = req.args.get('sort', '')
- asc = req.args.get('asc', 1)
- asc = bool(int(asc)) # string '0' or '1' to int/boolean
-
# Place retrieved columns in groups, according to naming conventions
# * _col_ means fullrow, i.e. a group with one header
# * col_ means finish the current group and start a new one
+
+ field_labels = TicketSystem(self.env).get_ticket_field_labels()
+
header_groups = [[]]
for idx, col in enumerate(cols):
+ if col in field_labels:
+ title = field_labels[col]
+ else:
+ title = col.strip('_').capitalize()
header = {
'col': col,
- 'title': col.strip('_').capitalize(),
+ 'title': title,
'hidden': False,
'asc': False
}
@@ -432,7 +502,8 @@
# Other row properties
row['__idx__'] = row_idx
if col in ('__style__', '__color__',
- '__fgcolor__', '__bgcolor__'):
+ '__fgcolor__', '__bgcolor__',
+ '__grouplink__'):
row[col] = value
if col in ('report', 'ticket', 'id', '_id'):
row['id'] = value
@@ -475,9 +546,6 @@
'sorting_enabled': len(row_groups)==1,
'email_map': email_map})
- if id and id != -1:
- self.add_alternate_links(req, args)
-
if format == 'rss':
data['context'] = Context.from_request(req, report_resource,
absurls=True)
@@ -492,46 +560,44 @@
mimetype='text/tab-separated-values',
filename=filename)
else:
- if id != -1:
- # reuse the session vars of the query module so that
- # the query navigation links on the ticket can be used to
- # navigate report results as well
- try:
- req.session['query_tickets'] = \
- ' '.join([str(int(row['id']))
- for rg in row_groups for row in rg[1]])
- req.session['query_href'] = \
- req.href.report(id, asc=req.args.get('asc', None),
- sort=req.args.get('sort', None),
- page=page, **args)
- # Kludge: we have to clear the other query session
- # variables, but only if the above succeeded
- for var in ('query_constraints', 'query_time'):
- if var in req.session:
- del req.session[var]
- except (ValueError, KeyError):
- pass
+ p = max is not None and page or None
+ add_link(req, 'alternate',
+ report_href(format='rss', page=None),
+ _('RSS Feed'), 'application/rss+xml', 'rss')
+ add_link(req, 'alternate', report_href(format='csv', page=p),
+ _('Comma-delimited Text'), 'text/plain')
+ add_link(req, 'alternate', report_href(format='tab', page=p),
+ _('Tab-delimited Text'), 'text/plain')
+ if 'REPORT_SQL_VIEW' in req.perm:
+ add_link(req, 'alternate',
+ req.href.report(id=id, format='sql'),
+ _('SQL Query'), 'text/plain')
+
+ # reuse the session vars of the query module so that
+ # the query navigation links on the ticket can be used to
+ # navigate report results as well
+ try:
+ req.session['query_tickets'] = \
+ ' '.join([str(int(row['id']))
+ for rg in row_groups for row in rg[1]])
+ req.session['query_href'] = \
+ req.session['query_href'] = report_href()
+ # Kludge: we have to clear the other query session
+ # variables, but only if the above succeeded
+ for var in ('query_constraints', 'query_time'):
+ if var in req.session:
+ del req.session[var]
+ except (ValueError, KeyError):
+ pass
+ if set(data['args']) - set(['USER']):
+ data['show_args_form'] = True
+ add_script(req, 'common/js/folding.js')
+ if missing_args:
+ add_warning(req, _(
+ 'The following arguments are missing: %(args)s',
+ args=", ".join(missing_args)))
return 'report_view.html', data, None
- def add_alternate_links(self, req, args):
- params = args.copy()
- if 'sort' in req.args:
- params['sort'] = req.args['sort']
- if 'asc' in req.args:
- params['asc'] = req.args['asc']
- href = ''
- if params:
- href = '&' + unicode_urlencode(params)
- add_link(req, 'alternate', '?format=rss' + href, _('RSS Feed'),
- 'application/rss+xml', 'rss')
- add_link(req, 'alternate', '?format=csv' + href,
- _('Comma-delimited Text'), 'text/plain')
- add_link(req, 'alternate', '?format=tab' + href,
- _('Tab-delimited Text'), 'text/plain')
- if 'REPORT_SQL_VIEW' in req.perm:
- add_link(req, 'alternate', '?format=sql', _('SQL Query'),
- 'text/plain')
-
def execute_report(self, req, db, id, sql, args):
"""Execute given sql report (0.10 backward compatibility method)
@@ -541,9 +607,9 @@
def execute_paginated_report(self, req, db, id, sql, args,
limit=0, offset=0):
- sql, args = self.sql_sub_vars(sql, args, db)
+ sql, args, missing_args = self.sql_sub_vars(sql, args, db)
if not sql:
- raise TracError(_('Report %(num)s has no SQL query.', num=id))
+ raise TracError(_('Report {%(num)s} has no SQL query.', num=id))
self.log.debug('Executing report with SQL "%s"' % sql)
self.log.debug('Request args: %r' % req.args)
cursor = db.cursor()
@@ -584,7 +650,7 @@
order = ', '.join(order_cols)
order_by = " ".join([' ORDER BY', order, asc_str])
sql = " ".join(['SELECT * FROM (', sql, ') AS tab', order_by])
- sql =" ".join([sql, 'LIMIT', str(limit), 'OFFSET', str(offset)])
+ sql = " ".join([sql, 'LIMIT', str(limit), 'OFFSET', str(offset)])
self.log.debug("Query SQL: " + sql)
cursor.execute(sql, args)
# FIXME: fetchall should probably not be used.
@@ -593,9 +659,10 @@
db.rollback()
- return cols, info, num_items
+ return cols, info, num_items, missing_args
def get_var_args(self, req):
+ # FIXME unicode: req.args keys are likely not unicode but str (UTF-8?)
report_args = {}
for arg in req.args.keys():
if not arg.isupper():
@@ -611,16 +678,19 @@
def sql_sub_vars(self, sql, args, db=None):
if db is None:
db = self.env.get_db_cnx()
+ names = set()
values = []
+ missing_args = []
def add_value(aname):
+ names.add(aname)
try:
arg = args[aname]
except KeyError:
- raise TracError(_("Dynamic variable '%(name)s' not defined.",
- name='$%s' % aname))
+ arg = args[str(aname)] = ''
+ missing_args.append(aname)
values.append(arg)
- var_re = re.compile("[$]([A-Z]+)")
+ var_re = re.compile("[$]([A-Z_][A-Z0-9_]*)")
# simple parameter substitution outside literal
def repl(match):
@@ -648,15 +718,19 @@
sql_io.write(repl_literal(expr))
else:
sql_io.write(var_re.sub(repl, expr))
- return sql_io.getvalue(), values
+
+ # Remove arguments that don't appear in the SQL query
+ for name in set(args) - names:
+ del args[name]
+ return sql_io.getvalue(), values, missing_args
def _send_csv(self, req, cols, rows, sep=',', mimetype='text/plain',
filename=None):
def iso_time(t):
- return format_time(t, 'iso8601')
+ return format_time(from_utimestamp(t), 'iso8601')
def iso_datetime(dt):
- return format_datetime(dt, 'iso8601')
+ return format_datetime(from_utimestamp(dt), 'iso8601')
col_conversions = {
'time': iso_time,
diff -Nru trac-0.11.7/trac/ticket/roadmap.py trac-0.12.1~ppa2/trac/ticket/roadmap.py
--- trac-0.11.7/trac/ticket/roadmap.py 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/roadmap.py 2010-04-20 22:17:52.000000000 +0100
@@ -18,7 +18,6 @@
from StringIO import StringIO
from datetime import datetime
import re
-from time import localtime, strftime, time
from genshi.builder import tag
@@ -30,18 +29,17 @@
from trac.perm import IPermissionRequestor
from trac.resource import *
from trac.search import ISearchSource, search_to_sql, shorten_result
-from trac.util.compat import set, sorted
-from trac.util.datefmt import parse_date, utc, to_timestamp, to_datetime, \
+from trac.util.datefmt import parse_date, utc, to_utimestamp, \
get_date_format_hint, get_datetime_format_hint, \
- format_date, format_datetime
-from trac.util.text import shorten_line, CRLF, to_unicode
-from trac.util.translation import _
+ format_date, format_datetime, from_utimestamp
+from trac.util.text import CRLF
+from trac.util.translation import _, tag_
from trac.ticket import Milestone, Ticket, TicketSystem, group_milestones
-from trac.ticket.query import Query, QueryModule
+from trac.ticket.query import QueryModule
from trac.timeline.api import ITimelineEventProvider
from trac.web import IRequestHandler, RequestDone
-from trac.web.chrome import add_link, add_notice, add_stylesheet, \
- add_warning, INavigationContributor
+from trac.web.chrome import add_link, add_notice, add_script, add_stylesheet, \
+ add_warning, Chrome, INavigationContributor
from trac.wiki.api import IWikiSyntaxProvider
from trac.wiki.formatter import format_to
@@ -61,7 +59,7 @@
`title` is the display name of this group of stats (e.g.
'ticket status').
- `unit` is the display name of the units for these stats (e.g. 'hour').
+ `unit` is the units for these stats in plural form, e.g. _('hours')
"""
self.title = title
self.unit = unit
@@ -204,7 +202,7 @@
for s, cnt in cursor:
status_cnt[s] = cnt
- stat = TicketGroupStats('ticket status', 'ticket')
+ stat = TicketGroupStats(_('ticket status'), _('tickets'))
remaining_statuses = set(all_statuses)
groups = self._get_ticket_groups()
catch_all_group = None
@@ -328,14 +326,20 @@
return req.path_info == '/roadmap'
def process_request(self, req):
- milestone_realm = Resource('milestone')
req.perm.require('MILESTONE_VIEW')
- showall = req.args.get('show') == 'all'
+ show = req.args.getlist('show')
+ if 'all' in show:
+ show = ['completed']
db = self.env.get_db_cnx()
- milestones = [m for m in Milestone.select(self.env, showall, db)
+ milestones = Milestone.select(self.env, 'completed' in show, db)
+ if 'noduedate' in show:
+ milestones = [m for m in milestones
+ if m.due is not None or m.completed]
+ milestones = [m for m in milestones
if 'MILESTONE_VIEW' in req.perm(m.resource)]
+
stats = []
queries = []
@@ -356,8 +360,7 @@
username = None
if req.authname and req.authname != 'anonymous':
username = req.authname
- icshref = req.href.roadmap(show=req.args.get('show'), user=username,
- format='ics')
+ icshref = req.href.roadmap(show=show, user=username, format='ics')
add_link(req, 'alternate', icshref, _('iCalendar'), 'text/calendar',
'ics')
@@ -365,8 +368,9 @@
'milestones': milestones,
'milestone_stats': stats,
'queries': queries,
- 'showall': showall,
+ 'show': show,
}
+ add_stylesheet(req, 'common/css/roadmap.css')
return 'roadmap.html', data, None
# Internal methods
@@ -392,7 +396,8 @@
elif status == 'assigned' or status == 'reopened':
return 'IN-PROCESS'
elif status == 'closed':
- if ticket['resolution'] == 'fixed': return 'COMPLETED'
+ if ticket['resolution'] == 'fixed':
+ return 'COMPLETED'
else: return 'CANCELLED'
else: return ''
@@ -405,7 +410,8 @@
+ ':' + escape_value(value)
firstline = 1
while text:
- if not firstline: text = ' ' + text
+ if not firstline:
+ text = ' ' + text
else: firstline = 0
buf.write(text[:75] + CRLF)
text = text[75:]
@@ -427,7 +433,7 @@
% __version__)
write_prop('METHOD', 'PUBLISH')
write_prop('X-WR-CALNAME',
- self.config.get('project', 'name') + ' - ' + _('Roadmap'))
+ self.env.project_name + ' - ' + _('Roadmap'))
for milestone in milestones:
uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone.name,
host)
@@ -436,9 +442,8 @@
write_prop('UID', uid)
write_utctime('DTSTAMP', milestone.due)
write_date('DTSTART', milestone.due)
- write_prop('SUMMARY', _('Milestone %(name)s') % {
- 'name': milestone.name
- })
+ write_prop('SUMMARY', _('Milestone %(name)s',
+ name=milestone.name))
write_prop('URL', req.base_url + '/milestone/' +
milestone.name)
if milestone.description:
@@ -456,9 +461,9 @@
if milestone.due:
write_prop('RELATED-TO', uid)
write_date('DUE', milestone.due)
- write_prop('SUMMARY', _('Ticket #%(num)s: %(summary)s') % {
- 'num': ticket.id, 'summary': ticket['summary']
- })
+ write_prop('SUMMARY', _('Ticket #%(num)s: %(summary)s',
+ num=ticket.id,
+ summary=ticket['summary']))
write_prop('URL', req.abs_href.ticket(ticket.id))
write_prop('DESCRIPTION', ticket['description'])
priority = get_priority(ticket)
@@ -473,7 +478,7 @@
(ticket.id,))
row = cursor.fetchone()
if row:
- write_utctime('COMPLETED', to_datetime(row[0], utc))
+ write_utctime('COMPLETED', from_utimestamp(row[0]))
write_prop('END', 'VTODO')
write_prop('END', 'VCALENDAR')
@@ -484,7 +489,6 @@
raise RequestDone
-
class MilestoneModule(Component):
implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
@@ -518,7 +522,7 @@
def get_timeline_filters(self, req):
if 'MILESTONE_VIEW' in req.perm:
- yield ('milestone', _('Milestones'))
+ yield ('milestone', _('Milestones reached'))
def get_timeline_events(self, req, start, stop, filters):
if 'milestone' in filters:
@@ -528,11 +532,11 @@
# TODO: creation and (later) modifications should also be reported
cursor.execute("SELECT completed,name,description FROM milestone "
"WHERE completed>=%s AND completed<=%s",
- (to_timestamp(start), to_timestamp(stop)))
+ (to_utimestamp(start), to_utimestamp(stop)))
for completed, name, description in cursor:
milestone = milestone_realm(id=name)
if 'MILESTONE_VIEW' in req.perm(milestone):
- yield('milestone', datetime.fromtimestamp(completed, utc),
+ yield('milestone', from_utimestamp(completed),
'', (milestone, description)) # FIXME: author?
# Attachments
@@ -545,7 +549,8 @@
if field == 'url':
return context.href.milestone(milestone.id)
elif field == 'title':
- return tag('Milestone ', tag.em(milestone.id), ' completed')
+ return tag_('Milestone %(name)s completed',
+ name=tag.em(milestone.id))
elif field == 'description':
return format_to(self.env, None, context(resource=milestone),
description)
@@ -566,8 +571,15 @@
add_link(req, 'up', req.href.roadmap(), _('Roadmap'))
db = self.env.get_db_cnx() # TODO: db can be removed
- milestone = Milestone(self.env, milestone_id, db)
action = req.args.get('action', 'view')
+ try:
+ milestone = Milestone(self.env, milestone_id, db)
+ except ResourceNotFound:
+ if 'MILESTONE_CREATE' not in req.perm('milestone', milestone_id):
+ raise
+ milestone = Milestone(self.env, None, db)
+ milestone.name = milestone_id
+ action = 'edit' # rather than 'new' so that it works for POST/save
if req.method == 'POST':
if req.args.has_key('cancel'):
@@ -578,7 +590,7 @@
elif action == 'edit':
return self._do_save(req, db, milestone)
elif action == 'delete':
- self._do_delete(req, db, milestone)
+ self._do_delete(req, milestone)
elif action in ('new', 'edit'):
return self._render_editor(req, db, milestone)
elif action == 'delete':
@@ -591,14 +603,13 @@
# Internal methods
- def _do_delete(self, req, db, milestone):
+ def _do_delete(self, req, milestone):
req.perm(milestone.resource).require('MILESTONE_DELETE')
retarget_to = None
if req.args.has_key('retarget'):
retarget_to = req.args.get('target') or None
milestone.delete(retarget_to, req.authname)
- db.commit()
add_notice(req, _('The milestone "%(name)s" has been deleted.',
name=milestone.name))
req.redirect(req.href.roadmap())
@@ -628,20 +639,22 @@
warnings.append(msg)
# -- check the name
- if new_name:
- if new_name != old_name:
- # check that the milestone doesn't already exists
- # FIXME: the whole .exists business needs to be clarified
- # (#4130) and should behave like a WikiPage does in
- # this respect.
- try:
- other_milestone = Milestone(self.env, new_name, db)
- warn(_('Milestone "%(name)s" already exists, please '
- 'choose another name', name=new_name))
- except ResourceNotFound:
- milestone.name = new_name
- else:
- warn(_('You must provide a name for the milestone.'))
+ # If the name has changed, check that the milestone doesn't already
+ # exist
+ # FIXME: the whole .exists business needs to be clarified
+ # (#4130) and should behave like a WikiPage does in
+ # this respect.
+ try:
+ new_milestone = Milestone(self.env, new_name, db)
+ if new_milestone.name == old_name:
+ pass # Creation or no name change
+ elif new_milestone.name:
+ warn(_('Milestone "%(name)s" already exists, please '
+ 'choose another name.', name=new_milestone.name))
+ else:
+ warn(_('You must provide a name for the milestone.'))
+ except ResourceNotFound:
+ milestone.name = new_name
# -- check completed date
if 'completed' in req.args:
@@ -660,15 +673,16 @@
milestone.update()
# eventually retarget opened tickets associated with the milestone
if 'retarget' in req.args and completed:
- cursor = db.cursor()
- cursor.execute("UPDATE ticket SET milestone=%s WHERE "
- "milestone=%s and status != 'closed'",
- (retarget_to, old_name))
+ @self.env.with_transaction()
+ def retarget(db):
+ cursor = db.cursor()
+ cursor.execute("UPDATE ticket SET milestone=%s WHERE "
+ "milestone=%s and status != 'closed'",
+ (retarget_to, old_name))
self.env.log.info('Tickets associated with milestone %s '
'retargeted to %s' % (old_name, retarget_to))
else:
milestone.insert()
- db.commit()
add_notice(req, _('Your changes have been saved.'))
req.redirect(req.href.milestone(milestone.name))
@@ -704,6 +718,7 @@
else:
req.perm(milestone.resource).require('MILESTONE_CREATE')
+ Chrome(self.env).add_wiki_toolbars(req)
return 'milestone_edit.html', data, None
def _render_view(self, req, db, milestone):
@@ -782,6 +797,8 @@
percent = float(gstat.count) / float(max_count) * 100
gs_dict['percent_of_max_total'] = percent
+ add_stylesheet(req, 'common/css/roadmap.css')
+ add_script(req, 'common/js/folding.js')
return 'milestone_view.html', data, None
# IWikiSyntaxProvider methods
@@ -851,10 +868,10 @@
for name, due, completed, description in cursor:
milestone = milestone_realm(id=name)
if 'MILESTONE_VIEW' in req.perm(milestone):
+ dt = (completed and from_utimestamp(completed) or
+ due and from_utimestamp(due) or datetime.now(utc))
yield (get_resource_url(self.env, milestone, req.href),
- get_resource_name(self.env, milestone),
- datetime.fromtimestamp(
- completed or due or time(), utc),
+ get_resource_name(self.env, milestone), dt,
'', shorten_result(description, terms))
# Attachments
diff -Nru trac-0.11.7/trac/ticket/templates/milestone_delete.html trac-0.12.1~ppa2/trac/ticket/templates/milestone_delete.html
--- trac-0.11.7/trac/ticket/templates/milestone_delete.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/templates/milestone_delete.html 2010-01-13 21:58:49.000000000 +0000
@@ -3,6 +3,7 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
@@ -31,17 +32,17 @@
+ value="${milestone.name}" py:content="milestone.name">
-
-
+
+
-
Note: See
+
diff -Nru trac-0.11.7/trac/ticket/templates/milestone_edit.html trac-0.12.1~ppa2/trac/ticket/templates/milestone_edit.html
--- trac-0.11.7/trac/ticket/templates/milestone_edit.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/templates/milestone_edit.html 2010-03-07 09:14:01.000000000 +0000
@@ -3,6 +3,7 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
@@ -12,7 +13,6 @@
-
-
@@ -23,34 +24,37 @@
- Completed ${dateinfo(milestone.completed)} ago
- (${format_datetime(milestone.completed)})
+
+ Completed ${dateinfo(milestone.completed)} ago (${format_datetime(milestone.completed)})
+
- ${dateinfo(milestone.due)} late
- (${format_date(milestone.due)})
+
+ ${dateinfo(milestone.due)} late (${format_date(milestone.due)})
+
- Due in ${dateinfo(milestone.due)}
- (${format_date(milestone.due)})
+
+ Due in ${dateinfo(milestone.due)} (${format_date(milestone.due)})
+
No date set
-
${progress_bar(stats, interval_hrefs, stats_href=stats_href)}
+
- ${stats.title.capitalize()} by
+ ${stats.title.capitalize()} by
-
+
@@ -66,11 +70,14 @@
${group.name}
- ${progress_bar(group.stats, (group.interval_hrefs, None)
- [grouped_by in ['owner', 'reporter']
- and group.name != format_author(group.name)],
- '%d / %d' % (group.stats.done_count, group.stats.count),
- legend=False, style="width: %d%%" % (group.percent_of_max_total * 0.8))}
+
@@ -81,7 +88,8 @@
${wiki_to_html(context, milestone.description)}
- ${list_of_attachments(attachments, compact=True)}
+
- Note: See
+
diff -Nru trac-0.11.7/trac/ticket/templates/query.html trac-0.12.1~ppa2/trac/ticket/templates/query.html
--- trac-0.11.7/trac/ticket/templates/query.html 2010-03-09 22:49:38.000000000 +0000
+++ trac-0.12.1~ppa2/trac/ticket/templates/query.html 2010-05-01 17:01:31.000000000 +0100
@@ -3,6 +3,7 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
@@ -24,111 +25,134 @@
-
- (${v or 'No'} match${v != 1 and 'es' or ''})
-
-
-
$title ${num_matches(query.num_items)}
+
$title (${ngettext('%(num)s match', '%(num)s matches', query.num_items)})
${wiki_to_html(context(report_resource), description)}
+ py:with="field_names = sorted(fields.iterkeys(), key=lambda name: fields[name].label.lower())">
-
Filters
-
-
-
-
-
-
-
-
+
+
+
+
+ Or
+
+
+
+
+
+
@@ -142,7 +166,7 @@
- ${labels.get(column, column or 'none')}
+ ${fields.get(column, {'label': column or 'none'}).label}
@@ -164,12 +188,12 @@
-
+
Show under each result:
- ${labels.get(column, column or 'none')}
+ ${fields.get(column, {'label': column or 'none'}).label}
@@ -182,7 +206,7 @@
-
+
@@ -211,36 +235,38 @@
-