diff --git a/backend/backend/urls.py b/backend/backend/urls.py index a3d79c9..5e94b5e 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('auth/', include('authentication.api')), path('admin/', include('hostadmin.api')), path('api/', include('toolshed.api.friend')), + path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.info')), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'), ] diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py new file mode 100644 index 0000000..3617ad2 --- /dev/null +++ b/backend/toolshed/api/inventory.py @@ -0,0 +1,63 @@ +from django.urls import path +from rest_framework import routers, viewsets +from rest_framework.decorators import authentication_classes, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from authentication.models import ToolshedUser +from authentication.signature_auth import SignatureAuthentication +from toolshed.models import InventoryItem +from toolshed.serializers import InventoryItemSerializer + +router = routers.SimpleRouter() + + +def inventory_items(identity): + try: + user = identity.user.get() + if user: + for item in user.inventory_items.all(): + yield item + except ToolshedUser.DoesNotExist: + pass + for friend in identity.friends.all(): + if friend_user := friend.user.get(): + for item in friend_user.inventory_items.all(): + yield item + + +class InventoryItemViewSet(viewsets.ModelViewSet): + serializer_class = InventoryItemSerializer + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return InventoryItem.objects.filter(owner=self.request.user.user.get()) + + def perform_create(self, serializer): + serializer.save(owner=self.request.user.user.get()) + + def perform_update(self, serializer): + if serializer.instance.owner == self.request.user.user.get(): + serializer.save() + + def perform_destroy(self, instance): + if instance.owner == self.request.user.user.get(): + instance.delete() + + +@api_view(['GET']) +@authentication_classes([SignatureAuthentication]) +@permission_classes([IsAuthenticated]) +def search_inventory_items(request): + query = request.query_params.get('query') + if query: + return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data) + return Response({'error': 'No query provided.'}, status=400) + + +router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') + +urlpatterns = router.urls + [ + path('search/', search_inventory_items, name='search_inventory_items'), +] diff --git a/backend/toolshed/migrations/0002_inventoryitem_itemtag_itemproperty_and_more.py b/backend/toolshed/migrations/0002_inventoryitem_itemtag_itemproperty_and_more.py new file mode 100644 index 0000000..4a9ba98 --- /dev/null +++ b/backend/toolshed/migrations/0002_inventoryitem_itemtag_itemproperty_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.2 on 2023-06-22 02:37 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('toolshed', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('published', models.BooleanField(default=False)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField()), + ('availability_policy', models.CharField(max_length=255)), + ('owned_quantity', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(0)])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='toolshed.category')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ItemTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('inventory_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='toolshed.inventoryitem')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='toolshed.tag')), + ], + ), + migrations.CreateModel( + name='ItemProperty', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(max_length=255)), + ('inventory_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='toolshed.inventoryitem')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='toolshed.property')), + ], + ), + migrations.AddField( + model_name='inventoryitem', + name='properties', + field=models.ManyToManyField(through='toolshed.ItemProperty', to='toolshed.property'), + ), + migrations.AddField( + model_name='inventoryitem', + name='tags', + field=models.ManyToManyField(related_name='inventory_items', through='toolshed.ItemTag', to='toolshed.tag'), + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 2810685..d3a4968 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -45,3 +45,28 @@ class Tag(models.Model): def __str__(self): return self.name + + +class InventoryItem(SoftDeleteModel): + published = models.BooleanField(default=False) + name = models.CharField(max_length=255) + description = models.TextField() + category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, + related_name='inventory_items') + availability_policy = models.CharField(max_length=255) + owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)]) + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items') + created_at = models.DateTimeField(auto_now_add=True) + tags = models.ManyToManyField('Tag', through='ItemTag', related_name='inventory_items') + properties = models.ManyToManyField('Property', through='ItemProperty') + + +class ItemProperty(models.Model): + property = models.ForeignKey(Property, on_delete=models.CASCADE) + inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) + value = models.CharField(max_length=255) + + +class ItemTag(models.Model): + tag = models.ForeignKey(Tag, on_delete=models.CASCADE) + inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 8e3de5d..41bd40a 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from authentication.models import KnownIdentity -from toolshed.models import Category, Property +from authentication.models import KnownIdentity, ToolshedUser +from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag class FriendSerializer(serializers.ModelSerializer): @@ -29,3 +29,69 @@ class CategorySerializer(serializers.ModelSerializer): def to_representation(self, instance): return str(instance) + + def to_internal_value(self, data): + return Category.objects.get(name=data.split("/")[-1]) + + +class InventoryItemOwnerSerializer(serializers.ReadOnlyField): + class Meta: + model = ToolshedUser + fields = '__all__' + + def to_representation(self, value): + return value.username + '@' + value.domain + + +class ItemPropertySerializer(serializers.ModelSerializer): + property = PropertySerializer(read_only=True) + + class Meta: + model = ItemProperty + fields = ['property', 'value'] + + def to_representation(self, instance): + return {'value': instance.value, 'name': instance.property.name} + + def to_internal_value(self, data): + prop = Property.objects.get(name=data['name']) + value = data['value'] + return {'property': prop, 'value': value} + + +class InventoryItemSerializer(serializers.ModelSerializer): + owner = InventoryItemOwnerSerializer(read_only=True) + tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name') + properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') + category = CategorySerializer(required=False, allow_null=True) + + class Meta: + model = InventoryItem + fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner', + 'tags', 'properties'] + + def create(self, validated_data): + tags = validated_data.pop('tags', []) + props = validated_data.pop('itemproperty_set', []) + item = InventoryItem.objects.create(**validated_data) + for tag in tags: + item.tags.add(tag, through_defaults={}) + for prop in props: + ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value']) + item.save() + return item + + def update(self, instance, validated_data): + tags = validated_data.pop('tags', []) + props = validated_data.pop('itemproperty_set', []) + item = super().update(instance, validated_data) + item.tags.clear() + item.properties.clear() + if 'category' not in validated_data: + item.category = None + for tag in tags: + item.tags.add(tag) + for prop in props: + ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value']) + item.save() + return item diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py new file mode 100644 index 0000000..98555e5 --- /dev/null +++ b/backend/toolshed/tests/test_inventory.py @@ -0,0 +1,229 @@ +from authentication.models import ToolshedUser +from authentication.tests import UserTestCase, SignatureAuthClient +from toolshed.models import InventoryItem, Tag, Property, ItemProperty, Category + +client = SignatureAuthClient() + + +class InventoryTestCase(UserTestCase): + + def setUp(self): + super().setUp() + self.user1 = ToolshedUser.objects.get(username="testuser") + self.user2 = ToolshedUser.objects.get(username="testuser2") + self.user1.friends.add(self.user2.public_identity) + self.user2.friends.add(self.user1.public_identity) + self.cat1 = Category.objects.create(name='cat1') + self.cat1.save() + self.cat2 = Category.objects.create(name='cat2') + self.cat2.save() + self.tag1 = Tag.objects.create(name='tag1', category=self.cat1) + self.tag1.save() + self.tag2 = Tag.objects.create(name='tag2', category=self.cat1) + self.tag2.save() + self.tag3 = Tag.objects.create(name='tag3') + self.tag3.save() + self.prop1 = Property.objects.create(name='prop1') + self.prop1.save() + self.prop2 = Property.objects.create(name='prop2') + self.prop2.save() + self.prop3 = Property.objects.create(name='prop3', category=self.cat1) + self.prop3.save() + + InventoryItem.objects.create(owner=self.user1, owned_quantity=1, name='test1', description='test', + category=self.cat1, availability_policy='friends').save() + item2 = InventoryItem.objects.create(owner=self.user1, owned_quantity=1, name='test2', description='test2', + category=self.cat1, availability_policy='friends') + item2.save() + item2.tags.add(self.tag1, through_defaults={}) + item2.tags.add(self.tag2, through_defaults={}) + ItemProperty.objects.create(inventory_item=item2, property=self.prop1, value='value1').save() + ItemProperty.objects.create(inventory_item=item2, property=self.prop2, value='value2').save() + + def test_get_inventory(self): + reply = client.get('/api/inventory_items/', self.user1) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 2) + self.assertEqual(reply.json()[0]['name'], 'test1') + self.assertEqual(reply.json()[0]['description'], 'test') + self.assertEqual(reply.json()[0]['owned_quantity'], 1) + self.assertEqual(reply.json()[0]['tags'], []) + self.assertEqual(reply.json()[0]['properties'], []) + self.assertEqual(reply.json()[0]['category'], 'cat1') + self.assertEqual(reply.json()[0]['availability_policy'], 'friends') + self.assertEqual(reply.json()[1]['name'], 'test2') + self.assertEqual(reply.json()[1]['description'], 'test2') + self.assertEqual(reply.json()[1]['owned_quantity'], 1) + self.assertEqual(reply.json()[1]['tags'], ['tag1', 'tag2']) + self.assertEqual(reply.json()[1]['properties'], + [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}]) + self.assertEqual(reply.json()[1]['category'], 'cat1') + self.assertEqual(reply.json()[1]['availability_policy'], 'friends') + + def test_post_new_item(self): + reply = client.post('/api/inventory_items/', self.user1, { + 'availability_policy': 'friends', + 'category': 'cat2', + 'name': 'test3', + 'description': 'test', + 'owned_quantity': 1, + 'image': '', + 'tags': ['tag1', 'tag2'], + 'properties': [{'name': 'prop1', 'value': 'value3'}, {'name': 'prop2', 'value': 'value4'}] + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(InventoryItem.objects.count(), 3) + item = InventoryItem.objects.get(name='test3') + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, Category.objects.get(name='cat2')) + self.assertEqual(item.name, 'test3') + self.assertEqual(item.description, 'test') + self.assertEqual(item.owned_quantity, 1) + self.assertEqual([t for t in item.tags.all()], [self.tag1, self.tag2]) + self.assertEqual([p for p in item.properties.all()], [self.prop1, self.prop2]) + self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value3', 'value4']) + + def test_post_new_item2(self): + reply = client.post('/api/inventory_items/', self.user1, { + 'availability_policy': 'friends', + 'name': 'test3', + 'description': 'test', + 'owned_quantity': 1, + 'image': '', + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(InventoryItem.objects.count(), 3) + item = InventoryItem.objects.get(name='test3') + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, None) + self.assertEqual(item.name, 'test3') + self.assertEqual(item.description, 'test') + self.assertEqual(item.owned_quantity, 1) + self.assertEqual([t for t in item.tags.all()], []) + self.assertEqual([p for p in item.properties.all()], []) + + def test_post_new_item3(self): + reply = client.post('/api/inventory_items/', self.user1, { + 'availability_policy': 'friends', + 'name': 'test3', + 'description': 'test', + 'owned_quantity': 1, + 'image': '', + 'category': None, + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(InventoryItem.objects.count(), 3) + item = InventoryItem.objects.get(name='test3') + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, None) + self.assertEqual(item.name, 'test3') + self.assertEqual(item.description, 'test') + self.assertEqual(item.owned_quantity, 1) + self.assertEqual([t for t in item.tags.all()], []) + self.assertEqual([p for p in item.properties.all()], []) + + def test_put_item(self): + reply = client.put('/api/inventory_items/1/', self.user1, { + 'availability_policy': 'friends', + 'name': 'test4', + 'description': 'new description', + 'owned_quantity': 100, + 'image': '', + 'tags': ['tag1', 'tag2', 'tag3'], + 'properties': [{'name': 'prop1', 'value': 'value5'}, {'name': 'prop2', 'value': 'value6'}, + {'name': 'prop3', 'value': 'value7'}] + }) + self.assertEqual(reply.status_code, 200) + self.assertEqual(InventoryItem.objects.count(), 2) + item = InventoryItem.objects.get(id=1) + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, None) + self.assertEqual(item.name, 'test4') + self.assertEqual(item.description, 'new description') + self.assertEqual(item.owned_quantity, 100) + self.assertEqual([t for t in item.tags.all()], [self.tag1, self.tag2, self.tag3]) + self.assertEqual([p for p in item.properties.all()], [self.prop1, self.prop2, self.prop3]) + self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value5', 'value6', 'value7']) + + def test_patch_item(self): + reply = client.patch('/api/inventory_items/1/', self.user1, { + 'description': 'new description2', + 'category': 'cat1', + 'owned_quantity': 100, + 'tags': ['tag3'], + 'properties': [{'name': 'prop3', 'value': 'value8'}] + }) + self.assertEqual(reply.status_code, 200) + self.assertEqual(InventoryItem.objects.count(), 2) + item = InventoryItem.objects.get(id=1) + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, Category.objects.get(name='cat1')) + self.assertEqual(item.name, 'test1') + self.assertEqual(item.description, 'new description2') + self.assertEqual(item.owned_quantity, 100) + self.assertEqual([t for t in item.tags.all()], [self.tag3]) + self.assertEqual([p for p in item.properties.all()], [self.prop3]) + self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value8']) + + def test_patch_item2(self): + reply = client.patch('/api/inventory_items/1/', self.user1, { + 'description': 'new description2', + 'category': None, + 'owned_quantity': 100, + 'tags': [], + 'properties': [] + }) + self.assertEqual(reply.status_code, 200) + self.assertEqual(InventoryItem.objects.count(), 2) + item = InventoryItem.objects.get(id=1) + self.assertEqual(item.availability_policy, 'friends') + self.assertEqual(item.category, None) + self.assertEqual(item.name, 'test1') + self.assertEqual(item.description, 'new description2') + self.assertEqual(item.owned_quantity, 100) + self.assertEqual([t for t in item.tags.all()], []) + self.assertEqual([p for p in item.properties.all()], []) + + def test_delete_item(self): + reply = client.delete('/api/inventory_items/1/', self.user1) + self.assertEqual(reply.status_code, 204) + self.assertEqual(InventoryItem.objects.count(), 1) + self.assertEqual(InventoryItem.objects.get(id=2).name, 'test2') + self.assertEqual(InventoryItem.objects.filter(name='test1').count(), 0) + + def test_search_items(self): + reply = client.get('/api/search/?query=test', self.user1) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 2) + self.assertEqual(reply.json()[0]['name'], 'test1') + self.assertEqual(reply.json()[0]['description'], 'test') + self.assertEqual(reply.json()[0]['owned_quantity'], 1) + self.assertEqual(reply.json()[0]['tags'], []) + self.assertEqual(reply.json()[0]['properties'], []) + self.assertEqual(reply.json()[0]['category'], 'cat1') + self.assertEqual(reply.json()[0]['availability_policy'], 'friends') + self.assertEqual(reply.json()[1]['name'], 'test2') + self.assertEqual(reply.json()[1]['description'], 'test2') + self.assertEqual(reply.json()[1]['owned_quantity'], 1) + self.assertEqual(reply.json()[1]['tags'], ['tag1', 'tag2']) + self.assertEqual(reply.json()[1]['properties'], + [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}]) + self.assertEqual(reply.json()[1]['category'], 'cat1') + self.assertEqual(reply.json()[1]['availability_policy'], 'friends') + + def test_search_items2(self): + reply = client.get('/api/search/?query=test', self.user2) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 2) + self.assertEqual(reply.json()[0]['name'], 'test1') + self.assertEqual(reply.json()[1]['name'], 'test2') + + def test_search_items_fail(self): + reply = client.get('/api/search/', self.user1) + self.assertEqual(reply.status_code, 400) + self.assertEqual(reply.json()['error'], 'No query provided.') + + def test_search_items_fail2(self): + reply = client.get('/api/search/?query=test', self.ext_user1) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 0)