From 65eeea6f1bfa5d0d733f22707fb3234b1eb9a386 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 23 Sep 2024 00:27:43 -0400 Subject: [PATCH] Add django-simple-history (#599) * Add django-simple-history * Fix: crash on permissions object * Fix typo --- pyproject.toml | 1 + scripts/import_spreadsheet_dump.sh | 24 +- src/meshapi/admin/models/access_point.py | 5 +- src/meshapi/admin/models/building.py | 5 +- src/meshapi/admin/models/device.py | 5 +- src/meshapi/admin/models/install.py | 5 +- src/meshapi/admin/models/link.py | 5 +- src/meshapi/admin/models/los.py | 5 +- src/meshapi/admin/models/member.py | 5 +- src/meshapi/admin/models/node.py | 3 +- src/meshapi/admin/models/sector.py | 5 +- .../management/commands/create_groups.py | 3 +- .../migrations/0033_add_simple_history.py | 1108 +++++++++++++++++ src/meshapi/models/building.py | 3 + src/meshapi/models/devices/access_point.py | 3 + src/meshapi/models/devices/device.py | 3 + src/meshapi/models/devices/sector.py | 3 + src/meshapi/models/install.py | 3 + src/meshapi/models/link.py | 3 + src/meshapi/models/los.py | 3 + src/meshapi/models/member.py | 3 + src/meshapi/models/node.py | 3 + src/meshdb/settings.py | 4 + 23 files changed, 1190 insertions(+), 20 deletions(-) create mode 100644 src/meshapi/migrations/0033_add_simple_history.py diff --git a/pyproject.toml b/pyproject.toml index 4b37ac0c..2225e9a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "six==1.16.0", "django-flags==5.0.*", "django-sql-explorer==5.2.*", + "django-simple-history==3.7.*", ] [project.optional-dependencies] diff --git a/scripts/import_spreadsheet_dump.sh b/scripts/import_spreadsheet_dump.sh index fa1a9dd2..05a11602 100755 --- a/scripts/import_spreadsheet_dump.sh +++ b/scripts/import_spreadsheet_dump.sh @@ -2,8 +2,28 @@ DOCKER_PG_COMMAND="docker exec -i meshdb-postgres-1 psql -U meshdb" DATA_DIR="./spreadsheet_data/" -tables=("meshapi_link" "meshapi_accesspoint" "meshapi_sector" "meshapi_device" "meshapi_building_nodes" "meshapi_node" "meshapi_install" "meshapi_building" "meshapi_member") -#tables=("meshapi_link" "meshapi_building_nodes" "meshapi_sector" "meshapi_device" "meshapi_install" "meshapi_member" "meshapi_building" "meshapi_node") +tables=( +"meshapi_los" +"meshapi_link" +"meshapi_accesspoint" +"meshapi_sector" +"meshapi_device" +"meshapi_building_nodes" +"meshapi_node" +"meshapi_install" +"meshapi_building" +"meshapi_member" +"meshapi_historicallos" +"meshapi_historicallink" +"meshapi_historicalaccesspoint" +"meshapi_historicalsector" +"meshapi_historicaldevice" +"meshapi_historicalbuilding_nodes" +"meshapi_historicalnode" +"meshapi_historicalinstall" +"meshapi_historicalbuilding" +"meshapi_historicalmember" +) set -ex # Make sure our files exist. diff --git a/src/meshapi/admin/models/access_point.py b/src/meshapi/admin/models/access_point.py index eaf69033..7739649a 100644 --- a/src/meshapi/admin/models/access_point.py +++ b/src/meshapi/admin/models/access_point.py @@ -4,7 +4,8 @@ from django.contrib.postgres.search import SearchVector from django.forms import Field, ModelForm from django.http import HttpRequest -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import AccessPoint from meshapi.widgets import AutoPopulateLocationWidget, DeviceIPAddressWidget, ExternalHyperlinkWidget @@ -32,7 +33,7 @@ class Meta: @admin.register(AccessPoint) -class AccessPointAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class AccessPointAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = AccessPointAdminForm search_fields = ["name__icontains", "@notes"] search_vector = SearchVector("name", weight="A") + SearchVector("notes", weight="D") diff --git a/src/meshapi/admin/models/building.py b/src/meshapi/admin/models/building.py index 50b26738..1a7c8231 100644 --- a/src/meshapi/admin/models/building.py +++ b/src/meshapi/admin/models/building.py @@ -7,7 +7,8 @@ from django.db.models import QuerySet from django.forms import ModelForm from django.http import HttpRequest -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Building from meshapi.widgets import AutoPopulateLocationWidget, PanoramaViewer @@ -60,7 +61,7 @@ class Meta: @admin.register(Building) -class BuildingAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class BuildingAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = BuildingAdminForm list_display = ["__str__", "street_address", "primary_node"] search_fields = [ diff --git a/src/meshapi/admin/models/device.py b/src/meshapi/admin/models/device.py index 111411a2..56fc96b4 100644 --- a/src/meshapi/admin/models/device.py +++ b/src/meshapi/admin/models/device.py @@ -5,7 +5,8 @@ from django.contrib.postgres.search import SearchVector from django.db.models import QuerySet from django.http import HttpRequest -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Device from meshapi.widgets import ExternalHyperlinkWidget @@ -30,7 +31,7 @@ class Meta: @admin.register(Device) -class DeviceAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class DeviceAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = DeviceAdminForm search_fields = ["name__icontains", "@notes"] search_vector = SearchVector("name", weight="A") + SearchVector("notes", weight="D") diff --git a/src/meshapi/admin/models/install.py b/src/meshapi/admin/models/install.py index 821dca53..f7470eb4 100644 --- a/src/meshapi/admin/models/install.py +++ b/src/meshapi/admin/models/install.py @@ -8,7 +8,8 @@ from django.db.models import QuerySet from django.http import HttpRequest from import_export import resources -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Install from meshapi.widgets import ExternalHyperlinkWidget @@ -43,7 +44,7 @@ class Meta: @admin.register(Install) -class InstallAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class InstallAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = InstallAdminForm resource_classes = [InstallImportExportResource] list_filter = [ diff --git a/src/meshapi/admin/models/link.py b/src/meshapi/admin/models/link.py index 3891184d..7f6c8ba4 100644 --- a/src/meshapi/admin/models/link.py +++ b/src/meshapi/admin/models/link.py @@ -1,7 +1,8 @@ from django import forms from django.contrib import admin from django.contrib.postgres.search import SearchVector -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Link @@ -18,7 +19,7 @@ class Meta: @admin.register(Link) -class LinkAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class LinkAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = LinkAdminForm search_fields = [ "from_device__node__name__icontains", diff --git a/src/meshapi/admin/models/los.py b/src/meshapi/admin/models/los.py index 6a763f32..01c05102 100644 --- a/src/meshapi/admin/models/los.py +++ b/src/meshapi/admin/models/los.py @@ -6,7 +6,8 @@ from django.contrib.postgres.search import SearchVector from django.forms import ModelForm from django.http import HttpRequest -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import LOS @@ -20,7 +21,7 @@ class Meta: @admin.register(LOS) -class LOSAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class LOSAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = LOSAdminForm search_fields = [ "from_building__nodes__network_number__iexact", diff --git a/src/meshapi/admin/models/member.py b/src/meshapi/admin/models/member.py index fe2352fc..b57e4e76 100644 --- a/src/meshapi/admin/models/member.py +++ b/src/meshapi/admin/models/member.py @@ -1,7 +1,8 @@ from django import forms from django.contrib import admin from django.contrib.postgres.search import SearchVector -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Member @@ -22,7 +23,7 @@ class Meta: @admin.register(Member) -class MemberAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class MemberAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = MemberAdminForm search_fields = [ # Search by name diff --git a/src/meshapi/admin/models/node.py b/src/meshapi/admin/models/node.py index 08d80a39..8166058a 100644 --- a/src/meshapi/admin/models/node.py +++ b/src/meshapi/admin/models/node.py @@ -9,6 +9,7 @@ from django.http import HttpRequest from import_export import resources from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Building, Node from meshapi.widgets import AutoPopulateLocationWidget @@ -49,7 +50,7 @@ class Meta: @admin.register(Node) -class NodeAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class NodeAdmin(RankedSearchMixin, ExportActionMixin, ImportExportModelAdmin, SimpleHistoryAdmin): form = NodeAdminForm resource_classes = [NodeImportExportResource] search_fields = [ diff --git a/src/meshapi/admin/models/sector.py b/src/meshapi/admin/models/sector.py index 28ea78f4..22efcaa2 100644 --- a/src/meshapi/admin/models/sector.py +++ b/src/meshapi/admin/models/sector.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.contrib.postgres.search import SearchVector -from import_export.admin import ExportActionMixin, ImportExportModelAdmin +from import_export.admin import ExportActionMixin, ImportExportMixin +from simple_history.admin import SimpleHistoryAdmin from meshapi.models import Sector from meshapi.widgets import ExternalHyperlinkWidget @@ -22,7 +23,7 @@ class Meta: @admin.register(Sector) -class SectorAdmin(RankedSearchMixin, ImportExportModelAdmin, ExportActionMixin): +class SectorAdmin(RankedSearchMixin, ImportExportMixin, ExportActionMixin, SimpleHistoryAdmin): form = SectorAdminForm search_fields = ["name__icontains", "@notes"] search_vector = SearchVector("name", weight="A") + SearchVector("notes", weight="D") diff --git a/src/meshapi/management/commands/create_groups.py b/src/meshapi/management/commands/create_groups.py index 3326f892..f4251932 100644 --- a/src/meshapi/management/commands/create_groups.py +++ b/src/meshapi/management/commands/create_groups.py @@ -35,7 +35,8 @@ def create_base_groups(self) -> None: for p in all_permissions: code = p.codename - act, obj = code.split("_") + + act, obj = code.split("_", maxsplit=1) # read_only if act == "view" and obj in models: diff --git a/src/meshapi/migrations/0033_add_simple_history.py b/src/meshapi/migrations/0033_add_simple_history.py new file mode 100644 index 00000000..88c8f8d8 --- /dev/null +++ b/src/meshapi/migrations/0033_add_simple_history.py @@ -0,0 +1,1108 @@ +# Generated by Django 4.2.13 on 2024-09-23 02:32 + +import uuid + +import django.contrib.postgres.fields +import django.core.validators +import django.db.models.deletion +import django_jsonform.models.fields +import simple_history.models +from django.conf import settings +from django.db import migrations, models + +import meshapi.models.util.auto_incrementing_integer_field +import meshapi.validation + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("meshapi", "0032_alter_permission_options"), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalBuilding", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "bin", + models.IntegerField( + blank=True, + help_text="NYC DOB Identifier for the structure containing this building", + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "street_address", + models.CharField( + blank=True, + help_text="Line 1 only of the address of this building: i.e. ", + null=True, + ), + ), + ( + "city", + models.CharField( + blank=True, + help_text='The name of the borough this building is in for buildings within NYC, "New York" for Manhattan to match street addresses. The actual city name for anything outside NYC', + null=True, + ), + ), + ( + "state", + models.CharField( + blank=True, + help_text='The 2 letter abreviation of the US State this building is contained within, e.g. "NY" or "NJ"', + null=True, + ), + ), + ( + "zip_code", + models.CharField( + blank=True, help_text="The five digit ZIP code this building is contained within", null=True + ), + ), + ( + "address_truth_sources", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("OSMNominatim", "OSMNominatim"), + ("OSMNominatimZIPOnly", "OSMNominatimZIPOnly"), + ("NYCPlanningLabs", "NYCPlanningLabs"), + ("PeliasStringParsing", "PeliasStringParsing"), + ("ReverseGeocodeFromCoordinates", "ReverseGeocodeFromCoordinates"), + ("HumanEntry", "HumanEntry"), + ] + ), + help_text="A list of strings that answers the question: How was the content of the street address, city, state, and ZIP fields determined? This is useful in understanding the level of validation applied to spreadsheet imported data. Possible values are: OSMNominatim, OSMNominatimZIPOnly, NYCPlanningLabs, PeliasStringParsing, ReverseGeocodeFromCoordinates, HumanEntry. Check the import script for details", + size=None, + ), + ), + ("latitude", models.FloatField(help_text="Building latitude in decimal degrees")), + ("longitude", models.FloatField(help_text="Building longitude in decimal degrees")), + ( + "altitude", + models.FloatField( + blank=True, + help_text='Building rooftop altitude in "absolute" meters above mean sea level', + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="A free-form text description of this building, to track any additional information. For Buidings imported from the spreadsheet, this starts with a formatted block of information about the import process and original spreadsheet data. However this structure can be changed by admins at any time and should not be relied on by automated systems. ", + null=True, + ), + ), + ( + "panoramas", + django_jsonform.models.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + help_text="Panoramas taken from the roof of this Building", + null=True, + size=None, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "primary_node", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The primary node for this Building, for cases where it has more than one. This is the node bearing the network number that the building is collquially referred to by volunteers and is usually the first NN held by any equipment on the building. If present, this must also be included in nodes", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "historical building", + "verbose_name_plural": "historical buildings", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalSector", + fields=[ + ( + "device_ptr", + models.ForeignKey( + auto_created=True, + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + parent_link=True, + related_name="+", + to="meshapi.device", + ), + ), + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "name", + models.CharField( + blank=True, + default=None, + help_text="The name of this device, usually configured as the hostname in the device firmware, usually in the format nycmesh-xxxx-yyyy-zzzz, where xxxx is the network number for the node this device is located at, yyyy is the type of the device, and zzzz is the network number of the other side of the link this device creates (if applicable)", + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[("Inactive", "Inactive"), ("Active", "Active"), ("Potential", "Potential")], + help_text="The current status of this device", + ), + ), + ( + "install_date", + models.DateField( + blank=True, + default=None, + help_text="The date this device first became active on the mesh", + null=True, + ), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The this device was abandoned, unplugged, or removed from service", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this Device, to track any additional information. For imported Devices, this starts with a formatted block of information about the import processand original data. However this structure can be changed by admins at any time and should not be relied onby automated systems. ", + null=True, + ), + ), + ( + "uisp_id", + models.CharField( + blank=True, + default=None, + help_text="The UUID used to indentify this device in UISP (if applicable)", + null=True, + ), + ), + ( + "radius", + models.FloatField( + help_text="The radius to display this sector on the map (in km)", + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "azimuth", + models.IntegerField( + help_text="The compass heading that this sector is pointed towards", + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(360), + ], + ), + ), + ( + "width", + models.IntegerField( + help_text="The approximate width of the beam this sector produces", + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(360), + ], + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "node", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The logical node this Device is located within. This node's network_number field corresponds to the static IP address and OSPF ID of this device or the DHCP range it receives an address from. The network number is also usually found in the device name", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "historical sector", + "verbose_name_plural": "historical sectors", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalNode", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4)), + ( + "network_number", + models.IntegerField( + blank=True, + db_index=True, + null=True, + validators=[django.core.validators.MaxValueValidator(8192)], + ), + ), + ( + "name", + models.CharField( + blank=True, + default=None, + help_text="The colloquial name of this node used among mesh volunteers, if applicable", + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[("Inactive", "Inactive"), ("Active", "Active"), ("Planned", "Planned")], + help_text="The current status of this Node", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("Standard", "Standard"), + ("Hub", "Hub"), + ("Supernode", "Supernode"), + ("POP", "Pop"), + ("AP", "Ap"), + ("Remote", "Remote"), + ], + default="Standard", + help_text="The type of node this is, controls the icon used on the network map", + ), + ), + ( + "latitude", + models.FloatField( + help_text="Approximate Node latitude in decimal degrees (this will match one of the attached Building objects in most cases, but has been manually moved around in some cases to more accurately reflect node location)" + ), + ), + ( + "longitude", + models.FloatField( + help_text="Approximate Node longitude in decimal degrees (this will match one of the attached Building objects in most cases, but has been manually moved around in some cases to more accurately reflect node location)" + ), + ), + ( + "altitude", + models.FloatField( + blank=True, + help_text='Approximate Node altitude in "absolute" meters above mean sea level (this will match one of the attached Building objects in most cases, but has been manually moved around in some cases to more accurately reflect node location)', + null=True, + ), + ), + ( + "install_date", + models.DateField( + blank=True, + default=None, + help_text="The date the first Install or Device associated with this Node became active on the mesh", + null=True, + ), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The date the last Install or Device associated with this Node was abandoned, unplugged, or removed from service", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="A free-form text description of this Node, to track any additional information. For Nodes imported from the spreadsheet, this starts with a formatted block of information about the import process and original spreadsheet data. However this structure can be changed by admins at any time and should not be relied on by automated systems. ", + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical node", + "verbose_name_plural": "historical nodes", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalMember", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("name", models.CharField(help_text='Member full name in the format: "First Last"')), + ( + "primary_email_address", + models.EmailField( + blank=True, + help_text="Primary email address used to contact the member", + max_length=254, + null=True, + ), + ), + ( + "stripe_email_address", + models.EmailField( + blank=True, + default=None, + help_text="Email address used by the member to donate via Stripe, if different to their primary email", + max_length=254, + null=True, + ), + ), + ( + "additional_email_addresses", + django_jsonform.models.fields.ArrayField( + base_field=models.EmailField(max_length=254), + blank=True, + default=list, + help_text="Any additional email addresses associated with this member", + null=True, + size=None, + ), + ), + ( + "phone_number", + models.CharField( + blank=True, + default=None, + help_text="A primary contact phone number for this member", + null=True, + validators=[meshapi.validation.validate_phone_number_field], + ), + ), + ( + "additional_phone_numbers", + django_jsonform.models.fields.ArrayField( + base_field=models.CharField(), + blank=True, + default=list, + help_text="Any additional phone numbers used by this member", + null=True, + size=None, + validators=[meshapi.validation.validate_multi_phone_number_field], + ), + ), + ( + "slack_handle", + models.CharField(blank=True, default=None, help_text="The member's slack handle", null=True), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of how to contact this member, to track any additional information. For Members imported from the spreadsheet, this starts with a formatted block of information about the import process and original spreadsheet data. However this structure can be changed by admins at any time and should not be relied on by automated systems. ", + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical member", + "verbose_name_plural": "historical members", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalLOS", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "analysis_date", + models.DateField( + blank=True, + default=None, + help_text="The date we conducted the analysis that concluded this LOS exists. Important since new buildings might have been built which block the LOS after this date", + null=True, + ), + ), + ( + "source", + models.CharField( + choices=[("Human Annotated", "Human Annotated"), ("Existing Link", "Existing Link")], + help_text="The source of information that tells us this LOS exists", + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this LOS, to track any additional information.", + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "from_building", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The building on one side of this LOS, from/to are not meaningful except to disambiguate", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.building", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "to_building", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The building on one side of this LOS, from/to are not meaningful except to disambiguate", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.building", + ), + ), + ], + options={ + "verbose_name": "historical LOS", + "verbose_name_plural": "historical LOSes", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalLink", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "status", + models.CharField( + choices=[("Inactive", "Inactive"), ("Planned", "Planned"), ("Active", "Active")], + help_text="The current status of this link", + ), + ), + ( + "type", + models.CharField( + blank=True, + choices=[ + ("5 GHz", "Five Ghz"), + ("24 GHz", "Twentyfour Ghz"), + ("60 GHz", "Sixty Ghz"), + ("70-80 GHz", "Seventy Eighty Ghz"), + ("VPN", "Vpn"), + ("Fiber", "Fiber"), + ("Ethernet", "Ethernet"), + ], + default=None, + help_text="The technology used for this link 5Ghz, 60Ghz, fiber, etc.", + null=True, + ), + ), + ( + "install_date", + models.DateField(blank=True, default=None, help_text="The date this link was created", null=True), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The date this link was powered off, disassembled, or abandoned", + null=True, + ), + ), + ( + "description", + models.CharField( + blank=True, + default=None, + help_text='A short description of "where to where" this link connects in human readable language', + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this Link, to track any additional information.", + null=True, + ), + ), + ( + "uisp_id", + models.CharField( + blank=True, + default=None, + help_text="The UUID used to indentify this link in UISP (if applicable)", + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "from_device", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The device on one side of this network link, from/to are not meaningful except to disambiguate", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.device", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "to_device", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The device on one side of this network link, from/to are not meaningful except to disambiguate", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.device", + ), + ), + ], + options={ + "verbose_name": "historical link", + "verbose_name_plural": "historical links", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalInstall", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "install_number", + meshapi.models.util.auto_incrementing_integer_field.AutoIncrementingIntegerField( + db_index=True, editable=False + ), + ), + ( + "status", + models.CharField( + choices=[ + ("Request Received", "Request Received"), + ("Pending", "Pending"), + ("Blocked", "Blocked"), + ("Active", "Active"), + ("Inactive", "Inactive"), + ("Closed", "Closed"), + ("NN Reassigned", "Nn Reassigned"), + ], + help_text="The current status of this install", + ), + ), + ( + "ticket_number", + models.IntegerField( + blank=True, + help_text="The ticket number of the OSTicket used to track communications with the member about this install", + null=True, + ), + ), + ("request_date", models.DateField(help_text="The date that this install request was received")), + ( + "install_date", + models.DateField( + blank=True, + default=None, + help_text="The date this install was completed and deployed to the mesh", + null=True, + ), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The date this install was abandoned, unplugged, or disassembled", + null=True, + ), + ), + ( + "unit", + models.CharField( + blank=True, default=None, help_text="Line 2 of this install's mailing address", null=True + ), + ), + ( + "roof_access", + models.BooleanField( + default=False, + help_text="True if the member indicated they had access to the roof when they submitted the join form", + ), + ), + ( + "referral", + models.TextField( + blank=True, + default=None, + help_text='The "How did you hear about us?" information provided to us when the member submitted the join form', + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this Install, to track any additional information. For Installs imported from the spreadsheet, this starts with a formatted block of information about the import process and original spreadsheet data. However this structure can be changed by admins at any time and should not be relied on by automated systems. ", + null=True, + ), + ), + ( + "diy", + models.BooleanField( + blank=True, + default=None, + help_text="Was this install conducted by the member themselves? If not, it was done by a volunteer installer on their behalf", + null=True, + verbose_name="Is DIY?", + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "building", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The building where the install is located. In the case of a structure with multiple buildings, this will be the building whose address makes sense for this install's unit.", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.building", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "member", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The member this install is associated with", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.member", + ), + ), + ( + "node", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The node this install is associated with. This node's network_number field corresponds to the static IP address and OSPF ID of the router this install utilizes, the DHCP range it receives an address from, etc.", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "historical install", + "verbose_name_plural": "historical installs", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalDevice", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "name", + models.CharField( + blank=True, + default=None, + help_text="The name of this device, usually configured as the hostname in the device firmware, usually in the format nycmesh-xxxx-yyyy-zzzz, where xxxx is the network number for the node this device is located at, yyyy is the type of the device, and zzzz is the network number of the other side of the link this device creates (if applicable)", + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[("Inactive", "Inactive"), ("Active", "Active"), ("Potential", "Potential")], + help_text="The current status of this device", + ), + ), + ( + "install_date", + models.DateField( + blank=True, + default=None, + help_text="The date this device first became active on the mesh", + null=True, + ), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The this device was abandoned, unplugged, or removed from service", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this Device, to track any additional information. For imported Devices, this starts with a formatted block of information about the import processand original data. However this structure can be changed by admins at any time and should not be relied onby automated systems. ", + null=True, + ), + ), + ( + "uisp_id", + models.CharField( + blank=True, + default=None, + help_text="The UUID used to indentify this device in UISP (if applicable)", + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "node", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The logical node this Device is located within. This node's network_number field corresponds to the static IP address and OSPF ID of this device or the DHCP range it receives an address from. The network number is also usually found in the device name", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "historical device", + "verbose_name_plural": "historical devices", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalBuilding_nodes", + fields=[ + ("id", models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "m2m_history_id", + models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ( + "building", + models.ForeignKey( + blank=True, + db_constraint=False, + db_tablespace="", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.building", + ), + ), + ( + "history", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.DO_NOTHING, + to="meshapi.historicalbuilding", + ), + ), + ( + "node", + models.ForeignKey( + blank=True, + db_constraint=False, + db_tablespace="", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "HistoricalBuilding_nodes", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalAccessPoint", + fields=[ + ( + "device_ptr", + models.ForeignKey( + auto_created=True, + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + parent_link=True, + related_name="+", + to="meshapi.device", + ), + ), + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ( + "name", + models.CharField( + blank=True, + default=None, + help_text="The name of this device, usually configured as the hostname in the device firmware, usually in the format nycmesh-xxxx-yyyy-zzzz, where xxxx is the network number for the node this device is located at, yyyy is the type of the device, and zzzz is the network number of the other side of the link this device creates (if applicable)", + null=True, + ), + ), + ( + "status", + models.CharField( + choices=[("Inactive", "Inactive"), ("Active", "Active"), ("Potential", "Potential")], + help_text="The current status of this device", + ), + ), + ( + "install_date", + models.DateField( + blank=True, + default=None, + help_text="The date this device first became active on the mesh", + null=True, + ), + ), + ( + "abandon_date", + models.DateField( + blank=True, + default=None, + help_text="The this device was abandoned, unplugged, or removed from service", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + default=None, + help_text="A free-form text description of this Device, to track any additional information. For imported Devices, this starts with a formatted block of information about the import processand original data. However this structure can be changed by admins at any time and should not be relied onby automated systems. ", + null=True, + ), + ), + ( + "uisp_id", + models.CharField( + blank=True, + default=None, + help_text="The UUID used to indentify this device in UISP (if applicable)", + null=True, + ), + ), + ( + "latitude", + models.FloatField( + help_text="Approximate AP latitude in decimal degrees (this will match the attached Node object in most cases, but has been manually moved around in some cases to more accurately reflect the device location)" + ), + ), + ( + "longitude", + models.FloatField( + help_text="Approximate AP longitude in decimal degrees (this will match the attached Node object in most cases, but has been manually moved around in some cases to more accurately reflect the device location)" + ), + ), + ( + "altitude", + models.FloatField( + blank=True, + help_text='Approximate AP altitude in "absolute" meters above mean sea level (this will match the attached Node object in most cases, but has been manually moved around in some cases to more accurately reflect the device location)', + null=True, + ), + ), + ("history_id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "node", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The logical node this Device is located within. This node's network_number field corresponds to the static IP address and OSPF ID of this device or the DHCP range it receives an address from. The network number is also usually found in the device name", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="meshapi.node", + ), + ), + ], + options={ + "verbose_name": "historical access point", + "verbose_name_plural": "historical access points", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/src/meshapi/models/building.py b/src/meshapi/models/building.py index ebe0b564..5090a44d 100644 --- a/src/meshapi/models/building.py +++ b/src/meshapi/models/building.py @@ -6,6 +6,7 @@ from django.db import models from django.db.models import ManyToManyField from django_jsonform.models.fields import ArrayField as JSONFormArrayField +from simple_history.models import HistoricalRecords from meshdb.utils.spreadsheet_import.building.constants import AddressTruthSource @@ -22,6 +23,8 @@ class Building(models.Model): "Building" object for each address, but these "Building" objects will all share a BIN. """ + history = HistoricalRecords(m2m_fields=["nodes"]) + class Meta: ordering = ["id"] diff --git a/src/meshapi/models/devices/access_point.py b/src/meshapi/models/devices/access_point.py index 0a1205ac..49a00a97 100644 --- a/src/meshapi/models/devices/access_point.py +++ b/src/meshapi/models/devices/access_point.py @@ -1,9 +1,12 @@ from django.db import models +from simple_history.models import HistoricalRecords from .device import Device class AccessPoint(Device): + history = HistoricalRecords() + latitude = models.FloatField( help_text="Approximate AP latitude in decimal degrees (this will match the attached " "Node object in most cases, but has been manually moved around in some cases to " diff --git a/src/meshapi/models/devices/device.py b/src/meshapi/models/devices/device.py index 92f35062..a10703ae 100644 --- a/src/meshapi/models/devices/device.py +++ b/src/meshapi/models/devices/device.py @@ -3,11 +3,14 @@ from django.db import models from django.db.models import F, FloatField, IntegerField +from simple_history.models import HistoricalRecords from meshapi.models.node import Node class Device(models.Model): + history = HistoricalRecords() + class Meta: ordering = [F("install_date").desc(nulls_last=True)] diff --git a/src/meshapi/models/devices/sector.py b/src/meshapi/models/devices/sector.py index 42b943ba..08718c84 100644 --- a/src/meshapi/models/devices/sector.py +++ b/src/meshapi/models/devices/sector.py @@ -1,10 +1,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from simple_history.models import HistoricalRecords from .device import Device class Sector(Device): + history = HistoricalRecords() + radius = models.FloatField( help_text="The radius to display this sector on the map (in km)", validators=[MinValueValidator(0)], diff --git a/src/meshapi/models/install.py b/src/meshapi/models/install.py index 5fad48c6..fe5cedd0 100644 --- a/src/meshapi/models/install.py +++ b/src/meshapi/models/install.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models import IntegerField +from simple_history.models import HistoricalRecords from .building import Building from .member import Member @@ -12,6 +13,8 @@ class Install(models.Model): + history = HistoricalRecords() + class Meta: permissions = [ ("assign_nn", "Can assign an NN to install"), diff --git a/src/meshapi/models/link.py b/src/meshapi/models/link.py index 96d646f7..31b98c71 100644 --- a/src/meshapi/models/link.py +++ b/src/meshapi/models/link.py @@ -3,11 +3,14 @@ from typing import Optional from django.db import models +from simple_history.models import HistoricalRecords from meshapi.models.devices.device import Device class Link(models.Model): + history = HistoricalRecords() + class LinkStatus(models.TextChoices): INACTIVE = "Inactive" PLANNED = "Planned" diff --git a/src/meshapi/models/los.py b/src/meshapi/models/los.py index dd58e58c..8efd77cd 100644 --- a/src/meshapi/models/los.py +++ b/src/meshapi/models/los.py @@ -1,6 +1,7 @@ import uuid from django.db import models +from simple_history.models import HistoricalRecords from meshapi.models import Building @@ -18,6 +19,8 @@ class LOS(models.Model): between any pair of street addresses (MeshDB Buildings), no need to create any other DB objects. """ + history = HistoricalRecords() + class Meta: verbose_name = "LOS" verbose_name_plural = "LOSes" diff --git a/src/meshapi/models/member.py b/src/meshapi/models/member.py index f10c3d2c..31cdf8d5 100644 --- a/src/meshapi/models/member.py +++ b/src/meshapi/models/member.py @@ -4,11 +4,14 @@ from django.db import models from django.db.models.fields import EmailField from django_jsonform.models.fields import ArrayField as JSONFormArrayField +from simple_history.models import HistoricalRecords from meshapi.validation import normalize_phone_number, validate_multi_phone_number_field, validate_phone_number_field class Member(models.Model): + history = HistoricalRecords() + class Meta: ordering = ["id"] diff --git a/src/meshapi/models/node.py b/src/meshapi/models/node.py index 327752bc..db6e45f8 100644 --- a/src/meshapi/models/node.py +++ b/src/meshapi/models/node.py @@ -5,6 +5,7 @@ from django.core.validators import MaxValueValidator from django.db import models, transaction from django.db.models.manager import Manager +from simple_history.models import HistoricalRecords from meshapi.util.django_pglocks import advisory_lock from meshapi.util.network_number import ( @@ -20,6 +21,8 @@ class Node(models.Model): + history = HistoricalRecords() + # This should be added automatically by django-stubs, but for some reason it's not :( buildings: Manager["Building"] diff --git a/src/meshdb/settings.py b/src/meshdb/settings.py index c59c1eee..5106a438 100644 --- a/src/meshdb/settings.py +++ b/src/meshdb/settings.py @@ -141,6 +141,7 @@ "import_export", "flags", "explorer", + "simple_history", ] MIDDLEWARE = [ @@ -153,6 +154,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "meshweb.middleware.MaintenanceModeMiddleware", + "simple_history.middleware.HistoryRequestMiddleware", ] @@ -410,6 +412,8 @@ IMPORT_EXPORT_IMPORT_PERMISSION_CODE = "add" IMPORT_EXPORT_EXPORT_PERMISSION_CODE = "view" +SIMPLE_HISTORY_HISTORY_ID_USE_UUID = True + EXPLORER_CONNECTIONS = {"Default": "readonly"} EXPLORER_DEFAULT_CONNECTION = "readonly" EXPLORER_NO_PERMISSION_VIEW = "meshweb.views.explorer_auth_redirect"