diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e36065822 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +htdocs/js/*.min.js binary +htdocs/css/selectize.bootstrap3.css binary diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml index bda5b5cf1..b26104841 100644 --- a/docs/api/schemas/latest/patchwork.yaml +++ b/docs/api/schemas/latest/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' @@ -2312,6 +2321,11 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' + labels: + title: Labels + type: array + items: + type: string PatchDetail: type: object title: Patches @@ -2390,6 +2404,11 @@ components: type: array items: type: integer + labels: + title: Labels + type: array + items: + type: string Person: type: object title: Person diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 67bd403c0..0fd3975f4 100644 --- a/docs/api/schemas/patchwork.j2 +++ b/docs/api/schemas/patchwork.j2 @@ -596,6 +596,17 @@ paths: schema: title: '' type: string +{% endif %} +{% if version >= (1, 4) %} + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string {% endif %} responses: '200': @@ -2397,6 +2408,13 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' +{% endif %} +{% if version >= (1, 4) %} + labels: + title: Labels + type: array + items: + type: string {% endif %} PatchDetail: type: object @@ -2477,6 +2495,13 @@ components: type: array items: type: integer +{% endif %} +{% if version >= (1, 4) %} + labels: + title: Labels + type: array + items: + type: string {% endif %} Person: type: object diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index 179384431..d2d1d68e9 100644 --- a/docs/api/schemas/v1.4/patchwork.yaml +++ b/docs/api/schemas/v1.4/patchwork.yaml @@ -578,6 +578,15 @@ paths: schema: title: '' type: string + - in: query + name: labels + description: | + List of labels assigned to queried patches, separated by ','. + If a label name begins with a '-', patches with that label won't + be included. + schema: + title: '' + type: string responses: '200': description: 'List of patches' @@ -2312,6 +2321,11 @@ components: type: array items: $ref: '#/components/schemas/PatchEmbedded' + labels: + title: Labels + type: array + items: + type: string PatchDetail: type: object title: Patches @@ -2390,6 +2404,11 @@ components: type: array items: type: integer + labels: + title: Labels + type: array + items: + type: string Person: type: object title: Person diff --git a/docs/deployment/management.rst b/docs/deployment/management.rst index 8f9eed43d..3eaefe916 100644 --- a/docs/deployment/management.rst +++ b/docs/deployment/management.rst @@ -192,3 +192,30 @@ patches for these new tags. .. option:: patch_id a patch ID number. If not supplied, all patches will be updated. + +.. _command-relabel: + +relabel +~~~~~~~ + +.. program:: manage.py relabel + +Relabel patches based on the subject line. + +.. code-block:: shell + + ./manage.py relabel [PROJECT [PROJECT...]] + +Patchwork extracts labels from square brackets at the beginning of the subject +line, eg. ``[label1, label2] Patch title``. + +Only labels created in the admin interface will be recognized. +The script prioritizes project-specific labels if both project-specific and +general labels with the same name exist. + +The script will not remove labels manually added to patches, but will re-add +labels manually removed from patches. + +.. option:: PROJECT + + list of IDs of projects to update. Relabels all projects if none specified. diff --git a/docs/usage/overview.rst b/docs/usage/overview.rst index 539de9ca3..b0bd0271c 100644 --- a/docs/usage/overview.rst +++ b/docs/usage/overview.rst @@ -162,6 +162,29 @@ or a cover letter or the web URL to a patch or a series: __ https://www.kernel.org/doc/html/latest/process/submitting-patches.html +Labels +~~~~~~ + +Labels are a way to organize and prioritize submissions. You can apply labels +to patches and cover letters to signify priority, category, or any other marker +you wish. + +Labels can be either global or tied to the project they are created in. Labels +can have an optional description attached, which will provide a little insight +into the purpose of the label. Labels are completely customizable and the +labels available will vary by instance. + +Labels are parsed out of the square brackets of the subject line. +For example, subject line ``[RFC, main] Add tests`` may cause the patch to be +labeled with ``RFC`` and ``main``, if such labels were created for the project +or instance. Labels can be created in the admin panel. + +.. note:: + + In order to refresh the list of labels for existing patches, for example + after updating Patchwork or adding a new label in the admin panel, + use the :ref:`command-relabel` management command. + Checks ~~~~~~ diff --git a/htdocs/README.rst b/htdocs/README.rst index 9a5211233..464fa6b34 100644 --- a/htdocs/README.rst +++ b/htdocs/README.rst @@ -133,6 +133,11 @@ js Part of Patchwork. +``selectize-plugins.js.`` + Extra plugins for selectize. + + Part of Patchwork. + ``selectize.min.js`` Selectize is the hybrid of a ``textbox`` and `` control + e.delegateTarget.parentElement.getElementsByTagName('input')[0].focus(); + prevSelect(e); + }; +}); + +function selectize_labels_input(selector, label_options) { + selector.selectize({ + plugins: ['mod_enter_key_submit', 'mod_focus_input_after_select'], + maxItems: null, + persist: false, + hideSelected: true, + // Search for options that begin with a minus sign + respect_word_boundaries: false, + delimiter: ' ', + options: label_options, + render: { + item: function (item, escape) { + return '
' + escape(item.text) + '
'; + }, + option: function (item, escape) { + console.log(item, escape); + return '
' + escape(item.text) + '
'; + }, + }, + }); +} diff --git a/htdocs/js/selectize.min.js b/htdocs/js/selectize.min.js index 0a5384be6..4fb14c096 100644 --- a/htdocs/js/selectize.min.js +++ b/htdocs/js/selectize.min.js @@ -1,2 +1,32 @@ -/*! selectize.js - v0.13.5 | https://github.com/selectize/selectize.js | Apache License (v2) */ -var highlight=function($element,pattern){var regex,highlight;if("string"!=typeof pattern||pattern.length)return regex="string"==typeof pattern?new RegExp(pattern,"i"):pattern,highlight=function(node){var skip=0;if(3===node.nodeType){var spannode,match,pos=node.data.search(regex);0<=pos&&0/g,">").replace(/"/g,""")},escape_replace=function(str){return(str+"").replace(/\$/g,"$$$$")},hook={before:function(self,method,fn){var original=self[method];self[method]=function(){return fn.apply(self,arguments),original.apply(self,arguments)}},after:function(self,method,fn){var original=self[method];self[method]=function(){var result=original.apply(self,arguments);return fn.apply(self,arguments),result}}},once=function(fn){var called=!1;return function(){called||(called=!0,fn.apply(this,arguments))}},debounce=function(fn,delay){var timeout;return function(){var self=this,args=arguments;window.clearTimeout(timeout),timeout=window.setTimeout(function(){fn.apply(self,args)},delay)}},debounce_events=function(self,types,fn){var type,trigger=self.trigger,event_args={};for(type in self.trigger=function(){var type=arguments[0];if(-1===types.indexOf(type))return trigger.apply(self,arguments);event_args[type]=arguments},fn.apply(self,[]),self.trigger=trigger,event_args)event_args.hasOwnProperty(type)&&trigger.apply(self,event_args[type])},watchChildEvent=function($parent,event,selector,fn){$parent.on(event,selector,function(e){for(var child=e.target;child&&child.parentNode!==$parent[0];)child=child.parentNode;return e.currentTarget=child,fn.apply(this,[e])})},getSelection=function(input){var sel,selLen,result={};return void 0===input?console.warn("WARN getSelection cannot locate input control"):"selectionStart"in input?(result.start=input.selectionStart,result.length=input.selectionEnd-result.start):document.selection&&(input.focus(),sel=document.selection.createRange(),selLen=document.selection.createRange().text.length,sel.moveStart("character",-input.value.length),result.start=sel.text.length-selLen,result.length=selLen),result},transferStyles=function($from,$to,properties){var i,n,styles={};if(properties)for(i=0,n=properties.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(str),transferStyles($parent,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function($input){function update(e,options){var printable,keyCode,selection;options=options||{},(e=e||window.event||{}).metaKey||e.altKey||!options.force&&!1===$input.data("grow")||(options=$input.val(),e.type&&"keydown"===e.type.toLowerCase()&&(printable=48<=(keyCode=e.keyCode)&&keyCode<=57||65<=keyCode&&keyCode<=90||96<=keyCode&&keyCode<=111||186<=keyCode&&keyCode<=222||32===keyCode,keyCode===KEY_DELETE||keyCode===KEY_BACKSPACE?(selection=getSelection($input[0])).length?options=options.substring(0,selection.start)+options.substring(selection.start+selection.length):keyCode===KEY_BACKSPACE&&selection.start?options=options.substring(0,selection.start-1)+options.substring(selection.start+1):keyCode===KEY_DELETE&&void 0!==selection.start&&(options=options.substring(0,selection.start)+options.substring(selection.start+1)):printable&&(keyCode=e.shiftKey,selection=String.fromCharCode(e.keyCode),options+=selection=keyCode?selection.toUpperCase():selection.toLowerCase())),e=(printable=$input.attr("placeholder"))?measureString(printable,$input)+4:0,(keyCode=Math.max(measureString(options,$input),e)+4)!==currentWidth&&(currentWidth=keyCode,$input.width(keyCode),$input.triggerHandler("resize")))}var currentWidth=null;$input.on("keydown keyup update blur",update),update()},domToString=function(d){var tmp=document.createElement("div");return tmp.appendChild(d.cloneNode(!0)),tmp.innerHTML},logError=function(message,options){options=options||{};console.error("Selectize: "+message),options.explanation&&(console.group&&console.group(),console.error(options.explanation),console.group&&console.groupEnd())},Selectize=function($input,settings){var i,n,input=$input[0],computedStyle=(input.selectize=this,window.getComputedStyle&&window.getComputedStyle(input,null));if(computedStyle=(computedStyle?computedStyle.getPropertyValue("direction"):input.currentStyle&&input.currentStyle.direction)||$input.parents("[dir]:first").attr("dir")||"",$.extend(this,{order:0,settings:settings,$input:$input,tabIndex:$input.attr("tabindex")||"",tagType:"select"===input.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(computedStyle),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:$input.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",caretPos:0,loading:0,loadedSearches:{},$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===settings.loadThrottle?this.onSearchChange:debounce(this.onSearchChange,settings.loadThrottle)}),this.sifter=new Sifter(this.options,{diacritics:settings.diacritics}),this.settings.options){for(i=0,n=this.settings.options.length;i").addClass(settings.wrapperClass).addClass(classes).addClass(inputMode),$control=$("
").addClass(settings.inputClass).addClass("items").appendTo($wrapper),$control_input=$('').appendTo($control).attr("tabindex",$input.is(":disabled")?"-1":self.tabIndex),$dropdown_parent=$(settings.dropdownParent||$wrapper),inputMode=$("
").addClass(settings.dropdownClass).addClass(inputMode).hide().appendTo($dropdown_parent),$dropdown_parent=$("
").addClass(settings.dropdownContentClass).attr("tabindex","-1").appendTo(inputMode);(inputId=$input.attr("id"))&&($control_input.attr("id",inputId+"-selectized"),$("label[for='"+inputId+"']").attr("for",inputId+"-selectized")),self.settings.copyClassesToDropdown&&inputMode.addClass(classes),$wrapper.css({width:$input[0].style.width}),self.plugins.names.length&&(inputId="plugin-"+self.plugins.names.join(" plugin-"),$wrapper.addClass(inputId),inputMode.addClass(inputId)),(null===settings.maxItems||1[data-selectable]",function(e){e.stopImmediatePropagation()}),inputMode.on("mouseenter","[data-selectable]",function(){return self.onOptionHover.apply(self,arguments)}),inputMode.on("mousedown click","[data-selectable]",function(){return self.onOptionSelect.apply(self,arguments)}),watchChildEvent($control,"mousedown","*:not(input)",function(){return self.onItemSelect.apply(self,arguments)}),autoGrow($control_input),$control.on({mousedown:function(){return self.onMouseDown.apply(self,arguments)},click:function(){return self.onClick.apply(self,arguments)}}),$control_input.on({mousedown:function(e){e.stopPropagation()},keydown:function(){return self.onKeyDown.apply(self,arguments)},keypress:function(){return self.onKeyPress.apply(self,arguments)},input:function(){return self.onInput.apply(self,arguments)},resize:function(){self.positionDropdown.apply(self,[])},blur:function(){return self.onBlur.apply(self,arguments)},focus:function(){return self.ignoreBlur=!1,self.onFocus.apply(self,arguments)},paste:function(){return self.onPaste.apply(self,arguments)}}),$document.on("keydown"+eventNS,function(e){self.isCmdDown=e[IS_MAC?"metaKey":"ctrlKey"],self.isCtrlDown=e[IS_MAC?"altKey":"ctrlKey"],self.isShiftDown=e.shiftKey}),$document.on("keyup"+eventNS,function(e){e.keyCode===KEY_CTRL&&(self.isCtrlDown=!1),e.keyCode===KEY_SHIFT&&(self.isShiftDown=!1),e.keyCode===KEY_CMD&&(self.isCmdDown=!1)}),$document.on("mousedown"+eventNS,function(e){if(self.isFocused){if(e.target===self.$dropdown[0]||e.target.parentNode===self.$dropdown[0])return!1;self.$control.has(e.target).length||e.target===self.$control[0]||self.blur(e.target)}}),$window.on(["scroll"+eventNS,"resize"+eventNS].join(" "),function(){self.isOpen&&self.positionDropdown.apply(self,arguments)}),$window.on("mousemove"+eventNS,function(){self.ignoreHover=!1}),this.revertSettings={$children:$input.children().detach(),tabindex:$input.attr("tabindex")},$input.attr("tabindex",-1).hide().after(self.$wrapper),Array.isArray(settings.items)&&(self.lastValidValue=settings.items,self.setValue(settings.items),delete settings.items),SUPPORTS_VALIDITY_API&&$input.on("invalid"+eventNS,function(e){e.preventDefault(),self.isInvalid=!0,self.refreshState()}),self.updateOriginalInput(),self.refreshItems(),self.refreshState(),self.updatePlaceholder(),self.isSetup=!0,$input.is(":disabled")&&self.disable(),self.on("change",this.onChange),$input.data("selectize",self),$input.addClass("selectized"),self.trigger("initialize"),!0===settings.preload&&self.onSearchChange("")},setupTemplates:function(){var field_label=this.settings.labelField,field_value=this.settings.valueField,field_optgroup=this.settings.optgroupLabelField;this.settings.render=$.extend({},{optgroup:function(data){return'
'+data.html+"
"},optgroup_header:function(data,escape){return'
'+escape(data[field_optgroup])+"
"},option:function(data,escape){return'
'+escape(data[field_label])+"
"},item:function(data,escape){return'
'+escape(data[field_label])+"
"},option_create:function(data,escape){return'
Add '+escape(data.input)+"…
"}},this.settings.render)},setupCallbacks:function(){var key,fn,callbacks={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(key in callbacks)callbacks.hasOwnProperty(key)&&(fn=this.settings[callbacks[key]])&&this.on(key,fn)},onClick:function(e){this.isFocused&&this.isOpen||(this.focus(),e.preventDefault())},onMouseDown:function(e){var self=this,defaultPrevented=e.isDefaultPrevented();$(e.target);if(self.isFocused){if(e.target!==self.$control_input[0])return"single"===self.settings.mode?self.isOpen?self.close():self.open():defaultPrevented||self.setActiveItem(null),!1}else defaultPrevented||window.setTimeout(function(){self.focus()},0)},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(e){var self=this;self.isFull()||self.isInputHidden||self.isLocked?e.preventDefault():self.settings.splitOn&&setTimeout(function(){var pastedText=self.$control_input.val();if(pastedText.match(self.settings.splitOn))for(var splitInput=pastedText.trim().split(self.settings.splitOn),i=0,n=splitInput.length;i=this.settings.maxItems},updateOriginalInput:function(opts){var i,n,options,label;if(opts=opts||{},this.tagType===TAG_SELECT){for(options=[],i=0,n=this.items.length;i'+escape_html(label)+"");options.length||this.$input.attr("multiple")||options.push(''),this.$input.html(options.join(""))}else this.$input.val(this.getValue()),this.$input.attr("value",this.$input.val());this.isSetup&&!opts.silent&&this.trigger("change",this.$input.val())},updatePlaceholder:function(){var $input;this.settings.placeholder&&($input=this.$control_input,this.items.length?$input.removeAttr("placeholder"):$input.attr("placeholder",this.settings.placeholder),$input.triggerHandler("update",{force:!0}))},open:function(){this.isLocked||this.isOpen||"multi"===this.settings.mode&&this.isFull()||(this.focus(),this.isOpen=!0,this.refreshState(),this.$dropdown.css({visibility:"hidden",display:"block"}),this.positionDropdown(),this.$dropdown.css({visibility:"visible"}),this.trigger("dropdown_open",this.$dropdown))},close:function(){var trigger=this.isOpen;"single"===this.settings.mode&&this.items.length&&(this.hideInput(),this.isBlurring&&this.$control_input.blur()),this.isOpen=!1,this.$dropdown.hide(),this.setActiveOption(null),this.refreshState(),trigger&&this.trigger("dropdown_close",this.$dropdown)},positionDropdown:function(){var $control=this.$control,offset="body"===this.settings.dropdownParent?$control.offset():$control.position();offset.top+=$control.outerHeight(!0),this.$dropdown.css({width:$control[0].getBoundingClientRect().width,top:offset.top,left:offset.left})},clear:function(silent){this.items.length&&(this.$control.children(":not(input)").remove(),this.items=[],this.lastQuery=null,this.setCaret(0),this.setActiveItem(null),this.updatePlaceholder(),this.updateOriginalInput({silent:silent}),this.refreshState(),this.showInput(),this.trigger("clear"))},insertAtCaret:function($el){var caret=Math.min(this.caretPos,this.items.length),$el=$el[0],target=this.buffer||this.$control[0];0===caret?target.insertBefore($el,target.firstChild):target.insertBefore($el,target.childNodes[caret]),this.setCaret(caret+1)},deleteSelection:function(e){var i,n,values,option_select,$tail,direction=e&&e.keyCode===KEY_BACKSPACE?-1:1,selection=getSelection(this.$control_input[0]);if(this.$activeOption&&!this.settings.hideSelected&&(option_select=("string"==typeof this.settings.deselectBehavior&&"top"===this.settings.deselectBehavior?this.getFirstOption():this.getAdjacentOption(this.$activeOption,-1)).attr("data-value")),values=[],this.$activeItems.length){for($tail=this.$control.children(".active:"+(0'+settings.emptyOptionLabel+""+input_html)),("select"===tag_name?init_select:function($input,settings_element){var i,n,values,option,data_raw=$input.attr(attr_data);if(data_raw)for(settings_element.options=JSON.parse(data_raw),i=0,n=settings_element.options.length;iwindow.innerHeight?POSITION.top:POSITION.bottom,styles={width:$control.outerWidth(),left:offset.left};dropdownHeight===POSITION.top?(Object.assign(styles,{bottom:offset.top,top:"unset",margin:"0 0 5px 0"}),this.$dropdown.addClass("selectize-position-top")):(Object.assign(styles,{top:offset.top,bottom:"unset",margin:"5px 0 0 0"}),this.$dropdown.removeClass("selectize-position-top")),this.$dropdown.css(styles)}}),Selectize.define("auto_select_on_type",function(options){var originalBlur,self=this;self.onBlur=(originalBlur=self.onBlur,function(e){var $matchedItem=self.getFirstItemMatchedByTextContent(self.lastValue,!0);return void 0!==$matchedItem.attr("data-value")&&self.getValue()!==$matchedItem.attr("data-value")&&self.setValue($matchedItem.attr("data-value")),originalBlur.apply(this,arguments)})}),Selectize.define("autofill_disable",function(options){var original,self=this;self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("drag_drop",function(options){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var self,original;"multi"===this.settings.mode&&((self=this).lock=(original=self.lock,function(){var sortable=self.$control.data("sortable");return sortable&&sortable.disable(),original.apply(self,arguments)}),self.unlock=function(){var original=self.unlock;return function(){var sortable=self.$control.data("sortable");return sortable&&sortable.enable(),original.apply(self,arguments)}}(),self.setup=function(){var original=self.setup;return function(){original.apply(this,arguments);var $control=self.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:self.isLocked,start:function(e,ui){ui.placeholder.css("width",ui.helper.css("width")),$control.addClass("dragging")},stop:function(){$control.removeClass("dragging");var active=self.$activeItems?self.$activeItems.slice():null,values=[];$control.children("[data-value]").each(function(){values.push($(this).attr("data-value"))}),self.isFocused=!1,self.setValue(values),self.isFocused=!0,self.setActiveItem(active),self.positionDropdown()}})}}())}),Selectize.define("dropdown_header",function(options){var original,self=this;options=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(data){return'
'+data.title+'×
'}},options),self.setup=(original=self.setup,function(){original.apply(self,arguments),self.$dropdown_header=$(options.html(options)),self.$dropdown.prepend(self.$dropdown_header)})}),Selectize.define("optgroup_columns",function(options){function equalizeSizes(){var i,height_max,width,width_parent,$optgroups=$("[data-group]",self.$dropdown_content),n=$optgroups.length;if(n&&self.$dropdown_content.width()){if(options.equalizeHeight){for(i=height_max=0;i
',div=div.firstChild,doc.body.appendChild(div),width=getScrollbarWidth.width=div.offsetWidth-div.clientWidth,doc.body.removeChild(div)),width});(options.equalizeHeight||options.equalizeWidth)&&(hook.after(this,"positionDropdown",equalizeSizes),hook.after(this,"refreshOptions",equalizeSizes))}),Selectize.define("remove_button",function(options){options=$.extend({label:"×",title:"Remove",className:"remove",append:!0},options),("single"===this.settings.mode?function(thisRef,options){options.className="remove-single";var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var id,render_item;options.append&&(id=$(self.$input.context).attr("id"),$("#"+id),render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,$("").append(html_container).append(html_element);var html_container,html_element}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){e.preventDefault(),self.isLocked||self.clear()})})}:function(thisRef,options){var original,self=thisRef,html=''+options.label+"";thisRef.setup=(original=self.setup,function(){var render_item;options.append&&(render_item=self.settings.render.item,self.settings.render.item=function(data){return html_container=render_item.apply(thisRef,arguments),html_element=html,pos=html_container.search(/(<\/[^>]+>\s*)$/),html_container.substring(0,pos)+html_element+html_container.substring(pos);var html_container,html_element,pos}),original.apply(thisRef,arguments),thisRef.$control.on("click","."+options.className,function(e){if(e.preventDefault(),!self.isLocked)return e=$(e.currentTarget).parent(),self.setActiveItem(e),self.deleteSelection()&&self.setCaret(self.items.length),!1})})})(this,options)}),Selectize.define("restore_on_backspace",function(options){var original,self=this;options.text=options.text||function(option){return option[this.settings.labelField]},this.onKeyDown=(original=self.onKeyDown,function(e){var index;return e.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(index=this.caretPos-1)&&index"+($items.length-limit)+""))}}}(),this.onFocus=function(){const original=self.onFocus;return function(e){if(original.apply(this,e),e){const $control=this.$control,$items=$control.find(".item");$items.show(),$control.find("span").remove()}}}()}); \ No newline at end of file +/** + * Selectize (v0.15.2) + * https://selectize.dev + * + * Copyright (c) 2013-2015 Brian Reavis & contributors + * Copyright (c) 2020-2022 Selectize Team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + * @author Brian Reavis + * @author Ris Adams + */ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define(['jquery'], factory); + } else if (typeof module === 'object' && typeof module.exports === 'object') { + module.exports = factory(require('jquery')); + } else { + root.Selectize = factory(root.jQuery); + } +}(this, function ($) { + 'use strict'; +var highlight=function(t,e){var r,a;if("string"!=typeof e||e.length)return r="string"==typeof e?new RegExp(e,"i"):e,a=function(t){var e=0;if(3===t.nodeType){var n,i,o=t.data.search(r);0<=o&&0/g,">").replace(/"/g,""")},escape_replace=function(t){return(t+"").replace(/\$/g,"$$$$")},hook={before:function(t,e,n){var i=t[e];t[e]=function(){return n.apply(t,arguments),i.apply(t,arguments)}},after:function(e,t,n){var i=e[t];e[t]=function(){var t=i.apply(e,arguments);return n.apply(e,arguments),t}}},once=function(t){var e=!1;return function(){e||(e=!0,t.apply(this,arguments))}},debounce=function(n,i){var o;return function(){var t=this,e=arguments;window.clearTimeout(o),o=window.setTimeout(function(){n.apply(t,e)},i)}},debounce_events=function(e,n,t){var i,o=e.trigger,s={};for(i in e.trigger=function(){var t=arguments[0];if(-1===n.indexOf(t))return o.apply(e,arguments);s[t]=arguments},t.apply(e,[]),e.trigger=o,s)s.hasOwnProperty(i)&&o.apply(e,s[i])},watchChildEvent=function(n,t,e,i){n.on(t,e,function(t){for(var e=t.target;e&&e.parentNode!==n[0];)e=e.parentNode;return t.currentTarget=e,i.apply(this,[t])})},getInputSelection=function(t){var e,n,i={};return void 0===t?console.warn("WARN getInputSelection cannot locate input control"):"selectionStart"in t?(i.start=t.selectionStart,i.length=t.selectionEnd-i.start):document.selection&&(t.focus(),e=document.selection.createRange(),n=document.selection.createRange().text.length,e.moveStart("character",-t.value.length),i.start=e.text.length-n,i.length=n),i},transferStyles=function(t,e,n){var i,o,s={};if(n)for(i=0,o=n.length;i").css({position:"absolute",width:"auto",padding:0,whiteSpace:"pre"}),$("
").css({position:"absolute",width:0,height:0,overflow:"hidden"}).append(Selectize.$testInput).appendTo("body")),Selectize.$testInput.text(t),transferStyles(e,Selectize.$testInput,["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"]),Selectize.$testInput.width()):0},autoGrow=function(s){function t(t,e){var n,i,o;e=e||{},(t=t||window.event||{}).metaKey||t.altKey||!e.force&&!1===s.data("grow")||(e=s.val(),t.type&&"keydown"===t.type.toLowerCase()&&(n=48<=(i=t.keyCode)&&i<=57||65<=i&&i<=90||96<=i&&i<=111||186<=i&&i<=222||32===i,i===KEY_DELETE||i===KEY_BACKSPACE?(o=getInputSelection(s[0])).length?e=e.substring(0,o.start)+e.substring(o.start+o.length):i===KEY_BACKSPACE&&o.start?e=e.substring(0,o.start-1)+e.substring(o.start+1):i===KEY_DELETE&&void 0!==o.start&&(e=e.substring(0,o.start)+e.substring(o.start+1)):n&&(i=t.shiftKey,o=String.fromCharCode(t.keyCode),e+=o=i?o.toUpperCase():o.toLowerCase())),t=(n=s.attr("placeholder"))?measureString(n,s)+4:0,(i=Math.max(measureString(e,s),t)+4)===r)||(r=i,s.width(i),s.triggerHandler("resize"))}var r=null;s.on("keydown keyup update blur",t),t()},domToString=function(t){var e=document.createElement("div");return e.appendChild(t.cloneNode(!0)),e.innerHTML},logError=function(t,e){e=e||{};console.error("Selectize: "+t),e.explanation&&(console.group&&console.group(),console.error(e.explanation),console.group)&&console.groupEnd()},isJSON=function(t){try{JSON.parse(str)}catch(t){return!1}return!0},Selectize=function(t,e){var n,i,o=this,s=t[0],r=(s.selectize=o,window.getComputedStyle&&window.getComputedStyle(s,null));if(r=(r?r.getPropertyValue("direction"):s.currentStyle&&s.currentStyle.direction)||t.parents("[dir]:first").attr("dir")||"",$.extend(o,{order:0,settings:e,$input:t,tabIndex:t.attr("tabindex")||"",tagType:"select"===s.tagName.toLowerCase()?TAG_SELECT:TAG_INPUT,rtl:/rtl/i.test(r),eventNS:".selectize"+ ++Selectize.count,highlightedValue:null,isBlurring:!1,isOpen:!1,isDisabled:!1,isRequired:t.is("[required]"),isInvalid:!1,isLocked:!1,isFocused:!1,isInputHidden:!1,isSetup:!1,isShiftDown:!1,isCmdDown:!1,isCtrlDown:!1,ignoreFocus:!1,ignoreBlur:!1,ignoreHover:!1,hasOptions:!1,currentResults:null,lastValue:"",lastValidValue:"",lastOpenTarget:!1,caretPos:0,loading:0,loadedSearches:{},isDropdownClosing:!1,$activeOption:null,$activeItems:[],optgroups:{},options:{},userOptions:{},items:[],renderCache:{},onSearchChange:null===e.loadThrottle?o.onSearchChange:debounce(o.onSearchChange,e.loadThrottle)}),o.sifter=new Sifter(this.options,{diacritics:e.diacritics}),o.settings.options){for(n=0,i=o.settings.options.length;n").addClass(t.wrapperClass).addClass(a+" selectize-control").addClass(r),p=$("
").addClass(t.inputClass+" selectize-input items").appendTo(l),c=$('').appendTo(p).attr("tabindex",s.is(":disabled")?"-1":e.tabIndex),u=$(t.dropdownParent||l),r=$("
").addClass(t.dropdownClass).addClass(r+" selectize-dropdown").hide().appendTo(u),u=$("
").addClass(t.dropdownContentClass+" selectize-dropdown-content").attr("tabindex","-1").appendTo(r),d=((d=s.attr("id"))&&(c.attr("id",d+"-selectized"),$("label[for='"+d+"']").attr("for",d+"-selectized")),e.settings.copyClassesToDropdown&&r.addClass(a),l.css({width:s[0].style.width}),e.plugins.names.length&&(d="plugin-"+e.plugins.names.join(" plugin-"),l.addClass(d),r.addClass(d)),(null===t.maxItems||1[data-selectable]",function(t){t.stopImmediatePropagation()}),r.on("mouseenter","[data-selectable]",function(){return e.onOptionHover.apply(e,arguments)}),r.on("mouseup click","[data-selectable]",function(){return e.onOptionSelect.apply(e,arguments)}),watchChildEvent(p,"mouseup","*:not(input)",function(){return e.onItemSelect.apply(e,arguments)}),autoGrow(c),p.on({mousedown:function(){return e.onMouseDown.apply(e,arguments)},click:function(){return e.onClick.apply(e,arguments)}}),c.on({mousedown:function(t){""===e.$control_input.val()&&!e.settings.openOnFocus||t.stopPropagation()},keydown:function(){return e.onKeyDown.apply(e,arguments)},keypress:function(){return e.onKeyPress.apply(e,arguments)},input:function(){return e.onInput.apply(e,arguments)},resize:function(){e.positionDropdown.apply(e,[])},focus:function(){return e.ignoreBlur=!1,e.onFocus.apply(e,arguments)},paste:function(){return e.onPaste.apply(e,arguments)}}),o.on("keydown"+n,function(t){e.isCmdDown=t[IS_MAC?"metaKey":"ctrlKey"],e.isCtrlDown=t[IS_MAC?"altKey":"ctrlKey"],e.isShiftDown=t.shiftKey}),o.on("keyup"+n,function(t){t.keyCode===KEY_CTRL&&(e.isCtrlDown=!1),t.keyCode===KEY_SHIFT&&(e.isShiftDown=!1),t.keyCode===KEY_CMD&&(e.isCmdDown=!1)}),o.on("mousedown"+n,function(t){if(e.isFocused){if(t.target===e.$dropdown[0]||t.target.parentNode===e.$dropdown[0])return!1;e.$dropdown.has(t.target).length||t.target===e.$control[0]||e.blur(t.target)}}),i.on(["scroll"+n,"resize"+n].join(" "),function(){e.isOpen&&e.positionDropdown.apply(e,arguments)}),i.on("mousemove"+n,function(){e.ignoreHover=e.settings.ignoreHover}),$("
")),a=s.children().detach();s.replaceWith(d),d.replaceWith(s),this.revertSettings={$children:a,tabindex:s.attr("tabindex")},s.attr("tabindex",-1).hide().after(e.$wrapper),Array.isArray(t.items)&&(e.lastValidValue=t.items,e.setValue(t.items),delete t.items),SUPPORTS_VALIDITY_API&&s.on("invalid"+n,function(t){t.preventDefault(),e.isInvalid=!0,e.refreshState()}),e.updateOriginalInput(),e.refreshItems(),e.refreshState(),e.updatePlaceholder(),e.isSetup=!0,s.is(":disabled")&&e.disable(),e.on("change",this.onChange),s.data("selectize",e),s.addClass("selectized"),e.trigger("initialize"),!0===t.preload&&e.onSearchChange("")},setupTemplates:function(){var t=this,i=t.settings.labelField,o=t.settings.valueField,n=t.settings.optgroupLabelField;t.settings.render=$.extend({},{optgroup:function(t){return'
'+t.html+"
"},optgroup_header:function(t,e){return'
'+e(t[n])+"
"},option:function(t,e){var n=t.classes?" "+t.classes:"";return n+=""===t[o]?" selectize-dropdown-emptyoptionlabel":"","'+e(t[i])+"
"},item:function(t,e){return'
'+e(t[i])+"
"},option_create:function(t,e){return'
Add '+e(t.input)+"…
"}},t.settings.render)},setupCallbacks:function(){var t,e,n={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur",dropdown_item_activate:"onDropdownItemActivate",dropdown_item_deactivate:"onDropdownItemDeactivate"};for(t in n)n.hasOwnProperty(t)&&(e=this.settings[n[t]])&&this.on(t,e)},onClick:function(t){this.isDropdownClosing||this.isFocused&&this.isOpen||(this.focus(),t.preventDefault())},onMouseDown:function(t){var e=this,n=t.isDefaultPrevented();$(t.target);if(e.isFocused||n||window.setTimeout(function(){e.focus()},0),t.target!==e.$control_input[0]||""===e.$control_input.val())return"single"===e.settings.mode?e.isOpen?e.close():e.open():(n||e.setActiveItem(null),e.settings.openOnFocus||(e.isOpen&&t.target===e.lastOpenTarget?(e.close(),e.lastOpenTarget=!1):(e.isOpen||(e.refreshOptions(),e.open()),e.lastOpenTarget=t.target))),!1},onChange:function(){""!==this.getValue()&&(this.lastValidValue=this.getValue()),this.$input.trigger("input"),this.$input.trigger("change")},onPaste:function(t){var o=this;o.isFull()||o.isInputHidden||o.isLocked?t.preventDefault():o.settings.splitOn&&setTimeout(function(){var t=o.$control_input.val();if(t.match(o.settings.splitOn))for(var e=t.trim().split(o.settings.splitOn),n=0,i=e.length;n=this.settings.maxItems},updateOriginalInput:function(t){var e,n,i,o,s,r,a=this;t=t||{},a.tagType===TAG_SELECT?(o=a.$input.find("option"),e=[],n=[],i=[],r=[],o.get().forEach(function(t){e.push(t.value)}),a.items.forEach(function(t){s=a.options[t][a.settings.labelField]||"",r.push(t),-1==e.indexOf(t)&&n.push('")}),i=e.filter(function(t){return r.indexOf(t)<0}).map(function(t){return'option[value="'+t+'"]'}),e.length-i.length+n.length!==0||a.$input.attr("multiple")||n.push(''),a.$input.find(i.join(", ")).remove(),a.$input.append(n.join(""))):(a.$input.val(a.getValue()),a.$input.attr("value",a.$input.val())),a.isSetup&&!t.silent&&a.trigger("change",a.$input.val())},updatePlaceholder:function(){var t;this.settings.placeholder&&(t=this.$control_input,this.items.length?t.removeAttr("placeholder"):t.attr("placeholder",this.settings.placeholder),t.triggerHandler("update",{force:!0}))},open:function(){var t=this;t.isLocked||t.isOpen||"multi"===t.settings.mode&&t.isFull()||(t.focus(),t.isOpen=!0,t.refreshState(),t.$dropdown.css({visibility:"hidden",display:"block"}),t.setupDropdownHeight(),t.positionDropdown(),t.$dropdown.css({visibility:"visible"}),t.trigger("dropdown_open",t.$dropdown))},close:function(){var t=this,e=t.isOpen;"single"===t.settings.mode&&t.items.length&&(t.hideInput(),t.isBlurring)&&t.$control_input[0].blur(),t.isOpen=!1,t.$dropdown.hide(),t.setActiveOption(null),t.refreshState(),e&&t.trigger("dropdown_close",t.$dropdown)},positionDropdown:function(){var t=this.$control,e="body"===this.settings.dropdownParent?t.offset():t.position(),t=(e.top+=t.outerHeight(!0),t[0].getBoundingClientRect().width);this.settings.minWidth&&this.settings.minWidth>t&&(t=this.settings.minWidth),this.$dropdown.css({width:t,top:e.top,left:e.left})},setupDropdownHeight:function(){if("object"==typeof this.settings.dropdownSize&&"auto"!==this.settings.dropdownSize.sizeType){var t=this.settings.dropdownSize.sizeValue;if("numberItems"===this.settings.dropdownSize.sizeType){for(var e=this.$dropdown_content.find("*").not(".optgroup, .highlight").not(this.settings.ignoreOnDropwdownHeight),n=0,i=0,o=0,s=0,r=0;r'+i+""+l)),{placeholder:n,options:[],optgroups:[],items:[]});if("select"===e)u(t,i);else{var o,s,r,a,l=t,p=i,n=l.attr(m);if(n)for(p.options=JSON.parse(n),o=0,s=p.options.length;owindow.innerHeight?o.top:o.bottom,i={width:t.outerWidth(),left:e.left};n===o.top?(n={bottom:e.top,top:"unset"},"body"===this.settings.dropdownParent&&(n.top=e.top-this.$dropdown.outerHeight(!0)-t.outerHeight(!0),n.bottom="unset"),Object.assign(i,n),this.$dropdown.addClass("selectize-position-top"),this.$control.addClass("selectize-position-top")):(Object.assign(i,{top:e.top,bottom:"unset"}),this.$dropdown.removeClass("selectize-position-top"),this.$control.removeClass("selectize-position-top")),this.$dropdown.css(i)}}),Selectize.define("auto_select_on_type",function(t){var n,i=this;i.onBlur=(n=i.onBlur,function(t){var e=i.getFirstItemMatchedByTextContent(i.lastValue,!0);return void 0!==e.attr("data-value")&&i.getValue()!==e.attr("data-value")&&i.setValue(e.attr("data-value")),n.apply(this,arguments)})}),Selectize.define("autofill_disable",function(t){var e,n=this;n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$control_input.attr({autocomplete:"new-password",autofill:"no"})})}),Selectize.define("clear_button",function(e){var t,n=this;e=$.extend({title:"Clear",className:"clear",label:"×",html:function(t){return' '+t.label+""}},e),n.setup=(t=n.setup,function(){t.apply(n,arguments),n.$button_clear=$(e.html(e)),"single"===n.settings.mode&&n.$wrapper.addClass("single"),n.$wrapper.append(n.$button_clear),""!==n.getValue()&&0!==n.getValue().length||n.$wrapper.find("."+e.className).css("display","none"),n.on("change",function(){""===n.getValue()||0===n.getValue().length?n.$wrapper.find("."+e.className).css("display","none"):n.$wrapper.find("."+e.className).css("display","")}),n.$wrapper.on("click","."+e.className,function(t){t.preventDefault(),t.stopImmediatePropagation(),t.stopPropagation(),n.isLocked||(n.clear(),n.$wrapper.find("."+e.className).css("display","none"))})})}),Selectize.define("drag_drop",function(t){if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');var i,e,n,o;"multi"===this.settings.mode&&((i=this).lock=(e=i.lock,function(){var t=i.$control.data("sortable");return t&&t.disable(),e.apply(i,arguments)}),i.unlock=(n=i.unlock,function(){var t=i.$control.data("sortable");return t&&t.enable(),n.apply(i,arguments)}),i.setup=(o=i.setup,function(){o.apply(this,arguments);var n=i.$control.sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:i.isLocked,start:function(t,e){e.placeholder.css("width",e.helper.css("width")),n.addClass("dragging")},stop:function(){n.removeClass("dragging");var t=i.$activeItems?i.$activeItems.slice():null,e=[];n.children("[data-value]").each(function(){e.push($(this).attr("data-value"))}),i.isFocused=!1,i.setValue(e),i.isFocused=!0,i.setActiveItem(t),i.positionDropdown()}})}))}),Selectize.define("dropdown_header",function(t){var e,n=this;t=$.extend({title:"Untitled",headerClass:"selectize-dropdown-header",titleRowClass:"selectize-dropdown-header-title",labelClass:"selectize-dropdown-header-label",closeClass:"selectize-dropdown-header-close",html:function(t){return'
'+t.title+'×
'}},t),n.setup=(e=n.setup,function(){e.apply(n,arguments),n.$dropdown_header=$(t.html(t)),n.$dropdown.prepend(n.$dropdown_header),n.$dropdown_header.find("."+t.closeClass).on("click",function(){n.close()})})}),Selectize.define("optgroup_columns",function(r){function t(){var t,e,n,i,o=$("[data-group]",a.$dropdown_content),s=o.length;if(s&&a.$dropdown_content.width()){if(r.equalizeHeight){for(t=e=0;t
',t=t.firstChild,n.body.appendChild(t),e=l.width=t.offsetWidth-t.clientWidth,n.body.removeChild(t)),e});(r.equalizeHeight||r.equalizeWidth)&&(hook.after(this,"positionDropdown",t),hook.after(this,"refreshOptions",t))}),Selectize.define("remove_button",function(t){var s,e,n,i,r;"single"!==this.settings.mode&&(t=$.extend({label:"×",title:"Remove",className:"remove",append:!0},t),i=s=this,r=''+e.label+"",s.setup=(n=i.setup,function(){var o;e.append&&(o=i.settings.render.item,i.settings.render.item=function(t){return e=o.apply(s,arguments),n=r,i=e.search(/(<\/[^>]+>\s*)$/),e.substring(0,i)+n+e.substring(i);var e,n,i}),n.apply(s,arguments),s.$control.on("click","."+e.className,function(t){if(t.preventDefault(),!i.isLocked)return t=$(t.currentTarget).parent(),i.setActiveItem(t),i.deleteSelection()&&i.setCaret(i.items.length),!1})}))}),Selectize.define("restore_on_backspace",function(n){var i,t=this;n.text=n.text||function(t){return t[this.settings.labelField]},this.onKeyDown=(i=t.onKeyDown,function(t){var e;if(!(t.keyCode===KEY_BACKSPACE&&""===this.$control_input.val()&&!this.$activeItems.length&&0<=(e=this.caretPos-1)&&e"+(e.length-n)+""))}}}(),this.onFocus=function(){const e=t.onFocus;return function(t){e.apply(this,t),t&&((t=this.$control).find(".item").show(),t.find("span").remove())}}()}); + return Selectize; +})); diff --git a/patchwork/admin.py b/patchwork/admin.py index d3bdae1bb..6eda7e2e9 100644 --- a/patchwork/admin.py +++ b/patchwork/admin.py @@ -13,6 +13,7 @@ from patchwork.models import Cover from patchwork.models import CoverComment from patchwork.models import DelegationRule +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import PatchRelation @@ -44,11 +45,18 @@ class DelegationRuleInline(admin.TabularInline): fields = ('path', 'user', 'priority') +class LabelInline(admin.TabularInline): + model = Label + fields = ('name', 'description', 'color') + extra = 0 + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ('name', 'linkname', 'listid', 'listemail') inlines = [ DelegationRuleInline, + LabelInline, ] @@ -91,6 +99,7 @@ class PatchAdmin(admin.ModelAdmin): 'is_pull_request', ) list_filter = ('project', 'submitter', 'state', 'archived') + readonly_fields = ('labels',) list_select_related = ('submitter', 'project', 'state') search_fields = ('name', 'submitter__name', 'submitter__email') date_hierarchy = 'date' @@ -194,3 +203,9 @@ class TagAdmin(admin.ModelAdmin): @admin.register(PatchRelation) class PatchRelationAdmin(admin.ModelAdmin): model = PatchRelation + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + list_display = ('name', 'color', 'project') + list_filter = ('project',) diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py index ee6b301c4..20ed81f5d 100644 --- a/patchwork/api/cover.py +++ b/patchwork/api/cover.py @@ -9,6 +9,7 @@ from rest_framework.generics import RetrieveAPIView from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField +from rest_framework.serializers import StringRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.filters import CoverFilterSet @@ -25,6 +26,7 @@ class CoverListSerializer(BaseHyperlinkedModelSerializer): mbox = SerializerMethodField() series = SeriesSerializer(read_only=True) comments = SerializerMethodField() + labels = StringRelatedField(many=True) def get_web_url(self, instance): request = self.context.get('request') @@ -62,11 +64,13 @@ class Meta: 'mbox', 'series', 'comments', + 'labels', ) read_only_fields = fields versioned_fields = { '1.1': ('web_url', 'mbox', 'comments'), '1.2': ('list_archive_url',), + '1.4': ('labels',), } extra_kwargs = { 'url': {'view_name': 'api-cover-detail'}, @@ -110,7 +114,10 @@ class CoverList(ListAPIView): def get_queryset(self): return ( Cover.objects.all() - .prefetch_related('series__project') + .prefetch_related( + 'series__project', + 'labels', + ) .select_related('project', 'submitter', 'series') .defer('content', 'headers') ) diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py index e332c5316..aeecacb2d 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -175,6 +175,21 @@ def msgid_filter(queryset, name, value): return queryset.filter(**{name: '<' + value + '>'}) +def labels_filter(queryset, _, value): + label_names = value.split(',') + + labels_pos, labels_neg = [], [] + for label in label_names: + if not label.startswith('-'): + labels_pos.append(label) + else: + labels_neg.append(label[1:]) + + queryset = queryset.exclude_labels(labels_neg) + queryset = queryset.include_labels(labels_pos) + return queryset + + class CoverFilterSet(TimestampMixin, BaseFilterSet): project = ProjectFilter(queryset=Project.objects.all(), distinct=False) # NOTE(stephenfin): We disable the select-based HTML widgets for these @@ -206,6 +221,7 @@ class PatchFilterSet(TimestampMixin, BaseFilterSet): state = StateFilter(queryset=State.objects.all(), distinct=False) hash = CharFilter(lookup_expr='iexact') msgid = CharFilter(method=msgid_filter) + labels = CharFilter(method=labels_filter) class Meta: model = Patch @@ -225,6 +241,7 @@ class Meta: ) versioned_fields = { '1.2': ('hash', 'msgid'), + '1.4': ('labels'), } diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py index a388bb0b2..3df4d848e 100644 --- a/patchwork/api/patch.py +++ b/patchwork/api/patch.py @@ -18,6 +18,7 @@ from rest_framework.reverse import reverse from rest_framework.serializers import SerializerMethodField from rest_framework import status +from rest_framework.serializers import SlugRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import LinkHeaderPagination @@ -30,6 +31,7 @@ from patchwork.api.filters import PatchFilterSet from patchwork.models import Patch from patchwork.models import PatchRelation +from patchwork.models import Label from patchwork.models import State from patchwork.parser import clean_subject @@ -77,6 +79,12 @@ class PatchConflict(APIException): ) +class LabelsField(SlugRelatedField): + def get_queryset(self): + project = self.parent.parent.instance.project + return Label.get_project_labels(project) + + class PatchListSerializer(BaseHyperlinkedModelSerializer): web_url = SerializerMethodField() project = ProjectSerializer(read_only=True) @@ -95,6 +103,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): default=[], style={'base_template': 'input.html'}, ) + labels = LabelsField(many=True, slug_field='name') def get_web_url(self, instance): request = self.context.get('request') @@ -178,6 +187,7 @@ class Meta: 'checks', 'tags', 'related', + 'labels', ) read_only_fields = ( 'url', @@ -202,6 +212,7 @@ class Meta: 'list_archive_url', 'related', ), + '1.4': ('labels',), } extra_kwargs = { 'url': {'view_name': 'api-patch-detail'}, @@ -384,6 +395,7 @@ def get_queryset(self): 'project', 'series__project', 'related__patches__project', + 'labels', ) .select_related('state', 'submitter', 'series') .defer('content', 'diff', 'headers') @@ -408,7 +420,9 @@ class PatchDetail(RetrieveUpdateAPIView): def get_queryset(self): return ( Patch.objects.all() - .prefetch_related('check_set', 'related__patches__project') + .prefetch_related( + 'check_set', 'related__patches__project', 'labels' + ) .select_related( 'project', 'state', 'submitter', 'delegate', 'series' ) diff --git a/patchwork/fields.py b/patchwork/fields.py index 9a9633df6..8958ea2a0 100644 --- a/patchwork/fields.py +++ b/patchwork/fields.py @@ -27,3 +27,27 @@ def from_db_value(self, value, *args, **kwargs): def db_type(self, connection=None): return 'char(%d)' % self.n_bytes + + +class ColorField(models.Field): + description = 'Hex color code' + + def get_internal_type(self): + return 'PositiveIntegerField' + + def to_python(self, value): + if isinstance(value, str) or value is None: + return value + return '#%06x' % value + + def from_db_value(self, value, *args, **kwargs): + return self.to_python(value) + + def get_prep_value(self, value): + return int(value.lstrip('#'), 16) + + def formfield(self, *args, **kwargs): + from patchwork import forms # noqa + + kwargs['form_class'] = forms.ColorField + return super(ColorField, self).formfield(*args, **kwargs) diff --git a/patchwork/filters.py b/patchwork/filters.py index 2523220b3..565630207 100644 --- a/patchwork/filters.py +++ b/patchwork/filters.py @@ -519,6 +519,63 @@ def set_status(self, *args, **kwargs): self.forced = True +class LabelsFilter(Filter): + name = 'Labels' + param = 'labels' + + def __init__(self, filters): + super(LabelsFilter, self).__init__(filters) + self.labels = None + + @property + def condition(self): + return self.labels + + @property + def key(self): + return self.labels + + @key.setter + def key(self, key): + key = key.strip() + if not key: + return + + self.labels = key + self.applied = True + + @property + def kwargs(self): + return None + + def q(self, queryset): + label_names = self.labels.split(' ') + if len(label_names) == 0: + return queryset + + labels_pos, labels_neg = [], [] + for label in label_names: + if not label.startswith('-'): + labels_pos.append(label) + else: + labels_neg.append(label[1:]) + + queryset = queryset.exclude_labels(labels_neg) + queryset = queryset.include_labels(labels_pos) + return queryset + + @property + def form(self): + value = '' + if self.labels: + value = escape(self.labels) + # labels value escaped above + return mark_safe( # noqa: S308 + '' % (self.param, value) + ) + + FILTERS = [ SeriesFilter, SubmitterFilter, @@ -526,6 +583,7 @@ def set_status(self, *args, **kwargs): SearchFilter, ArchiveFilter, DelegateFilter, + LabelsFilter, ] @@ -555,14 +613,22 @@ def available_filters(self): def apply(self, queryset): kwargs = collections.OrderedDict() + q_filters = [] for f in self._filters: if f.applied: - kwargs.update(f.kwargs) + if f.kwargs is not None: + kwargs.update(f.kwargs) + else: + q_filters.append(f) - if not kwargs: + if not kwargs and not q_filters: return queryset - return queryset.filter(**kwargs) + queryset = queryset.filter(**kwargs) + for f in q_filters: + queryset = f.q(queryset) + + return queryset def querystring(self, remove=None): params = self.params diff --git a/patchwork/fixtures/default_labels.xml b/patchwork/fixtures/default_labels.xml new file mode 100644 index 000000000..1e1aa705b --- /dev/null +++ b/patchwork/fixtures/default_labels.xml @@ -0,0 +1,9 @@ + + + + + RFC + "RFC" stands for "Request for Comment", which tells maintainers that they should review your patch thoroughly and provide feedback. RFC is typically used when sending feature patches for the first time, or anytime the patch is more than just a simple bug fix. + #EDEDED + + diff --git a/patchwork/forms.py b/patchwork/forms.py index efedfa976..2499f9c17 100644 --- a/patchwork/forms.py +++ b/patchwork/forms.py @@ -3,15 +3,18 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +import re + from django.contrib.auth.models import User from django import forms -from django.forms import renderers +from django.forms import widgets, renderers from django.db.models import Q from django.db.utils import ProgrammingError from django.template.backends import django as django_template_backend from patchwork.models import Bundle from patchwork.models import Patch +from patchwork.models import Label from patchwork.models import State from patchwork.models import UserProfile @@ -142,10 +145,17 @@ def __init__(self, instance=None, project=None, *args, **kwargs): widget=forms.Select(attrs={'class': 'change-property-delegate'}), required=False, ) + self.fields['labels'] = forms.ModelMultipleChoiceField( + queryset=Label.get_project_labels(project), + required=False, + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels'} + ), + ) class Meta: model = Patch - fields = ['state', 'archived', 'delegate'] + fields = ['state', 'archived', 'delegate', 'labels'] widgets = { 'state': forms.Select(attrs={'class': 'change-property-state'}), 'archived': forms.CheckboxInput( @@ -221,6 +231,14 @@ def __init__(self, project, *args, **kwargs): label='Delegate to', required=False, ) + + self.fields['labels'] = forms.ModelMultipleChoiceField( + queryset=Label.get_project_labels(project), + widget=forms.SelectMultiple( + attrs={'class': 'labels-field', 'placeholder': 'Labels to add'} + ), + required=False, + ) self.fields['state'] = OptionalModelChoiceField( queryset=State.objects.all(), placeholder='Change state', @@ -252,4 +270,36 @@ def save(self, instance, commit=True): if commit: instance.save() + + for f in opts.many_to_many: + if f.name not in data: + continue + + getattr(instance, f.name).add(*data[f.name]) + return instance + + +class ColorInput(widgets.Input): + input_type = 'color' + + +class ColorField(forms.CharField): + widget = ColorInput + default_error_messages = { + 'invalid': 'Enter a valid colour value: e.g. "#ff0022"', + } + + def clean(self, value): + if not re.match('^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', value): + raise forms.ValidationError(self.error_messages['invalid']) + + value = int(value.lstrip('#'), 16) + super(ColorField, self).clean(value) + + return value + + def widget_attrs(self, widget): + attrs = super().widget_attrs(widget) + attrs['maxlength'] = 7 + return attrs diff --git a/patchwork/management/commands/relabel.py b/patchwork/management/commands/relabel.py new file mode 100644 index 000000000..57dfdd169 --- /dev/null +++ b/patchwork/management/commands/relabel.py @@ -0,0 +1,198 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026, The Linux Foundation +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from django.core.management.base import BaseCommand + +from email.parser import HeaderParser + +from patchwork.models import Label, Patch, Cover, Series +from patchwork.parser import clean_subject + + +# Prepares a dictionary that maps from project id to a list of +# labels of this project (including global labels, if no local version exists) +def get_labels_per_project(): + global_labels = list(Label.objects.filter(project_id=None).all()) + + labels_per_project = {} + for label in Label.objects.filter(project_id__isnull=False).all(): + labels_list = labels_per_project.get(label.project_id, []) + labels_list.append(label) + labels_per_project[label.project_id] = labels_list + + for project_labels in labels_per_project.values(): + local_label_names = [label.name for label in project_labels] + for global_label in global_labels: + if global_label.name not in local_label_names: + project_labels.append(global_label) + + return labels_per_project, global_labels + + +# Parses stripped name and labels out of patch headers +def get_name_and_labels(headers, labels): + parser = HeaderParser() + headers = parser.parsestr(headers) + subject = headers['Subject'] + prefixes = [] + if subject is None: + return None, None + + name, prefixes, cleaned_prefixes = clean_subject(subject, labels) + + return name, cleaned_prefixes + + +class Command(BaseCommand): + help = ( + 'Update labels for existing patches based on the subject line. ' + 'NOTE: Labels have to be created in the admin interface first.' + ) + + def add_arguments(self, parser): + parser.add_argument( + 'projects', + metavar='PROJECT', + nargs='*', + help='list of listIDs of projects to be relabeld', + ) + + def flush(self, patch_obj): + if len(self.patches_to_update) > 0: + patch_obj.objects.bulk_update(self.patches_to_update, ['name']) + self.patches_to_update = [] + self.patches_to_update_ids = set() + + if len(self.series_to_update) > 0: + Series.objects.bulk_update(self.series_to_update, ['name']) + self.series_to_update = [] + + if len(self.labels_to_add) > 0: + patch_obj.labels.through.objects.bulk_create(self.labels_to_add) + self.labels_to_add = [] + + def reset(self): + self.patches_to_update = [] + self.series_to_update = [] + self.patches_to_update_ids = set() + self.labels_to_add = [] + + def update_patches(self, projects): + self.reset() + + query = Patch.objects.all().only('id', 'headers', 'project_id') + + if projects: + query = query.filter(project__listid__in=projects) + + count = query.count() + + for i, patch in enumerate(query.iterator(chunk_size=1000)): + project_labels = self.labels_per_project.get( + patch.project_id, self.global_labels + ) + + name, cleaned_prefixes = get_name_and_labels( + patch.headers, [label.name for label in project_labels] + ) + if name is None or cleaned_prefixes is None: + continue + + for label in project_labels: + if ( + label.name in cleaned_prefixes + and label not in patch.labels.all() + ): + self.labels_to_add.append( + Patch.labels.through( + patch_id=patch.id, label_id=label.id + ) + ) + self.patches_to_update_ids.add(patch.id) + + if patch.name != name: + patch.name = name[:255] + self.patches_to_update.append(patch) + + if ( + patch.series is not None + and patch.series.name != name + and patch.number == 1 + and patch.series.cover_letter is None + ): + patch.series.name = name[:255] + self.series_to_update.append(patch.series) + + if (i % 100) == 0: + self.stdout.write('%06d/%06d\r' % (i, count), ending='') + self.stdout.flush() + + if len(self.patches_to_update) >= 1000: + self.flush(Patch) + + self.flush(Patch) + + def update_covers(self, projects): + self.reset() + + query = Cover.objects.all().only('id', 'headers', 'project_id') + + if projects: + query = query.filter(project__listid__in=projects) + + count = query.count() + + for i, cover in enumerate(query.iterator(chunk_size=1000)): + project_labels = self.labels_per_project.get( + cover.project_id, self.global_labels + ) + + name, cleaned_prefixes = get_name_and_labels( + cover.headers, [label.name for label in project_labels] + ) + if name is None or cleaned_prefixes is None: + continue + + for label in project_labels: + if ( + label.name in cleaned_prefixes + and label not in cover.labels.all() + ): + self.labels_to_add.append( + Cover.labels.through( + cover_id=cover.id, label_id=label.id + ) + ) + self.patches_to_update_ids.add(cover.id) + + if cover.name != name: + cover.name = name[:255] + self.patches_to_update.append(cover) + + series = Series.objects.get(cover_letter=cover) + series.name = name[:255] + self.series_to_update.append(series) + + if (i % 100) == 0: + self.stdout.write('%06d/%06d\r' % (i, count), ending='') + self.stdout.flush() + + if len(self.patches_to_update) >= 1000: + self.flush(Cover) + + self.flush(Cover) + + def handle(self, *args, **options): + self.labels_per_project, self.global_labels = get_labels_per_project() + + self.stdout.write('\nupdating covers') + + self.update_covers(options['projects']) + + self.stdout.write('\nupdating patches') + + self.update_patches(options['projects']) + + self.stdout.write('\ndone') diff --git a/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py b/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py new file mode 100644 index 000000000..f086b2b15 --- /dev/null +++ b/patchwork/migrations/0054_label_coverlabel_cover_labels_patchlabel_and_more.py @@ -0,0 +1,158 @@ +# Generated by Django 6.0.7 on 2026-11-01 11:48 + +import django.db.models.deletion +import django.db.models.functions.text +import patchwork.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('patchwork', '0053_series_ordering_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='The label value.', max_length=25 + ), + ), + ( + 'description', + models.TextField( + blank=True, + help_text='A description of what the label is ' + 'and when it should be applied. The text will be ' + 'visible in tooltips of label elements in the UI.', + max_length=1000, + null=True, + ), + ), + ( + 'color', + patchwork.fields.ColorField( + help_text='The color code to use in the UI.' + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + help_text='The project this label is associated with. ' + 'If unset, this label is global to the instance', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='labels', + related_query_name='label', + to='patchwork.project', + ), + ), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CoverLabel', + fields=[ + ( + 'pk', + models.CompositePrimaryKey( + 'cover', + 'label', + blank=True, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'cover', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.cover', + ), + ), + ( + 'label', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.label', + ), + ), + ], + ), + migrations.AddField( + model_name='cover', + name='labels', + field=models.ManyToManyField( + through='patchwork.CoverLabel', to='patchwork.label' + ), + ), + migrations.CreateModel( + name='PatchLabel', + fields=[ + ( + 'pk', + models.CompositePrimaryKey( + 'patch', + 'label', + blank=True, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'label', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.label', + ), + ), + ( + 'patch', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='patchwork.patch', + ), + ), + ], + ), + migrations.AddField( + model_name='patch', + name='labels', + field=models.ManyToManyField( + through='patchwork.PatchLabel', to='patchwork.label' + ), + ), + migrations.AddConstraint( + model_name='label', + constraint=models.UniqueConstraint( + models.F('project'), + django.db.models.functions.text.Lower('name'), + name='uniq_labels_project_id_name', + ), + ), + migrations.AlterUniqueTogether( + name='coverlabel', + unique_together={('cover', 'label')}, + ), + migrations.AlterUniqueTogether( + name='patchlabel', + unique_together={('patch', 'label')}, + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index e9e54307f..e1428229c 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -15,11 +15,14 @@ from django.core.validators import validate_unicode_slug from django.core.validators import MaxValueValidator from django.db import models +from django.db.models.constraints import UniqueConstraint from django.urls import reverse from django.utils.functional import cached_property from django.utils import timezone as tz_utils +from django.db.models.functions import Lower +from django.db.models import OuterRef, Exists, Q -from patchwork.fields import HashField +from patchwork.fields import HashField, ColorField from patchwork.hasher import hash_diff if settings.ENABLE_REST_API: @@ -261,6 +264,78 @@ class Meta: ordering = ['ordering'] +class Label(models.Model): + """Labels for patches. + + Labels are arbitrary bits of metadata attached to a patch. They can + be used to signify priority, category, or other similar information. + They can also be used to filter patches and identify the ones most + interesting to a given user. + """ + + project = models.ForeignKey( + Project, + related_name='labels', + related_query_name='label', + null=True, + blank=True, + on_delete=models.CASCADE, + help_text='The project this label is associated with. If unset, this ' + 'label is global to the instance', + ) + + name = models.CharField(max_length=25, help_text='The label value.') + description = models.TextField( + null=True, + blank=True, + max_length=1000, + help_text='A description of what the label is and when it should be ' + 'applied. The text will be visible in tooltips of label elements in ' + 'the UI.', + ) + color = ColorField(help_text='The color code to use in the UI.') + + def __str__(self): + return self.name + + @staticmethod + def get_project_labels(project): + project_label_names = Label.objects.filter( + project=project + ).values_list('name') + return Label.objects.filter( + models.Q(project=project) + | ( + models.Q(project__isnull=True) + & ~models.Q(name__in=project_label_names) + ) + ) + + @staticmethod + def filter_by_names(labels): + labels = [label.lower() for label in labels] + result = {} + label_q = Label.objects.annotate(lname=Lower('name')).filter( + lname__in=labels + ) + + for label in label_q: + if label.name not in result: + result[label.name] = [label.id] + else: + result[label.name].append(label.id) + + return result + + class Meta: + ordering = ['name'] + constraints = [ + UniqueConstraint( + 'project', Lower('name'), name='uniq_labels_project_id_name' + ) + ] + + class Tag(models.Model): name = models.CharField(max_length=20) pattern = models.CharField( @@ -342,6 +417,37 @@ def with_tag_counts(self, project=None): return qs + def exclude_labels(self, labels): + lids = Label.filter_by_names(labels) + for _, ids_with_name in lids.items(): + for lid in ids_with_name: + label_q = Patch.labels.through.objects.filter( + patch_id=OuterRef('pk'), label_id=lid + ) + self = self.exclude(Exists(label_q)) + + return self + + def include_labels(self, labels): + if len(labels) == 0: + return self + + lids = Label.filter_by_names(labels) + if len(lids) > 0: + for _, ids_with_name in lids.items(): + q = Q() + for lid in ids_with_name: + q |= Exists( + Patch.labels.through.objects.filter( + patch_id=OuterRef('pk'), label_id=lid + ) + ) + self = self.filter(q) + + return self + else: + return Patch.objects.none() + class PatchManager(models.Manager): def get_queryset(self): @@ -466,7 +572,18 @@ class Meta: abstract = True +class CoverLabel(models.Model): + cover = models.ForeignKey('Cover', on_delete=models.CASCADE) + label = models.ForeignKey('Label', on_delete=models.CASCADE) + pk = models.CompositePrimaryKey("cover", "label") + + class Meta: + unique_together = [('cover', 'label')] + + class Cover(SubmissionMixin): + labels = models.ManyToManyField(Label, through=CoverLabel) + def get_absolute_url(self): return reverse( 'cover-detail', @@ -500,11 +617,21 @@ class Meta: ] +class PatchLabel(models.Model): + patch = models.ForeignKey('Patch', on_delete=models.CASCADE) + label = models.ForeignKey('Label', on_delete=models.CASCADE) + pk = models.CompositePrimaryKey("patch", "label") + + class Meta: + unique_together = [('patch', 'label')] + + class Patch(SubmissionMixin): diff = models.TextField(null=True, blank=True) commit_ref = models.CharField(max_length=255, null=True, blank=True) pull_url = models.CharField(max_length=255, null=True, blank=True) tags = models.ManyToManyField(Tag, through=PatchTag) + labels = models.ManyToManyField(Label, through=PatchLabel) # patchwork metadata @@ -994,6 +1121,12 @@ def add_cover_letter(self, cover): self.save() + labels = [label.id for label in cover.labels.all()] + if len(labels) != 0: + for patch in Patch.objects.filter(series=self).all(): + patch.labels.add(*labels) + patch.save() + def add_patch(self, patch, number): """Add a patch to the series.""" # both user defined names and cover letter-based names take precedence @@ -1003,6 +1136,9 @@ def add_patch(self, patch, number): patch.series = self patch.number = number + if self.cover_letter: + labels = [label.id for label in self.cover_letter.labels.all()] + patch.labels.add(*labels) patch.save() return patch diff --git a/patchwork/parser.py b/patchwork/parser.py index e421b316e..d9c33e75c 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -26,6 +26,7 @@ from patchwork.models import CoverComment from patchwork.models import DelegationRule from patchwork.models import get_default_initial_patch_state +from patchwork.models import Label from patchwork.models import Patch from patchwork.models import PatchComment from patchwork.models import Person @@ -246,7 +247,7 @@ def _find_series_by_references(project, mail): The matching ``Series`` instance, if any """ subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + name, prefixes, _ = clean_subject(subject, [project.linkname]) version = parse_version(name, prefixes) msg_id = find_message_id(mail) @@ -300,7 +301,7 @@ def _find_series_by_markers(project, mail, author): """ subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + name, prefixes, _ = clean_subject(subject, [project.linkname]) _, total = parse_series_marker(prefixes) version = parse_version(name, prefixes) @@ -837,6 +838,7 @@ def clean_subject(subject, drop_prefixes=None): subject = normalise_space(subject) prefixes = [] + cleaned_prefixes = [] match = prefix_re.match(subject) @@ -848,6 +850,10 @@ def clean_subject(subject, drop_prefixes=None): if p.lower() not in drop_prefixes ] + cleaned_prefixes += [ + p for p in split_prefixes(prefix_str) if p.lower() in drop_prefixes + ] + subject = match.group(2) match = prefix_re.match(subject) @@ -855,7 +861,7 @@ def clean_subject(subject, drop_prefixes=None): if prefixes: subject = '[%s] %s' % (','.join(prefixes), subject) - return (subject, prefixes) + return (subject, prefixes, cleaned_prefixes) def subject_check(subject): @@ -1236,7 +1242,19 @@ def parse_mail(mail, list_id=None): msgid = find_message_id(mail) subject = mail.get('Subject') - name, prefixes = clean_subject(subject, [project.linkname]) + + project_labels = Label.get_project_labels(project) + project_labels_names = [label.name.lower() for label in project_labels] + name, prefixes, cleaned_prefixes = clean_subject( + subject, [project.linkname, *project_labels_names] + ) + cleaned_prefixes = [prefix.lower() for prefix in cleaned_prefixes] + labels = [ + label + for label in project_labels + if label.name.lower() in cleaned_prefixes + ] + is_comment = subject_check(subject) x, n = parse_series_marker(prefixes) version = parse_version(name, prefixes) @@ -1284,6 +1302,9 @@ def parse_mail(mail, list_id=None): delegate=delegate, state=find_state(mail), ) + if labels: + patch.labels.set(labels) + logger.debug('Patch saved') for attempt in range(1, 11): # arbitrary retry count @@ -1456,6 +1477,8 @@ def parse_mail(mail, list_id=None): submitter=author, content=message, ) + if labels: + cover_letter.labels.set(labels) logger.debug('Cover letter saved') diff --git a/patchwork/templates/patchwork/partials/filters.html b/patchwork/templates/patchwork/partials/filters.html index 1175af147..86e63f235 100644 --- a/patchwork/templates/patchwork/partials/filters.html +++ b/patchwork/templates/patchwork/partials/filters.html @@ -1,5 +1,7 @@ {% load static %} +{{ project_labels|json_script:"project-labels-data" }} + diff --git a/patchwork/templates/patchwork/partials/patch-forms.html b/patchwork/templates/patchwork/partials/patch-forms.html index 80f828153..effa227d8 100644 --- a/patchwork/templates/patchwork/partials/patch-forms.html +++ b/patchwork/templates/patchwork/partials/patch-forms.html @@ -1,3 +1,23 @@ +{% load static %} + +{{ project_labels|json_script:"project-labels-data" }} + + +
{% if patch_form %}
@@ -13,6 +33,10 @@ {{ patch_form.archived.errors }} {{ patch_form.archived.label_tag }} {{ patch_form.archived }}
+
+ {{ patch_form.labels.errors }} + {{ patch_form.labels }} +
diff --git a/patchwork/templates/patchwork/partials/patch-list.html b/patchwork/templates/patchwork/partials/patch-list.html index b9bb62d21..d5c69a4d8 100644 --- a/patchwork/templates/patchwork/partials/patch-list.html +++ b/patchwork/templates/patchwork/partials/patch-list.html @@ -172,6 +172,7 @@ {{ patch.name|default:"[no subject]"|truncatechars:100 }} + {{ patch|patch_labels }} {% if patch.series %} diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html index cd74491c0..f65480abb 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -130,6 +130,12 @@

{{ submission.name }}

{% endif %} + + Labels + + {{ submission|patch_labels }} + +
diff --git a/patchwork/templatetags/patch.py b/patchwork/templatetags/patch.py index 36179bbfc..7fee28eee 100644 --- a/patchwork/templatetags/patch.py +++ b/patchwork/templatetags/patch.py @@ -83,3 +83,34 @@ def patch_commit_display(patch): return mark_safe( # noqa: S308 '%s' % (escape(fmt.format(commit)), escape(commit)) ) + + +@register.filter(name='patch_labels') +def patch_labels(patch): + + def text_color(hex_color): + """Generate the ideal text color given a background color. + + From https://www.w3.org/TR/AERT/#color-contrast + """ + red, green, blue = [ + int(hex_color.lstrip('#')[i : i + 2], 16) for i in (0, 2, 4) + ] + brightness = (red * 299 + green * 587 + blue * 114) / 1000 + + return '#000' if brightness >= 123 else '#fff' + + output = [] + for label in patch.labels.all(): + style = 'background-color: %s; color: %s' % ( + label.color, + text_color(label.color), + ) + output.append( + '%s' + % (style, escape(label.description), escape(label.name)) + ) + + # Label name is escaped above. Color is formatted in a way that + # does not permit XSS + return mark_safe(''.join(output)) # noqa: S308 diff --git a/patchwork/tests/unit/api/test_cover.py b/patchwork/tests/unit/api/test_cover.py index 643806d92..092a2a137 100644 --- a/patchwork/tests/unit/api/test_cover.py +++ b/patchwork/tests/unit/api/test_cover.py @@ -40,6 +40,11 @@ def assertSerialized(self, cover_obj, cover_json): self.assertIn(cover_obj.get_absolute_url(), cover_json['web_url']) self.assertIn('comments', cover_json) + # list fields + + for label in cover_obj.labels.all(): + self.assertIn(label.name, cover_json['labels']) + # nested fields self.assertEqual(cover_obj.submitter.id, cover_json['submitter']['id']) @@ -130,13 +135,25 @@ def test_list_version_1_0(self): self.assertIn('url', resp.data[0]) self.assertNotIn('mbox', resp.data[0]) self.assertNotIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) + + def test_list_version_1_1(self): + create_cover() + + resp = self.client.get(self.api_url(version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertIn('url', resp.data[0]) + self.assertIn('mbox', resp.data[0]) + self.assertIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) def test_list_bug_335(self): """Ensure we retrieve the embedded series project once.""" series = create_series() create_covers(5, series=series) - with self.assertNumQueries(3): + with self.assertNumQueries(4): self.client.get(self.api_url()) @utils.store_samples('cover-detail') @@ -177,6 +194,15 @@ def test_detail_invalid(self): with self.assertRaises(NoReverseMatch): self.client.get(self.api_url('foo')) + def test_detail_version_1_1(self): + cover = create_cover() + + resp = self.client.get(self.api_url(cover.id, version='1.1')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + def test_create_update_delete(self): user = create_maintainer() user.is_superuser = True diff --git a/patchwork/tests/unit/api/test_patch.py b/patchwork/tests/unit/api/test_patch.py index d7ab81135..de05e8d60 100644 --- a/patchwork/tests/unit/api/test_patch.py +++ b/patchwork/tests/unit/api/test_patch.py @@ -13,6 +13,7 @@ from patchwork.models import Patch from patchwork.tests.unit.api import utils +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_maintainer from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_patches @@ -54,6 +55,11 @@ def assertSerialized(self, patch_obj, patch_json): self.assertIn(patch_obj.get_absolute_url(), patch_json['web_url']) self.assertIn('comments', patch_json) + # list fields + + for label in patch_obj.labels.all(): + self.assertIn(label.name, patch_json['labels']) + # nested fields self.assertEqual(patch_obj.submitter.id, patch_json['submitter']['id']) @@ -247,6 +253,94 @@ def test_list_filter_msgid(self): resp = self.client.get(self.api_url(), {'msgid': 'fishfish@fish.fish'}) self.assertEqual(0, len(resp.data)) + def test_list_filter_labels(self): + """Filter patches by labels.""" + label1 = create_label() + label2 = create_label() + label3 = create_label() + + person_obj = create_person(email='test@example.com') + project_obj = create_project(linkname='myproject') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + patch_no_labels = create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label1]) + patch2 = create_patch(**patch_kwargs, labels=[label1, label2]) + + resp = self.client.get(self.api_url(), {'labels': label1.name}) + self.assertEqual([patch1.id, patch2.id], [x['id'] for x in resp.data]) + + resp = self.client.get(self.api_url(), {'labels': label3.name}) + self.assertEqual(0, len(resp.data)) + + resp = self.client.get(self.api_url(), {'labels': '-' + label2.name}) + self.assertEqual( + [patch_no_labels.id, patch1.id], [x['id'] for x in resp.data] + ) + + resp = self.client.get( + self.api_url(), {'labels': label1.name + ',' + label2.name} + ) + self.assertEqual([patch2.id], [x['id'] for x in resp.data]) + + def test_list_filter_labels_local_global(self): + """Test filtering for patches with different labels, one local and + one global (without a project) that have the same name. + """ + project_obj = create_project(linkname='myproject') + project_obj2 = create_project(linkname='myproject2') + + label_local = create_label(name='label', project=project_obj) + label_local2 = create_label(name='label', project=project_obj2) + label_global = create_label(name='label', project=None) + + person_obj = create_person(email='test@example.com') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + patch_no_labels = create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label_local]) + patch2 = create_patch(**patch_kwargs, labels=[label_global]) + patch3 = create_patch( + **{'project': project_obj2, **patch_kwargs}, labels=[label_local2] + ) + + resp = self.client.get(self.api_url(), {'labels': 'label'}) + self.assertEqual( + [patch1.id, patch2.id, patch3.id], [x['id'] for x in resp.data] + ) + + resp = self.client.get(self.api_url(), {'labels': '-label'}) + self.assertEqual([patch_no_labels.id], [x['id'] for x in resp.data]) + + def test_list_filter_labels_case_insensitive(self): + """Test if filtering by label is case-insensitive.""" + label = create_label(name='RFC') + + person_obj = create_person(email='test@example.com') + project_obj = create_project(linkname='myproject') + state_obj = create_state(name='Under Review') + patch_kwargs = { + 'state': state_obj, + 'project': project_obj, + 'submitter': person_obj, + } + create_patch(**patch_kwargs) + patch1 = create_patch(**patch_kwargs, labels=[label]) + + resp = self.client.get(self.api_url(), {'labels': 'RFC'}) + self.assertEqual([patch1.id], [x['id'] for x in resp.data]) + + resp = self.client.get(self.api_url(), {'labels': 'rfc'}) + self.assertEqual([patch1.id], [x['id'] for x in resp.data]) + @utils.store_samples('patch-list-1-0') def test_list_version_1_0(self): """List patches using API v1.0.""" @@ -257,6 +351,16 @@ def test_list_version_1_0(self): self.assertEqual(1, len(resp.data)) self.assertIn('url', resp.data[0]) self.assertNotIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) + + def test_list_version_1_1(self): + create_patch() + + resp = self.client.get(self.api_url(version='1.1')) + self.assertEqual(status.HTTP_200_OK, resp.status_code) + self.assertEqual(1, len(resp.data)) + self.assertIn('web_url', resp.data[0]) + self.assertNotIn('labels', resp.data[0]) def test_list_bug_335(self): """Ensure we retrieve the embedded series project in O(1).""" @@ -264,7 +368,7 @@ def test_list_bug_335(self): create_patches(5, series=series) # Count, page IDs, patch rows and the constant-size prefetches. - with self.assertNumQueries(6): + with self.assertNumQueries(7): self.client.get(self.api_url()) @utils.store_samples('patch-detail') @@ -297,6 +401,23 @@ def test_detail_version_1_0(self): self.assertIn('url', resp.data) self.assertNotIn('web_url', resp.data) self.assertNotIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + + def test_detail_version_1_1(self): + patch = create_patch() + + resp = self.client.get(self.api_url(item=patch.id, version='1.1')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertNotIn('labels', resp.data) + + def test_detail_version_1_4(self): + label = create_label() + patch = create_patch(labels=[label]) + + resp = self.client.get(self.api_url(item=patch.id, version='1.4')) + self.assertEqual(resp.data['labels'], [label.name]) def test_detail_non_existent(self): """Ensure we get a 404 for a non-existent patch.""" @@ -308,6 +429,15 @@ def test_detail_invalid(self): with self.assertRaises(NoReverseMatch): self.client.get(self.api_url('foo')) + def test_detail_labels(self): + patch = create_patch() + + resp = self.client.get(self.api_url(item=patch.id, version='1.4')) + self.assertIn('url', resp.data) + self.assertIn('web_url', resp.data) + self.assertIn('comments', resp.data) + self.assertIn('labels', resp.data) + def test_create(self): """Ensure creations are rejected.""" project = create_project() @@ -399,6 +529,49 @@ def test_update_maintainer_version_1_0(self): self.assertEqual(Patch.objects.get(id=patch.id).state, state) self.assertEqual(Patch.objects.get(id=patch.id).delegate, user) + def test_update_labels(self): + """Update labels as maintainer.""" + project = create_project() + project2 = create_project() + label1 = create_label(name='label', project=project) + label2 = create_label(name='label2') + label_p2 = create_label(name='label_p2', project=project2) + label1_global = create_label(name='label', project=None) + patch = create_patch(project=project, labels=[label1_global]) + user = create_maintainer(project) + + # Test adding labels labels, make sure local labels are prioritized + + self.client.authenticate(user=user) + resp = self.client.patch( + self.api_url(patch.id), {'labels': [label1.name, label2.name]} + ) + self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) + self.assertEqual( + sorted( + Patch.objects.get(id=patch.id).labels.values_list( + 'id', flat=True + ) + ), + sorted([label1.id, label2.id]), + ) + + # Test removing labels + + self.client.authenticate(user=user) + resp = self.client.patch(self.api_url(patch.id), {'labels': []}) + self.assertEqual(status.HTTP_200_OK, resp.status_code, resp) + self.assertEqual(Patch.objects.get(id=patch.id).labels.count(), 0) + + # Make sure labels from other projects cannot be added + + self.client.authenticate(user=user) + resp = self.client.patch( + self.api_url(patch.id), {'labels': [label1.name, label_p2.name]} + ) + self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code, resp) + self.assertEqual(Patch.objects.get(id=patch.id).labels.count(), 0) + @utils.store_samples('patch-update-error-bad-request') def test_update_invalid_state(self): """Update patch with invalid fields. diff --git a/patchwork/tests/unit/test_parser.py b/patchwork/tests/unit/test_parser.py index 1411ef0bc..33bf1acaa 100644 --- a/patchwork/tests/unit/test_parser.py +++ b/patchwork/tests/unit/test_parser.py @@ -41,6 +41,7 @@ from patchwork.tests.utils import create_cover_comment from patchwork.tests.utils import create_patch from patchwork.tests.utils import create_patch_comment +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_project from patchwork.tests.utils import create_series from patchwork.tests.utils import create_series_reference @@ -542,7 +543,7 @@ def _create_email(subject): def _test_encoding(self, subject_header, parsed_subject): email = self._create_email(subject_header) - name, _ = clean_subject(email.get('Subject')) + name, _, _ = clean_subject(email.get('Subject')) self.assertEqual(name, parsed_subject) def test_subject_ascii_encoding(self): @@ -1106,52 +1107,61 @@ def test_tags(self): class SubjectTest(TestCase): def test_clean_subject(self): - self.assertEqual(clean_subject('meep'), ('meep', [])) - self.assertEqual(clean_subject('Re: meep'), ('meep', [])) - self.assertEqual(clean_subject('[PATCH] meep'), ('meep', [])) + self.assertEqual(clean_subject('meep'), ('meep', [], [])) + self.assertEqual(clean_subject('Re: meep'), ('meep', [], [])) self.assertEqual( - clean_subject('[PATCH] meep \n meep'), ('meep meep', []) + clean_subject('[PATCH] meep'), ('meep', [], ['PATCH']) ) self.assertEqual( - clean_subject('[PATCH] meep,\n meep'), ('meep, meep', []) + clean_subject('[PATCH] meep \n meep'), ('meep meep', [], ['PATCH']) ) self.assertEqual( - clean_subject('[PATCH RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH] meep,\n meep'), + ('meep, meep', [], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH,RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH,1/2] meep'), ('[1/2] meep', ['1/2']) + clean_subject('[PATCH,RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), + ) + self.assertEqual( + clean_subject('[PATCH,1/2] meep'), + ('[1/2] meep', ['1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH RFC 1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( - clean_subject('[PATCH] [RFC] meep'), ('[RFC] meep', ['RFC']) + clean_subject('[PATCH] [RFC] meep'), + ('[RFC] meep', ['RFC'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC,1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC] [1/2] meep'), - ('[RFC,1/2] meep', ['RFC', '1/2']), + ('[RFC,1/2] meep', ['RFC', '1/2'], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] rewrite [a-z] regexes'), - ('rewrite [a-z] regexes', []), + ('rewrite [a-z] regexes', [], ['PATCH']), ) self.assertEqual( clean_subject('[PATCH] [RFC] rewrite [a-z] regexes'), - ('[RFC] rewrite [a-z] regexes', ['RFC']), + ('[RFC] rewrite [a-z] regexes', ['RFC'], ['PATCH']), ) self.assertEqual( - clean_subject('[foo] [bar] meep', ['foo']), ('[bar] meep', ['bar']) + clean_subject('[foo] [bar] meep', ['foo']), + ('[bar] meep', ['bar'], ['foo']), ) self.assertEqual( - clean_subject('[FOO] [bar] meep', ['foo']), ('[bar] meep', ['bar']) + clean_subject('[FOO] [bar] meep', ['foo']), + ('[bar] meep', ['bar'], ['FOO']), ) def test_subject_check(self): @@ -1570,3 +1580,146 @@ def test_parse_depends_on_url(self): result = parser.parse_depends_on('\n'.join([content2, content3])) self.assertIn(self.series1, result) self.assertIn(self.series2, result) + + +class TestParseLabels(TestCase): + orig_diff = read_patch('0001-add-line.patch') + + def setUp(self): + create_state() + self.project = create_project() + self.project2 = create_project() + + self.label = create_label(name='label', project=None) + self.label2 = create_label(name='label2', project=None) + self.label_rfc_global = create_label(name='RFC', project=None) + self.label_rfc = create_label(name='RFC', project=self.project) + self.label_p2 = create_label(name='label_p2', project=self.project2) + + def create_email( + self, subject, project, contents=None, in_reply_to=None, msgid=None + ): + msgid = make_msgid() if msgid is None else msgid + email = create_email( + self.orig_diff if contents is None else contents, + subject=subject, + listid=project.listid, + msgid=msgid, + in_reply_to=in_reply_to, + ) + _parse_mail(email) + patch = Patch.objects.get(msgid=msgid) + return patch + + def cmp_labels(self, patch, target_labels): + patch_labels = list(patch.labels.values_list('id', flat=True)) + target_labels = [label.id for label in target_labels] + self.assertEqual(len(patch_labels), len(target_labels)) + + for label in target_labels: + self.assertIn(label, patch_labels) + + def test_single_label(self): + patch = self.create_email('[PATCH,label] test subject', self.project) + self.cmp_labels(patch, [self.label]) + self.assertEqual(patch.name, 'test subject') + + def test_many_labels(self): + patch = self.create_email( + '[PATCH label v2 RFC 3/8] test subject', self.project + ) + self.cmp_labels(patch, [self.label, self.label_rfc]) + self.assertEqual(patch.name, '[v2,3/8] test subject') + + def test_unknown_label(self): + patch = self.create_email( + '[PATCH,unknown-label] test subject', self.project + ) + self.cmp_labels(patch, []) + self.assertEqual(patch.name, '[unknown-label] test subject') + + def test_label_from_different_project(self): + patch = self.create_email( + '[PATCH,label_p2] test subject', self.project + ) + self.cmp_labels(patch, []) + self.assertEqual(patch.name, '[label_p2] test subject') + + patch = self.create_email( + '[PATCH,label_p2] test subject', self.project2 + ) + self.cmp_labels(patch, [self.label_p2]) + self.assertEqual(patch.name, 'test subject') + + def test_local_global_labels(self): + patch = self.create_email('[PATCH,RFC] test subject', self.project) + self.cmp_labels(patch, [self.label_rfc]) + self.assertEqual(patch.name, 'test subject') + + patch = self.create_email('[PATCH,RFC] test subject', self.project2) + self.cmp_labels(patch, [self.label_rfc_global]) + self.assertEqual(patch.name, 'test subject') + + def test_cover_patch_inheritance(self): + cover_msgid = make_msgid() + cover_email = create_email( + 'test cover letter', + msgid=cover_msgid, + subject='[RFC,label2,0/2] a cover letter', + listid=self.project.listid, + ) + _parse_mail(cover_email) + cover = Cover.objects.filter(msgid=cover_msgid)[0] + self.cmp_labels(cover, [self.label_rfc, self.label2]) + + patch1_msgid = make_msgid() + patch = self.create_email( + '[label,PATCH,1/2] test patch', + self.project, + in_reply_to=cover_msgid, + msgid=patch1_msgid, + ) + self.cmp_labels(patch, [self.label, self.label_rfc, self.label2]) + + patch2 = self.create_email( + '[PATCH,2/2] test patch', + self.project, + in_reply_to=patch1_msgid, + ) + self.cmp_labels(patch2, [self.label_rfc, self.label2]) + + def test_cover_patch_inheritance_out_of_order(self): + cover_msgid = make_msgid() + + patch1_msgid = make_msgid() + patch1 = self.create_email( + '[label,PATCH,1/2] test patch', + self.project, + in_reply_to=cover_msgid, + msgid=patch1_msgid, + ) + self.cmp_labels(patch1, [self.label]) + + patch2 = self.create_email( + '[PATCH,2/2] test patch', + self.project, + in_reply_to=patch1_msgid, + ) + self.cmp_labels(patch2, []) + + cover_email = create_email( + 'test cover letter', + msgid=cover_msgid, + subject='[RFC,0/2] a cover letter', + listid=self.project.listid, + ) + _parse_mail(cover_email) + cover = Cover.objects.get(msgid=cover_msgid) + + self.cmp_labels(cover, [self.label_rfc]) + + patch1 = Patch.objects.get(msgid=patch1_msgid) + self.cmp_labels(patch1, [self.label, self.label_rfc]) + + patch2 = Patch.objects.get(msgid=patch2.msgid) + self.cmp_labels(patch2, [self.label_rfc]) diff --git a/patchwork/tests/unit/test_series.py b/patchwork/tests/unit/test_series.py index cce754893..40a6d76df 100644 --- a/patchwork/tests/unit/test_series.py +++ b/patchwork/tests/unit/test_series.py @@ -1037,7 +1037,7 @@ def test_dependency_multi_1(self): self.assertEqual(series3.dependents.count(), 0) # Test that the ordering is correct. - d1, d2 = [dep for dep in series3.dependencies.all()] + d1, d2 = [dep for dep in series3.dependencies.order_by('pk')] # First item in the query set should be the first dependency. self.assertEqual(d1, series1) diff --git a/patchwork/tests/unit/views/test_patch.py b/patchwork/tests/unit/views/test_patch.py index 133bae366..6bdee67b9 100644 --- a/patchwork/tests/unit/views/test_patch.py +++ b/patchwork/tests/unit/views/test_patch.py @@ -25,6 +25,7 @@ from patchwork.tests.utils import create_project from patchwork.tests.utils import create_state from patchwork.tests.utils import create_user +from patchwork.tests.utils import create_label from patchwork.tests.utils import read_patch @@ -203,6 +204,147 @@ def test_utf8_handling(self): self.assertEqual(response.status_code, 200) +class PatchListLabelFilteringTest(TestCase): + def setUp(self): + self.project = create_project() + self.label1 = create_label(project=self.project) + self.label2 = create_label(project=self.project) + self.label_RFC = create_label(project=self.project, name='RFC') + self.label_RFC_global = create_label(project=None, name='RFC') + self.global_label = create_label(project=None) + + self.person = create_person(name='test', email='test@example.com') + + self.p_no_labels = self.create_patch([]) + self.p_label1 = self.create_patch([self.label1]) + self.p_label1_2 = self.create_patch([self.label1, self.label2]) + self.p_label1_global = self.create_patch( + [self.label1, self.global_label] + ) + self.p_label_rfc = self.create_patch([self.label_RFC]) + self.p_label_rfc_global = self.create_patch([self.label_RFC_global]) + + project2 = create_project() + self.p2_no_labels = create_patch( + submitter=self.person, project=project2 + ) + self.p2_global_label = create_patch( + submitter=self.person, + project=project2, + labels=[self.global_label], + ) + + # NOTE: does not include project2 patches on purpose + self.patches = [ + self.p_no_labels, + self.p_label1, + self.p_label1_2, + self.p_label1_global, + self.p_label_rfc, + self.p_label_rfc_global, + ] + + def _extract_patch_ids(self, response): + """Extract patch identifiers from patch list HTML.""" + id_re = re.compile(r' +