From 35b9d80819d91010a7f06584868b3e7dc8a3eb95 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Aug 2025 15:06:23 -0400 Subject: [PATCH] Closes #19968: Use multiple selection lists for the assignment of object types when editing a permission (#19991) * Closes #19968: Use multiple selection lists for the assignment of object types when editing a permission * Remove errant logging statements * Defer compilation of choices for object_types * Fix test data --- netbox/project-static/dist/netbox.js | Bin 382185 -> 382549 bytes netbox/project-static/dist/netbox.js.map | Bin 1733459 -> 1734998 bytes .../project-static/src/buttons/moveOptions.ts | 49 ++++++++--- netbox/templates/extras/tableconfig_edit.html | 4 +- netbox/users/forms/model_forms.py | 15 +++- netbox/users/tests/test_views.py | 2 +- netbox/utilities/forms/widgets/select.py | 77 ++++++++++++++++++ .../templates/helpers/table_config_form.html | 8 +- .../templates/widgets/splitmultiselect.html | 31 +++++++ 9 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 netbox/utilities/templates/widgets/splitmultiselect.html diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 9e2d6b383c2f22e003900f3bd894ce5d117a9676..7572150d4a6efa855f939ab1f78d159ee4acc9b2 100644 GIT binary patch delta 952 zcma)5TTB#J7|uWc91x;Gv0%jq))mN1GCM4-)(csU_r(i^O;jRnT--g3qw5Tg!^U7X zULa^xWWkmH!Ke>Llcr5*lP0H$HsyiZl#9@$MjmWapPD|D5FdQ*S)0l}^ud?&o%8+q zFW-00`Rj{S&%auAu@Amg{*J-%EAuf$%*SxTTw!qie5dPaDeedSfZG|Mu}fI7N%zgO zJjD|}PuA6G-0bM;;!bks2|Jb4d^3@99p3CFxwbZ}>xOT7{Ir|qnyypiGJZmTTtW*D zQ1miREi?0xklW?kt`np$vshi?c6$jPlzF#W?^Krh9iGK~d9-O^E3RG|x0A3ZfrNlso;lNL0oq8B&N z*AZM(vDl!||DPi8DGl~wC7AT67dO6N$^jAV!!iX9(ntN+1E=VRtym>~>c=Obp0e~t z3SYw(I4I_>Vpf4>VPD4$08MoN2Iiod)(_zIiY80*jkc7Yk<)iOiM(HdnD})7Ct-Qa z(wqW1N~43AR$45&nZ-7`kj00vhiZp#cTtO_=|+^ghNOZ$bY%z^poY#3V+eN8qhSog zVew)Zw?pZUd`aW5TCT^Jvd2b4%gA2IUJU0J-6|Wa>OF&mHv(mVKFs~U_JCu z#|VbYPfA^dHn$%U4@PjbX2lsR@^Lua_)f$jk$8+-%HfQ-H;w%Ra7fg)t0j!);%XH| z<7yW6Qhi)qO|RqX9PAg99qK63W3GNcH@O;;(I;HpUAEsUaA13aUS*(?=KG*nggRB@ z8nlU{3o0*?IQB*@nO0gY`eud&Y1=qkLrcclN@d?76N01S^f+?>TIuDFti5QT^ev-P z!!)a=&z`c2GXC3BW-AI$Q}k!{dmgn3=7TTpPOw2J_tVn9f{C2;+jb&8#g-_`owWQ6 ri}z^Lxo6BOv(p8fm!>iK-bL{YYXI1X&qv4X!a%Q5FboAFhY=`MM;yUg^8OWGv zR_bi}R#Mc37gFGj@ZA{I#Z>&0X$W1_jTjNxqMPWdGo#vNR}bIw@I1WVZ~a;Br^(!& zC`=1q+)&=Ps0BwBzHrfHIY1bgep^Y97;;sGFilkXR0XZy(eVuBH=5JR`E?zo> zi;(S7BsI-;Q+^CXLWM#&Nd?yJ_}@YGWE(>>Js-s&-5*5@)s5kZ zzPLGt=OOYLaP^&uXORYTX9!^>IG$Lh1wOM=zXL)gpIE8xf(I=MDrcc~Q4n;h!2dW}A48S=) z`%Uy3bhZ8vGnWOALQW?hqNfR#Pg;WQC3}LMqS*v93Y7{ao5d5{ILqu{@aWjWG7T@W z0t(ErVngk3Dnj|N_}#Jpfk znQ|I`Ewp_IUs`56glsJ&rH3kQtS}`@4W$+QA$sY=1N{0Mb_~Ez6RYeB1Uas;!=S7D HmRa!^Smh9T diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0ba7a62d196c16b438f603fcc2e4ccad2acccbf2..b794d3ce5005d23f632d5333e39541eb2ba48384 100644 GIT binary patch delta 1927 zcmb_cO>7%Q6jo@{22b5UoGgi}&~aPZI1G+rkWh*ub#}6G+}1T_6G947vN)TtG}$%l zO%tdf99F70@FRgVrydYjq)G_MQg2mDiS0;$5ZvIvffFYVoXXFW9 zPj-0xVlzI-|NJNxul;rleX>8M1dObDmwD+XdW_e%(7@2h2rhV+Tk7mZtf~%G)A(gO zShsB#uQ=79%75HKW5+Wh*?vMnPNmcMijXb-%AQ*dTC&IMXpn#K73$+p*HJdHJ2tN< z@Y1`nBi#$m;{1s#Y8G!Ur1^V`JxTt<&F-$aloGT7t3V4(Fm{%3G>r(2Q{&^hNxNpm=5W=C$apyL&OS8pynV%tjLvqSw$r=)Jau68yq)Pn_eQ z&{6)&Efl+Yy@`Y!5*8PBSXj5PhlD*WtVdWvSa0omv#<91W?)caQfkhS9Hq027?fsp z`Ojg5Q8s3fk_?=qbcPftEf8@#HwnNZDZ*h;w!sYLn5ArbLS5%5os)%%WCn6YLS9s) z;A3wvDp6XiM6RNXpsC0(?$xzd&z}W|nS-+gWK?QJm;NGwUb1b8K(9$a6rlL19=R<_ zEy=WG@IM-w#-C|u0|yyMwWs)t4b3^-zDJ+~CLc8t^O+IFnX8k$@Int-JPQ?%Jg4&8 z(r{UXlmf^OnF27)1JeyA^Gh-a9$L7-FEzEJn?-1~4l^+Vwrd4)qL1sOOlcYLIZ4a_ zbm0YXh)n`LSy3G@5jHJjX4{x#zpTr5Kx{D^QrHybE&@#{0RSwRups+h+`nNP6SF|( zk`9kWtgUrtV{A%wcXUb}9WREUJ7VMo0J`tV3Ao~O#KDn(8m+m_e{>8SI(ssIS7@{!~6A-_` zt9Q3GVUGywuif1q_|Wb>M({E9C5M~(GVo8c$$tKRHksl#vdPJQ6_38tJFxj$?8#*Q F%wLx`ar^)P delta 862 zcmbu7KTH#G6vtBqO7B+?Y55^tIQ|Sa{u06@po72r^?H=HX-aJx5(lcDdRVU&tC$dz znw(+M#o?JSF}rFyIjKt%dQcN52TcqU#y`8k-@Dcj4U2L3Uf#X;z3=$o5@f9KP zrJ*QLYZBEr0ikV@4OrLI)79eYbrZh7R4Ams`V{E>q(sBZ6O-%Sp!#4r8dcXX*{TX3 zw#l6Tj#)CVRHIM}^vl$SGWOTXkI%he~b9qQM=}Jm4UB1NenH5ZMjtx=KyncoixVSj) z4cQXsO9nd9=$O_z?IPzz!*k4o&!tm-of({)3vkw@*4)wYRSi9f&JIbFm$|~$SVDGn z49lc2jKVjUb}zO=j>ND<$KGZTsCEG93qFhmH~h_pNET8*cB=A$YV) zslS5^TRSvVx)Jk8b#xY3l8ehMD=Q&Op7*YHrcLeL4llzINX*8(VTM^6=az;a('#move-option-up')) { + // Move selected option(s) between lists + for (const button of getElements('.move-option')) { + const source = button.getAttribute('data-source'); const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionUp(select)); - } + const source_select = document.getElementById(`id_${source}`) as HTMLSelectElement; + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (source_select !== null && target_select !== null) { + button.addEventListener('click', () => moveOption(source_select, target_select)); } } - for (const button of getElements('#move-option-down')) { + + // Move selected option(s) up in current list + for (const button of getElements('.move-option-up')) { const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionDown(select)); - } + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionUp(target_select)); + } + } + + // Move selected option(s) down in current list + for (const button of getElements('.move-option-down')) { + const target = button.getAttribute('data-target'); + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionDown(target_select)); } } } diff --git a/netbox/templates/extras/tableconfig_edit.html b/netbox/templates/extras/tableconfig_edit.html index df1e67082..31057c298 100644 --- a/netbox/templates/extras/tableconfig_edit.html +++ b/netbox/templates/extras/tableconfig_edit.html @@ -36,10 +36,10 @@
{{ form.columns }} - + {% trans "Move Up" %} - + {% trans "Move Down" %}
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index d8773feb4..d875b0792 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -15,7 +15,7 @@ from users.models import * from utilities.data import flatten_dict from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import DateTimePicker +from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget from utilities.permissions import qs_filter_from_constraints __all__ = ( @@ -272,12 +272,21 @@ class GroupForm(forms.ModelForm): return instance +def get_object_types_choices(): + return [ + (ot.pk, str(ot)) + for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model') + ] + + class ObjectPermissionForm(forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.all(), - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, - widget=forms.SelectMultiple(attrs={'size': 6}) + widget=SplitMultiSelectWidget( + choices=get_object_types_choices + ), + help_text=_('Select the types of objects to which the permission will appy.') ) can_view = forms.BooleanField( required=False diff --git a/netbox/users/tests/test_views.py b/netbox/users/tests/test_views.py index 8226a8be9..e66c00d0a 100644 --- a/netbox/users/tests/test_views.py +++ b/netbox/users/tests/test_views.py @@ -180,7 +180,7 @@ class ObjectPermissionTestCase( cls.form_data = { 'name': 'Permission X', 'description': 'A new permission', - 'object_types': [object_type.pk], + 'object_types_1': [object_type.pk], # SplitMultiSelectWidget requires _1 suffix on field name 'actions': 'view,edit,delete', } diff --git a/netbox/utilities/forms/widgets/select.py b/netbox/utilities/forms/widgets/select.py index 8115e2449..7f4e9c87f 100644 --- a/netbox/utilities/forms/widgets/select.py +++ b/netbox/utilities/forms/widgets/select.py @@ -8,6 +8,7 @@ __all__ = ( 'ColorSelect', 'HTMXSelect', 'SelectWithPK', + 'SplitMultiSelectWidget', ) @@ -63,3 +64,79 @@ class SelectWithPK(forms.Select): Include the primary key of each option in the option label (e.g. "Router7 (4721)"). """ option_template_name = 'widgets/select_option_with_pk.html' + + +class AvailableOptions(forms.SelectMultiple): + """ + Renders a including only choices that have _not_ been selected. (For unbound fields, this + will include _all_ choices.) Employed by SplitMultiSelectWidget. + """ + def optgroups(self, name, value, attrs=None): + self.choices = [ + choice for choice in self.choices if str(choice[0]) in value + ] + value = [] # Clear selected choices + return super().optgroups(name, value, attrs) + + +class SplitMultiSelectWidget(forms.MultiWidget): + """ + Renders two