Skip to content

Commit 08e227d

Browse files
authored
Django 5 support. (#537)
Django 5 support. * Add python 3.12 to github actions. * Drop python 3.7 support due to djangorestframework and django-auth-ldap having dropped support. * Drop Django 3 support due to django-filter 24.* needing 4.2+. * Add tzdada dependency (Needed for docker unit tests) * Clean up implementation of manually finding conflicts from requests via get_object_from_request. * "Fix" and test filtering for HostFilterSet. :( Note: This relies on django-rest-framework 3.14.0 and not 3.15.1 (which has formal Django 5 support)... This is due to changes in 3.15.* with regards to unique_together in models. For us, that hits Ipaddress when creating a host, leading to, where we an error as follows (this is reported as part of encode/django-rest-framework#9358). ```python ERROR django.request:log.py:241 Internal Server Error: /api/v1/hosts/ Traceback (most recent call last): File ".../projects/uio/mreg/env/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner response = get_response(request) ^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response response = wrapped_callback(request, *callback_args, **callback_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/django/views/decorators/csrf.py", line 65, in _view_wrapper return view_func(request, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view return self.dispatch(request, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/views.py", line 509, in dispatch response = self.handle_exception(exc) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/views.py", line 469, in handle_exception self.raise_uncaught_exception(exc) File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception raise exc File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/views.py", line 506, in dispatch response = handler(request, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/mreg/api/v1/views.py", line 354, in post if ipserializer.is_valid(): ^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/serializers.py", line 223, in is_valid self._validated_data = self.run_validation(self.initial_data) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/serializers.py", line 444, in run_validation self.run_validators(value) File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/serializers.py", line 477, in run_validators super().run_validators(to_validate) File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/fields.py", line 553, in run_validators validator(value, self) File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/validators.py", line 169, in __call__ checked_values = [ ^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/rest_framework/validators.py", line 172, in <listcomp> if field in self.fields and value != getattr(serializer.instance, field) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File ".../projects/uio/mreg/env/lib/python3.11/site-packages/django/db/models/fields/related_descriptors.py", line 264, in __get__ raise self.RelatedObjectDoesNotExist( mreg.models.host.Ipaddress.host.RelatedObjectDoesNotExist: Ipaddress has no host. ```
1 parent b497579 commit 08e227d

File tree

14 files changed

+186
-75
lines changed

14 files changed

+186
-75
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ jobs:
3232
matrix:
3333
os: [ubuntu-latest]
3434
python-version:
35-
- "3.7"
3635
- "3.8"
3736
- "3.9"
3837
- "3.10"
3938
- "3.11"
39+
- "3.12"
4040
steps:
4141
- name: Checkout
4242
uses: actions/checkout@v3
@@ -90,11 +90,11 @@ jobs:
9090
matrix:
9191
os: [ubuntu-latest]
9292
python-version:
93-
- "3.7"
9493
- "3.8"
9594
- "3.9"
9695
- "3.10"
9796
- "3.11"
97+
- "3.12"
9898
steps:
9999
- name: Checkout
100100
uses: actions/checkout@v3

hostpolicy/api/v1/views.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class HostPolicyPermissionsUpdateDestroy(M2MPermissions,
6666
permission_classes = (IsSuperOrHostPolicyAdminOrReadOnly, )
6767

6868

69-
class HostPolicyAtomList(HostPolicyAtomLogMixin, MregListCreateAPIView):
69+
class HostPolicyAtomList(HostPolicyAtomLogMixin, LowerCaseLookupMixin, MregListCreateAPIView):
7070

7171
queryset = HostPolicyAtom.objects.all()
7272
serializer_class = serializers.HostPolicyAtomSerializer
@@ -75,11 +75,9 @@ class HostPolicyAtomList(HostPolicyAtomLogMixin, MregListCreateAPIView):
7575
filterset_class = HostPolicyAtomFilterSet
7676

7777
def post(self, request, *args, **kwargs):
78-
if "name" in request.data:
79-
# Due to the overriding of get_queryset, we need to manually use lower()
80-
if self.get_queryset().filter(name=request.data['name'].lower()).exists():
81-
content = {'ERROR': 'name already in use'}
82-
return Response(content, status=status.HTTP_409_CONFLICT)
78+
if self.get_object_from_request(request):
79+
content = {"ERROR": "name already in use"}
80+
return Response(content, status=status.HTTP_409_CONFLICT)
8381

8482
return super().post(request, *args, **kwargs)
8583

@@ -99,7 +97,7 @@ def _role_prefetcher(qs):
9997
'atoms', queryset=HostPolicyAtom.objects.order_by('name')))
10098

10199

102-
class HostPolicyRoleList(HostPolicyRoleLogMixin, MregListCreateAPIView):
100+
class HostPolicyRoleList(HostPolicyRoleLogMixin, LowerCaseLookupMixin, MregListCreateAPIView):
103101

104102
queryset = HostPolicyRole.objects.all()
105103
serializer_class = serializers.HostPolicyRoleSerializer
@@ -108,11 +106,9 @@ class HostPolicyRoleList(HostPolicyRoleLogMixin, MregListCreateAPIView):
108106
filterset_class = HostPolicyRoleFilterSet
109107

110108
def post(self, request, *args, **kwargs):
111-
if "name" in request.data:
112-
# Due to the overriding of get_queryset, we need to manually use lower()
113-
if self.get_queryset().filter(name=request.data['name'].lower()).exists():
114-
content = {'ERROR': 'name already in use'}
115-
return Response(content, status=status.HTTP_409_CONFLICT)
109+
if self.get_object_from_request(request):
110+
content = {"ERROR": "name already in use"}
111+
return Response(content, status=status.HTTP_409_CONFLICT)
116112
return super().post(request, *args, **kwargs)
117113

118114

mreg/api/v1/filters.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ class Meta:
6565

6666

6767
class HostFilterSet(filters.FilterSet):
68+
69+
# It's weird that we have to define the id field here, but it's necessary for the filters to work.
70+
id = filters.NumberFilter(field_name="id")
71+
id__in = filters.BaseInFilter(field_name="id")
72+
id__gt = filters.NumberFilter(field_name="id", lookup_expr="gt")
73+
id__lt = filters.NumberFilter(field_name="id", lookup_expr="lt")
6874
class Meta:
6975
model = Host
7076
fields = "__all__"

mreg/api/v1/tests/test_labels.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,10 @@ def test_change_label_name(self):
4040
response = self.assert_get('/api/v1/labels/')
4141
data = response.json()
4242
self.assertEqual("newname", data['results'][0]['name'])
43+
44+
def test_label_name_case_insensitive(self):
45+
"""Test that label names are case insensitive."""
46+
self.assert_post('/api/v1/labels/', {'name': 'case_insensitive', 'description': 'Case insensitive'})
47+
self.assert_post_and_409('/api/v1/labels/', {'name': 'CASE_INSENSITIVE', 'description': 'Case insensitive'})
48+
self.assert_get_and_200('/api/v1/labels/name/case_insensitive')
49+
self.assert_get_and_200('/api/v1/labels/name/CASE_INSENSITIVE')

mreg/api/v1/tests/tests.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,38 @@ def setUp(self):
474474
clean_and_save(self.host_one)
475475
clean_and_save(self.host_two)
476476

477+
def _one_hit_and_host_one(self, query: str):
478+
"""Check that we only have one hit and it is host_one"""
479+
response = self.assert_get(f"/hosts/?{query}")
480+
hits = response.json()['results']
481+
self.assertEqual(len(hits), 1)
482+
self.assertEqual(hits[0]['name'], self.host_one.name)
483+
477484
def test_hosts_get_200_ok(self):
478485
""""Getting an existing entry should return 200"""
479-
self.assert_get('/hosts/%s' % self.host_one.name)
486+
self.assert_get('/hosts/%s' % self.host_one.name)
487+
488+
def test_host_get_200_ok_by_id(self):
489+
"""Getting an existing entry by id should return 200"""
490+
self._one_hit_and_host_one(f"id={self.host_one.id}")
491+
492+
def test_host_get_200_ok_by_id_gt_and_lt(self):
493+
"""Getting an existing entry by id should return 200"""
494+
id = self.host_one.id
495+
(id_after, id_before) = (id + 1, id - 1)
496+
self._one_hit_and_host_one(f"id__gt={id_before}&id__lt={id_after}")
497+
498+
def test_host_get_200_ok_by_id_in(self):
499+
"""Getting an existing entry by id should return 200"""
500+
self._one_hit_and_host_one(f"id__in={self.host_one.id}")
501+
502+
def test_host_get_200_ok_by_contact(self):
503+
"""Getting an existing entry by ip should return 200"""
504+
self._one_hit_and_host_one(f"contact={self.host_one.contact}")
505+
506+
def test_host_get_200_ok_by_name(self):
507+
"""Getting an existing entry by name should return 200"""
508+
self._one_hit_and_host_one(f"name={self.host_one.name}")
480509

481510
def test_hosts_get_case_insensitive_200_ok(self):
482511
""""Getting an existing entry should return 200"""

mreg/api/v1/tests/tests_bacnet.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ def test_post_no_id_201_created(self):
5252
response = self.assert_post(self.basepath, post_data)
5353
response = self.assert_get(response['Location'])
5454
self.assertIn('id', response.data)
55-
self.assertEquals(response.data['host'], self.host_two.id)
55+
self.assertEqual(response.data['host'], self.host_two.id)
5656

5757
def test_post_with_hostname_instead_of_id(self):
5858
post_data = {'hostname': self.host_two.name}
5959
response = self.assert_post(self.basepath, post_data)
6060
response = self.assert_get(response['Location'])
6161
self.assertIn('id', response.data)
62-
self.assertEquals(response.data['host'], self.host_two.id)
62+
self.assertEqual(response.data['host'], self.host_two.id)
6363

6464
def test_post_without_host_400(self):
6565
"""Posting a new entry without specifying a host should return 400 bad request"""

mreg/api/v1/views.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,13 @@ def post(self, request, *args, **kwargs):
343343
ipdata = {"host": host.pk, "ipaddress": ipkey}
344344
ip = Ipaddress()
345345
ipserializer = IpaddressSerializer(ip, data=ipdata)
346-
if ipserializer.is_valid(raise_exception=True):
347-
self.perform_create(ipserializer)
348-
location = request.path + host.name
349-
return Response(
350-
status=status.HTTP_201_CREATED,
351-
headers={"Location": location},
352-
)
346+
ipserializer.is_valid(raise_exception=True)
347+
self.perform_create(ipserializer)
348+
location = request.path + host.name
349+
return Response(
350+
status=status.HTTP_201_CREATED,
351+
headers={"Location": location},
352+
)
353353
else:
354354
host = Host()
355355
hostserializer = HostSerializer(host, data=hostdata)

mreg/api/v1/views_hostgroups.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def _hostgroup_prefetcher(qs):
6363
'owners', queryset=Group.objects.order_by('name')))
6464

6565

66-
class HostGroupList(HostGroupLogMixin, MregListCreateAPIView):
66+
class HostGroupList(HostGroupLogMixin, LowerCaseLookupMixin, MregListCreateAPIView):
6767
"""
6868
get:
6969
Lists all hostgroups in use.
@@ -76,14 +76,12 @@ class HostGroupList(HostGroupLogMixin, MregListCreateAPIView):
7676
serializer_class = serializers.HostGroupSerializer
7777
permission_classes = (IsSuperOrGroupAdminOrReadOnly, )
7878
filterset_class = HostGroupFilterSet
79+
lookup_field = 'name'
7980

8081
def post(self, request, *args, **kwargs):
81-
if "name" in request.data:
82-
# We need to manually use lower() here due to the overriden get_queryset()
83-
if self.get_queryset().filter(name=request.data['name'].lower()).exists():
84-
content = {'ERROR': 'hostgroup name already in use'}
85-
return Response(content, status=status.HTTP_409_CONFLICT)
86-
self.lookup_field = 'name'
82+
if self.get_object_from_request(request):
83+
content = {'ERROR': 'hostgroup name already in use'}
84+
return Response(content, status=status.HTTP_409_CONFLICT)
8785
return super().post(request, *args, **kwargs)
8886

8987

mreg/api/v1/views_labels.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,17 @@
1111
from .filters import LabelFilterSet
1212

1313

14-
class LabelList(MregListCreateAPIView):
14+
class LabelList(MregListCreateAPIView, LowerCaseLookupMixin):
1515
queryset = Label.objects.all()
1616
serializer_class = serializers.LabelSerializer
1717
permission_classes = (IsSuperOrAdminOrReadOnly,)
1818
filterset_class = LabelFilterSet
19+
lookup_field = "name"
1920

20-
def post(self, request, *args, **kwargs):
21-
if "name" in request.data:
22-
if self.get_queryset().filter(name=request.data["name"]).exists():
23-
content = {"ERROR": "Label name already in use"}
24-
return Response(content, status=status.HTTP_409_CONFLICT)
25-
self.lookup_field = "name"
21+
def post(self, request, *args, **kwargs):
22+
if self.get_object_from_request(request):
23+
content = {"ERROR": "Label name already in use"}
24+
return Response(content, status=status.HTTP_409_CONFLICT)
2625
return super().post(request, *args, **kwargs)
2726

2827

@@ -43,8 +42,9 @@ class LabelDetail(LowerCaseLookupMixin, MregRetrieveUpdateDestroyAPIView):
4342
permission_classes = (IsSuperOrAdminOrReadOnly,)
4443

4544

46-
class LabelDetailByName(MregRetrieveUpdateDestroyAPIView):
45+
class LabelDetailByName(LowerCaseLookupMixin, MregRetrieveUpdateDestroyAPIView):
4746
queryset = Label.objects.all()
4847
serializer_class = serializers.LabelSerializer
4948
permission_classes = (IsSuperOrAdminOrReadOnly,)
49+
filterset_class = LabelFilterSet
5050
lookup_field = "name"

mreg/managers.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
2+
from typing import Any, Dict, Type
13
from django.db import models
24

35
from .fields import LowerCaseCharField
46

57

6-
class LowerCaseManager(models.Manager):
8+
class LowerCaseManager(models.Manager[Any]):
79
"""A manager that lowercases all values of LowerCaseCharFields in filter/exclude/get calls."""
810

911
@property
1012
def lowercase_fields(self):
13+
"""A list of field names that are LowerCaseCharFields.
14+
15+
Note: This is a cached property to avoid recalculating the list every time it is accessed.
16+
We are making the assumption that the model's fields do not change during runtime...
17+
"""
18+
1119
if not hasattr(self, "_lowercase_fields_cache"):
1220
self._lowercase_fields_cache = [
1321
field.name
@@ -16,27 +24,35 @@ def lowercase_fields(self):
1624
]
1725
return self._lowercase_fields_cache
1826

19-
def _lowercase_fields(self, **kwargs):
20-
lower_kwargs = {}
27+
def _lowercase_fields(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
28+
"""Lowercase all values of LowerCaseCharFields in kwargs."""
29+
30+
lower_kwargs: Dict[str, Any] = {}
2131
for key, value in kwargs.items():
2232
field_name = key.split("__")[0]
2333
if field_name in self.lowercase_fields and isinstance(value, str):
2434
value = value.lower()
2535
lower_kwargs[key] = value
2636
return lower_kwargs
2737

28-
def filter(self, **kwargs):
38+
def filter(self, **kwargs: Dict[str, Any]):
39+
"""Lowercase all values of LowerCaseCharFields in kwargs during filtering."""
2940
return super().filter(**self._lowercase_fields(**kwargs))
3041

31-
def exclude(self, **kwargs):
42+
def exclude(self, **kwargs: Dict[str, Any]):
43+
"""Lowercase all values of LowerCaseCharFields in kwargs during excluding."""
3244
return super().exclude(**self._lowercase_fields(**kwargs))
3345

34-
def get(self, **kwargs):
46+
def get(self, **kwargs: Dict[str, Any]):
47+
"""Lowercase all values of LowerCaseCharFields in kwargs during get."""
3548
return super().get(**self._lowercase_fields(**kwargs))
3649

3750

38-
def lower_case_manager_factory(base_manager):
51+
def lower_case_manager_factory(base_manager: Type[models.Manager[Any]]):
52+
"""A factory function to create a LowerCaseManager for a given base_manager."""
53+
3954
class LowerCaseBaseManager(base_manager, LowerCaseManager):
55+
"""A manager that lowercases all values of LowerCaseCharFields in filter/exclude/get calls."""
4056
pass
4157

4258
return LowerCaseBaseManager

0 commit comments

Comments
 (0)