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.
Files changed (70) hide show
  1. netbox_ot/__init__.py +47 -0
  2. netbox_ot/accessmap.py +173 -0
  3. netbox_ot/adopt.py +213 -0
  4. netbox_ot/api/__init__.py +0 -0
  5. netbox_ot/api/serializers.py +254 -0
  6. netbox_ot/api/urls.py +21 -0
  7. netbox_ot/api/views.py +194 -0
  8. netbox_ot/checks.py +380 -0
  9. netbox_ot/choices.py +331 -0
  10. netbox_ot/customfields.py +121 -0
  11. netbox_ot/filtersets.py +509 -0
  12. netbox_ot/forms.py +927 -0
  13. netbox_ot/graphql/__init__.py +3 -0
  14. netbox_ot/graphql/filters.py +97 -0
  15. netbox_ot/graphql/schema.py +39 -0
  16. netbox_ot/graphql/types.py +104 -0
  17. netbox_ot/importers/__init__.py +0 -0
  18. netbox_ot/importers/functions.py +234 -0
  19. netbox_ot/importers/netbox_zones.py +32 -0
  20. netbox_ot/importers/observations.py +794 -0
  21. netbox_ot/importers/zeek.py +302 -0
  22. netbox_ot/lifecycle.py +89 -0
  23. netbox_ot/management/__init__.py +0 -0
  24. netbox_ot/management/commands/__init__.py +0 -0
  25. netbox_ot/management/commands/ot_check.py +44 -0
  26. netbox_ot/management/commands/ot_demo.py +244 -0
  27. netbox_ot/management/commands/ot_import.py +167 -0
  28. netbox_ot/management/commands/ot_rules.py +44 -0
  29. netbox_ot/migrations/0001_initial.py +280 -0
  30. netbox_ot/migrations/__init__.py +0 -0
  31. netbox_ot/models.py +811 -0
  32. netbox_ot/navigation.py +68 -0
  33. netbox_ot/register.py +159 -0
  34. netbox_ot/rules.py +137 -0
  35. netbox_ot/search.py +73 -0
  36. netbox_ot/sl.py +87 -0
  37. netbox_ot/tables.py +196 -0
  38. netbox_ot/template_content.py +71 -0
  39. netbox_ot/templates/netbox_ot/accessmap.html +31 -0
  40. netbox_ot/templates/netbox_ot/checks.html +31 -0
  41. netbox_ot/templates/netbox_ot/inc/access_attr.html +1 -0
  42. netbox_ot/templates/netbox_ot/inc/accessmap_table.html +81 -0
  43. netbox_ot/templates/netbox_ot/inc/asset_panel.html +100 -0
  44. netbox_ot/templates/netbox_ot/inc/checks_table.html +37 -0
  45. netbox_ot/templates/netbox_ot/inc/sl_panel.html +24 -0
  46. netbox_ot/templates/netbox_ot/inc/verdict_attr.html +3 -0
  47. netbox_ot/templates/netbox_ot/site_accessmap.html +15 -0
  48. netbox_ot/templates/netbox_ot/site_register.html +97 -0
  49. netbox_ot/tests/__init__.py +0 -0
  50. netbox_ot/tests/query_counts.json +16 -0
  51. netbox_ot/tests/test_adopt.py +155 -0
  52. netbox_ot/tests/test_api.py +282 -0
  53. netbox_ot/tests/test_checks.py +151 -0
  54. netbox_ot/tests/test_demo.py +44 -0
  55. netbox_ot/tests/test_filtersets.py +214 -0
  56. netbox_ot/tests/test_importer.py +453 -0
  57. netbox_ot/tests/test_industrial.py +252 -0
  58. netbox_ot/tests/test_lifecycle.py +119 -0
  59. netbox_ot/tests/test_models.py +195 -0
  60. netbox_ot/tests/test_rules.py +133 -0
  61. netbox_ot/tests/test_views.py +393 -0
  62. netbox_ot/tests/test_zeek.py +252 -0
  63. netbox_ot/upgrade.py +31 -0
  64. netbox_ot/urls.py +24 -0
  65. netbox_ot/views.py +794 -0
  66. netbox_ot-0.3.0.dist-info/METADATA +156 -0
  67. netbox_ot-0.3.0.dist-info/RECORD +70 -0
  68. netbox_ot-0.3.0.dist-info/WHEEL +5 -0
  69. netbox_ot-0.3.0.dist-info/licenses/LICENSE +178 -0
  70. 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