1
0
mirror of https://github.com/peeringdb/peeringdb.git synced 2024-05-11 05:55:09 +00:00

Gh 399 Review

This commit is contained in:
Stefan Pratter
2019-02-15 17:46:20 +00:00
parent b4ded90fa9
commit a4eed0b777
7 changed files with 428 additions and 13 deletions

View File

@@ -1,10 +1,13 @@
import StringIO import StringIO
import json import json
import reversion import reversion
from reversion.models import Version
from dal import autocomplete from dal import autocomplete
from django import forms from django import forms
from django.core.management import call_command from django.core.management import call_command
from peeringdb_server.models import (COMMANDLINE_TOOLS, CommandLineTool, from peeringdb_server.models import (REFTAG_MAP, COMMANDLINE_TOOLS, CommandLineTool,
InternetExchange, Facility) InternetExchange, Facility)
from peeringdb_server import maintenance from peeringdb_server import maintenance
@@ -99,6 +102,9 @@ class CommandLineToolWrapper(object):
def set_arguments(self, form_data): def set_arguments(self, form_data):
pass pass
def validate(self):
pass
def _run(self, user, commit=False): def _run(self, user, commit=False):
r = StringIO.StringIO() r = StringIO.StringIO()
@@ -106,6 +112,7 @@ class CommandLineToolWrapper(object):
maintenance.on() maintenance.on()
try: try:
self.validate()
if commit: if commit:
call_command(self.tool, *self.args, commit=True, stdout=r, call_command(self.tool, *self.args, commit=True, stdout=r,
**self.kwargs) **self.kwargs)
@@ -274,3 +281,44 @@ class ToolReset(CommandLineToolWrapper):
def set_arguments(self, form_data): def set_arguments(self, form_data):
self.kwargs = form_data self.kwargs = form_data
@register_tool
class ToolUndelete(CommandLineToolWrapper):
"""
Allows restoration of an object object and it's child objects
"""
tool = "pdb_undelete"
# These are the reftags that are currently supported by this
# tool.
supported_reftags = ["ixlan","fac"]
class Form(forms.Form):
version = forms.ModelChoiceField(
queryset=Version.objects.all().order_by("-revision_id"),
widget=autocomplete.ModelSelect2(
url="/autocomplete/admin/deletedversions"),
help_text=_("Restore this object - search by [reftag] [id]"))
@property
def description(self):
return "{reftag} {id}".format(**self.kwargs)
def set_arguments(self, form_data):
version = form_data.get("version")
if not version:
return
reftag = version.content_type.model_class().HandleRef.tag
self.kwargs = {"reftag":reftag, "id":version.object_id, "version_id":version.id}
def validate(self):
if self.kwargs.get("reftag") not in self.supported_reftags:
raise ValueError(_("Only {} type objects may be restored " \
"through this interface at this point").format(",".join(self.supported_reftags)))
obj = REFTAG_MAP[self.kwargs.get("reftag")].objects.get(id=self.kwargs.get("id"))
if obj.status != "deleted":
raise ValueError("{} is not currently marked as deleted".format(obj))

View File

@@ -1,10 +1,14 @@
import json
from django.db.models import Q from django.db.models import Q
from django import http from django import http
from django.utils import html from django.utils import html
from django.core.exceptions import ObjectDoesNotExist
from reversion.models import Version
from dal import autocomplete from dal import autocomplete
from peeringdb_server.models import (InternetExchange, Facility, from peeringdb_server.models import (InternetExchange, Facility,
NetworkFacility, InternetExchangeFacility, NetworkFacility, InternetExchangeFacility,
Organization, IXLan, CommandLineTool) Organization, IXLan, CommandLineTool, REFTAG_MAP)
from peeringdb_server.admin_commandline_tools import TOOL_MAP from peeringdb_server.admin_commandline_tools import TOOL_MAP
@@ -121,6 +125,61 @@ class IXLanAutocomplete(AutocompleteHTMLResponse):
html.escape(item.name)) html.escape(item.name))
class DeletedVersionAutocomplete(autocomplete.Select2QuerySetView):
"""
Autocomplete that will show reversion versions where an object
was set to deleted
"""
def get_queryset(self):
# Only staff needs to be able to see these
if not self.request.user.is_staff:
return []
# no query supplied, return empty result
if not self.q:
return []
try:
# query is expected to be of format "<reftag> <id>"
# return empty result on parsing failure
reftag, _id = tuple(self.q.split(" "))
except ValueError:
return []
try:
# make sure target object exists, return
# empty result if not
obj = REFTAG_MAP[reftag].objects.get(id=_id)
except (KeyError, ObjectDoesNotExist):
return []
versions = Version.objects.get_for_object(obj).order_by("revision_id").select_related("revision")
rv = []
previous = {}
# cycle through all versions of the object and collect the ones where
# status was changed from 'ok' to 'deleted'
#
# order them by most recent first
for version in versions:
data = json.loads(version.serialized_data)[0].get("fields")
if previous.get("status", "ok") == "ok" and data.get("status") == "deleted":
rv.insert(0, version)
previous = data
return rv
def get_result_label(self, item):
# label should be obj representation as well as date of deletion
# we split the date string to remove the ms and tz parts
return "{} - {}".format(item, str(item.revision.date_created).split(".")[0])
class CommandLineToolHistoryAutocomplete(autocomplete.Select2QuerySetView): class CommandLineToolHistoryAutocomplete(autocomplete.Select2QuerySetView):
""" """
Autocomplete for command line tools that were ran via the admin ui Autocomplete for command line tools that were ran via the admin ui

View File

@@ -4,6 +4,7 @@ from peeringdb_server.models import REFTAG_MAP
import reversion import reversion
import json import json
import re
class Command(BaseCommand): class Command(BaseCommand):
@@ -24,17 +25,85 @@ class Command(BaseCommand):
else: else:
self.stdout.write("[pretend] {}".format(msg)) self.stdout.write("[pretend] {}".format(msg))
def log_err(self, msg):
self.log("[error] {}".format(msg))
def log_warn(self, msg):
self.log("[warning] {}".format(msg))
def handle(self, *args, **options): def handle(self, *args, **options):
self.commit = options.get("commit", False) self.commit = options.get("commit", False)
self.version_id = options.get("version_id") self.version_id = options.get("version_id")
version = reversion.models.Version.objects.get(id=self.version_id) self.suppress_warning = None
self.version = version = reversion.models.Version.objects.get(
id=self.version_id)
self.date = version.revision.date_created self.date = version.revision.date_created
self.log("UNDELETING FROM DATE: {}".format(self.date)) self.log("UNDELETING FROM DATE: {}".format(self.date))
self.undelete(options.get("reftag"), options.get("id")) self.undelete(options.get("reftag"), options.get("id"))
def handle_netixlan(self, netixlan):
model = REFTAG_MAP["netixlan"]
conflict_ip4, conflict_ip6 = netixlan.ipaddress_conflict()
if conflict_ip4:
# ipv4 exists in another netixlan now
others = model.objects.filter(ipaddr4=netixlan.ipaddr4,
status="ok")
for other in [
o for o in others if o.ixlan.ix_id == netixlan.ixlan.ix_id
]:
# netixlan is at same ix as the one being undeleted, delete the other
# one so we can proceed with undeletion
self.log("Found duplicate netixlan at same ix: {} - deleting".
format(other.ipaddr4))
if self.commit:
other.delete()
else:
# when in pretend mode we need suppress the next warning as we
# are not deleting the conflict
self.suppress_warning = True
for other in [
o for o in others if o.ixlan.ix_id != netixlan.ixlan.ix_id
]:
# unless ipv4 also exists in a netixlan that is NOT at the same ix
# then we need the warning again
self.suppress_warning = False
if conflict_ip6:
# ipv6 exists in another netixlan now
others = model.objects.filter(ipaddr6=netixlan.ipaddr6,
status="ok")
for other in [
o for o in others if o.ixlan.ix_id == netixlan.ixlan.ix_id
]:
# netixlan is at same ix as the one being undeleted, delete the other
# one so we can proceed with undeletion
self.log("Found duplicate netixlan at same ix: {} - deleting".
format(other.ipaddr6))
if self.commit:
other.delete()
else:
# when in pretend mode we need suppress the next warning as we
# are not deleting the conflict
self.suppress_warning = True
for other in [
o for o in others if o.ixlan.ix_id != netixlan.ixlan.ix_id
]:
# unless ipv6 also exists in a netixlan that is NOT at the same ix
# then we need the warning again
self.suppress_warning = False
def undelete(self, reftag, _id, parent=None, date=None): def undelete(self, reftag, _id, parent=None, date=None):
cls = REFTAG_MAP.get(reftag) cls = REFTAG_MAP.get(reftag)
obj = cls.objects.get(id=_id) obj = cls.objects.get(id=_id)
self.suppress_warning = False
def _label(obj):
if hasattr(obj, "descriptive_name"):
return obj.descriptive_name
return obj
if date: if date:
version = reversion.models.Version.objects.get_for_object( version = reversion.models.Version.objects.get_for_object(
@@ -46,9 +115,9 @@ class Command(BaseCommand):
except: except:
status = None status = None
if status == "deleted": if status == "deleted":
self.log( self.log_warn(
"{} was already deleted at snapshot, skipping ..".format( "{} was already deleted at snapshot, skipping ..".format(
obj)) _label(obj)))
return return
can_undelete_obj = True can_undelete_obj = True
@@ -63,18 +132,29 @@ class Command(BaseCommand):
continue continue
if relation.status == "deleted" and relation != parent: if relation.status == "deleted" and relation != parent:
can_undelete_obj = False can_undelete_obj = False
self.log( self.log_warn(
"Cannot undelete {}, dependent relation marked as deleted: {}". "Cannot undelete {}, dependent relation marked as deleted: {}"
format(obj, relation)) .format(_label(obj), relation))
if not can_undelete_obj: if not can_undelete_obj:
return return
if obj.status == "deleted": if obj.status == "deleted":
obj.status = "ok" obj.status = "ok"
self.log("Undeleting {}".format(obj)) self.log("Undeleting {}".format(_label(obj)))
handler = getattr(self, "handle_{}".format(reftag), None)
if handler:
handler(obj)
try:
obj.clean()
if self.commit: if self.commit:
obj.save() obj.save()
except Exception as exc:
if not self.suppress_warning:
self.log_warn("Cannot undelete {}: {}".format(
_label(obj), exc))
for field in cls._meta.get_fields(): for field in cls._meta.get_fields():
if field.is_relation: if field.is_relation:

View File

@@ -47,7 +47,8 @@ PARTNERSHIP_LEVELS = ((1, _("Data Validation")), (2, _("RIR")))
COMMANDLINE_TOOLS = (("pdb_renumber_lans", COMMANDLINE_TOOLS = (("pdb_renumber_lans",
_("Renumber IP Space")), ("pdb_fac_merge", _("Renumber IP Space")), ("pdb_fac_merge",
_("Merge Facilities")), _("Merge Facilities")),
("pdb_fac_merge_undo", _("Merge Facilities: UNDO"))) ("pdb_fac_merge_undo", _("Merge Facilities: UNDO")),
("pdb_undelete", _("Restore Object(s)")))
if settings.TUTORIAL_MODE: if settings.TUTORIAL_MODE:
@@ -987,6 +988,14 @@ class Facility(pdb_models.FacilityBase, GeocodeBaseMixin):
""" """
return self.netfac_set.filter(status="ok") return self.netfac_set.filter(status="ok")
@property
def ixfac_set_active(self):
"""
Returns queryset of active InternetExchangeFacility objects connected
to this facility
"""
return self.ixfac_set.filter(status="ok")
@property @property
def net_count(self): def net_count(self):
""" """
@@ -1262,6 +1271,16 @@ class InternetExchangeFacility(pdb_models.InternetExchangeFacilityBase):
ix = models.ForeignKey(InternetExchange, related_name="ixfac_set") ix = models.ForeignKey(InternetExchange, related_name="ixfac_set")
facility = models.ForeignKey(Facility, default=0, related_name="ixfac_set") facility = models.ForeignKey(Facility, default=0, related_name="ixfac_set")
@property
def descriptive_name(self):
"""
Returns a descriptive label of the ixfac for logging purposes
"""
return "ixfac{} {} <-> {}".format(
self.id, self.ix.name, self.facility.name)
@classmethod @classmethod
def nsp_namespace_from_id(cls, org_id, ix_id, id): def nsp_namespace_from_id(cls, org_id, ix_id, id):
""" """
@@ -1305,6 +1324,15 @@ class IXLan(pdb_models.IXLanBase):
class Meta: class Meta:
db_table = u'peeringdb_ixlan' db_table = u'peeringdb_ixlan'
@property
def descriptive_name(self):
"""
Returns a descriptive label of the ixlan for logging purposes
"""
return "ixlan{} {} {}".format(
self.id, self.name, self.ix.name)
@classmethod @classmethod
def nsp_namespace_from_id(cls, org_id, ix_id, id): def nsp_namespace_from_id(cls, org_id, ix_id, id):
""" """
@@ -1837,6 +1865,15 @@ class IXLanPrefix(pdb_models.IXLanPrefixBase):
ixlan = models.ForeignKey(IXLan, default=0, related_name="ixpfx_set") ixlan = models.ForeignKey(IXLan, default=0, related_name="ixpfx_set")
@property
def descriptive_name(self):
"""
Returns a descriptive label of the ixpfx for logging purposes
"""
return "ixpfx{} {}".format(
self.id, self.prefix)
@classmethod @classmethod
def nsp_namespace_from_id(cls, org_id, ix_id, ixlan_id, id): def nsp_namespace_from_id(cls, org_id, ix_id, ixlan_id, id):
""" """
@@ -2210,6 +2247,8 @@ class NetworkFacility(pdb_models.NetworkFacilityBase):
db_table = u'peeringdb_network_facility' db_table = u'peeringdb_network_facility'
unique_together = ('network', 'facility', 'local_asn') unique_together = ('network', 'facility', 'local_asn')
@classmethod @classmethod
def nsp_namespace_from_id(cls, org_id, net_id, fac_id): def nsp_namespace_from_id(cls, org_id, net_id, fac_id):
""" """
@@ -2265,6 +2304,15 @@ class NetworkFacility(pdb_models.NetworkFacilityBase):
qset = cls.handleref.undeleted() qset = cls.handleref.undeleted()
return qset.filter(**make_relation_filter(field, filt, value)) return qset.filter(**make_relation_filter(field, filt, value))
@property
def descriptive_name(self):
"""
Returns a descriptive label of the netfac for logging purposes
"""
return "netfac{} AS{} {} <-> {}".format(
self.id, self.network.asn, self.network.name, self.facility.name)
def nsp_has_perms_PUT(self, user, request): def nsp_has_perms_PUT(self, user, request):
return validate_PUT_ownership(user, self, request.data, ["net"]) return validate_PUT_ownership(user, self, request.data, ["net"])
@@ -2290,6 +2338,14 @@ class NetworkIXLan(pdb_models.NetworkIXLanBase):
def name(self): def name(self):
return "" return ""
@property
def descriptive_name(self):
"""
Returns a descriptive label of the netixlan for logging purposes
"""
return "netixlan{} AS{} {} {}".format(
self.id, self.asn, self.ipaddr4, self.ipaddr6)
@property @property
def ix_name(self): def ix_name(self):
""" """

View File

@@ -11,7 +11,7 @@ from peeringdb_server.autocomplete_views import (
FacilityAutocompleteForNetwork, FacilityAutocompleteForExchange, FacilityAutocompleteForNetwork, FacilityAutocompleteForExchange,
OrganizationAutocomplete, ExchangeAutocomplete, ExchangeAutocompleteJSON, OrganizationAutocomplete, ExchangeAutocomplete, ExchangeAutocompleteJSON,
IXLanAutocomplete, FacilityAutocomplete, FacilityAutocompleteJSON, IXLanAutocomplete, FacilityAutocomplete, FacilityAutocompleteJSON,
clt_history) DeletedVersionAutocomplete, clt_history)
from peeringdb_server.export_views import ( from peeringdb_server.export_views import (
view_export_ixf_ix_members, view_export_ixf_ix_members,
@@ -156,6 +156,8 @@ urlpatterns += [
name="autocomplete-fac"), name="autocomplete-fac"),
url(r'^autocomplete/ixlan/$', IXLanAutocomplete.as_view(), url(r'^autocomplete/ixlan/$', IXLanAutocomplete.as_view(),
name="autocomplete-ixlan"), name="autocomplete-ixlan"),
url(r'^autocomplete/admin/deletedversions$', DeletedVersionAutocomplete.as_view(),
name="autocomplete-admin-deleted-versions"),
] ]
# Admin autocomplete for commandlinetool history # Admin autocomplete for commandlinetool history

View File

@@ -0,0 +1,43 @@
import json
from django.urls import reverse
from django.test import Client, RequestFactory
import reversion
from peeringdb_server.models import User, Organization
from peeringdb_server import autocomplete_views
from util import ClientCase
class TestAutocomplete(ClientCase):
@classmethod
def setUpTestData(cls):
super(TestAutocomplete, cls).setUpTestData()
cls.staff_user = User.objects.create_user("staff", "staff@localhost",
"staff", is_staff=True)
def setUp(self):
self.factory = RequestFactory()
def test_deleted_versions(self):
with reversion.create_revision():
org = Organization.objects.create(name="Test Org", status="ok")
with reversion.create_revision():
org.delete()
with reversion.create_revision():
org.status = "ok"
org.save()
with reversion.create_revision():
org.delete()
url = reverse("autocomplete-admin-deleted-versions")
r = self.factory.get("{}?q=org {}".format(url, org.id))
r.user = self.staff_user
r = autocomplete_views.DeletedVersionAutocomplete.as_view()(r)
content = json.loads(r.content)
assert reversion.models.Version.objects.all().count() == 4
assert len(content.get("results")) == 2

127
tests/test_undelete.py Normal file
View File

@@ -0,0 +1,127 @@
import datetime
from util import ClientCase, Group
from django.core.management import call_command
from django.contrib.auth import get_user_model
from django.conf import settings
from peeringdb_server.models import REFTAG_MAP
from peeringdb_server.management.commands.pdb_undelete import Command
class TestUndelete(ClientCase):
@classmethod
def setUpTestData(cls):
super(TestUndelete, cls).setUpTestData()
call_command("pdb_generate_test_data", limit=2, commit=True)
cls.org_a = REFTAG_MAP["org"].objects.get(id=1)
cls.org_b = REFTAG_MAP["org"].objects.get(id=2)
cls.net_a = cls.org_a.net_set.first()
cls.ix_a = cls.org_a.ix_set.first()
cls.ix_b = cls.org_b.ix_set.first()
cls.fac_a = cls.org_a.fac_set.first()
cls.fac_b = cls.org_b.fac_set.first()
cls.ixlan_a = cls.ix_a.ixlan_set.first()
cls.ixlan_b = cls.ix_b.ixlan_set.first()
cls.date = datetime.date
@property
def _command(self):
command = Command()
command.commit = True
return command
def _undelete(self, obj):
command = self._command
command.date = obj.updated
command.undelete(obj.HandleRef.tag, obj.id)
obj.refresh_from_db()
def test_undelete_ixlan(self):
assert self.ixlan_a.netixlan_set_active.count() == 1
assert self.ixlan_a.ixpfx_set_active.count() == 2
self.ixlan_a.delete()
assert self.ixlan_a.status == "deleted"
self._undelete(self.ixlan_a)
assert self.ixlan_a.status == "ok"
assert self.ixlan_a.netixlan_set_active.count() == 1
assert self.ixlan_a.ixpfx_set_active.count() == 2
def test_undelete_fac(self):
assert self.fac_a.netfac_set_active.count() == 1
assert self.fac_a.ixfac_set_active.count() == 1
self.fac_a.delete()
assert self.fac_a.status == "deleted"
self._undelete(self.fac_a)
assert self.fac_a.status == "ok"
assert self.fac_a.netfac_set_active.count() == 1
assert self.fac_a.ixfac_set_active.count() == 1
def test_undelete_ixlan_netixlan_dupe_other(self):
netixlan_a = self.ixlan_a.netixlan_set.first()
self.ixlan_a.delete()
netixlan_c = REFTAG_MAP["netixlan"].objects.create(
asn=self.net_a.asn, ixlan=self.ixlan_b, status="ok",
ipaddr4=netixlan_a.ipaddr4, network=self.net_a, speed=100)
self._undelete(self.ixlan_a)
assert self.ixlan_a.status == "ok"
assert self.ixlan_a.netixlan_set_active.count() == 0
def test_undelete_ixlan_netixlan_dupe_other_ipv6(self):
netixlan_a = self.ixlan_a.netixlan_set.first()
self.ixlan_a.delete()
netixlan_c = REFTAG_MAP["netixlan"].objects.create(
asn=self.net_a.asn, ixlan=self.ixlan_b, status="ok",
ipaddr6=netixlan_a.ipaddr6, network=self.net_a, speed=100)
self._undelete(self.ixlan_a)
assert self.ixlan_a.status == "ok"
assert self.ixlan_a.netixlan_set_active.count() == 0
def test_undelete_ixlan_netixlan_dupe_same_ix(self):
ixlan_c = REFTAG_MAP["ixlan"].objects.create(ix=self.ix_a, status="ok")
netixlan_a = self.ixlan_a.netixlan_set.first()
self.ixlan_a.delete()
netixlan_c = REFTAG_MAP["netixlan"].objects.create(
asn=self.net_a.asn, ixlan=ixlan_c, status="ok",
ipaddr4=netixlan_a.ipaddr4, network=self.net_a, speed=100)
assert ixlan_c.netixlan_set_active.count() == 1
self._undelete(self.ixlan_a)
assert self.ixlan_a.status == "ok"
assert self.ixlan_a.netixlan_set_active.count() == 1
assert ixlan_c.netixlan_set_active.count() == 0
def test_undelete_ixlan_netixlan_dupe_same_ix_ipv6(self):
ixlan_c = REFTAG_MAP["ixlan"].objects.create(ix=self.ix_a, status="ok")
netixlan_a = self.ixlan_a.netixlan_set.first()
self.ixlan_a.delete()
netixlan_c = REFTAG_MAP["netixlan"].objects.create(
asn=self.net_a.asn, ixlan=ixlan_c, status="ok",
ipaddr6=netixlan_a.ipaddr6, network=self.net_a, speed=100)
assert ixlan_c.netixlan_set_active.count() == 1
self._undelete(self.ixlan_a)
assert self.ixlan_a.status == "ok"
assert self.ixlan_a.netixlan_set_active.count() == 1
assert ixlan_c.netixlan_set_active.count() == 0