netbox-ot 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_ot/__init__.py +47 -0
- netbox_ot/accessmap.py +173 -0
- netbox_ot/adopt.py +213 -0
- netbox_ot/api/__init__.py +0 -0
- netbox_ot/api/serializers.py +254 -0
- netbox_ot/api/urls.py +21 -0
- netbox_ot/api/views.py +194 -0
- netbox_ot/checks.py +380 -0
- netbox_ot/choices.py +331 -0
- netbox_ot/customfields.py +121 -0
- netbox_ot/filtersets.py +509 -0
- netbox_ot/forms.py +927 -0
- netbox_ot/graphql/__init__.py +3 -0
- netbox_ot/graphql/filters.py +97 -0
- netbox_ot/graphql/schema.py +39 -0
- netbox_ot/graphql/types.py +104 -0
- netbox_ot/importers/__init__.py +0 -0
- netbox_ot/importers/functions.py +234 -0
- netbox_ot/importers/netbox_zones.py +32 -0
- netbox_ot/importers/observations.py +794 -0
- netbox_ot/importers/zeek.py +302 -0
- netbox_ot/lifecycle.py +89 -0
- netbox_ot/management/__init__.py +0 -0
- netbox_ot/management/commands/__init__.py +0 -0
- netbox_ot/management/commands/ot_check.py +44 -0
- netbox_ot/management/commands/ot_demo.py +244 -0
- netbox_ot/management/commands/ot_import.py +167 -0
- netbox_ot/management/commands/ot_rules.py +44 -0
- netbox_ot/migrations/0001_initial.py +280 -0
- netbox_ot/migrations/__init__.py +0 -0
- netbox_ot/models.py +811 -0
- netbox_ot/navigation.py +68 -0
- netbox_ot/register.py +159 -0
- netbox_ot/rules.py +137 -0
- netbox_ot/search.py +73 -0
- netbox_ot/sl.py +87 -0
- netbox_ot/tables.py +196 -0
- netbox_ot/template_content.py +71 -0
- netbox_ot/templates/netbox_ot/accessmap.html +31 -0
- netbox_ot/templates/netbox_ot/checks.html +31 -0
- netbox_ot/templates/netbox_ot/inc/access_attr.html +1 -0
- netbox_ot/templates/netbox_ot/inc/accessmap_table.html +81 -0
- netbox_ot/templates/netbox_ot/inc/asset_panel.html +100 -0
- netbox_ot/templates/netbox_ot/inc/checks_table.html +37 -0
- netbox_ot/templates/netbox_ot/inc/sl_panel.html +24 -0
- netbox_ot/templates/netbox_ot/inc/verdict_attr.html +3 -0
- netbox_ot/templates/netbox_ot/site_accessmap.html +15 -0
- netbox_ot/templates/netbox_ot/site_register.html +97 -0
- netbox_ot/tests/__init__.py +0 -0
- netbox_ot/tests/query_counts.json +16 -0
- netbox_ot/tests/test_adopt.py +155 -0
- netbox_ot/tests/test_api.py +282 -0
- netbox_ot/tests/test_checks.py +151 -0
- netbox_ot/tests/test_demo.py +44 -0
- netbox_ot/tests/test_filtersets.py +214 -0
- netbox_ot/tests/test_importer.py +453 -0
- netbox_ot/tests/test_industrial.py +252 -0
- netbox_ot/tests/test_lifecycle.py +119 -0
- netbox_ot/tests/test_models.py +195 -0
- netbox_ot/tests/test_rules.py +133 -0
- netbox_ot/tests/test_views.py +393 -0
- netbox_ot/tests/test_zeek.py +252 -0
- netbox_ot/upgrade.py +31 -0
- netbox_ot/urls.py +24 -0
- netbox_ot/views.py +794 -0
- netbox_ot-0.3.0.dist-info/METADATA +156 -0
- netbox_ot-0.3.0.dist-info/RECORD +70 -0
- netbox_ot-0.3.0.dist-info/WHEEL +5 -0
- netbox_ot-0.3.0.dist-info/licenses/LICENSE +178 -0
- netbox_ot-0.3.0.dist-info/top_level.txt +1 -0
netbox_ot/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
__version__ = '0.3.0'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetBoxOTConfig(PluginConfig):
|
|
7
|
+
name = 'netbox_ot'
|
|
8
|
+
verbose_name = 'OT'
|
|
9
|
+
description = (
|
|
10
|
+
'OT asset and security register: IEC 62443 zones, conduits and flows, Purdue levels, an access map, '
|
|
11
|
+
'a zone register and lifecycle checks'
|
|
12
|
+
)
|
|
13
|
+
version = __version__
|
|
14
|
+
author = 'Packetflow'
|
|
15
|
+
base_url = 'ot'
|
|
16
|
+
# Tested in CI on 4.7.0, the newest 4.7 release and NetBox's development branch for the next release (weekly)
|
|
17
|
+
min_version = '4.7.0'
|
|
18
|
+
max_version = '4.8.99'
|
|
19
|
+
default_settings = {
|
|
20
|
+
# Create the "Purdue level" custom field on devices and virtual machines after migrate.
|
|
21
|
+
'create_custom_fields': True,
|
|
22
|
+
# The access map lists flows into zones at this Purdue level or below.
|
|
23
|
+
'access_map_max_level': '2',
|
|
24
|
+
# A flow not seen for this many days needs attention.
|
|
25
|
+
'stale_after_days': 90,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def ready(self):
|
|
29
|
+
from django.conf import settings
|
|
30
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
31
|
+
|
|
32
|
+
if 'netbox_iec62443' in settings.PLUGINS:
|
|
33
|
+
# netbox-ot takes the old plugin's tables over on migrate (adopt.py); both at once would break both
|
|
34
|
+
raise ImproperlyConfigured(
|
|
35
|
+
'netbox-ot replaces netbox-iec62443: remove netbox_iec62443 from PLUGINS (and its PLUGINS_CONFIG '
|
|
36
|
+
'entry; move the settings to netbox_ot), then run migrate.'
|
|
37
|
+
)
|
|
38
|
+
super().ready()
|
|
39
|
+
from django.db.models.signals import post_migrate
|
|
40
|
+
|
|
41
|
+
from .customfields import ensure_custom_fields
|
|
42
|
+
from .upgrade import after_upgrade
|
|
43
|
+
post_migrate.connect(ensure_custom_fields, sender=self)
|
|
44
|
+
post_migrate.connect(after_upgrade, sender=self)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
config = NetBoxOTConfig
|
netbox_ot/accessmap.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The access map: every flow into a zone at Purdue level <= N (default 2), i.e. every path to the controllers, with
|
|
3
|
+
its "allowed?" verdict. Not allowed first, then allowed-but-expired, unknown, and allowed. Plus a small
|
|
4
|
+
server-rendered SVG with one arrow per directed zone pair.
|
|
5
|
+
"""
|
|
6
|
+
import datetime
|
|
7
|
+
import math
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
from django.db.models import Case, IntegerField, Q, Value, When
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
from django.utils.html import escape
|
|
13
|
+
from django.utils.safestring import mark_safe
|
|
14
|
+
from django.utils.translation import gettext as _
|
|
15
|
+
|
|
16
|
+
from .choices import FlowAllowedChoices, PurdueLevelChoices
|
|
17
|
+
from .models import Flow, Zone, stale_after_days
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verdict_order():
|
|
21
|
+
"""0 not allowed, 1 allowed but expired, 2 unknown, 3 allowed."""
|
|
22
|
+
return Case(
|
|
23
|
+
When(allowed=FlowAllowedChoices.NO, then=Value(0)),
|
|
24
|
+
When(allowed=FlowAllowedChoices.YES, expires__lt=timezone.localdate(), then=Value(1)),
|
|
25
|
+
When(allowed=FlowAllowedChoices.UNKNOWN, then=Value(2)),
|
|
26
|
+
default=Value(3),
|
|
27
|
+
output_field=IntegerField(),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def access_map_flows(queryset=None, site=None, max_level=PurdueLevelChoices.LEVEL_2):
|
|
32
|
+
"""Flows into zones at Purdue level <= max_level, from any zone of the site or into one."""
|
|
33
|
+
levels = PurdueLevelChoices.at_or_below(max_level)
|
|
34
|
+
qs = (queryset if queryset is not None else Flow.objects.all()).select_related(
|
|
35
|
+
'source_zone__site', 'destination_zone__site', 'conduit',
|
|
36
|
+
).prefetch_related('hops__device', 'hops__virtual_machine', 'hops__tunnel')
|
|
37
|
+
if site is not None:
|
|
38
|
+
qs = qs.filter(Q(source_zone__site=site) | Q(destination_zone__site=site))
|
|
39
|
+
qs = qs.filter(destination_zone__purdue_level__in=levels)
|
|
40
|
+
return qs.annotate(verdict_order=verdict_order()).order_by(
|
|
41
|
+
'verdict_order', 'destination_zone__purdue_level', 'destination_zone__name', 'name'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def attention(queryset):
|
|
46
|
+
"""Counts of flows that need a person: allowed but expired, review due, not seen for a while, no conduit."""
|
|
47
|
+
today = timezone.localdate()
|
|
48
|
+
stale_before = timezone.now() - datetime.timedelta(days=stale_after_days())
|
|
49
|
+
return {
|
|
50
|
+
'expired': queryset.filter(allowed=FlowAllowedChoices.YES, expires__lt=today).count(),
|
|
51
|
+
'review_due': queryset.filter(review_by__lte=today).count(),
|
|
52
|
+
'stale': queryset.filter(last_seen__lt=stale_before).count(),
|
|
53
|
+
'no_conduit': queryset.filter(conduit__isnull=True).count(),
|
|
54
|
+
'stale_after_days': stale_after_days(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#
|
|
59
|
+
# SVG diagram: one row per Purdue level (5 at the top, 0 at the bottom), zones as boxes, one arrow per directed
|
|
60
|
+
# zone pair coloured by its worst verdict.
|
|
61
|
+
#
|
|
62
|
+
|
|
63
|
+
LINE_COLORS = {'no': '#d63939', 'expired': '#d63939', 'unknown': '#f59f00', 'yes': '#2fb344'}
|
|
64
|
+
WORST = ('no', 'expired', 'unknown', 'yes')
|
|
65
|
+
BOX_W, BOX_H, GAP_X, ROW_H, LABEL_W, PAD = 190, 46, 40, 96, 150, 16
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _level_rows(zones):
|
|
69
|
+
order = [None] + [value for value, _label in reversed(PurdueLevelChoices.CHOICES)]
|
|
70
|
+
labels = {value: str(label).split(' (')[0] for value, label in PurdueLevelChoices.CHOICES}
|
|
71
|
+
labels[None] = str(_('No level'))
|
|
72
|
+
rows = []
|
|
73
|
+
for level in order:
|
|
74
|
+
members = sorted((z for z in zones if (z.purdue_level or None) == level), key=lambda z: z.name)
|
|
75
|
+
if members:
|
|
76
|
+
rows.append((labels[level], members))
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _verdict(flow):
|
|
81
|
+
return 'expired' if flow.is_expired else flow.allowed
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def access_map_svg(flows, zones=()):
|
|
85
|
+
"""Return an inline SVG (safe string) for the given flows; `zones` adds zones without flows."""
|
|
86
|
+
flows = list(flows)
|
|
87
|
+
all_zones = {z.pk: z for z in zones}
|
|
88
|
+
for f in flows:
|
|
89
|
+
all_zones.setdefault(f.source_zone_id, f.source_zone)
|
|
90
|
+
all_zones.setdefault(f.destination_zone_id, f.destination_zone)
|
|
91
|
+
if not all_zones:
|
|
92
|
+
return ''
|
|
93
|
+
rows = _level_rows(all_zones.values())
|
|
94
|
+
width = LABEL_W + max(len(m) for _l, m in rows) * (BOX_W + GAP_X) + PAD
|
|
95
|
+
height = len(rows) * ROW_H + PAD
|
|
96
|
+
pos = {}
|
|
97
|
+
out, lines, badges = [], [], []
|
|
98
|
+
for r, (label, members) in enumerate(rows):
|
|
99
|
+
y = PAD + r * ROW_H
|
|
100
|
+
out.append(f'<text x="8" y="{y + BOX_H / 2 + 4}" class="lvl">{escape(label)}</text>')
|
|
101
|
+
out.append(f'<line x1="0" x2="{width}" y1="{y + ROW_H - 25}" y2="{y + ROW_H - 25}" class="sep"/>')
|
|
102
|
+
for i, z in enumerate(members):
|
|
103
|
+
x = LABEL_W + i * (BOX_W + GAP_X)
|
|
104
|
+
pos[z.pk] = (x, y)
|
|
105
|
+
out.append(
|
|
106
|
+
f'<a href="{escape(z.get_absolute_url())}"><rect x="{x}" y="{y}" width="{BOX_W}" height="{BOX_H}" '
|
|
107
|
+
f'rx="4" class="zone"/><text x="{x + BOX_W / 2}" y="{y + BOX_H / 2 + 4}" text-anchor="middle" '
|
|
108
|
+
f'class="zn">{escape(z.name[:26])}</text></a>'
|
|
109
|
+
)
|
|
110
|
+
# One arrow per directed zone pair. Each zone spreads the ends of its arrows across its box, ordered by where
|
|
111
|
+
# the other zone sits, so arrows do not share a line; the count badge sits just before the arrowhead, in the
|
|
112
|
+
# gap above or below the destination box, where it cannot cover a zone's name.
|
|
113
|
+
pairs = {}
|
|
114
|
+
for f in flows:
|
|
115
|
+
pairs.setdefault((f.source_zone_id, f.destination_zone_id), []).append(f)
|
|
116
|
+
ends = defaultdict(list)
|
|
117
|
+
for src, dst in pairs:
|
|
118
|
+
ends[src].append(((src, dst), dst))
|
|
119
|
+
ends[dst].append(((src, dst), src))
|
|
120
|
+
for zone_pk in ends:
|
|
121
|
+
ends[zone_pk].sort(key=lambda end: (pos[end[1]][0], pos[end[1]][1]))
|
|
122
|
+
|
|
123
|
+
def anchor_x(zone_pk, key):
|
|
124
|
+
items = [k for k, _other in ends[zone_pk]]
|
|
125
|
+
n, i = len(items), items.index(key)
|
|
126
|
+
step = min(28, (BOX_W - 30) / max(n - 1, 1))
|
|
127
|
+
return pos[zone_pk][0] + BOX_W / 2 + (i - (n - 1) / 2) * step
|
|
128
|
+
|
|
129
|
+
for (src, dst), members in pairs.items():
|
|
130
|
+
(_sx, sy), (_dx, dy) = pos[src], pos[dst]
|
|
131
|
+
x1, x2 = anchor_x(src, (src, dst)), anchor_x(dst, (src, dst))
|
|
132
|
+
if sy == dy: # same row: arc above the boxes
|
|
133
|
+
y1 = y2 = sy
|
|
134
|
+
path = f'M{x1},{y1} Q{(x1 + x2) / 2},{sy - 60} {x2},{y2}'
|
|
135
|
+
lx, ly = (x1 + x2) / 2, sy - 32
|
|
136
|
+
else:
|
|
137
|
+
y1 = sy + BOX_H if sy < dy else sy
|
|
138
|
+
y2 = dy if sy < dy else dy + BOX_H
|
|
139
|
+
path = f'M{x1},{y1} L{x2},{y2}'
|
|
140
|
+
length = math.hypot(x2 - x1, y2 - y1) or 1
|
|
141
|
+
lx, ly = x2 - (x2 - x1) / length * 18, y2 - (y2 - y1) / length * 18
|
|
142
|
+
worst = min((_verdict(f) for f in members), key=WORST.index)
|
|
143
|
+
color = LINE_COLORS.get(worst, '#888')
|
|
144
|
+
dash = ' stroke-dasharray="6 4"' if any(f.remote_access for f in members) else ''
|
|
145
|
+
title = escape('\n'.join(str(f) for f in members))
|
|
146
|
+
lines.append(
|
|
147
|
+
f'<path d="{path}" fill="none" stroke="{color}" stroke-width="2.5"{dash} '
|
|
148
|
+
f'marker-end="url(#iec-arrow)"><title>{title}</title></path>'
|
|
149
|
+
)
|
|
150
|
+
badges.append(
|
|
151
|
+
f'<circle cx="{lx}" cy="{ly}" r="9" fill="{color}"><title>{title}</title></circle>'
|
|
152
|
+
f'<text x="{lx}" y="{ly + 4}" text-anchor="middle" class="num">{len(members)}</text>'
|
|
153
|
+
)
|
|
154
|
+
aria = escape(_('Access map diagram'))
|
|
155
|
+
svg = (
|
|
156
|
+
f'<svg viewBox="0 0 {width} {height}" width="100%" style="max-width:{width}px" role="img" '
|
|
157
|
+
f'aria-label="{aria}" class="ot-map">'
|
|
158
|
+
'<defs>'
|
|
159
|
+
'<marker id="iec-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" '
|
|
160
|
+
'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="context-stroke"/></marker>'
|
|
161
|
+
'</defs>'
|
|
162
|
+
'<style>.ot-map .zone{fill:var(--tblr-bg-surface,#fff);stroke:rgba(32,107,196,.7);stroke-width:1.5}'
|
|
163
|
+
'.ot-map .zn{fill:currentColor;font-size:13px;font-weight:600}'
|
|
164
|
+
'.ot-map .lvl{fill:currentColor;opacity:.7;font-size:12px}'
|
|
165
|
+
'.ot-map .sep{stroke:currentColor;opacity:.12}'
|
|
166
|
+
'.ot-map .num{fill:#fff;font-size:11px;font-weight:700}</style>'
|
|
167
|
+
+ ''.join(lines + out + badges) + '</svg>'
|
|
168
|
+
)
|
|
169
|
+
return mark_safe(svg)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def site_zones(site):
|
|
173
|
+
return Zone.objects.filter(site=site) if site is not None else Zone.objects.none()
|
netbox_ot/adopt.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
netbox-ot was called netbox-iec62443 up to 0.2.0 (Django app `netbox_iec62443`). Its first migration takes over
|
|
3
|
+
an installation of the old name in place instead of creating empty tables, so nothing has to be exported or
|
|
4
|
+
re-imported:
|
|
5
|
+
|
|
6
|
+
- every table, sequence, index and constraint named `netbox_iec62443_*` in the current schema is renamed to
|
|
7
|
+
`netbox_ot_*`;
|
|
8
|
+
- the content types move to the app label `netbox_ot`, so object IDs, permissions, tags, journal entries, change
|
|
9
|
+
log, custom fields on the plugin's models, saved filters and bookmarks keep pointing at the same objects;
|
|
10
|
+
- the custom fields `iec62443_purdue_level` and `iec62443_sl_capability` become `ot_purdue_level` and
|
|
11
|
+
`ot_sl_capability`, values included;
|
|
12
|
+
- dashboard widgets and other user preferences that name a `netbox_iec62443.*` model are rewritten;
|
|
13
|
+
- the old app's rows in `django_migrations` are removed.
|
|
14
|
+
|
|
15
|
+
The old installation must be at the last migration netbox-iec62443 released (0.2.0); anything else stops the
|
|
16
|
+
migration before it changes a thing. Without a netbox-iec62443 installation the tables are created as usual.
|
|
17
|
+
|
|
18
|
+
It works on the current schema, so a netbox-branching branch is taken over when the branch is migrated.
|
|
19
|
+
"""
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from django.db.migrations.operations.base import Operation
|
|
23
|
+
|
|
24
|
+
OLD_APP = 'netbox_iec62443'
|
|
25
|
+
NEW_APP = 'netbox_ot'
|
|
26
|
+
OLD_LAST_MIGRATION = '0010_drop_single_security_level'
|
|
27
|
+
OLD_PREFIX = f'{OLD_APP}_'
|
|
28
|
+
NEW_PREFIX = f'{NEW_APP}_'
|
|
29
|
+
CUSTOM_FIELD_RENAMES = {
|
|
30
|
+
'iec62443_purdue_level': 'ot_purdue_level',
|
|
31
|
+
'iec62443_sl_capability': 'ot_sl_capability',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger('netbox.plugins.netbox_ot')
|
|
35
|
+
|
|
36
|
+
# Set when this process took over an old installation; post_migrate then rebuilds the search index.
|
|
37
|
+
adopted = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AdoptionError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def old_installation(cursor):
|
|
45
|
+
"""True when the current schema holds a netbox-iec62443 installation at its last released migration."""
|
|
46
|
+
cursor.execute('SELECT name FROM django_migrations WHERE app = %s', [OLD_APP])
|
|
47
|
+
applied = {row[0] for row in cursor.fetchall()}
|
|
48
|
+
if not applied:
|
|
49
|
+
return False
|
|
50
|
+
if OLD_LAST_MIGRATION not in applied:
|
|
51
|
+
raise AdoptionError(
|
|
52
|
+
f'netbox-iec62443 is installed but not at its last release (migration {OLD_LAST_MIGRATION}). Upgrade '
|
|
53
|
+
'it to netbox-iec62443 0.2.0 and run migrate first, then install netbox-ot.'
|
|
54
|
+
)
|
|
55
|
+
unknown = sorted(name for name in applied if name > OLD_LAST_MIGRATION)
|
|
56
|
+
if unknown:
|
|
57
|
+
raise AdoptionError(
|
|
58
|
+
f'netbox-iec62443 has migrations applied that no release contains ({", ".join(unknown)}): a development '
|
|
59
|
+
f'build. Migrate it back first: manage.py migrate {OLD_APP} {OLD_LAST_MIGRATION}'
|
|
60
|
+
)
|
|
61
|
+
cursor.execute('SELECT to_regclass(%s)', [f'{OLD_PREFIX}zone'])
|
|
62
|
+
if cursor.fetchone()[0] is None:
|
|
63
|
+
raise AdoptionError(f'django_migrations lists {OLD_APP} but its tables are missing from the current schema.')
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rename_database_objects(cursor, quote, old_prefix=OLD_PREFIX, new_prefix=NEW_PREFIX):
|
|
68
|
+
"""Rename the tables, sequences, indexes and constraints in the current schema named old_prefix*."""
|
|
69
|
+
def new_name(name):
|
|
70
|
+
return new_prefix + name[len(old_prefix):]
|
|
71
|
+
|
|
72
|
+
like = old_prefix.replace('_', r'\_') + '%'
|
|
73
|
+
# Constraints without an index of their own (foreign keys, checks, not-null); index-backed ones (primary key,
|
|
74
|
+
# unique, exclusion) are renamed with their index below.
|
|
75
|
+
cursor.execute(
|
|
76
|
+
"""
|
|
77
|
+
SELECT t.relname, c.conname FROM pg_constraint c
|
|
78
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
79
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
80
|
+
WHERE n.nspname = current_schema() AND t.relname LIKE %s AND c.conname LIKE %s
|
|
81
|
+
AND c.contype NOT IN ('p', 'u', 'x')
|
|
82
|
+
""",
|
|
83
|
+
[like, like],
|
|
84
|
+
)
|
|
85
|
+
for table, constraint in cursor.fetchall():
|
|
86
|
+
cursor.execute(
|
|
87
|
+
f'ALTER TABLE {quote(table)} RENAME CONSTRAINT {quote(constraint)} TO {quote(new_name(constraint))}'
|
|
88
|
+
)
|
|
89
|
+
counts = {}
|
|
90
|
+
for relkind, statement in (('i', 'ALTER INDEX'), ('S', 'ALTER SEQUENCE'), ('r', 'ALTER TABLE')):
|
|
91
|
+
cursor.execute(
|
|
92
|
+
"""
|
|
93
|
+
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
94
|
+
WHERE n.nspname = current_schema() AND c.relkind = %s AND c.relname LIKE %s
|
|
95
|
+
""",
|
|
96
|
+
[relkind, like],
|
|
97
|
+
)
|
|
98
|
+
names = [row[0] for row in cursor.fetchall()]
|
|
99
|
+
for name in names:
|
|
100
|
+
cursor.execute(f'{statement} {quote(name)} RENAME TO {quote(new_name(name))}')
|
|
101
|
+
counts[relkind] = len(names)
|
|
102
|
+
return counts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def rename_custom_fields(cursor, quote, apps):
|
|
106
|
+
for old, new in CUSTOM_FIELD_RENAMES.items():
|
|
107
|
+
cursor.execute('SELECT id FROM extras_customfield WHERE name = %s', [old])
|
|
108
|
+
old_row = cursor.fetchone()
|
|
109
|
+
cursor.execute('SELECT id FROM extras_customfield WHERE name = %s', [new])
|
|
110
|
+
new_row = cursor.fetchone()
|
|
111
|
+
if old_row and new_row:
|
|
112
|
+
logger.warning('Custom field %s already exists; %s left as it is', new, old)
|
|
113
|
+
continue
|
|
114
|
+
if old_row:
|
|
115
|
+
cursor.execute('UPDATE extras_customfield SET name = %s WHERE id = %s', [new, old_row[0]])
|
|
116
|
+
elif not new_row:
|
|
117
|
+
continue
|
|
118
|
+
# Without an old row the field was renamed already: a netbox-branching branch migrates after main, and
|
|
119
|
+
# custom field definitions live in main only, while the branch has its own copy of the objects' values.
|
|
120
|
+
row = old_row or new_row
|
|
121
|
+
field = apps.get_model('extras', 'CustomField')._meta.get_field('object_types')
|
|
122
|
+
cursor.execute(
|
|
123
|
+
f"""
|
|
124
|
+
SELECT ct.app_label, ct.model FROM {quote(field.m2m_db_table())} cot
|
|
125
|
+
JOIN django_content_type ct ON ct.id = cot.{quote(field.m2m_reverse_name())}
|
|
126
|
+
WHERE cot.{quote(field.m2m_column_name())} = %s
|
|
127
|
+
""",
|
|
128
|
+
[row[0]],
|
|
129
|
+
)
|
|
130
|
+
for app_label, model in cursor.fetchall():
|
|
131
|
+
try:
|
|
132
|
+
table = apps.get_model(app_label, model)._meta.db_table
|
|
133
|
+
except LookupError:
|
|
134
|
+
continue
|
|
135
|
+
cursor.execute(
|
|
136
|
+
f"""
|
|
137
|
+
UPDATE {quote(table)}
|
|
138
|
+
SET custom_field_data = (custom_field_data - %s) || jsonb_build_object(%s, custom_field_data -> %s)
|
|
139
|
+
WHERE custom_field_data ? %s
|
|
140
|
+
""",
|
|
141
|
+
[old, new, old, old],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def adopt(schema_editor, apps):
|
|
146
|
+
global adopted
|
|
147
|
+
quote = schema_editor.quote_name
|
|
148
|
+
with schema_editor.connection.cursor() as cursor:
|
|
149
|
+
counts = rename_database_objects(cursor, quote)
|
|
150
|
+
cursor.execute(
|
|
151
|
+
'UPDATE django_content_type SET app_label = %s WHERE app_label = %s', [NEW_APP, OLD_APP]
|
|
152
|
+
)
|
|
153
|
+
rename_custom_fields(cursor, quote, apps)
|
|
154
|
+
cursor.execute(
|
|
155
|
+
"""
|
|
156
|
+
UPDATE users_userconfig SET data = replace(data::text, %s, %s)::jsonb
|
|
157
|
+
WHERE data::text LIKE %s
|
|
158
|
+
""",
|
|
159
|
+
[f'"{OLD_APP}.', f'"{NEW_APP}.', f'%"{OLD_APP}.%'],
|
|
160
|
+
)
|
|
161
|
+
cursor.execute('DELETE FROM django_migrations WHERE app = %s', [OLD_APP])
|
|
162
|
+
adopted = True
|
|
163
|
+
logger.info(
|
|
164
|
+
'Took over the netbox-iec62443 installation: %s tables, %s sequences, %s indexes renamed',
|
|
165
|
+
counts['r'], counts['S'], counts['i'],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AdoptOrCreate(Operation):
|
|
170
|
+
"""Take over a netbox-iec62443 installation (see the module docstring), or run `operations` to create the
|
|
171
|
+
tables. The migration state is always that of `operations`."""
|
|
172
|
+
|
|
173
|
+
reversible = True
|
|
174
|
+
|
|
175
|
+
def __init__(self, operations):
|
|
176
|
+
self.operations = operations
|
|
177
|
+
|
|
178
|
+
def deconstruct(self):
|
|
179
|
+
return self.__class__.__qualname__, [], {'operations': self.operations}
|
|
180
|
+
|
|
181
|
+
def state_forwards(self, app_label, state):
|
|
182
|
+
for operation in self.operations:
|
|
183
|
+
operation.state_forwards(app_label, state)
|
|
184
|
+
|
|
185
|
+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
|
186
|
+
with schema_editor.connection.cursor() as cursor:
|
|
187
|
+
take_over = old_installation(cursor)
|
|
188
|
+
if take_over:
|
|
189
|
+
adopt(schema_editor, from_state.apps)
|
|
190
|
+
return
|
|
191
|
+
for operation in self.operations:
|
|
192
|
+
state = from_state.clone()
|
|
193
|
+
operation.state_forwards(app_label, state)
|
|
194
|
+
operation.database_forwards(app_label, schema_editor, from_state, state)
|
|
195
|
+
from_state = state
|
|
196
|
+
|
|
197
|
+
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
|
198
|
+
# As SeparateDatabaseAndState does it: undo the operations last to first, each between its own states.
|
|
199
|
+
before = {}
|
|
200
|
+
for index, operation in enumerate(self.operations):
|
|
201
|
+
before[index] = to_state
|
|
202
|
+
to_state = to_state.clone()
|
|
203
|
+
operation.state_forwards(app_label, to_state)
|
|
204
|
+
for index in reversed(range(len(self.operations))):
|
|
205
|
+
from_state, to_state = to_state, before[index]
|
|
206
|
+
self.operations[index].database_backwards(app_label, schema_editor, from_state, to_state)
|
|
207
|
+
|
|
208
|
+
def describe(self):
|
|
209
|
+
return 'Take over a netbox-iec62443 installation, or create the tables'
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def migration_name_fragment(self):
|
|
213
|
+
return 'initial'
|
|
File without changes
|