mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Closes #7743: Remove legacy ASN field from site model
This commit is contained in:
@ -8,15 +8,18 @@
|
||||
### Breaking Changes
|
||||
|
||||
* Automatic redirection of legacy slug-based URL paths has been removed.
|
||||
* The `asn` field has been removed from the site model. Please use the ASN model introduced in NetBox v3.1 to track ASN assignments for sites.
|
||||
* The `asn` query filter for sites now matches against the AS number of assigned ASNs.
|
||||
* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please use the new contact model introduced in NetBox v3.1 to store contact information for sites.
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later
|
||||
* [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model
|
||||
* [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model
|
||||
* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* dcim.Site
|
||||
* Removed the `contact_name`, `contact_phone`, and `contact_email` fields
|
||||
* Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
|
||||
|
@ -132,7 +132,7 @@ class SiteSerializer(PrimaryModelSerializer):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns',
|
||||
'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count',
|
||||
'rack_count', 'virtualmachine_count', 'vlan_count',
|
||||
|
@ -131,6 +131,12 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Group (slug)',
|
||||
)
|
||||
asn = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='asns__asn',
|
||||
queryset=ASN.objects.all(),
|
||||
to_field_name='asn',
|
||||
label='AS (ID)',
|
||||
)
|
||||
asn_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='asns',
|
||||
queryset=ASN.objects.all(),
|
||||
@ -141,7 +147,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = (
|
||||
'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude',
|
||||
'id', 'name', 'slug', 'facility', 'latitude', 'longitude',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
|
@ -111,12 +111,6 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
asn = forms.IntegerField(
|
||||
min_value=BGP_ASN_MIN,
|
||||
max_value=BGP_ASN_MAX,
|
||||
required=False,
|
||||
label='ASN'
|
||||
)
|
||||
asns = DynamicModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
label=_('ASNs'),
|
||||
@ -134,7 +128,7 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone',
|
||||
'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
|
||||
]
|
||||
|
||||
|
||||
|
@ -130,15 +130,13 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'tags',
|
||||
]
|
||||
fields = (
|
||||
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
|
||||
)
|
||||
fieldsets = (
|
||||
('Site', (
|
||||
'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description',
|
||||
'tags',
|
||||
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
|
||||
)),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')),
|
||||
@ -159,7 +157,6 @@ class SiteForm(TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
help_texts = {
|
||||
'name': "Full name of the site",
|
||||
'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model",
|
||||
'facility': "Data center provider and facility (e.g. Equinix NY7)",
|
||||
'time_zone': "Local time zone",
|
||||
'description': "Short description (will appear in sites list)",
|
||||
|
@ -8,6 +8,10 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='site',
|
||||
name='asn',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='site',
|
||||
name='contact_email',
|
||||
|
@ -248,12 +248,6 @@ class Site(PrimaryModel):
|
||||
blank=True,
|
||||
help_text='Local facility ID or description'
|
||||
)
|
||||
asn = ASNField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='ASN',
|
||||
help_text='32-bit autonomous system number'
|
||||
)
|
||||
asns = models.ManyToManyField(
|
||||
to='ipam.ASN',
|
||||
related_name='sites',
|
||||
@ -307,7 +301,7 @@ class Site(PrimaryModel):
|
||||
)
|
||||
|
||||
clone_fields = [
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude',
|
||||
]
|
||||
|
||||
|
@ -151,9 +151,9 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', latitude=10, longitude=10),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', latitude=20, longitude=20),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', latitude=30, longitude=30),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
sites[0].asns.set([asns[0]])
|
||||
@ -173,7 +173,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_asn(self):
|
||||
params = {'asn': [65001, 65002]}
|
||||
params = {'asn': ['64512', '64513']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_asn_id(self):
|
||||
|
@ -2,6 +2,7 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Site
|
||||
from extras.validators import CustomValidator
|
||||
|
||||
@ -66,26 +67,26 @@ custom_validator = MyValidator()
|
||||
|
||||
class CustomValidatorTest(TestCase):
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]})
|
||||
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]})
|
||||
def test_configuration(self):
|
||||
self.assertIn('dcim.site', settings.CUSTOM_VALIDATORS)
|
||||
validator = settings.CUSTOM_VALIDATORS['dcim.site'][0]
|
||||
self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS)
|
||||
validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0]
|
||||
self.assertIsInstance(validator, CustomValidator)
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_validator]})
|
||||
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]})
|
||||
def test_min(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='abcdef123', slug='abcdefghijk', asn=1).clean()
|
||||
Provider(name='Provider 1', slug='provider-1', asn=1).clean()
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_validator]})
|
||||
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]})
|
||||
def test_max(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='abcdef123', slug='abcdefghijk', asn=65535).clean()
|
||||
Provider(name='Provider 1', slug='provider-1', asn=65535).clean()
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]})
|
||||
def test_min_length(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='abc', slug='abc', asn=65000).clean()
|
||||
Site(name='abc', slug='abc').clean()
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [max_length_validator]})
|
||||
def test_max_length(self):
|
||||
|
@ -78,10 +78,6 @@
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">AS Number</th>
|
||||
<td>{{ object.asn|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Time Zone</th>
|
||||
<td>
|
||||
|
@ -5,9 +5,8 @@ from django.test import TestCase
|
||||
from mptt.fields import TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.filtersets import CircuitFilterSet
|
||||
from circuits.models import Circuit, Provider, CircuitType
|
||||
from circuits.filtersets import CircuitFilterSet, ProviderFilterSet
|
||||
from circuits.models import Circuit, Provider
|
||||
from dcim.choices import *
|
||||
from dcim.fields import MACAddressField
|
||||
from dcim.filtersets import DeviceFilterSet, SiteFilterSet
|
||||
@ -337,16 +336,16 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
||||
"""
|
||||
Validate function of automatically generated filters using the Device model as an example.
|
||||
"""
|
||||
device_queryset = Device.objects.all()
|
||||
device_filterset = DeviceFilterSet
|
||||
site_queryset = Site.objects.all()
|
||||
site_filterset = SiteFilterSet
|
||||
circuit_queryset = Circuit.objects.all()
|
||||
circuit_filterset = CircuitFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1', asn=65001),
|
||||
Provider(name='Provider 2', slug='provider-2', asn=65101),
|
||||
Provider(name='Provider 3', slug='provider-3', asn=65201),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
manufacturers = (
|
||||
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
|
||||
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
|
||||
@ -384,9 +383,9 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
||||
region.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001),
|
||||
Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101),
|
||||
Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201),
|
||||
Site(name='Site 1', slug='abc-site-1', region=regions[0]),
|
||||
Site(name='Site 2', slug='def-site-2', region=regions[1]),
|
||||
Site(name='Site 3', slug='ghi-site-3', region=regions[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@ -429,112 +428,112 @@ class DynamicFilterLookupExpressionTest(TestCase):
|
||||
|
||||
def test_site_name_negation(self):
|
||||
params = {'name__n': ['Site 1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_slug_icontains(self):
|
||||
params = {'slug__ic': ['-1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_site_slug_icontains_negation(self):
|
||||
params = {'slug__nic': ['-1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_slug_startswith(self):
|
||||
params = {'slug__isw': ['abc']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_site_slug_startswith_negation(self):
|
||||
params = {'slug__nisw': ['abc']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_slug_endswith(self):
|
||||
params = {'slug__iew': ['-1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_site_slug_endswith_negation(self):
|
||||
params = {'slug__niew': ['-1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_asn_lt(self):
|
||||
def test_provider_asn_lt(self):
|
||||
params = {'asn__lt': [65101]}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1)
|
||||
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_site_asn_lte(self):
|
||||
def test_provider_asn_lte(self):
|
||||
params = {'asn__lte': [65101]}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_asn_gt(self):
|
||||
def test_provider_asn_gt(self):
|
||||
params = {'asn__lt': [65101]}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1)
|
||||
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_site_asn_gte(self):
|
||||
def test_provider_asn_gte(self):
|
||||
params = {'asn__gte': [65101]}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_region_negation(self):
|
||||
params = {'region__n': ['region-1']}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_site_region_id_negation(self):
|
||||
params = {'region_id__n': [Region.objects.first().pk]}
|
||||
self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2)
|
||||
self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_name_eq(self):
|
||||
params = {'name': ['Device 1']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_device_name_negation(self):
|
||||
params = {'name__n': ['Device 1']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_name_startswith(self):
|
||||
params = {'name__isw': ['Device']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 3)
|
||||
|
||||
def test_device_name_startswith_negation(self):
|
||||
params = {'name__nisw': ['Device 1']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_name_endswith(self):
|
||||
params = {'name__iew': [' 1']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_device_name_endswith_negation(self):
|
||||
params = {'name__niew': [' 1']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_name_icontains(self):
|
||||
params = {'name__ic': [' 2']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_device_name_icontains_negation(self):
|
||||
params = {'name__nic': [' ']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 0)
|
||||
|
||||
def test_device_mac_address_negation(self):
|
||||
params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_mac_address_startswith(self):
|
||||
params = {'mac_address__isw': ['aa:']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_device_mac_address_startswith_negation(self):
|
||||
params = {'mac_address__nisw': ['aa:']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_mac_address_endswith(self):
|
||||
params = {'mac_address__iew': [':02']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
||||
def test_device_mac_address_endswith_negation(self):
|
||||
params = {'mac_address__niew': [':02']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_mac_address_icontains(self):
|
||||
params = {'mac_address__ic': ['aa:', 'bb']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2)
|
||||
|
||||
def test_device_mac_address_icontains_negation(self):
|
||||
params = {'mac_address__nic': ['aa:', 'bb']}
|
||||
self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)
|
||||
self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1)
|
||||
|
Reference in New Issue
Block a user