diff --git a/requirements.txt b/requirements.txt index 40f57d6..a1858c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # -# pip-compile requirements.in +# pip-compile --output-file=requirements.txt requirements.in # amqp==5.3.1 # via kombu @@ -31,11 +31,11 @@ automat==25.4.16 # via twisted billiard==4.2.4 # via celery -black==26.3.0 +black==26.3.1 # via -r requirements.in -boto3==1.42.63 +boto3==1.42.68 # via django-storages -botocore==1.42.63 +botocore==1.42.68 # via # boto3 # s3transfer @@ -80,7 +80,7 @@ click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery -coloraide==8.6 +coloraide==8.7 # via -r requirements.in constantly==23.10.4 # via twisted @@ -121,7 +121,7 @@ django-storages[boto3]==1.14.6 # via -r requirements.in django-timezone-field==7.2.1 # via django-celery-beat -django-unfold==0.83.1 +django-unfold==0.84.0 # via -r requirements.in docstring-parser==0.17.0 # via anthropic @@ -131,11 +131,11 @@ execnet==2.1.2 # via pytest-xdist factory-boy==3.3.3 # via -r requirements.in -faker==40.8.0 +faker==40.11.0 # via factory-boy flake8==7.3.0 # via -r requirements.in -fonttools[woff]==4.61.1 +fonttools[woff]==4.62.1 # via weasyprint gunicorn==25.1.0 # via -r requirements.in @@ -311,7 +311,7 @@ tzdata==2025.3 # kombu tzlocal==5.3.1 # via celery -ujson==5.11.0 +ujson==5.12.0 # via autobahn urllib3==2.6.3 # via diff --git a/src/assets/tests/test_views.py b/src/assets/tests/test_views.py index 30af9c6..3253e56 100644 --- a/src/assets/tests/test_views.py +++ b/src/assets/tests/test_views.py @@ -7910,3 +7910,96 @@ def test_missing_q_param_returns_empty_json(self, client_logged_in): resp = client_logged_in.get(url) assert resp.status_code == 200 assert resp.json() == [] + + +@pytest.mark.django_db +class TestLocationCreateInline: + """Tests for the inline location creation AJAX endpoint.""" + + def test_create_new_top_level_location(self, client_logged_in): + """Creating a new top-level location returns success JSON.""" + url = reverse("assets:location_create_inline") + resp = client_logged_in.post( + url, + data=json.dumps({"name": "Test Box 1"}), + content_type="application/json", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] is True + assert data["name"] == "Test Box 1" + assert Location.objects.filter(name="Test Box 1").exists() + + def test_duplicate_top_level_location_returns_existing( + self, client_logged_in + ): + """Creating a location with an existing top-level name returns + the existing location instead of crashing (bug #189).""" + Location.objects.create(name="Skirts long box 1") + url = reverse("assets:location_create_inline") + resp = client_logged_in.post( + url, + data=json.dumps({"name": "Skirts long box 1"}), + content_type="application/json", + ) + # Should NOT return 500 — should return the existing location + assert resp.status_code == 200 + data = resp.json() + assert data["id"] is not None + assert data["name"] == "Skirts long box 1" + # Should not have created a duplicate + assert ( + Location.objects.filter( + name="Skirts long box 1", parent__isnull=True + ).count() + == 1 + ) + + def test_duplicate_child_location_under_same_parent( + self, client_logged_in, location + ): + """Creating a child location with same name under same parent + returns existing.""" + Location.objects.create(name="Sub Box", parent=location) + url = reverse("assets:location_create_inline") + resp = client_logged_in.post( + url, + data=json.dumps({"name": "Sub Box", "parent_id": location.pk}), + content_type="application/json", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["id"] is not None + # Should not have created a duplicate + assert ( + Location.objects.filter(name="Sub Box", parent=location).count() + == 1 + ) + + def test_same_name_different_parent_creates_new( + self, client_logged_in, location + ): + """Same name under different parent should create a new location.""" + Location.objects.create(name="Box A", parent=None) + url = reverse("assets:location_create_inline") + resp = client_logged_in.post( + url, + data=json.dumps({"name": "Box A", "parent_id": location.pk}), + content_type="application/json", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] is True + + def test_created_false_when_existing_returned(self, client_logged_in): + """Response should indicate created=False when returning an + existing location.""" + Location.objects.create(name="Existing Box") + url = reverse("assets:location_create_inline") + resp = client_logged_in.post( + url, + data=json.dumps({"name": "Existing Box"}), + content_type="application/json", + ) + assert resp.status_code == 200 + assert resp.json()["created"] is False diff --git a/src/assets/views.py b/src/assets/views.py index 929ed1b..d34c4c5 100644 --- a/src/assets/views.py +++ b/src/assets/views.py @@ -3700,8 +3700,8 @@ def location_create_inline(request): return JsonResponse( {"error": "Invalid parent location"}, status=400 ) - loc = Location.objects.create(name=name, parent=parent) - return JsonResponse({"id": loc.id, "name": str(loc), "created": True}) + loc, created = Location.objects.get_or_create(name=name, parent=parent) + return JsonResponse({"id": loc.id, "name": str(loc), "created": created}) # --- Stocktake ---