stash
This commit is contained in:
parent
3299c97392
commit
de7d9426be
10 changed files with 259 additions and 99 deletions
|
|
@ -36,6 +36,7 @@ urlpatterns = [
|
|||
path('admin/', include('hostadmin.api')),
|
||||
path('api/', include('toolshed.api.friend')),
|
||||
path('api/', include('toolshed.api.group')),
|
||||
path('api/', include('toolshed.api.idmap')),
|
||||
path('api/', include('toolshed.api.inventory')),
|
||||
path('api/', include('toolshed.api.info')),
|
||||
path('api/', include('toolshed.api.files')),
|
||||
|
|
|
|||
28
backend/toolshed/api/idmap.py
Normal file
28
backend/toolshed/api/idmap.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from django.urls import path
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
|
||||
from authentication.models import KnownIdentity
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
|
||||
|
||||
|
||||
class IdMap(APIView, ViewSetMixin):
|
||||
authentication_classes = [SignatureAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, format=None): # /api/idmap/
|
||||
identity = request.user
|
||||
identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk)
|
||||
groups = identity.member_of_groups.all()
|
||||
return Response({
|
||||
'identities': FriendSerializer(identities, many=True).data,
|
||||
'groups': GroupIdMapSerializer(groups, many=True).data,
|
||||
})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('idmap/', IdMap.as_view(), name='idmap'),
|
||||
]
|
||||
|
|
@ -92,6 +92,17 @@ class GroupSerializer(serializers.ModelSerializer):
|
|||
return str(obj)
|
||||
|
||||
|
||||
class GroupIdMapSerializer(serializers.ModelSerializer):
|
||||
handle = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ['id', 'name', 'domain', 'handle']
|
||||
|
||||
def get_handle(self, obj):
|
||||
return str(obj)
|
||||
|
||||
|
||||
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
||||
group = serializers.SerializerMethodField()
|
||||
inviter = serializers.SerializerMethodField()
|
||||
|
|
|
|||
48
backend/toolshed/tests/test_idmap.py
Normal file
48
backend/toolshed/tests/test_idmap.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from django.test import Client
|
||||
|
||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
||||
|
||||
client = SignatureAuthClient()
|
||||
|
||||
|
||||
class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.prepare_users()
|
||||
|
||||
def test_idmap_includes_self(self):
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities])
|
||||
|
||||
def test_idmap_includes_friends(self):
|
||||
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||
|
||||
def test_idmap_excludes_non_friends(self):
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
identities = reply.json()['identities']
|
||||
self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||
|
||||
def test_idmap_includes_member_groups(self):
|
||||
self.prepare_groups()
|
||||
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
groups = reply.json()['groups']
|
||||
self.assertIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||
|
||||
def test_idmap_excludes_non_member_groups(self):
|
||||
self.prepare_groups()
|
||||
reply = client.get('/api/idmap/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
groups = reply.json()['groups']
|
||||
self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||
|
||||
def test_idmap_unauthenticated(self):
|
||||
reply = Client().get('/api/idmap/')
|
||||
self.assertEqual(reply.status_code, 403)
|
||||
|
|
@ -28,6 +28,7 @@ import Scan from "@/views/Scan.vue";
|
|||
import Workflows from '@/views/Workflows.vue';
|
||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||
import ShortId from '@/views/ShortId.vue';
|
||||
import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id';
|
||||
|
||||
import Account from '@/views/settings/Account.vue';
|
||||
import Password from '@/views/settings/Password.vue';
|
||||
|
|
@ -46,6 +47,23 @@ export function decodeHandleFromUrl(segment) {
|
|||
return segment.replace(/\+/g, "#");
|
||||
}
|
||||
|
||||
const EXPANDED_ROUTE_BUILDERS = {
|
||||
item: ({item_local_id}) => `/inventory/${item_local_id}`,
|
||||
group_item: ({item_local_id}) => `/inventory/${item_local_id}`,
|
||||
group: ({group_id}) => `/groups/${group_id}`,
|
||||
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
|
||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||
};
|
||||
|
||||
export function expandedRoute({kind, ...fields}) {
|
||||
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||
return buildRoute ? buildRoute(fields) : null;
|
||||
}
|
||||
|
||||
export function shortenedRoute(named) {
|
||||
return '/' + encodeShortId(serializeShortId(named));
|
||||
}
|
||||
|
||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||
path: '/inventory',
|
||||
component: Inventory,
|
||||
|
|
@ -72,6 +90,16 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
const {id} = to.params
|
||||
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
|
||||
}
|
||||
}, {
|
||||
path: '/:short_id',
|
||||
redirect: to => {
|
||||
console.log(to)
|
||||
const p = deserializeShortId(decodeShortId(to.params.short_id))
|
||||
console.log(p)
|
||||
const url = expandedRoute(p)
|
||||
console.log(url)
|
||||
return url;
|
||||
}
|
||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||
path: '/friends',
|
||||
component: Friends,
|
||||
|
|
@ -161,9 +189,14 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
component: StorageLocationNew,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/:short_id',
|
||||
path: '/debug/:short_id',
|
||||
component: ShortId,
|
||||
props: true
|
||||
}, {
|
||||
path: '/:short_id',
|
||||
redirect: to => {
|
||||
|
||||
}
|
||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||
|
||||
const router = createRouter({
|
||||
|
|
|
|||
|
|
@ -8,31 +8,37 @@ const SCHEMAS = {
|
|||
fields: ['owner_identity_id', 'item_local_id'],
|
||||
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
|
||||
},
|
||||
//0-1 left free for later use
|
||||
'1-2': {
|
||||
name: 'group_item',
|
||||
fields: ['owner_group_id', 'item_local_id'],
|
||||
interpret: ([owner_group_id, item_local_id]) => ({kind: 'group_item', owner_group_id, item_local_id})
|
||||
},
|
||||
'1-1': {
|
||||
name: 'group',
|
||||
fields: ['group_id'],
|
||||
interpret: ([group_id]) => ({kind: 'group', group_id})
|
||||
},
|
||||
'2-2': {
|
||||
name: 'storage_location',
|
||||
fields: ['owner_identity_id', 'storage_location_id'],
|
||||
interpret: ([owner_identity_id, storage_location_id]) =>
|
||||
({kind: 'storage_location', owner_identity_id, storage_location_id})
|
||||
},
|
||||
'2-1': {
|
||||
name: 'category',
|
||||
fields: ['category_id'],
|
||||
interpret: ([category_id]) => ({kind: 'category', category_id})
|
||||
name: 'file',
|
||||
fields: ['file_id'],
|
||||
interpret: ([file_id]) => ({kind: 'file', file_id})
|
||||
},
|
||||
'3-2': {
|
||||
name: 'workflow',
|
||||
fields: ['owner_identity_id', 'workflow_id'],
|
||||
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
|
||||
},
|
||||
'4-1': {
|
||||
name: 'group',
|
||||
fields: ['group_id'],
|
||||
interpret: ([group_id]) => ({kind: 'group', group_id})
|
||||
},
|
||||
'5-1': {
|
||||
name: 'file',
|
||||
fields: ['file_id'],
|
||||
interpret: ([file_id]) => ({kind: 'file', file_id})
|
||||
'3-1': {
|
||||
name: 'category',
|
||||
fields: ['category_id'],
|
||||
interpret: ([category_id]) => ({kind: 'category', category_id})
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +88,10 @@ class BitReader {
|
|||
}
|
||||
return value
|
||||
}
|
||||
|
||||
bitsLeft() {
|
||||
return this.bits.length - this.pos
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlToBits(text) {
|
||||
|
|
@ -128,25 +138,6 @@ function readChunkedInt(reader, chunkBits) {
|
|||
}
|
||||
}
|
||||
|
||||
function entriesForKindIndex(kindIndex) {
|
||||
return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`))
|
||||
}
|
||||
|
||||
function schemaForKindIndex(kindIndex) {
|
||||
const matches = entriesForKindIndex(kindIndex)
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`unknown short id kind index: ${kindIndex}`)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one `
|
||||
+ `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} `
|
||||
+ `- only deserializeShortId's (kind, fields.length) lookup can tell those apart`
|
||||
)
|
||||
}
|
||||
return matches[0][1]
|
||||
}
|
||||
|
||||
function schemaForKindAndArity(kindIndex, arity) {
|
||||
const schema = SCHEMAS[`${kindIndex}-${arity}`]
|
||||
if (!schema) {
|
||||
|
|
@ -160,10 +151,12 @@ export function encodeShortId(ints) {
|
|||
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
|
||||
}
|
||||
const [kindIndex, ...fieldValues] = ints
|
||||
const schema = schemaForKindIndex(kindIndex)
|
||||
if (fieldValues.length !== schema.fields.length) {
|
||||
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
|
||||
if (fieldValues[fieldValues.length - 1] === 0) {
|
||||
throw new Error(
|
||||
`short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}`
|
||||
`short id kind index ${kindIndex} ('${schema.name}'): the last field's value must not be 0 - `
|
||||
+ `reserved so decodeShortId can tell a real trailing field apart from zero-padding without `
|
||||
+ `knowing the kind's arity up front (see docs/handles-and-shortids.md)`
|
||||
)
|
||||
}
|
||||
const writer = new BitWriter()
|
||||
|
|
@ -188,8 +181,14 @@ export function decodeShortId(token) {
|
|||
if (kindIndex === DIRECT_KIND_COUNT) {
|
||||
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
|
||||
}
|
||||
const schema = schemaForKindIndex(kindIndex)
|
||||
const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS)))
|
||||
const fieldValues = []
|
||||
while (reader.bitsLeft() >= CHUNK_BITS + 1) {
|
||||
const value = readChunkedInt(reader, CHUNK_BITS)
|
||||
if (value === 0n && reader.bitsLeft() === 0) {
|
||||
break // a trailing all-zero chunk with nothing after it is padding, not a real field
|
||||
}
|
||||
fieldValues.push(Number(value))
|
||||
}
|
||||
return [kindIndex, ...fieldValues]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default createStore({
|
|||
friendProfiles: {},
|
||||
groups: [],
|
||||
groupInvites: [],
|
||||
idmap: {identities: [], groups: []},
|
||||
item_map: {},
|
||||
home_servers: null,
|
||||
all_friends_servers: null,
|
||||
|
|
@ -112,6 +113,9 @@ export default createStore({
|
|||
setGroupInvites(state, invites) {
|
||||
state.groupInvites = invites;
|
||||
},
|
||||
setIdMap(state, idmap) {
|
||||
state.idmap = idmap;
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
},
|
||||
|
|
@ -498,6 +502,12 @@ export default createStore({
|
|||
commit('setGroups', data)
|
||||
return data
|
||||
},
|
||||
async fetchIdMap({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const idmap = await servers.get(getters.signAuth, '/api/idmap/')
|
||||
commit('setIdMap', idmap)
|
||||
return idmap
|
||||
},
|
||||
async fetchGroup({dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
|
||||
|
|
@ -786,6 +796,12 @@ export default createStore({
|
|||
return state.item_map['/'] || []
|
||||
},
|
||||
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
||||
identityIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||
},
|
||||
groupIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
||||
},
|
||||
loaded_items(state) {
|
||||
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
||||
return acc.concat(items)
|
||||
|
|
|
|||
|
|
@ -5,23 +5,60 @@ test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
|||
expect(token).toBe('~DyU')
|
||||
})
|
||||
|
||||
test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => {
|
||||
test('kind ids are scoped per arity: id 0 means item (arity 2) or category (arity 1)', () => {
|
||||
expect(deserializeShortId(decodeShortId(encodeShortId([0, 7, 42])))).toEqual({
|
||||
kind: 'item', owner_identity_id: 7, item_local_id: 42
|
||||
})
|
||||
expect(deserializeShortId(decodeShortId(encodeShortId([0, 5])))).toEqual({
|
||||
kind: 'category', category_id: 5
|
||||
})
|
||||
})
|
||||
|
||||
test('every kind round-trips through serialize/encode/decode/deserialize', () => {
|
||||
const cases = [
|
||||
[0, 7, 42],
|
||||
[1, 3, 1000],
|
||||
[2, 0],
|
||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||
{kind: 'category', category_id: 5},
|
||||
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||
{kind: 'file', file_id: 123},
|
||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
]
|
||||
for (const ints of cases) {
|
||||
const token = encodeShortId(ints)
|
||||
expect(decodeShortId(token)).toEqual(ints)
|
||||
for (const named of cases) {
|
||||
const token = encodeShortId(serializeShortId(named))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||
}
|
||||
})
|
||||
|
||||
test('round-trips small and large values, including zero', () => {
|
||||
const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789]
|
||||
test('a non-last field may be 0 - only the last field is restricted', () => {
|
||||
const named = {kind: 'item', owner_identity_id: 0, item_local_id: 42}
|
||||
const token = encodeShortId(serializeShortId(named))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||
})
|
||||
|
||||
test('rejects a 0 last-field value, for a single-field kind', () => {
|
||||
expect(() => encodeShortId(serializeShortId({kind: 'category', category_id: 0}))).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a 0 last-field value, for a multi-field kind', () => {
|
||||
expect(() => encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 0}))).toThrow()
|
||||
})
|
||||
|
||||
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
|
||||
'mistaken for a second field', () => {
|
||||
// category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly
|
||||
// 5 zero bits - the same 5 bits as a genuine one-chunk field of value 0. This is the concrete
|
||||
// case the last-field-nonzero rule exists to disambiguate.
|
||||
const token = encodeShortId([0, 5])
|
||||
expect(token).toBe('~Cg')
|
||||
expect(decodeShortId(token)).toEqual([0, 5])
|
||||
})
|
||||
|
||||
test('round-trips small and large nonzero values, in both last and non-last field position', () => {
|
||||
const values = [1, 7, 42, 100, 10000, 100000, 123456789]
|
||||
for (const value of values) {
|
||||
const token = encodeShortId([2, value])
|
||||
expect(decodeShortId(token)).toEqual([2, value])
|
||||
expect(decodeShortId(encodeShortId([0, value]))).toEqual([0, value])
|
||||
expect(decodeShortId(encodeShortId([0, value, 1]))).toEqual([0, value, 1])
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -31,30 +68,11 @@ test('tokens are URL-safe: only unreserved characters, plus the leading ~', () =
|
|||
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
|
||||
})
|
||||
|
||||
test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => {
|
||||
const cases = [
|
||||
[[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}],
|
||||
[[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}],
|
||||
[[2, 0], {kind: 'category', category_id: 0}],
|
||||
[[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}],
|
||||
[[4, 11], {kind: 'group', group_id: 11}],
|
||||
[[5, 123], {kind: 'file', file_id: 123}],
|
||||
]
|
||||
for (const [ints, named] of cases) {
|
||||
expect(deserializeShortId(ints)).toEqual(named)
|
||||
expect(serializeShortId(named)).toEqual(ints)
|
||||
}
|
||||
test('kinds at escaped ids (3+) round-trip through the escape tag, not just the direct 0-2 range', () => {
|
||||
const token = encodeShortId(serializeShortId({kind: 'workflow', owner_identity_id: 2, workflow_id: 9}))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual({
|
||||
kind: 'workflow', owner_identity_id: 2, workflow_id: 9
|
||||
})
|
||||
|
||||
test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => {
|
||||
for (const named of [
|
||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'file', file_id: 123},
|
||||
]) {
|
||||
const token = encodeShortId(serializeShortId(named))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||
}
|
||||
})
|
||||
|
||||
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
|
||||
|
|
@ -75,30 +93,19 @@ test('rejects an unknown kind index', () => {
|
|||
expect(() => encodeShortId([99, 1])).toThrow()
|
||||
})
|
||||
|
||||
test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => {
|
||||
// kind=1 with the arity storage_location actually has (2 fields) is fine...
|
||||
expect(deserializeShortId([1, 3, 1000])).toEqual({
|
||||
kind: 'storage_location',
|
||||
owner_identity_id: 3,
|
||||
storage_location_id: 1000
|
||||
})
|
||||
// ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must
|
||||
// not be silently misread as a truncated storage_location.
|
||||
expect(() => deserializeShortId([1, 359])).toThrow()
|
||||
test('rejects an arity with no registered schema at that kind index', () => {
|
||||
// kind 0 is registered for arity 2 (item) and arity 1 (category), but not arity 3.
|
||||
expect(() => encodeShortId([0, 7, 8, 9])).toThrow()
|
||||
})
|
||||
|
||||
test('rejects an unknown kind name', () => {
|
||||
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a field count mismatch', () => {
|
||||
expect(() => encodeShortId([0, 7])).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a missing field', () => {
|
||||
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a negative field value', () => {
|
||||
expect(() => encodeShortId([2, -1])).toThrow()
|
||||
expect(() => encodeShortId([0, -1, 42])).toThrow()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>
|
||||
<router-link :to="shortIdLink(item)">
|
||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
</td>
|
||||
|
|
@ -73,7 +73,8 @@
|
|||
<router-link :to="`/inventory/${item.id}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
</router-link>
|
||||
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm">
|
||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -100,7 +101,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||
import {encodeShortId, serializeShortId} from "@/short-id";
|
||||
import {shortenedRoute} from "@/router";
|
||||
|
||||
export default {
|
||||
name: "Inventory",
|
||||
|
|
@ -115,11 +116,11 @@ export default {
|
|||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["inventory_items", "loaded_items"]),
|
||||
...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
|
||||
...mapState(["user", "storage_locations"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]),
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
|
|
@ -128,16 +129,20 @@ export default {
|
|||
return loc ? loc.path : null
|
||||
},
|
||||
shortIdLink(item) {
|
||||
// owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk
|
||||
// per item (see docs/handles-and-shortids.md) - it doesn't decode to a
|
||||
// meaningful owner yet.
|
||||
const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id});
|
||||
return '/' + encodeShortId(ints);
|
||||
if (item.owner_group) {
|
||||
const owner_group_id = this.groupIdByHandle[item.owner_group]
|
||||
if (owner_group_id === undefined) return null
|
||||
return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id})
|
||||
}
|
||||
const owner_identity_id = this.identityIdByHandle[item.owner]
|
||||
if (owner_identity_id === undefined) return null
|
||||
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
await this.fetchStorageLocations()
|
||||
await this.fetchIdMap()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Expanded</div>
|
||||
<div class="card-body">
|
||||
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
|
||||
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">Examples</div>
|
||||
<table class="table table-striped mb-0">
|
||||
|
|
@ -56,14 +63,16 @@
|
|||
<script>
|
||||
import BaseLayout from '@/components/BaseLayout.vue';
|
||||
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
||||
import {expandedRoute as buildExpandedRoute} from '@/router';
|
||||
|
||||
const EXAMPLES = [
|
||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||
{kind: 'file', file_id: 123},
|
||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||
{kind: 'category', category_id: 5},
|
||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'file', file_id: 123},
|
||||
];
|
||||
|
||||
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
||||
|
|
@ -130,6 +139,9 @@ export default {
|
|||
const {kind, ...fields} = this.deserialized;
|
||||
return fields;
|
||||
},
|
||||
expandedRoute() {
|
||||
return buildExpandedRoute(this.deserialized);
|
||||
},
|
||||
examples() {
|
||||
return EXAMPLES.map(named => {
|
||||
const {kind, ...fields} = named;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue