stash visibility

This commit is contained in:
j3d1 2026-09-01 02:10:50 +02:00
parent 23164af04f
commit 1853577ff5
26 changed files with 276 additions and 39 deletions

View file

@ -18,10 +18,11 @@ class ItemPropertyInline(admin.TabularInline):
class InventoryItemAdmin(admin.ModelAdmin): class InventoryItemAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner', list_display = ('name', 'description', 'category', 'availability_policy', 'visibility_policy', 'owned_quantity',
'owner_group', 'storage_location', 'get_tags', 'get_properties') 'owner', 'owner_group', 'storage_location', 'get_tags', 'get_properties')
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username', search_fields = ('name', 'description', 'category__name', 'availability_policy', 'visibility_policy',
'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name') 'owner__username', 'owner_group__name', 'storage_location__name', 'tags__name',
'itemproperty__property__name')
inlines = (ItemTagInline, ItemPropertyInline) inlines = (ItemTagInline, ItemPropertyInline)
def get_queryset(self, request): def get_queryset(self, request):
@ -64,9 +65,10 @@ admin.site.register(Category, CategoryAdmin)
class StorageLocationAdmin(admin.ModelAdmin): class StorageLocationAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'parent', 'owner') list_display = ('name', 'description', 'category', 'parent', 'owner', 'visibility_policy')
search_fields = ('name', 'description', 'category__name', 'parent__name', 'owner__username') search_fields = ('name', 'description', 'category__name', 'parent__name', 'owner__username',
list_filter = ('category', 'owner') 'visibility_policy')
list_filter = ('category', 'owner', 'visibility_policy')
admin.site.register(StorageLocation, StorageLocationAdmin) admin.site.register(StorageLocation, StorageLocationAdmin)

View file

@ -43,7 +43,8 @@ def resolve_short_id(request, kind, owner_id, local_id):
except model.DoesNotExist: except model.DoesNotExist:
return Response(status=404) return Response(status=404)
is_owner = request.user.user.filter(pk=owner.pk).exists() is_owner = request.user.user.filter(pk=owner.pk).exists()
if getattr(obj, 'availability_policy', 'share') == 'private' and not is_owner: if not is_owner and (getattr(obj, 'availability_policy', 'share') == 'private'
or obj.visibility_policy == 'private'):
return Response(status=403) return Response(status=403)
return Response({'handle': f'{owner.username}@{owner.domain}', 'id': obj.id}) return Response({'handle': f'{owner.username}@{owner.domain}', 'id': obj.id})
if kind in ('group_item', 'group_storage_location'): if kind in ('group_item', 'group_storage_location'):

View file

@ -46,6 +46,13 @@ def list_availability_policies(request, format=None):
return Response(InventoryItem.AVAILABILITY_POLICY_CHOICES) return Response(InventoryItem.AVAILABILITY_POLICY_CHOICES)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def list_visibility_policies(request, format=None):
return Response(InventoryItem.VISIBILITY_POLICY_CHOICES)
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@ -54,15 +61,17 @@ def combined_info(request, format=None):
properties = PropertySerializer(Property.objects.all(), many=True).data properties = PropertySerializer(Property.objects.all(), many=True).data
categories = [str(category) for category in Category.objects.all()] categories = [str(category) for category in Category.objects.all()]
policies = InventoryItem.AVAILABILITY_POLICY_CHOICES policies = InventoryItem.AVAILABILITY_POLICY_CHOICES
visibility_policies = InventoryItem.VISIBILITY_POLICY_CHOICES
domains = [domain.name for domain in Domain.objects.filter(open_registration=True)] domains = [domain.name for domain in Domain.objects.filter(open_registration=True)]
return Response( return Response(
{'tags': tags, 'properties': properties, 'availability_policies': policies, 'categories': categories, {'tags': tags, 'properties': properties, 'availability_policies': policies,
'domains': domains}) 'visibility_policies': visibility_policies, 'categories': categories, 'domains': domains})
urlpatterns = [ urlpatterns = [
path('availability_policies/', list_availability_policies, name='availability_policies'), path('availability_policies/', list_availability_policies, name='availability_policies'),
path('visibility_policies/', list_visibility_policies, name='visibility_policies'),
path('properties/', list_properties, name='propertylist'), path('properties/', list_properties, name='propertylist'),
path('categories/', list_categories, name='categorylist'), path('categories/', list_categories, name='categorylist'),
path('domains/', list_domains, name='domainlist'), path('domains/', list_domains, name='domainlist'),

View file

@ -25,7 +25,7 @@ def inventory_items(identity):
friend_user = friend.user.first() friend_user = friend.user.first()
if friend_user: if friend_user:
for item in friend_user.inventory_items.all(): for item in friend_user.inventory_items.all():
if item.availability_policy != 'private': if item.availability_policy != 'private' and item.visibility_policy != 'private':
yield item yield item
@ -55,7 +55,7 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
return InventoryItem.objects.none() return InventoryItem.objects.none()
queryset = InventoryItem.objects.filter(owner=owner_user) queryset = InventoryItem.objects.filter(owner=owner_user)
if not identity.user.filter(pk=owner_user.pk).exists(): if not identity.user.filter(pk=owner_user.pk).exists():
queryset = queryset.exclude(availability_policy='private') queryset = queryset.exclude(availability_policy='private').exclude(visibility_policy='private')
else: else:
return InventoryItem.objects.none() return InventoryItem.objects.none()
# InventoryItemSerializer touches owner/owner_group/category/storage_location (FKs) and # InventoryItemSerializer touches owner/owner_group/category/storage_location (FKs) and
@ -132,7 +132,7 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'pk' lookup_url_kwarg = 'pk'
def get_queryset(self): def get_queryset(self):
# See docs/implementation.md#owner-handle-scoped-routes; unlike items, StorageLocation has no availability_policy, so a friend sees all of a user's locations. # See docs/implementation.md#owner-handle-scoped-routes; unlike items, StorageLocation has no availability_policy, but visibility_policy='private' still hides a location from friends.
if type(self.request.user) != KnownIdentity: if type(self.request.user) != KnownIdentity:
return StorageLocation.objects.none() return StorageLocation.objects.none()
identity = self.request.user identity = self.request.user
@ -147,7 +147,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
if owner_user: if owner_user:
if owner_user not in identity.friends_or_self(): if owner_user not in identity.friends_or_self():
return StorageLocation.objects.none() return StorageLocation.objects.none()
return StorageLocation.objects.filter(owner=owner_user) queryset = StorageLocation.objects.filter(owner=owner_user)
if not identity.user.filter(pk=owner_user.pk).exists():
queryset = queryset.exclude(visibility_policy='private')
return queryset
return StorageLocation.objects.none() return StorageLocation.objects.none()
def perform_create(self, serializer): def perform_create(self, serializer):

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.2 on 2026-08-31 23:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0024_workflowinstance_current_step'),
]
operations = [
migrations.AddField(
model_name='inventoryitem',
name='visibility_policy',
field=models.CharField(choices=[('public', 'Public'), ('friends', 'Friends'), ('private', 'Private')], default='friends', max_length=20),
),
migrations.AddField(
model_name='storagelocation',
name='visibility_policy',
field=models.CharField(choices=[('public', 'Public'), ('friends', 'Friends'), ('private', 'Private')], default='friends', max_length=20),
),
]

View file

@ -107,6 +107,13 @@ class OwnerItemSequence(models.Model):
return seq.last_id return seq.last_id
VISIBILITY_POLICY_CHOICES = (
('public', 'Public'),
('friends', 'Friends'),
('private', 'Private'),
)
class InventoryItem(SoftDeleteModel): class InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = ( AVAILABILITY_POLICY_CHOICES = (
('sell', 'Sell'), ('sell', 'Sell'),
@ -115,6 +122,7 @@ class InventoryItem(SoftDeleteModel):
('share', 'Share'), ('share', 'Share'),
('private', 'Private'), ('private', 'Private'),
) )
VISIBILITY_POLICY_CHOICES = VISIBILITY_POLICY_CHOICES
internal_id = models.AutoField(primary_key=True) internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within owner/owner_group's own items (see # Externally visible id, sequential/gapless within owner/owner_group's own items (see
@ -125,6 +133,7 @@ class InventoryItem(SoftDeleteModel):
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items') category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private') availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
visibility_policy = models.CharField(max_length=20, choices=VISIBILITY_POLICY_CHOICES, default='private')
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)]) owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items') related_name='inventory_items')
@ -195,6 +204,8 @@ class OwnerStorageLocationSequence(models.Model):
class StorageLocation(models.Model): class StorageLocation(models.Model):
VISIBILITY_POLICY_CHOICES = VISIBILITY_POLICY_CHOICES
internal_id = models.AutoField(primary_key=True) internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner/owner_group's own locations (see # Externally visible id, sequential/gapless within the owner/owner_group's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
@ -209,6 +220,7 @@ class StorageLocation(models.Model):
related_name='storage_locations') related_name='storage_locations')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
related_name='storage_locations') related_name='storage_locations')
visibility_policy = models.CharField(max_length=20, choices=VISIBILITY_POLICY_CHOICES, default='private')
class Meta: class Meta:
constraints = [ constraints = [

View file

@ -24,6 +24,7 @@ def inventory_rows(user):
'description': item.description or '', 'description': item.description or '',
'category': item.category.get_handle() if item.category else '', 'category': item.category.get_handle() if item.category else '',
'availability_policy': item.availability_policy, 'availability_policy': item.availability_policy,
'visibility_policy': item.visibility_policy,
'owned_quantity': item.owned_quantity, 'owned_quantity': item.owned_quantity,
'storage_location': str(item.storage_location) if item.storage_location else '', 'storage_location': str(item.storage_location) if item.storage_location else '',
'tags': ', '.join(tag.get_handle() for tag in item.tags.all()), 'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
@ -67,6 +68,7 @@ def location_rows(user):
'category': str(location.category) if location.category else '', 'category': str(location.category) if location.category else '',
'parent': str(location.parent) if location.parent else '', 'parent': str(location.parent) if location.parent else '',
'path': location_path(location), 'path': location_path(location),
'visibility_policy': location.visibility_policy,
} }
@ -342,6 +344,7 @@ def import_locations(user, data):
defaults = { defaults = {
'description': row.get('description', '') or '', 'description': row.get('description', '') or '',
'category': category, 'category': category,
'visibility_policy': row.get('visibility_policy') or 'private',
} }
try: try:
location = StorageLocation.objects.get(owner=user, name=name, parent=parent) location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
@ -551,6 +554,7 @@ def import_inventory(user, data, available_files):
description=row.get('description', '') or '', description=row.get('description', '') or '',
category=category, category=category,
availability_policy=row.get('availability_policy') or 'private', availability_policy=row.get('availability_policy') or 'private',
visibility_policy=row.get('visibility_policy') or 'private',
owned_quantity=owned_quantity, owned_quantity=owned_quantity,
storage_location=storage_location, storage_location=storage_location,
) )

View file

@ -211,7 +211,8 @@ class StorageLocationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = StorageLocation model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'owner_group', 'parent'] fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'owner_group', 'parent',
'visibility_policy']
read_only_fields = ['id', 'path'] read_only_fields = ['id', 'path']
@staticmethod @staticmethod
@ -258,7 +259,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy', fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy',
'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location'] 'visibility_policy', 'owned_quantity', 'tags', 'tags_input', 'properties', 'files',
'storage_location']
read_only_fields = ['id'] read_only_fields = ['id']
def get_tags(self, obj): def get_tags(self, obj):

View file

@ -37,10 +37,10 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
self.f['item1'] = InventoryItem.create_for_owner( self.f['item1'] = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='test1', description='test', category=self.f['cat1'], owner=self.f['local_user1'], owned_quantity=1, name='test1', description='test', category=self.f['cat1'],
availability_policy='friends') availability_policy='friends', visibility_policy='friends')
self.f['item2'] = InventoryItem.create_for_owner( self.f['item2'] = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='test2', description='test2', category=self.f['cat1'], owner=self.f['local_user1'], owned_quantity=1, name='test2', description='test2', category=self.f['cat1'],
availability_policy='friends') availability_policy='friends', visibility_policy='friends')
self.f['item2'].tags.add(self.f['tag1'], through_defaults={}) self.f['item2'].tags.add(self.f['tag1'], through_defaults={})
self.f['item2'].tags.add(self.f['tag2'], through_defaults={}) self.f['item2'].tags.add(self.f['tag2'], through_defaults={})
ItemProperty.objects.create(inventory_item=self.f['item2'], property=self.f['prop1'], value='value1').save() ItemProperty.objects.create(inventory_item=self.f['item2'], property=self.f['prop1'], value='value1').save()
@ -49,12 +49,14 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
class LocationTestMixin: class LocationTestMixin:
def prepare_locations(self): def prepare_locations(self):
self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1']) self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'],
visibility_policy='friends')
self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'], self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1']) category=self.f['cat1'], visibility_policy='friends')
self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'],
visibility_policy='friends')
self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1']) category=self.f['cat1'], visibility_policy='friends')
class WorkflowTestMixin: class WorkflowTestMixin:

View file

@ -46,6 +46,15 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
self.assertEqual(response.json(), [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'], ['share', 'Share'], self.assertEqual(response.json(), [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'], ['share', 'Share'],
['private', 'Private']]) ['private', 'Private']])
def test_visibility_policy_api_anonymous(self):
response = anonymous_client.get('/api/v1/visibility_policies/')
self.assertEqual(response.status_code, 403)
def test_visibility_policy_api(self):
response = client.get('/api/v1/visibility_policies/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [['public', 'Public'], ['friends', 'Friends'], ['private', 'Private']])
def test_combined_api_anonymous(self): def test_combined_api_anonymous(self):
response = anonymous_client.get('/api/v1/info/') response = anonymous_client.get('/api/v1/info/')
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -55,6 +64,8 @@ class CombinedApiTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, Proper
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'], self.assertEqual(response.json()['availability_policies'], [['sell', 'Sell'], ['rent', 'Rent'], ['lend', 'Lend'],
['share', 'Share'], ['private', 'Private']]) ['share', 'Share'], ['private', 'Private']])
self.assertEqual(response.json()['visibility_policies'],
[['public', 'Public'], ['friends', 'Friends'], ['private', 'Private']])
self.assertEqual(response.json()['categories'], self.assertEqual(response.json()['categories'],
['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat1', ['cat1', 'cat2', 'cat3', 'cat1/subcat1', 'cat1/subcat2', 'cat1/subcat1/subcat1',
'cat1/subcat1/subcat2']) 'cat1/subcat1/subcat2'])

View file

@ -89,6 +89,17 @@ class ResolveShortIdApiTestCase(UserTestMixin, InventoryTestMixin, GroupTestMixi
self.f['local_user1']) self.f['local_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
def test_resolve_item_visibility_private_not_owner(self):
private_item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='friends',
visibility_policy='private')
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 403)
reply = client.get(f'/api/v1/resolve_short_id/item/{self.owner_id}/{private_item.id}/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_resolve_item_unknown_owner(self): def test_resolve_item_unknown_owner(self):
reply = client.get('/api/v1/resolve_short_id/item/999999/1/', self.f['local_user2']) reply = client.get('/api/v1/resolve_short_id/item/999999/1/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
@ -109,6 +120,16 @@ class ResolveShortIdApiTestCase(UserTestMixin, InventoryTestMixin, GroupTestMixi
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['loc1'].id}) self.assertEqual(reply.json(), {'handle': 'testuser1@example.com', 'id': self.f['loc1'].id})
def test_resolve_storage_location_visibility_private_not_owner(self):
private_location = StorageLocation.create_for_owner(
owner=self.f['local_user1'], name='secret-loc', visibility_policy='private')
reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/{private_location.id}/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 403)
reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/{private_location.id}/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_resolve_storage_location_unknown_local_id(self): def test_resolve_storage_location_unknown_local_id(self):
reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/999999/', reply = client.get(f'/api/v1/resolve_short_id/storage_location/{self.owner_id}/999999/',
self.f['local_user1']) self.f['local_user1'])

View file

@ -205,6 +205,15 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()[0]['name'], 'test1') self.assertEqual(reply.json()[0]['name'], 'test1')
self.assertEqual(reply.json()[1]['name'], 'test2') self.assertEqual(reply.json()[1]['name'], 'test2')
def test_search_items_excludes_visibility_private(self):
InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='test-secret', availability_policy='friends',
visibility_policy='private')
reply = client.get('/api/v1/search/?query=test', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 2)
self.assertNotIn('test-secret', [item['name'] for item in reply.json()])
def test_search_items_fail(self): def test_search_items_fail(self):
reply = client.get('/api/v1/search/', self.f['local_user1']) reply = client.get('/api/v1/search/', self.f['local_user1'])
self.assertEqual(reply.status_code, 400) self.assertEqual(reply.status_code, 400)
@ -246,6 +255,17 @@ class InventoryApiTestCase(UserTestMixin, InventoryTestMixin, ToolshedTestCase):
self.f['local_user1']) self.f['local_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
def test_get_shared_item_visibility_private(self):
private_item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], owned_quantity=1, name='secret', availability_policy='friends',
visibility_policy='private')
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
reply = client.get('/api/v1/inventory_items/testuser1@example.com/' + str(private_item.id) + '/',
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_get_shared_item_unknown_handle(self): def test_get_shared_item_unknown_handle(self):
reply = client.get('/api/v1/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/', reply = client.get('/api/v1/inventory_items/nobody@example.com/' + str(self.f['item1'].id) + '/',
self.f['local_user2']) self.f['local_user2'])

View file

@ -128,6 +128,23 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0) self.assertEqual(len(reply.json()), 0)
def test_get_shared_location_visibility_private(self):
private_location = StorageLocation.create_for_owner(
owner=self.f['local_user1'], name='secret-loc', visibility_policy='private')
reply = client.get(
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, private_location.id), self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
reply = client.get(
'/api/v1/storage_locations/{}/{}/'.format(self.own_handle, private_location.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
def test_list_locations_excludes_visibility_private_for_friend(self):
StorageLocation.create_for_owner(owner=self.f['local_user1'], name='secret-loc', visibility_policy='private')
reply = client.get('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 4)
self.assertNotIn('secret-loc', [loc['name'] for loc in reply.json()])
def test_cannot_delete_other_users_location(self): def test_cannot_delete_other_users_location(self):
# local_user2 is a friend of local_user1 (see prepare_inventory), so local_user1's own # local_user2 is a friend of local_user1 (see prepare_inventory), so local_user1's own
# handle resolves and the location is visible - friends can read but never write, so this # handle resolves and the location is visible - friends can read but never write, so this

View file

@ -233,7 +233,7 @@ class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTest
item = InventoryItem.create_for_owner( item = InventoryItem.create_for_owner(
owner=self.f['local_user1'], name='drill', description='cordless drill', owner=self.f['local_user1'], name='drill', description='cordless drill',
category=self.f['cat1'], availability_policy='friends', owned_quantity=2) category=self.f['cat1'], availability_policy='friends', visibility_policy='public', owned_quantity=2)
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={}) item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm') ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b') ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
@ -251,6 +251,7 @@ class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTest
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill') new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
self.assertEqual(new_item.category, self.f['cat1']) self.assertEqual(new_item.category, self.f['cat1'])
self.assertEqual(new_item.visibility_policy, 'public')
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2']) self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')} values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}

View file

@ -98,6 +98,7 @@
<label class="form-label fw-bold">{{ field.label }}</label> <label class="form-label fw-bold">{{ field.label }}</label>
<field-input :type="field.type" v-model="presetValues[field.key]" <field-input :type="field.type" v-model="presetValues[field.key]"
:owner-groups="ownerGroups" :availability-policies="availability_policies" :owner-groups="ownerGroups" :availability-policies="availability_policies"
:visibility-policies="visibility_policies"
:storage-locations="storage_locations"/> :storage-locations="storage_locations"/>
</div> </div>
</div> </div>
@ -187,6 +188,7 @@
<field-input :type="field.type" v-model="currentItemDetails[field.key]" <field-input :type="field.type" v-model="currentItemDetails[field.key]"
:owner-groups="ownerGroups" :owner-groups="ownerGroups"
:availability-policies="availability_policies" :availability-policies="availability_policies"
:visibility-policies="visibility_policies"
:storage-locations="storage_locations"/> :storage-locations="storage_locations"/>
</div> </div>
</form> </form>
@ -293,6 +295,7 @@ const PRESET_FIELD_DEFS = [
{key: 'owner_group', label: 'Owner', type: 'owner_group'}, {key: 'owner_group', label: 'Owner', type: 'owner_group'},
{key: 'owned_quantity', label: 'Quantity', type: 'number'}, {key: 'owned_quantity', label: 'Quantity', type: 'number'},
{key: 'availability_policy', label: 'Availability Policy', type: 'availability_policy'}, {key: 'availability_policy', label: 'Availability Policy', type: 'availability_policy'},
{key: 'visibility_policy', label: 'Visibility Policy', type: 'visibility_policy'},
{key: 'storage_location', label: 'Storage Location', type: 'storage_location'} {key: 'storage_location', label: 'Storage Location', type: 'storage_location'}
]; ];
@ -307,6 +310,7 @@ function defaultValueFor(key) {
case 'owned_quantity': case 'owned_quantity':
return 1; return 1;
case 'availability_policy': case 'availability_policy':
case 'visibility_policy':
return 'private'; return 'private';
default: default:
return ''; return '';
@ -390,7 +394,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(['user', 'availability_policies', 'storage_locations', 'groups', 'groupMemberships']), ...mapState(['user', 'availability_policies', 'visibility_policies', 'storage_locations', 'groups', 'groupMemberships']),
ownerGroups() { ownerGroups() {
const hostedHandles = new Set(this.groups.map(group => group.handle)); const hostedHandles = new Set(this.groups.map(group => group.handle));
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle)); const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle));
@ -619,6 +623,7 @@ export default {
properties: details.properties, properties: details.properties,
owned_quantity: details.owned_quantity, owned_quantity: details.owned_quantity,
availability_policy: details.availability_policy, availability_policy: details.availability_policy,
visibility_policy: details.visibility_policy,
storage_location: details.storage_location, storage_location: details.storage_location,
owner_group: details.owner_group owner_group: details.owner_group
}); });

View file

@ -20,6 +20,12 @@
</option> </option>
</select> </select>
<select v-else-if="type === 'visibility_policy'" class="form-select" v-model="localValue">
<option v-for="policy in visibilityPolicies" :key="policy.slug" :value="policy.slug">
{{ policy.text }}
</option>
</select>
<select v-else-if="type === 'storage_location'" class="form-select" v-model="localValue"> <select v-else-if="type === 'storage_location'" class="form-select" v-model="localValue">
<option :value="null">No storage location</option> <option :value="null">No storage location</option>
<option v-for="location in storageLocations" :key="location.id" :value="location.id"> <option v-for="location in storageLocations" :key="location.id" :value="location.id">
@ -53,6 +59,10 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
visibilityPolicies: {
type: Array,
default: () => []
},
storageLocations: { storageLocations: {
type: Array, type: Array,
default: () => [] default: () => []

View file

@ -42,6 +42,7 @@ export default createStore({
files: [], files: [],
categories: [], categories: [],
availability_policies: [], availability_policies: [],
visibility_policies: [],
domains: [], domains: [],
storage_locations: [], storage_locations: [],
active_workflows: [], active_workflows: [],
@ -100,6 +101,9 @@ export default createStore({
setAvailabilityPolicies(state, availability_policies) { setAvailabilityPolicies(state, availability_policies) {
state.availability_policies = availability_policies; state.availability_policies = availability_policies;
}, },
setVisibilityPolicies(state, visibility_policies) {
state.visibility_policies = visibility_policies;
},
setDomains(state, domains) { setDomains(state, domains) {
state.domains = domains; state.domains = domains;
}, },
@ -343,7 +347,7 @@ export default createStore({
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers') : await dispatch('getHomeServers')
const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user
const data = {availability_policy: 'private', ...item} const data = {availability_policy: 'private', visibility_policy: 'private', ...item}
delete data.owner_group delete data.owner_group
const reply = await servers.post(getters.signAuth, '/api/v1/inventory_items/' + owner + '/', data) const reply = await servers.post(getters.signAuth, '/api/v1/inventory_items/' + owner + '/', data)
state.last_load.files = 0 state.last_load.files = 0
@ -353,7 +357,7 @@ export default createStore({
const servers = item.owner_group const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers') : await dispatch('getHomeServers')
const data = {availability_policy: 'friends', ...item} const data = {availability_policy: 'friends', visibility_policy: 'friends', ...item}
data.files = data.files.map(file => file.id) data.files = data.files.map(file => file.id)
// Path is scoped by the owner's handle, not the item's own id domain - see docs/implementation.md#owner-handle-scoped-routes. // Path is scoped by the owner's handle, not the item's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/' const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
@ -661,6 +665,16 @@ export default createStore({
state.last_load.availability_policies = Date.now() state.last_load.availability_policies = Date.now()
return data return data
}, },
async fetchVisibilityPolicies({state, commit, dispatch, getters}) {
if (state.last_load.visibility_policies > Date.now() - 1000 * 60 * 60 * 24) {
return state.visibility_policies
}
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/v1/visibility_policies/')
commit('setVisibilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]})))
state.last_load.visibility_policies = Date.now()
return data
},
async fetchStorageLocations({state, commit, dispatch, getters}) { async fetchStorageLocations({state, commit, dispatch, getters}) {
if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) { if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) {
return state.storage_locations return state.storage_locations
@ -734,7 +748,8 @@ export default createStore({
state.last_load.tags, state.last_load.tags,
state.last_load.properties, state.last_load.properties,
state.last_load.categories, state.last_load.categories,
state.last_load.availability_policies) state.last_load.availability_policies,
state.last_load.visibility_policies)
if (last_load_info > Date.now() - 1000 * 60 * 60 * 24) { if (last_load_info > Date.now() - 1000 * 60 * 60 * 24) {
return state.info return state.info
} }
@ -747,11 +762,16 @@ export default createStore({
slug: policy[0], slug: policy[0],
text: policy[1] text: policy[1]
}))) })))
commit('setVisibilityPolicies', data.visibility_policies.map(policy => ({
slug: policy[0],
text: policy[1]
})))
commit('setDomains', data.domains) commit('setDomains', data.domains)
state.last_load.tags = Date.now() state.last_load.tags = Date.now()
state.last_load.properties = Date.now() state.last_load.properties = Date.now()
state.last_load.categories = Date.now() state.last_load.categories = Date.now()
state.last_load.availability_policies = Date.now() state.last_load.availability_policies = Date.now()
state.last_load.visibility_policies = Date.now()
return data return data
}, },
async userIdentityRecord({state}, {password}) { async userIdentityRecord({state}, {password}) {

View file

@ -67,6 +67,18 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h5 class="card-title">Visibility Policies</h5>
</div>
<div class="card-body">
<ul>
<li v-for="policy in visibility_policies.sort()" :key="policy.id">
{{ policy.text }}
</li>
</ul>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="card-title">Storage Locations</h5> <h5 class="card-title">Storage Locations</h5>
@ -98,7 +110,7 @@ export default {
...BIcons ...BIcons
}, },
computed: { computed: {
...mapState(["tags", "properties", "categories", "availability_policies", "domains", "storage_locations"]) ...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"])
}, },
methods: { methods: {
...mapActions(["fetchInfo", "fetchStorageLocations"]) ...mapActions(["fetchInfo", "fetchStorageLocations"])

View file

@ -35,8 +35,9 @@
<table class="table table-striped" v-if="layout === 'table'"> <table class="table table-striped" v-if="layout === 'table'">
<thead> <thead>
<tr> <tr>
<th style="width:40%;">Name</th> <th style="width:35%;">Name</th>
<th style="width:25%">Availability Policy</th> <th style="width:20%">Availability Policy</th>
<th style="width:20%">Visibility Policy</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th> <th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@ -49,6 +50,9 @@
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span> <span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
</td> </td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td> <td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action"> <td class="table-action">
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"> <router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`">
@ -84,6 +88,7 @@
</router-link></h5> </router-link></h5>
<div class="card-text text-black-50"> <div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span> <span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span> <span class="float-right">{{ item.owned_quantity }}</span>
</div> </div>
<div class="btn-group"> <div class="btn-group">

View file

@ -39,6 +39,16 @@
</option> </option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="item.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === item.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="storage_location" class="form-label">Storage Location</label> <label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location" <select class="form-select" id="storage_location" name="storage_location"
@ -115,7 +125,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(["availability_policies", "storage_locations"]), ...mapState(["availability_policies", "visibility_policies", "storage_locations"]),
decodedHandle() { decodedHandle() {
return decodeHandleFromUrl(this.handle) return decodeHandleFromUrl(this.handle)
} }

View file

@ -48,6 +48,16 @@
</option> </option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="item.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === item.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="storage_location" class="form-label">Storage Location</label> <label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location" <select class="form-select" id="storage_location" name="storage_location"
@ -109,6 +119,7 @@ export default {
description: "", description: "",
owned_quantity: 0, owned_quantity: 0,
availability_policy: "", availability_policy: "",
visibility_policy: "",
image: "", image: "",
tags: [], tags: [],
properties: [], properties: [],
@ -126,7 +137,7 @@ export default {
'fetchGroupMemberships']) 'fetchGroupMemberships'])
}, },
computed: { computed: {
...mapState(["availability_policies", "storage_locations", "groups", "groupMemberships"]), ...mapState(["availability_policies", "visibility_policies", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (see // Groups hosted here plus groups only known via a GroupMembership pointer (see
// Groups.vue's allGroups for the same merge/dedupe). // Groups.vue's allGroups for the same merge/dedupe).
ownerGroups() { ownerGroups() {

View file

@ -32,9 +32,10 @@
<table class="table table-striped" v-if="layout === 'table'"> <table class="table table-striped" v-if="layout === 'table'">
<thead> <thead>
<tr> <tr>
<th style="width:40%;">Name</th> <th style="width:35%;">Name</th>
<th style="width:25%">Path</th> <th style="width:20%">Path</th>
<th class="d-none d-md-table-cell" style="width:25%">Category</th> <th class="d-none d-md-table-cell" style="width:20%">Category</th>
<th class="d-none d-md-table-cell" style="width:20%">Visibility Policy</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -50,6 +51,9 @@
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span> <span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="text-muted" v-else>-</span> <span class="text-muted" v-else>-</span>
</td> </td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</td>
<td class="table-action"> <td class="table-action">
<router-link :to="`${locationRoute(location)}/edit`"> <router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
@ -81,6 +85,7 @@
<div class="card-text text-black-50"> <div class="card-text text-black-50">
<small class="text-muted d-block">{{ location.path }}</small> <small class="text-muted d-block">{{ location.path }}</small>
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span> <span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</div> </div>
<div class="card-text" v-if="location.description"> <div class="card-text" v-if="location.description">
<small>{{ location.description }}</small> <small>{{ location.description }}</small>

View file

@ -25,6 +25,12 @@
<label for="owner" class="form-label">Owner</label> <label for="owner" class="form-label">Owner</label>
<div>{{ location.owner || location.owner_group || '-' }}</div> <div>{{ location.owner || location.owner_group || '-' }}</div>
</div> </div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<div>
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</div>
</div>
</div> </div>
</div> </div>
<div class="card" v-if="canEdit"> <div class="card" v-if="canEdit">

View file

@ -38,6 +38,16 @@
</option> </option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="location.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === location.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%" <button type="submit" class="btn btn-primary" style="width: 100%"
@click="submitForm()">Update @click="submitForm()">Update
@ -80,6 +90,7 @@ export default {
description: "", description: "",
category: null, category: null,
parent: null, parent: null,
visibility_policy: "private",
}, },
// The location's own owner's full location list, for the parent dropdown - own // The location's own owner's full location list, for the parent dropdown - own
// personal locations, or the owning group's own (see loadLocation). // personal locations, or the owning group's own (see loadLocation).
@ -87,7 +98,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(["categories"]), ...mapState(["categories", "visibility_policies"]),
decodedHandle() { decodedHandle() {
return decodeHandleFromUrl(this.handle) return decodeHandleFromUrl(this.handle)
}, },

View file

@ -45,6 +45,16 @@
</option> </option>
</select> </select>
</div> </div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="location.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === location.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%" <button type="submit" class="btn btn-primary" style="width: 100%"
@click="submitForm()">Add @click="submitForm()">Add
@ -84,6 +94,7 @@ export default {
description: "", description: "",
category: null, category: null,
parent: null, parent: null,
visibility_policy: "private",
// The group's own "#name@domain" handle once picked, or null for a personal // The group's own "#name@domain" handle once picked, or null for a personal
// location - createStorageLocation resolves the target domain from it directly. // location - createStorageLocation resolves the target domain from it directly.
owner_group: null owner_group: null
@ -115,7 +126,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(["categories", "storage_locations", "groups", "groupMemberships"]), ...mapState(["categories", "visibility_policies", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (see // Groups hosted here plus groups only known via a GroupMembership pointer (see
// Groups.vue's allGroups for the same merge/dedupe). // Groups.vue's allGroups for the same merge/dedupe).
ownerGroups() { ownerGroups() {

View file

@ -280,6 +280,7 @@ def generate_locations_csv(count):
'category': '', 'category': '',
'parent': '', 'parent': '',
'path': name, 'path': name,
'visibility_policy': 'friends',
}) })
# Second pass: create remaining locations, some as children # Second pass: create remaining locations, some as children
@ -302,12 +303,13 @@ def generate_locations_csv(count):
'category': '', 'category': '',
'parent': parent_id, 'parent': parent_id,
'path': path, 'path': path,
'visibility_policy': 'friends',
}) })
location_id_map[name] = str(current_id) location_id_map[name] = str(current_id)
current_id += 1 current_id += 1
output = io.StringIO() output = io.StringIO()
fieldnames = ['id', 'name', 'description', 'category', 'parent', 'path'] fieldnames = ['id', 'name', 'description', 'category', 'parent', 'path', 'visibility_policy']
writer = csv.DictWriter(output, fieldnames=fieldnames) writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader() writer.writeheader()
writer.writerows(rows) writer.writerows(rows)
@ -386,6 +388,7 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
'description': description, 'description': description,
'category': category_handle, 'category': category_handle,
'availability_policy': policy, 'availability_policy': policy,
'visibility_policy': 'friends',
'owned_quantity': str(qty), 'owned_quantity': str(qty),
'storage_location': location, 'storage_location': location,
'tags': ', '.join(tags_for_item), 'tags': ', '.join(tags_for_item),
@ -395,7 +398,7 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
}) })
output = io.StringIO() output = io.StringIO()
fieldnames = ['id', 'name', 'description', 'category', 'availability_policy', fieldnames = ['id', 'name', 'description', 'category', 'availability_policy', 'visibility_policy',
'owned_quantity', 'storage_location', 'tags', 'properties', 'files', 'created_at'] 'owned_quantity', 'storage_location', 'tags', 'properties', 'files', 'created_at']
writer = csv.DictWriter(output, fieldnames=fieldnames) writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader() writer.writeheader()