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..f428ccad1 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 diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2 index 67bd403c0..1fa9eb155 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 diff --git a/docs/api/schemas/v1.4/patchwork.yaml b/docs/api/schemas/v1.4/patchwork.yaml index 179384431..f2c625bc8 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 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 ``').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..267a3820f 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,12 @@ class TagAdmin(admin.ModelAdmin): @admin.register(PatchRelation) class PatchRelationAdmin(admin.ModelAdmin): model = PatchRelation + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + list_display = ('name', 'color') + + def get_queryset(self, request): + qs = super(LabelAdmin, self).get_queryset(request) + return qs.filter(project=None) 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..9bd42254f 100644 --- a/patchwork/api/filters.py +++ b/patchwork/api/filters.py @@ -26,6 +26,9 @@ from patchwork.models import Series from patchwork.models import State +from patchwork.models import exclude_submissions_by_labels +from patchwork.models import filter_submissions_by_labels + # custom backend @@ -175,6 +178,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 = exclude_submissions_by_labels(queryset, labels_neg) + queryset = filter_submissions_by_labels(queryset, 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 +224,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 +244,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..407d9e718 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 StringRelatedField from patchwork.api.base import BaseHyperlinkedModelSerializer from patchwork.api.base import LinkHeaderPagination @@ -95,6 +96,7 @@ class PatchListSerializer(BaseHyperlinkedModelSerializer): default=[], style={'base_template': 'input.html'}, ) + labels = StringRelatedField(many=True) def get_web_url(self, instance): request = self.context.get('request') @@ -178,6 +180,7 @@ class Meta: 'checks', 'tags', 'related', + 'labels', ) read_only_fields = ( 'url', @@ -195,6 +198,7 @@ class Meta: 'check', 'checks', 'tags', + 'labels', ) versioned_fields = { '1.1': ('comments', 'web_url'), @@ -202,6 +206,7 @@ class Meta: 'list_archive_url', 'related', ), + '1.4': ('labels',), } extra_kwargs = { 'url': {'view_name': 'api-patch-detail'}, @@ -384,6 +389,7 @@ def get_queryset(self): 'project', 'series__project', 'related__patches__project', + 'labels', ) .select_related('state', 'submitter', 'series') .defer('content', 'diff', 'headers') @@ -408,7 +414,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..588ff81c0 100644 --- a/patchwork/filters.py +++ b/patchwork/filters.py @@ -13,6 +13,8 @@ from patchwork.models import Person from patchwork.models import Series from patchwork.models import State +from patchwork.models import exclude_submissions_by_labels +from patchwork.models import filter_submissions_by_labels class Filter(object): @@ -519,6 +521,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 = exclude_submissions_by_labels(queryset, labels_neg) + queryset = filter_submissions_by_labels(queryset, 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 +585,7 @@ def set_status(self, *args, **kwargs): SearchFilter, ArchiveFilter, DelegateFilter, + LabelsFilter, ] @@ -555,14 +615,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..394812835 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,19 @@ 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.objects.filter( + Q(project=project) | Q(project=None) + ), + 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 +233,15 @@ def __init__(self, project, *args, **kwargs): label='Delegate to', required=False, ) + self.fields['labels'] = forms.ModelMultipleChoiceField( + queryset=Label.objects.filter( + Q(project=project) | Q(project=None) + ), + 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 +273,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..954f2cd3c --- /dev/null +++ b/patchwork/management/commands/relabel.py @@ -0,0 +1,75 @@ +# 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 +from patchwork.parser import clean_subject + + +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 handle(self, *args, **options): + query = Patch.objects.all().only('id', 'headers', 'project_id') + labels = { + (label.name, label.project_id): label + for label in Label.objects.all() + } + labels_to_add = [] + + if options['projects']: + query = query.filter(project__listid__in=options['projects']) + + count = query.count() + + for i, patch in enumerate(query.iterator(chunk_size=1000)): + parser = HeaderParser() + headers = parser.parsestr(patch.headers) + subject = headers['Subject'] + if subject is None: + continue + + _, prefixes = clean_subject(subject) + + for prefix in set(prefixes): + label = labels.get((prefix, patch.project_id)) + + if label is None: + label = labels.get((prefix, None)) + + if label is not None: + labels_to_add.append( + Patch.labels.through( + patch_id=patch.id, label_id=label.id + ) + ) + + if (i % 100) == 0: + self.stdout.write('%06d/%06d\r' % (i, count), ending='') + self.stdout.flush() + + if len(labels_to_add) >= 1000: + Patch.labels.through.objects.bulk_create(labels_to_add) + labels_to_add = [] + + if len(labels_to_add) > 0: + Patch.labels.through.objects.bulk_create(labels_to_add) + labels_to_add = [] + + self.stdout.write('\ndone') diff --git a/patchwork/migrations/0054_add_patch_labels.py b/patchwork/migrations/0054_add_patch_labels.py new file mode 100644 index 000000000..76ca45369 --- /dev/null +++ b/patchwork/migrations/0054_add_patch_labels.py @@ -0,0 +1,76 @@ +# Generated by Django 6.0.7 on 2026-07-31 21:26 + +import django.db.models.deletion +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.', + 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'], + 'unique_together': {('project', 'name')}, + }, + ), + migrations.AddField( + model_name='cover', + name='labels', + field=models.ManyToManyField(to='patchwork.label'), + ), + migrations.AddField( + model_name='patch', + name='labels', + field=models.ManyToManyField(to='patchwork.label'), + ), + ] diff --git a/patchwork/models.py b/patchwork/models.py index e9e54307f..9288563f9 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -19,7 +19,7 @@ from django.utils.functional import cached_property from django.utils import timezone as tz_utils -from patchwork.fields import HashField +from patchwork.fields import HashField, ColorField from patchwork.hasher import hash_diff if settings.ENABLE_REST_API: @@ -261,6 +261,43 @@ 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, + help_text='A description of what the label is and when it should be ' + 'applied.', + ) + color = ColorField(help_text='The color code to use in the UI.') + + def __str__(self): + return self.name + + class Meta: + ordering = ['name'] + unique_together = [('project', 'name')] + + class Tag(models.Model): name = models.CharField(max_length=20) pattern = models.CharField( @@ -441,6 +478,7 @@ class SubmissionMixin(FilenameMixin, EmailMixin, models.Model): # submission metadata name = models.CharField(max_length=255) + labels = models.ManyToManyField(Label) @cached_property def list_archive_url(self): @@ -466,6 +504,24 @@ class Meta: abstract = True +def exclude_submissions_by_labels(queryset, labels): + if len(labels) > 0: + queryset = queryset.exclude(labels__name__in=labels) + + return queryset + + +def filter_submissions_by_labels(queryset, labels): + if len(labels) > 0: + queryset = ( + queryset.filter(labels__name__in=labels) + .annotate(num_labels=models.Count('labels', distinct=True)) + .filter(num_labels__gte=len(labels)) + ) + + return queryset + + class Cover(SubmissionMixin): def get_absolute_url(self): return reverse( diff --git a/patchwork/parser.py b/patchwork/parser.py index e421b316e..4546ac44d 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -17,6 +17,7 @@ from urllib.parse import urlparse, parse_qs from django.contrib.auth.models import User +from django.db.models import Q from django.db.utils import IntegrityError from django.db import transaction from django.utils import timezone as tz_utils @@ -26,6 +27,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 @@ -627,6 +629,17 @@ def parse_version(subject, subject_prefixes): return 1 +def parse_labels(subject_prefixes, project): + """Extract labels from subject. + + Args: + subject_prefixes: List of subject prefixes to extract tags from + """ + return Label.objects.filter( + Q(project=project) | Q(project=None), name__in=subject_prefixes + ) + + def _find_content(mail): """Extract the payload(s) from a mail. @@ -1271,6 +1284,8 @@ def parse_mail(mail, list_id=None): if Patch.objects.filter(project=project, msgid=msgid): raise DuplicateMailError(msgid=msgid) + labels = parse_labels(prefixes, project) + patch = Patch.objects.create( msgid=msgid, project=project, @@ -1284,6 +1299,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 diff --git a/patchwork/templates/patchwork/partials/filters.html b/patchwork/templates/patchwork/partials/filters.html index 1175af147..7f20bea35 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..e145acdca 100644 --- a/patchwork/templates/patchwork/partials/patch-forms.html +++ b/patchwork/templates/patchwork/partials/patch-forms.html @@ -1,3 +1,19 @@ +{% load static %} + + +
{% if patch_form %}
@@ -13,6 +29,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..220b9ef72 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.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..b5030fd1a 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,40 @@ 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]) + @utils.store_samples('patch-list-1-0') def test_list_version_1_0(self): """List patches using API v1.0.""" @@ -257,6 +297,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 +314,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 +347,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 +375,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() diff --git a/patchwork/tests/unit/test_parser.py b/patchwork/tests/unit/test_parser.py index 1411ef0bc..5e104b642 100644 --- a/patchwork/tests/unit/test_parser.py +++ b/patchwork/tests/unit/test_parser.py @@ -28,6 +28,7 @@ from patchwork.parser import find_comment_content from patchwork.parser import find_project from patchwork.parser import find_series +from patchwork.parser import parse_labels from patchwork.parser import parse_mail as _parse_mail from patchwork.parser import parse_pull_request from patchwork.parser import parse_series_marker @@ -41,6 +42,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 @@ -1199,6 +1201,20 @@ def test_version(self): self.assertEqual(parse_version('Hello, world (v2)', []), 2) self.assertEqual(parse_version('Hello, world (V6)', []), 6) + def test_labels(self): + label = create_label(name='RFC') + + self.assertEqual(list(parse_labels(['RFC'], label.project)), [label]) + self.assertEqual(list(parse_labels(['rfcx'], label.project)), []) + + project = create_project() + + self.assertEqual(list(parse_labels(['RFC'], project)), []) + + label = create_label(name='stuff', project=None) + + self.assertEqual(list(parse_labels(['stuff'], project)), [label]) + class SubjectMatchTest(TestCase): def setUp(self): diff --git a/patchwork/tests/unit/views/test_patch.py b/patchwork/tests/unit/views/test_patch.py index 133bae366..983cf85b6 100644 --- a/patchwork/tests/unit/views/test_patch.py +++ b/patchwork/tests/unit/views/test_patch.py @@ -23,6 +23,7 @@ from patchwork.tests.utils import create_patches from patchwork.tests.utils import create_person from patchwork.tests.utils import create_project +from patchwork.tests.utils import create_label from patchwork.tests.utils import create_state from patchwork.tests.utils import create_user from patchwork.tests.utils import read_patch @@ -203,6 +204,128 @@ 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.global_label = create_label(project=None) + + person = create_person(name='test', email='test@example.com') + + self.patches = [ + create_patch(submitter=person, project=self.project, labels=[]), + create_patch( + submitter=person, project=self.project, labels=[self.label1] + ), + create_patch( + submitter=person, + project=self.project, + labels=[self.label1, self.label2], + ), + create_patch( + submitter=person, + project=self.project, + labels=[self.label1, self.global_label], + ), + ] + + def _extract_patch_ids(self, response): + id_re = re.compile(r' { + // delegateTarget - div with dropdown + // delegateTarget.parentElement - input div created by selectize + // ^ contains selected items and actual control + e.delegateTarget.parentElement.getElementsByTagName('input')[0].focus(); + prevSelect(e); + }; +}); diff --git a/templates/base.html b/templates/base.html index 9519ecc55..8e1120dbe 100644 --- a/templates/base.html +++ b/templates/base.html @@ -20,6 +20,7 @@ +