Skip to content

Commit dd50bb8

Browse files
authored
Allow a selection of target servertypes for attributes (#427)
It is currently only possible to select one or none (all) target servertype for attribtues of type relation, supernet and domain. We have a use case where we want to allow some servertypes as target but not all.
1 parent 85e603b commit dd50bb8

12 files changed

Lines changed: 201 additions & 39 deletions

db/integrity.sql

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,15 @@ returning *;
140140
--
141141

142142
delete from server_relation_attribute as extra
143-
using attribute, server as target
143+
using attribute
144+
join attribute_target_servertype as ats on ats.attribute_id = attribute.attribute_id,
145+
server as target
144146
where attribute.attribute_id = extra.attribute_id
145147
and target.server_id = extra.value
146-
and attribute.target_servertype_id != target.servertype_id
148+
and target.servertype_id not in (
149+
select ats2.servertype_id from attribute_target_servertype as ats2
150+
where ats2.attribute_id = attribute.attribute_id
151+
)
147152
returning attribute.attribute_id, target.hostname;
148153

149154
commit;

serveradmin/access_control/fixtures/serverdb.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"help_link": null,
2727
"inet_address_family": "",
2828
"readonly": false,
29-
"target_servertype": "hv",
29+
"target_servertype": ["hv"],
3030
"reversed_attribute": null,
3131
"clone": false,
3232
"history": true,
@@ -44,7 +44,7 @@
4444
"help_link": null,
4545
"inet_address_family": "",
4646
"readonly": false,
47-
"target_servertype": null,
47+
"target_servertype": [],
4848
"reversed_attribute": null,
4949
"clone": false,
5050
"history": true,

serveradmin/serverdb/admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class ServerAdmin(admin.ModelAdmin):
6969

7070
class AttributeAdmin(admin.ModelAdmin):
7171
form = AttributeAdminForm
72+
filter_horizontal = ('target_servertype',)
7273
list_display = [
7374
'attribute_id',
7475
'type',
@@ -90,7 +91,7 @@ def get_readonly_fields(self, request, obj=None):
9091
# support it.
9192
if obj:
9293
fields += (
93-
'type', 'attribute_id', 'target_servertype',
94+
'type', 'attribute_id',
9495
'reversed_attribute'
9596
)
9697

serveradmin/serverdb/fixtures/attribute.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"group": "project",
1818
"help_link": null,
1919
"readonly": false,
20-
"target_servertype": null,
20+
"target_servertype": [],
2121
"reversed_attribute": null,
2222
"clone": false,
2323
"history": false,
@@ -34,7 +34,7 @@
3434
"group": "project",
3535
"help_link": null,
3636
"readonly": false,
37-
"target_servertype": null,
37+
"target_servertype": [],
3838
"reversed_attribute": null,
3939
"clone": false,
4040
"history": true,

serveradmin/serverdb/fixtures/ip_addr_type.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"group": "other",
5151
"help_link": null,
5252
"readonly": false,
53-
"target_servertype": null,
53+
"target_servertype": [],
5454
"reversed_attribute": null,
5555
"clone": false,
5656
"regexp": "\\A.*\\Z"
@@ -67,7 +67,7 @@
6767
"group": "other",
6868
"help_link": null,
6969
"readonly": false,
70-
"target_servertype": null,
70+
"target_servertype": [],
7171
"reversed_attribute": null,
7272
"clone": false,
7373
"regexp": "\\A.*\\Z"
@@ -83,7 +83,7 @@
8383
"group": "other",
8484
"help_link": null,
8585
"readonly": true,
86-
"target_servertype": "route_network",
86+
"target_servertype": ["route_network"],
8787
"reversed_attribute": null,
8888
"clone": false,
8989
"regexp": "\\A.*\\Z"
@@ -100,7 +100,7 @@
100100
"group": "other",
101101
"help_link": null,
102102
"readonly": true,
103-
"target_servertype": "provider_network",
103+
"target_servertype": ["provider_network"],
104104
"reversed_attribute": null,
105105
"clone": false,
106106
"regexp": "\\A.*\\Z"
@@ -117,7 +117,7 @@
117117
"group": "other",
118118
"help_link": null,
119119
"readonly": true,
120-
"target_servertype": "provider_network",
120+
"target_servertype": ["provider_network"],
121121
"reversed_attribute": null,
122122
"clone": false,
123123
"regexp": "\\A.*\\Z"
@@ -133,7 +133,7 @@
133133
"group": "other",
134134
"help_link": null,
135135
"readonly": false,
136-
"target_servertype": null,
136+
"target_servertype": [],
137137
"reversed_attribute": null,
138138
"clone": false,
139139
"regexp": "\\A.*\\Z"
@@ -149,7 +149,7 @@
149149
"group": "other",
150150
"help_link": null,
151151
"readonly": false,
152-
"target_servertype": null,
152+
"target_servertype": [],
153153
"reversed_attribute": null,
154154
"clone": false,
155155
"regexp": "\\A.*\\Z"
@@ -165,7 +165,7 @@
165165
"group": "other",
166166
"help_link": null,
167167
"readonly": false,
168-
"target_servertype": null,
168+
"target_servertype": [],
169169
"reversed_attribute": null,
170170
"clone": false,
171171
"regexp": "\\A.*\\Z"
@@ -181,7 +181,7 @@
181181
"group": "other",
182182
"help_link": null,
183183
"readonly": false,
184-
"target_servertype": null,
184+
"target_servertype": [],
185185
"reversed_attribute": null,
186186
"clone": false,
187187
"regexp": "\\A.*\\Z"

serveradmin/serverdb/fixtures/test_dataset.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"group": "other",
5050
"help_link": null,
5151
"readonly": false,
52-
"target_servertype": null,
52+
"target_servertype": [],
5353
"reversed_attribute": null,
5454
"clone": false,
5555
"regexp": "\\A.*\\Z"
@@ -65,7 +65,7 @@
6565
"group": "other",
6666
"help_link": null,
6767
"readonly": false,
68-
"target_servertype": null,
68+
"target_servertype": [],
6969
"reversed_attribute": null,
7070
"clone": false,
7171
"regexp": "\\A.*\\Z"
@@ -81,7 +81,7 @@
8181
"group": "other",
8282
"help_link": null,
8383
"readonly": false,
84-
"target_servertype": null,
84+
"target_servertype": [],
8585
"reversed_attribute": null,
8686
"clone": false,
8787
"regexp": "\\A.*\\Z"
@@ -97,7 +97,7 @@
9797
"group": "other",
9898
"help_link": null,
9999
"readonly": false,
100-
"target_servertype": null,
100+
"target_servertype": [],
101101
"reversed_attribute": null,
102102
"clone": false,
103103
"regexp": "\\A(0|[1-9][0-9]*)\\Z"
@@ -113,7 +113,7 @@
113113
"group": "other",
114114
"help_link": null,
115115
"readonly": false,
116-
"target_servertype": null,
116+
"target_servertype": [],
117117
"reversed_attribute": null,
118118
"clone": false,
119119
"regexp": "\\A.*\\Z"
@@ -129,7 +129,7 @@
129129
"group": "other",
130130
"help_link": null,
131131
"readonly": false,
132-
"target_servertype": "hypervisor",
132+
"target_servertype": ["hypervisor"],
133133
"reversed_attribute": null,
134134
"clone": false,
135135
"regexp": "\\A.*\\Z"
@@ -145,7 +145,7 @@
145145
"group": "other",
146146
"help_link": null,
147147
"readonly": false,
148-
"target_servertype": null,
148+
"target_servertype": [],
149149
"reversed_attribute": null,
150150
"clone": false,
151151
"regexp": "\\A.*\\Z"
@@ -161,7 +161,7 @@
161161
"group": "other",
162162
"help_link": null,
163163
"readonly": false,
164-
"target_servertype": null,
164+
"target_servertype": [],
165165
"reversed_attribute": null,
166166
"clone": false,
167167
"regexp": "\\A.*\\Z"
@@ -177,7 +177,7 @@
177177
"group": "other",
178178
"help_link": null,
179179
"readonly": false,
180-
"target_servertype": null,
180+
"target_servertype": [],
181181
"reversed_attribute": null,
182182
"clone": false,
183183
"regexp": "\\A.*\\Z"
@@ -193,7 +193,7 @@
193193
"group": "other",
194194
"help_link": null,
195195
"readonly": false,
196-
"target_servertype": null,
196+
"target_servertype": [],
197197
"reversed_attribute": null,
198198
"clone": false,
199199
"regexp": "\\A(wheezy|squeeze|jessie|buster|bullseye)\\Z"
@@ -209,7 +209,7 @@
209209
"group": "other",
210210
"help_link": null,
211211
"readonly": true,
212-
"target_servertype": null,
212+
"target_servertype": [],
213213
"reversed_attribute": "hypervisor",
214214
"clone": false,
215215
"regexp": "\\A.*\\Z"

serveradmin/serverdb/forms.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ class Meta:
4444
def clean(self):
4545
attr_type = self.cleaned_data.get('type') or self.instance.type # New or existing attribute ?
4646

47-
if attr_type != 'relation' and self.cleaned_data.get('target_servertype') is not None:
47+
target_servertypes = self.cleaned_data.get('target_servertype')
48+
if attr_type in ('domain', 'supernet') and not (target_servertypes and target_servertypes.exists()):
49+
raise ValidationError('Attributes of type domain or supernet must have at least one target servertype!')
50+
if attr_type not in ('domain', 'supernet', 'relation') and target_servertypes and target_servertypes.exists():
4851
raise ValidationError('Attribute type must be relation when target servertype is selected!')
4952

5053
if attr_type == 'inet' and self.cleaned_data.get('multi') is True:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
('serverdb', '0021_serverinetattribute_server_inet_attribute_value_idx'),
7+
]
8+
9+
operations = [
10+
migrations.RunSQL(
11+
sql=(
12+
"ALTER TABLE attribute "
13+
"DROP CONSTRAINT IF EXISTS"
14+
" attribute_target_servertype_id_check"
15+
),
16+
reverse_sql=(
17+
"ALTER TABLE attribute ADD CONSTRAINT"
18+
" attribute_target_servertype_id_check "
19+
"CHECK((type IN ('domain', 'supernet', 'relation')) = "
20+
"(target_servertype_id IS NOT NULL OR type = 'relation'))"
21+
),
22+
),
23+
]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Convert Attribute.target_servertype from ForeignKey to ManyToManyField.
2+
3+
Uses SeparateDatabaseAndState so that:
4+
- Django's state tracker sees standard RemoveField + AddField operations
5+
- The actual DB operations are controlled manually to allow a data
6+
migration between creating the M2M table and dropping the FK column
7+
8+
Steps:
9+
1. Create the M2M join table
10+
2. Copy existing FK data into the join table
11+
3. Drop the old FK column
12+
"""
13+
14+
import django.db.models
15+
from django.db import migrations, models
16+
17+
18+
def copy_fk_to_m2m(apps, schema_editor):
19+
"""Copy existing target_servertype_id FK values to the new M2M table."""
20+
with schema_editor.connection.cursor() as cursor:
21+
cursor.execute(
22+
"INSERT INTO attribute_target_servertype (attribute_id, servertype_id) "
23+
"SELECT attribute_id, target_servertype_id FROM attribute "
24+
"WHERE target_servertype_id IS NOT NULL"
25+
)
26+
27+
28+
def copy_m2m_to_fk(apps, schema_editor):
29+
"""Reverse: copy M2M values back into the FK column (takes first value)."""
30+
with schema_editor.connection.cursor() as cursor:
31+
cursor.execute(
32+
"UPDATE attribute SET target_servertype_id = m2m.servertype_id "
33+
"FROM attribute_target_servertype AS m2m "
34+
"WHERE attribute.attribute_id = m2m.attribute_id"
35+
)
36+
37+
38+
class Migration(migrations.Migration):
39+
40+
dependencies = [
41+
('serverdb', '0022_attribute_relax_target_servertype_constraints')
42+
]
43+
44+
operations = [
45+
# Phase 1: Create the M2M join table (DB only, state updated later).
46+
migrations.SeparateDatabaseAndState(
47+
database_operations=[
48+
migrations.RunSQL(
49+
sql=(
50+
"CREATE TABLE attribute_target_servertype ("
51+
" id BIGSERIAL PRIMARY KEY,"
52+
" attribute_id VARCHAR(32) NOT NULL"
53+
" REFERENCES attribute(attribute_id) ON DELETE CASCADE,"
54+
" servertype_id VARCHAR(32) NOT NULL"
55+
" REFERENCES servertype(servertype_id) ON DELETE CASCADE,"
56+
" UNIQUE (attribute_id, servertype_id)"
57+
")"
58+
),
59+
reverse_sql="DROP TABLE IF EXISTS attribute_target_servertype",
60+
),
61+
],
62+
state_operations=[],
63+
),
64+
65+
# Phase 2: Copy existing FK data into the M2M table.
66+
migrations.RunPython(copy_fk_to_m2m, copy_m2m_to_fk),
67+
68+
# Phase 3: Drop old FK column + constraints; update Django state.
69+
migrations.SeparateDatabaseAndState(
70+
database_operations=[
71+
migrations.RunSQL(
72+
sql=(
73+
"ALTER TABLE attribute "
74+
"DROP CONSTRAINT IF EXISTS"
75+
" attribute_target_servertype_id_0eab2dcc_fk_servertyp"
76+
),
77+
reverse_sql=(
78+
"ALTER TABLE attribute ADD CONSTRAINT"
79+
" attribute_target_servertype_id_0eab2dcc_fk_servertyp"
80+
" FOREIGN KEY (target_servertype_id)"
81+
" REFERENCES servertype(servertype_id)"
82+
" DEFERRABLE INITIALLY DEFERRED"
83+
),
84+
),
85+
migrations.RunSQL(
86+
sql=(
87+
"ALTER TABLE attribute "
88+
"DROP COLUMN IF EXISTS target_servertype_id"
89+
),
90+
reverse_sql=(
91+
"ALTER TABLE attribute ADD COLUMN target_servertype_id "
92+
"VARCHAR(32)"
93+
),
94+
),
95+
],
96+
state_operations=[
97+
migrations.RemoveField(
98+
model_name='attribute',
99+
name='target_servertype',
100+
),
101+
migrations.AddField(
102+
model_name='attribute',
103+
name='target_servertype',
104+
field=models.ManyToManyField(
105+
blank=True,
106+
help_text='Selecting no servertype allows all servertypes.',
107+
to='serverdb.servertype',
108+
),
109+
),
110+
],
111+
),
112+
]

0 commit comments

Comments
 (0)