Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions backend/.flake8
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
[flake8]
max-line-length = 127
exclude =
.git,
__pycache__,
*/migrations/*,
*/venv/*,
*/env/*,
exclude =
venv,
.venv,
env,
.env,
__pycache__,
*/migrations/*,
build,
dist
ignore = E203, E266, W503
max-complexity = 10
dist,
.git
1 change: 1 addition & 0 deletions backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
path("admin/", admin.site.urls),
path("api/auth/", include("users.urls_auth")),
path("api/users/", include("users.urls_user")),
path("api/boxes/", include("inventory.urls_boxes")),
path("api/inventory/", include("inventory.urls")),
path("api/movements/", include("movements.urls")),
]
Expand Down
2 changes: 1 addition & 1 deletion backend/inventory/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class BoxAdmin(admin.ModelAdmin):

list_display = ["box_code", "label", "location", "created_at"]
list_filter = ["location", "created_at"]
search_fields = ["box_code", "label"]
search_fields = ["box_code", "label", "description"]
readonly_fields = ["created_at", "updated_at"]


Expand Down
18 changes: 18 additions & 0 deletions backend/inventory/migrations/0003_box_description.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-02-07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("inventory", "0002_initial"),
]

operations = [
migrations.AddField(
model_name="box",
name="description",
field=models.TextField(blank=True),
),
]
1 change: 1 addition & 0 deletions backend/inventory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class Box(models.Model):

box_code = models.CharField(max_length=100, unique=True, help_text="Scannable code")
label = models.CharField(max_length=255, blank=True, help_text="Human-friendly label")
description = models.TextField(blank=True)
location = models.ForeignKey(Location, on_delete=models.PROTECT, related_name="boxes")

created_at = models.DateTimeField(auto_now_add=True)
Expand Down
73 changes: 70 additions & 3 deletions backend/inventory/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator

from .models import CollectionItem, Location
from .models import Box, CollectionItem, Location


class LocationSerializer(serializers.ModelSerializer):
Expand All @@ -15,7 +14,73 @@ class LocationSerializer(serializers.ModelSerializer):
class Meta:
model = Location
fields = ["id", "name", "location_type", "location_type_display", "description"]
read_only_fields = ["id", "name", "location_type", "location_type_display", "description"]
read_only_fields = [
"id",
"name",
"location_type",
"location_type_display",
"description",
]


class BoxSerializer(serializers.ModelSerializer):
class Meta:
model = Box
fields = ["id", "box_code", "label", "description", "location"]
read_only_fields = ["id"]


class CollectionItemSummarySerializer(serializers.ModelSerializer):
class Meta:
model = CollectionItem
fields = ["id", "item_code", "title", "platform"]
read_only_fields = ["id", "item_code", "title", "platform"]


class BoxDetailSerializer(serializers.ModelSerializer):
items = CollectionItemSummarySerializer(many=True, read_only=True)

class Meta:
model = Box
fields = ["id", "box_code", "label", "description", "location", "items"]
read_only_fields = [
"id",
"box_code",
"label",
"description",
"location",
"items",
]


class CollectionItemSerializer(serializers.ModelSerializer):
class Meta:
model = CollectionItem
fields = [
"id",
"item_code",
"title",
"platform",
"description",
"box",
"current_location",
"is_public_visible",
"is_on_floor",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"item_code",
"title",
"platform",
"description",
"current_location",
"is_public_visible",
"is_on_floor",
"created_at",
"updated_at",
]


class PublicCollectionItemSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -74,6 +139,8 @@ class Meta:
"current_location",
"is_public_visible",
"is_on_floor",
"box",
"current_location",
]
read_only_fields = ["id"]
extra_kwargs = {
Expand Down
76 changes: 74 additions & 2 deletions backend/inventory/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import pytest

from django.test import TestCase, Client
from rest_framework_simplejwt.tokens import AccessToken
from rest_framework import status
from inventory.models import CollectionItem, Location, ItemHistory
from rest_framework_simplejwt.tokens import AccessToken
from inventory.models import Box, CollectionItem, Location, ItemHistory
from django.utils import timezone
from datetime import timedelta
from inventory.utils import get_current_location
Expand Down Expand Up @@ -77,7 +79,14 @@ def hidden_item(floor_location):


@pytest.fixture
def test_data(client, floor_location, storage_location, public_item_snes, public_item_ps2, hidden_item):
def test_data(
client,
floor_location,
storage_location,
public_item_snes,
public_item_ps2,
hidden_item,
):
"""Fixture that creates all test data and returns a dict with everything."""
return {
"client": client,
Expand Down Expand Up @@ -978,3 +987,66 @@ def test_command_single_item(self):

output = out.getvalue()
self.assertIn(f"Updated item {self.item.id}", output)


class BoxEndpointsTest(TestCase):
"""Test box list/detail endpoints and item box assignment."""

def setUp(self):
self.user = User.objects.create_user(
email="boxuser@example.com",
name="Box User",
password="testpass",
role="VOLUNTEER",
)
token = AccessToken.for_user(self.user)
self.client = Client(HTTP_AUTHORIZATION=f"Bearer {token}")
self.location = Location.objects.create(name="Storage Z", location_type="STORAGE")
self.box_a = Box.objects.create(
box_code="BOX001",
label="Box A",
description="First box",
location=self.location,
)
self.box_b = Box.objects.create(box_code="BOX002", label="Box B", description="", location=self.location)
self.item_a = CollectionItem.objects.create(
item_code="ITEM001",
title="Item One",
current_location=self.location,
box=self.box_a,
)
self.item_b = CollectionItem.objects.create(
item_code="ITEM002",
title="Item Two",
current_location=self.location,
)

def test_list_boxes(self):
response = self.client.get("/api/boxes/")

assert response.status_code == status.HTTP_200_OK
data = json.loads(response.content)
results = data.get("results", data)
box_codes = [box["box_code"] for box in results]
assert "BOX001" in box_codes
assert "BOX002" in box_codes

def test_box_detail_includes_items(self):
response = self.client.get(f"/api/boxes/{self.box_a.id}/")

assert response.status_code == status.HTTP_200_OK
data = json.loads(response.content)
item_codes = [item["item_code"] for item in data["items"]]
assert "ITEM001" in item_codes
assert "ITEM002" not in item_codes

def test_patch_item_box_updates_box_id(self):
response = self.client.patch(
f"/api/inventory/items/{self.item_b.id}/",
data=json.dumps({"box": self.box_b.id}),
content_type="application/json",
)

assert response.status_code == status.HTTP_200_OK
self.item_b.refresh_from_db()
assert self.item_b.box_id == self.box_b.id
13 changes: 10 additions & 3 deletions backend/inventory/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import PublicCollectionItemViewSet, AdminCollectionItemViewSet
from .views import (
CollectionItemViewSet,
PublicCollectionItemViewSet,
AdminCollectionItemViewSet,
)

# from .views import InventoryItemViewSet

Expand Down Expand Up @@ -29,9 +33,12 @@
# DELETE /api/inventory/items/{id}/ - Soft delete (admin only)

router = DefaultRouter()
router.register(r"public/items", PublicCollectionItemViewSet, basename="public-item")
router.register(r"items", AdminCollectionItemViewSet, basename="admin-item")
router.register(r"items", CollectionItemViewSet, basename="item")

public_router = DefaultRouter()
public_router.register(r"items", PublicCollectionItemViewSet, basename="public-item")

urlpatterns = [
path("", include(router.urls)),
path("public/", include(public_router.urls)),
]
11 changes: 11 additions & 0 deletions backend/inventory/urls_boxes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter

from .views import BoxViewSet

router = DefaultRouter()
router.register(r"", BoxViewSet, basename="box")

urlpatterns = [
path("", include(router.urls)),
]
2 changes: 1 addition & 1 deletion backend/inventory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Utility functions for inventory management.
"""

from .models import ItemHistory, Location
from .models import ItemHistory
from .constants import LOCATION_CHANGING_EVENTS


Expand Down
53 changes: 51 additions & 2 deletions backend/inventory/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,57 @@
from rest_framework.response import Response
from users.permissions import IsAdmin, IsVolunteer

from .models import CollectionItem
from .serializers import PublicCollectionItemSerializer, AdminCollectionItemSerializer
from users.permissions import IsVolunteer
from .models import Box, CollectionItem
from .serializers import (
BoxDetailSerializer,
BoxSerializer,
CollectionItemSerializer,
PublicCollectionItemSerializer,
AdminCollectionItemSerializer,
)


class CollectionItemViewSet(viewsets.ModelViewSet):
"""
Internal ViewSet for collection items.
Supports update of item box assignment via PATCH.
"""

queryset = CollectionItem.objects.all().select_related("box", "current_location")

def get_serializer_class(self):
if self.action in ["create", "update", "partial_update"]:
return AdminCollectionItemSerializer
return CollectionItemSerializer

def get_permissions(self):
# Match the Admin view: only Admins should be able to trigger 'destroy'
if self.action == "destroy":
return [IsAdmin()]
return [IsVolunteer()]

def destroy(self, request, *args, **kwargs):
instance = self.get_object()
instance.is_public_visible = False
instance.save(update_fields=["is_public_visible", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)


class BoxViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for boxes.
- GET /api/boxes/ - List all boxes
- GET /api/boxes/{id}/ - Retrieve box with items
"""

queryset = Box.objects.all().prefetch_related("items")
permission_classes = [IsVolunteer]

def get_serializer_class(self):
if self.action == "retrieve":
return BoxDetailSerializer
return BoxSerializer


class PublicCollectionItemViewSet(viewsets.ReadOnlyModelViewSet):
Expand Down
3 changes: 2 additions & 1 deletion backend/users/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def process_request(self, request):
if request.user.is_authenticated:
if not request.user.has_active_access():
return JsonResponse(
{"detail": "Your access has expired or been deactivated. Please contact an administrator."}, status=403
{"detail": "Your access has expired or been deactivated. Please contact an administrator."},
status=403,
)

return None
18 changes: 16 additions & 2 deletions backend/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,22 @@ class Meta:
model = User
# Changed 'date_joined' to 'created_at'
# Also added 'role' so frontend can see if ADMIN or VOLUNTEER
fields = ["id", "name", "email", "created_at", "role", "is_active", "access_expires_at"]
read_only_fields = ["id", "created_at", "role", "is_active", "access_expires_at"]
fields = [
"id",
"name",
"email",
"created_at",
"role",
"is_active",
"access_expires_at",
]
read_only_fields = [
"id",
"created_at",
"role",
"is_active",
"access_expires_at",
]


class UserRegistrationSerializer(serializers.ModelSerializer):
Expand Down
Loading