stash
This commit is contained in:
parent
8d96bc97c4
commit
ed04d98bf1
54 changed files with 661 additions and 1214 deletions
|
|
@ -50,10 +50,7 @@ def post_item_file(request, item_id):
|
|||
if item is None:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
if 'file_hash' in request.data:
|
||||
# Attach a file the caller already staged on one of their own workflows, identified
|
||||
# by its content hash (which the client already computed before ever uploading it),
|
||||
# instead of re-uploading bytes that are already stored server-side. Workflows are
|
||||
# always personally owned, so this only applies to a caller with a local account.
|
||||
# Attaches an already-staged file by hash instead of re-uploading it. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||
if not request.user.user.exists():
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
|
|
@ -74,10 +71,7 @@ def post_item_file(request, item_id):
|
|||
def get_staged_files(request, workflow_id):
|
||||
try:
|
||||
workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user)
|
||||
# Hash alone identifies a staged file (client and server hash content the same way, and
|
||||
# bytes are fetchable from a hash-derived storage path) - useful mainly for discovering
|
||||
# what another session/device already staged on this workflow, unlike the fuller
|
||||
# FileSerializer representation item_files uses.
|
||||
# Hash alone is enough to discover what another session/device already staged. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||
return Response(list(workflow.staged_files.values_list('hash', flat=True)))
|
||||
except WorkflowInstance.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
|
|
|||
|
|
@ -35,26 +35,22 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
|
|||
serializer_class = InventoryItemSerializer
|
||||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
# Detail routes address an item by its owner-scoped id, not the internal row id - the
|
||||
# router still names the URL capture group 'pk', so keep that as lookup_url_kwarg and just
|
||||
# change which model field it's matched against. get_queryset() below is always already
|
||||
# scoped to the requester's own items/groups, so this can't cross into another owner's ids.
|
||||
# Detail routes address an item by its owner-scoped id, not the internal row id. See
|
||||
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
|
||||
lookup_field = 'id'
|
||||
lookup_url_kwarg = 'pk'
|
||||
|
||||
def get_queryset(self):
|
||||
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
|
||||
# hosted on this backend) never has a local ToolshedUser account here - group-owned
|
||||
# items must stay reachable for such an identity, only personal ("owner=...") items
|
||||
# require .user.exists().
|
||||
# A pure group-member KnownIdentity may have no local ToolshedUser account; only
|
||||
# personal items require .user.exists(). See
|
||||
# docs/implementation.md#group-member-identities-without-local-accounts.
|
||||
if type(self.request.user) != KnownIdentity:
|
||||
return InventoryItem.objects.none()
|
||||
identity = self.request.user
|
||||
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
|
||||
if self.action != 'list':
|
||||
# retrieve/update/destroy: anything the caller may act on - their own items, or any
|
||||
# group they're currently a member of. The narrower per-group listing below is only
|
||||
# for the list action, so the main Inventory page stays scoped to personal items.
|
||||
# retrieve/update/destroy: any item the caller may act on, own or group. See
|
||||
# docs/implementation.md#inventory-queryset-scope-by-action.
|
||||
if identity.user.exists():
|
||||
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
|
||||
return group_items
|
||||
|
|
@ -127,13 +123,8 @@ def search_inventory_items(request):
|
|||
@authentication_classes([SignatureAuthentication])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_shared_item(request, handle, id):
|
||||
"""Fetch a single item by its owner's handle (username@domain) and local id, e.g. for the
|
||||
/i/<handle>/<id> item URL (see docs/design-in-progress/items-labels.md) or the
|
||||
/inventory/shared/<handle>/<id> in-app view. Unlike InventoryItemViewSet, which only ever
|
||||
returns the requester's own items, this looks the item up by owner instead of by requester,
|
||||
so it's the only endpoint that can serve a friend's item - subject to the same
|
||||
friends-or-self and availability_policy checks getUserProfile/_accessible_files already
|
||||
use elsewhere."""
|
||||
"""Fetch a single item by its owner's handle and local id, for /i/<handle>/<id> or
|
||||
/inventory/shared/<handle>/<id>. See docs/implementation.md#get-shared-item-looks-up-by-owner."""
|
||||
try:
|
||||
username, domain = split_userhandle_or_throw(handle)
|
||||
except ValueError:
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ class Tag(models.Model):
|
|||
|
||||
|
||||
class OwnerItemSequence(models.Model):
|
||||
"""Tracks the last InventoryItem id handed out to a given owner or owner_group, so ids can
|
||||
be allocated sequentially and without gaps within that scope (see InventoryItem.create_for_owner).
|
||||
Exactly one of owner/owner_group is set, mirroring InventoryItem's own owner/owner_group split."""
|
||||
"""Tracks the last InventoryItem id handed out per owner/owner_group scope for sequential,
|
||||
gapless allocation (see InventoryItem.create_for_owner); exactly one of owner/owner_group is
|
||||
set, mirroring InventoryItem's own split."""
|
||||
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
|
||||
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, related_name='+')
|
||||
last_id = models.PositiveIntegerField(default=0)
|
||||
|
|
@ -117,9 +117,8 @@ class InventoryItem(SoftDeleteModel):
|
|||
)
|
||||
|
||||
internal_id = models.AutoField(primary_key=True)
|
||||
# The externally visible identifier: sequential and gapless within owner/owner_group's own
|
||||
# items (see OwnerItemSequence), never the internal_id above. Always allocate through
|
||||
# create_for_owner rather than InventoryItem.objects.create() directly.
|
||||
# Externally visible id, sequential/gapless within owner/owner_group's own items (see
|
||||
# OwnerItemSequence), never internal_id; always allocate via create_for_owner, not .objects.create().
|
||||
id = models.PositiveIntegerField(editable=False)
|
||||
published = models.BooleanField(default=False)
|
||||
name = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
|
@ -152,8 +151,8 @@ class InventoryItem(SoftDeleteModel):
|
|||
|
||||
@classmethod
|
||||
def create_for_owner(cls, *, owner=None, owner_group=None, **kwargs):
|
||||
"""The only supported way to create an InventoryItem: allocates the next id for this
|
||||
owner/owner_group scope and creates the item with it, atomically."""
|
||||
"""The only supported way to create an InventoryItem: atomically allocates the next id
|
||||
for this owner/owner_group scope."""
|
||||
with transaction.atomic():
|
||||
next_id = OwnerItemSequence.allocate(owner=owner, owner_group=owner_group)
|
||||
return cls.objects.create(owner=owner, owner_group=owner_group, id=next_id, **kwargs)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
"""Data helpers for building/importing a user's offline export.
|
||||
|
||||
These functions deal with the toolshed models and CSV row shapes only - they know nothing
|
||||
about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py
|
||||
for the zip-building/parsing and the API views themselves.
|
||||
"""
|
||||
"""Data helpers for building/importing a user's offline export; deal with toolshed models and CSV row shapes only, and know nothing about the zip container format or request auth - see toolshed/api/offlinedata.py for that."""
|
||||
|
||||
|
||||
def inventory_rows(user):
|
||||
|
|
@ -76,11 +71,7 @@ def location_rows(user):
|
|||
|
||||
|
||||
def inventory_files(user):
|
||||
"""Generator that yields (arcname, data) for each unique file attached to the user's inventory items.
|
||||
|
||||
Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original
|
||||
extension (guessed from mime_type) so attachments and images remain viewable once extracted.
|
||||
"""
|
||||
"""Yields (arcname, data) for each unique file attached to the user's inventory items, deduplicated by hash. See docs/implementation.md#file-naming-convention-in-exports for the naming scheme."""
|
||||
import mimetypes
|
||||
|
||||
from toolshed.models import InventoryItem
|
||||
|
|
@ -125,11 +116,7 @@ def profile_data(user):
|
|||
|
||||
|
||||
def profile_picture_files(user):
|
||||
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
|
||||
|
||||
Kept separate from `inventory_files()` so the profile picture is included in the export
|
||||
even for users with no inventory items or whose picture isn't attached to any item.
|
||||
"""
|
||||
"""Generator that yields (arcname, data) for the user's profile picture, if one is set. Kept separate from `inventory_files()` so the picture is included even for users with no inventory items or whose picture isn't attached to any item."""
|
||||
import mimetypes
|
||||
|
||||
if not user.profile_picture:
|
||||
|
|
@ -148,21 +135,12 @@ def profile_picture_files(user):
|
|||
|
||||
|
||||
def settings_data(user):
|
||||
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
|
||||
|
||||
Only account preferences (AccountPreference) are exported; device-level preferences stay
|
||||
in the browser's local storage since they describe the device, not the account.
|
||||
"""
|
||||
"""Return the given user's account-level preferences as a {key: value} dict for settings.json. Only account preferences are exported; device-level preferences stay in the browser's local storage since they describe the device, not the account."""
|
||||
return {pref.key: pref.value for pref in user.preferences.all()}
|
||||
|
||||
|
||||
def import_profile(user, data, available_files):
|
||||
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
|
||||
|
||||
Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in
|
||||
`available_files`) are applied; 'username' and 'domain' are ignored since they identify the
|
||||
account itself and can't be changed by an import.
|
||||
"""
|
||||
"""Fault-tolerant import of profile.json, updating the user's editable profile fields. See docs/implementation.md#profile-import-semantics for which fields are applied and why."""
|
||||
import json
|
||||
|
||||
try:
|
||||
|
|
@ -189,11 +167,7 @@ def import_profile(user, data, available_files):
|
|||
|
||||
|
||||
def import_settings(user, data):
|
||||
"""Fault-tolerant import of settings.json into the user's account-level preferences.
|
||||
|
||||
Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a
|
||||
non-object payload is skipped rather than aborting the whole import.
|
||||
"""
|
||||
"""Fault-tolerant import of settings.json; each top-level key/value pair is upserted as an AccountPreference, and unreadable data or a non-object payload is skipped rather than aborting the whole import."""
|
||||
import json
|
||||
|
||||
from authentication.models import AccountPreference
|
||||
|
|
@ -217,18 +191,7 @@ def import_settings(user, data):
|
|||
|
||||
|
||||
def delete_user_data(user):
|
||||
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact.
|
||||
|
||||
This removes the user's inventory items (hard delete, bypassing soft-delete), storage
|
||||
locations, account preferences, the friends relation on their public identity, the profile
|
||||
picture, and any File blobs (profile picture / inventory attachments) that would otherwise be
|
||||
orphaned - i.e. not referenced by any other inventory item or user. Files still referenced
|
||||
elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account
|
||||
(and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it
|
||||
doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that.
|
||||
|
||||
Returns a summary dict describing what was removed.
|
||||
"""
|
||||
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact. Returns a summary dict describing what was removed. See docs/implementation.md#account-data-deletion for exactly what's removed and why the account itself survives."""
|
||||
from django.db import transaction
|
||||
|
||||
from toolshed.models import InventoryItem, StorageLocation
|
||||
|
|
@ -262,15 +225,7 @@ def delete_user_data(user):
|
|||
|
||||
|
||||
def delete_user_account(user):
|
||||
"""Permanently delete the local user's account, after wiping all of its data.
|
||||
|
||||
This first calls `delete_user_data()` to remove everything covered by the data export
|
||||
(inventory, locations, settings, friends relation, profile picture and orphaned files),
|
||||
then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote
|
||||
friends/history relating to this identity remain intact - only the local account is closed.
|
||||
|
||||
Returns a summary dict describing what was removed, with `account` set to True.
|
||||
"""
|
||||
"""Permanently delete the local user's account, after wiping all of its data via `delete_user_data()`. Returns a summary dict with `account` set to True. See docs/implementation.md#account-data-deletion for why the underlying KnownIdentity is kept."""
|
||||
from django.db import transaction
|
||||
|
||||
with transaction.atomic():
|
||||
|
|
@ -282,11 +237,7 @@ def delete_user_account(user):
|
|||
|
||||
|
||||
def _delete_orphaned_files(file_ids):
|
||||
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
|
||||
|
||||
A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no
|
||||
WorkflowInstance (staged file) references it anymore. Returns the number of files deleted.
|
||||
"""
|
||||
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. Returns the number of files deleted. See docs/implementation.md#account-data-deletion for the orphan definition."""
|
||||
from files.models import File
|
||||
|
||||
deleted = 0
|
||||
|
|
@ -360,15 +311,7 @@ def get_or_create_category(path):
|
|||
|
||||
|
||||
def import_locations(user, data):
|
||||
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`.
|
||||
|
||||
Rows are read by header label. Rows missing the required 'name' column are skipped.
|
||||
Optional columns ('description', 'category') are simply omitted if absent. Locations are
|
||||
processed in path-depth order so a child's parent already exists by the time it's needed.
|
||||
Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a
|
||||
constraint violation) can't poison the surrounding transaction and silently break every
|
||||
subsequent row.
|
||||
"""
|
||||
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`. See docs/implementation.md#location-import-ordering-and-savepoints for row ordering and error-isolation rules."""
|
||||
from django.db import transaction
|
||||
|
||||
from toolshed.models import StorageLocation
|
||||
|
|
@ -444,22 +387,11 @@ def import_friends(user, data):
|
|||
|
||||
|
||||
class _HandleNotFound(Exception):
|
||||
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
|
||||
|
||||
This intentionally aborts the whole row (rather than falling back to creating a new
|
||||
entity), since a handle references a *specific* entity from a *specific* origin - silently
|
||||
creating a new local entity named after the raw handle string would be incorrect.
|
||||
"""
|
||||
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved, aborting the row rather than creating a new entity. See docs/implementation.md#handle-resolution-semantics."""
|
||||
|
||||
|
||||
def _resolve_handle(value, entity_type, model):
|
||||
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
|
||||
|
||||
Never creates anything: a handle references a specific entity from a specific origin, so
|
||||
silently creating a new local entity named after the raw handle string would be incorrect.
|
||||
Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists,
|
||||
so the caller can skip the row and report a helpful error instead.
|
||||
"""
|
||||
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance; never creates one. See docs/implementation.md#handle-resolution-semantics for the rationale."""
|
||||
origin, rest = value.split('#', 1)
|
||||
if ':' in rest:
|
||||
found_type, name = rest.split(':', 1)
|
||||
|
|
@ -475,20 +407,14 @@ def _resolve_handle(value, entity_type, model):
|
|||
|
||||
|
||||
def _quote_value_if_needed(value):
|
||||
"""Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
|
||||
comma or a quote character, so it survives sitting inside a comma-separated "handle=value"
|
||||
list unambiguously.
|
||||
"""
|
||||
"""Wrap `value` in double quotes (CSV-style, doubling embedded quotes) if it contains a comma or quote character. See docs/implementation.md#properties-csv-encoding."""
|
||||
if any(ch in value for ch in ',"'):
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
return value
|
||||
|
||||
|
||||
def _encode_properties_cell(item_properties):
|
||||
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties'
|
||||
CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it
|
||||
still round-trips correctly. See `_parse_properties_cell()` for the reader side.
|
||||
"""
|
||||
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties' CSV cell. See docs/implementation.md#properties-csv-encoding and `_parse_properties_cell()` for the reader side."""
|
||||
entries = [
|
||||
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
|
||||
for ip in item_properties
|
||||
|
|
@ -497,15 +423,7 @@ def _encode_properties_cell(item_properties):
|
|||
|
||||
|
||||
def _split_quoted_comma_list(raw_value):
|
||||
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
|
||||
wherever they appear in an entry) so a quoted value's own commas aren't mistaken for
|
||||
separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal
|
||||
quote, and the surrounding quotes themselves are stripped from the result.
|
||||
|
||||
Only the single space after each ", " separator (as written by `_encode_properties_cell()`)
|
||||
is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that
|
||||
is genuinely part of a (quoted) value.
|
||||
"""
|
||||
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style) so a quoted value's own commas aren't mistaken for separators. See docs/implementation.md#properties-csv-encoding for the quoting/whitespace rules this implements."""
|
||||
entries = []
|
||||
current = []
|
||||
in_quotes = False
|
||||
|
|
@ -537,12 +455,7 @@ def _split_quoted_comma_list(raw_value):
|
|||
|
||||
|
||||
def _parse_properties_cell(raw_value, resolve_property):
|
||||
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples.
|
||||
|
||||
The cell is a comma-separated list of "handle=value" entries; a value containing a comma or
|
||||
a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives
|
||||
the round trip intact.
|
||||
"""
|
||||
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples. See docs/implementation.md#properties-csv-encoding for how the cell is encoded by `_encode_properties_cell()`."""
|
||||
raw_value = (raw_value or '').strip()
|
||||
if not raw_value:
|
||||
return []
|
||||
|
|
@ -561,17 +474,7 @@ def _parse_properties_cell(raw_value, resolve_property):
|
|||
|
||||
|
||||
def import_inventory(user, data, available_files):
|
||||
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
|
||||
|
||||
`available_files` maps the 'files' column's paths to File instances successfully extracted
|
||||
from the zip; references to missing files are ignored rather than failing the row.
|
||||
|
||||
If a row references a fully qualified tag/property/category handle that doesn't exist
|
||||
locally, the whole item is skipped (rather than creating a bogus local entity named after
|
||||
the raw handle) and a helpful message is added to the returned `errors` list. Each row runs
|
||||
in its own transaction savepoint so a DB-level failure on one row can't poison the
|
||||
surrounding transaction and silently break every subsequent row.
|
||||
"""
|
||||
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`. See docs/implementation.md#inventory-import-semantics for file/handle resolution and error-isolation rules."""
|
||||
from django.db import transaction
|
||||
|
||||
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
|
||||
|
|
|
|||
|
|
@ -255,11 +255,7 @@ class InventoryItemSerializer(serializers.ModelSerializer):
|
|||
|
||||
class WorkflowInstanceSerializer(serializers.ModelSerializer):
|
||||
owner = serializers.StringRelatedField(read_only=True)
|
||||
# Hash is enough to identify a staged file (the client computes the same SHA-256 the backend
|
||||
# does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this*
|
||||
# session there's nothing more to say, and for a file staged elsewhere (another device/tab),
|
||||
# hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files,
|
||||
# no fuller FileSerializer representation is needed here.
|
||||
# Only the hash is needed to identify a staged file, unlike InventoryItemSerializer.files. See docs/implementation.md#staged-files-are-identified-by-hash-alone.
|
||||
staged_files = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -85,20 +85,8 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(self.f['local_user1'].friends.count(), 1)
|
||||
|
||||
|
||||
# what ~should~ happen:
|
||||
# 1. user x@A sends a friend request to user y@B
|
||||
# 1.1. x@A's client sends a POST request to A/api/friendrequests/ with body {from: x@A, to: y@B}
|
||||
# 1.2. A's backend creates a FriendRequestOutgoing object, containing x@A's identity and y@B's name
|
||||
# 1.3. x@A's client sends a POST request to B/api/friendrequests/ with body
|
||||
# {from: x@A, to: y@B, public_key: x@A's public key}
|
||||
# 1.4. B's backend creates a FriendRequestIncoming object, containing y@B's and x@A's identities
|
||||
# 2. user y@B accepts the friend request
|
||||
# 2.1. y@B's client sends a POST request to A/api/friendsrequests/ with body
|
||||
# {from: x@A, to: y@B, public_key: y@B's public key}
|
||||
# 2.2. A's backend matches the data to the FriendRequestOutgoing object, deletes both and creates a Friend object,
|
||||
# containing x@A's and y@B's identities
|
||||
# 2.3. y@B's client sends a POST request to B/api/friends/ containing the id of the FriendRequestIncoming object
|
||||
# 2.4. B's backend creates a Friend object, using the identities from the FriendRequestIncoming object
|
||||
# Friend request/accept protocol walkthrough. See
|
||||
# docs/implementation.md#friend-request-and-accept-protocol-flow.
|
||||
|
||||
|
||||
class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase):
|
||||
|
|
|
|||
|
|
@ -388,9 +388,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
|
||||
|
||||
def test_remote_member_without_local_account_can_edit(self):
|
||||
# A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity -
|
||||
# see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned
|
||||
# items here; it must not be treated as unauthorized just because .user.exists() is False.
|
||||
# A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned
|
||||
# items - not unauthorized just because .user.exists() is False.
|
||||
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
|
||||
|
|
@ -426,8 +425,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_create_group_owned_item_with_full_fields(self):
|
||||
# Parity with InventoryApiTestCase.test_post_new_item - tags/properties/category must
|
||||
# attach to a group-owned item exactly the same way they do for a personal one.
|
||||
# Parity with InventoryApiTestCase.test_post_new_item: tags/properties/category attach
|
||||
# to a group-owned item the same way as a personal one.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
'availability_policy': 'rent',
|
||||
'category': 'cat2',
|
||||
|
|
@ -450,8 +449,7 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual([p.value for p in item.itemproperty_set.all()], ['value1', 'value2'])
|
||||
|
||||
def test_create_group_owned_item_empty_fails(self):
|
||||
# Parity with InventoryApiTestCase.test_post_new_item_empty - clean()'s name-or-files
|
||||
# validation must still apply to group-owned items.
|
||||
# Parity with InventoryApiTestCase.test_post_new_item_empty: clean()'s name-or-files validation still applies.
|
||||
reply = client.post('/api/inventory_items/', self.f['local_user1'], {
|
||||
'availability_policy': 'private', 'owned_quantity': 1, 'owner_group': self.f['group1'].id,
|
||||
})
|
||||
|
|
@ -471,9 +469,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_put_group_item(self):
|
||||
# Parity with InventoryApiTestCase.test_put_item - full replace, by a different member
|
||||
# than the one who created it, exercising the group_items_id -> _is_authorized branch
|
||||
# in perform_update for a PUT (not just PATCH).
|
||||
# Parity with InventoryApiTestCase.test_put_item, but as a PUT by a different member than
|
||||
# the creator, to exercise the _is_authorized branch in perform_update for PUT too.
|
||||
item_id = self.create_group_item().json()['id']
|
||||
reply = client.put('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
|
||||
'availability_policy': 'sell',
|
||||
|
|
@ -539,9 +536,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
self.assertEqual([f for f in item.files.all()], [self.f['test_file3']])
|
||||
|
||||
def test_group_items_excluded_from_search(self):
|
||||
# Group-owned items are only ever reachable via the group's own detail page for MVP
|
||||
# (see docs/design-in-progress/groups-mvp.md) - search must not surface them, same as
|
||||
# the main Inventory list already doesn't.
|
||||
# Group-owned items are reachable only via the group's own detail page for MVP (see
|
||||
# docs/design-in-progress/groups-mvp.md) - search must not surface them either.
|
||||
self.create_group_item(name='searchable-drill')
|
||||
InventoryItem.create_for_owner(owner=self.f['local_user1'], owned_quantity=1, name='searchable-personal')
|
||||
reply = client.get('/api/search/?query=searchable', self.f['local_user1'])
|
||||
|
|
@ -551,8 +547,8 @@ class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, CategoryTest
|
|||
|
||||
|
||||
class InventoryItemIdAllocationTestCase(UserTestMixin, ToolshedTestCase):
|
||||
"""InventoryItem.id is sequential and gapless within each owner/owner_group's own items,
|
||||
never reused, and allocated independently per scope - see OwnerItemSequence."""
|
||||
"""InventoryItem.id is sequential, gapless, and never reused within each owner/owner_group's
|
||||
own items, allocated independently per scope (see OwnerItemSequence)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
|||
|
|
@ -123,10 +123,7 @@ class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
|
|||
|
||||
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||
ToolshedTestCase):
|
||||
"""Properties must round-trip through export/import even when their value contains a
|
||||
comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
|
||||
the 'properties' CSV cell would misinterpret as a field/entry separator.
|
||||
"""
|
||||
"""Properties must round-trip through export/import even when their value contains a comma or '=' sign, which a naive "handle=value, handle2=value2" encoding would otherwise misinterpret as a separator."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
@ -222,10 +219,7 @@ class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTes
|
|||
|
||||
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
|
||||
ToolshedTestCase):
|
||||
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
|
||||
clients), rather than calling the internal helper functions directly - this is what a real
|
||||
export/import round trip between two accounts looks like.
|
||||
"""
|
||||
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by clients), rather than calling the internal helper functions directly - this is what a real export/import round trip between two accounts looks like."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue