This commit is contained in:
j3d1 2026-09-06 01:51:19 +02:00
parent 240a508306
commit 7135fce421
18 changed files with 288 additions and 112 deletions

View file

@ -1,3 +1,10 @@
# Worker stays unprivileged as the image's default "nginx" user, but gains the "root" group so
# it can read /var/www/userfiles - backend-b (running as root) writes those with
# FILE_UPLOAD_DIRECTORY_PERMISSIONS=0750, group-readable but not world-readable. Mirrors
# deploy/prod/playbook.yml's "Allow nginx to read backend user files" (www-data joins the
# backend's file-owning group instead of loosening the mode).
user nginx root;
events {}
http {

View file

@ -197,93 +197,6 @@
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"string-width-cjs": {
"version": "npm:string-width@4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"dependencies": {
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
},
"strip-ansi-cjs": {
"version": "npm:strip-ansi@6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
},
"wrap-ansi-cjs": {
"version": "npm:wrap-ansi@7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
}
}
},
"@jridgewell/sourcemap-codec": {
@ -1704,6 +1617,40 @@
"strip-ansi": "^7.0.1"
}
},
"string-width-cjs": {
"version": "npm:string-width@4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
},
"strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
@ -1713,6 +1660,23 @@
"ansi-regex": "^6.2.2"
}
},
"strip-ansi-cjs": {
"version": "npm:strip-ansi@6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
}
}
},
"strip-literal": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz",
@ -2010,6 +1974,60 @@
"strip-ansi": "^7.0.1"
}
},
"wrap-ansi-cjs": {
"version": "npm:wrap-ansi@7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
},
"ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",

View file

@ -24,10 +24,10 @@
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'masonry'" class="btn">
<b-icon-list></b-icon-list>
<b-icon-columns-gap></b-icon-columns-gap>
</button>
<button v-else-if="layout === 'masonry'" @click="layout = 'table'" class="btn">
<b-icon-columns-gap></b-icon-columns-gap>
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
@ -311,6 +311,10 @@ export default {
object-fit: cover;
}
.table-action {
white-space: nowrap;
}
.masonry-grid {
column-count: 2;
column-gap: 1rem;

View file

@ -20,7 +20,10 @@
<div class="btn-group">
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<button v-if="layout === 'grid'" @click="layout = 'tree'" class="btn">
<b-icon-list-nested></b-icon-list-nested>
</button>
<button v-else-if="layout === 'tree'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
@ -73,6 +76,40 @@
</tr>
</tbody>
</table>
<div class="card-body" v-else-if="layout === 'tree'">
<TreeView :items="locationTree">
<template #default="{item: location}">
<div class="tree-location-row">
<router-link :to="locationRoute(location)" class="tree-location-name text-truncate">
{{ location.name }}
</router-link>
<span class="tree-location-category">
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="text-muted" v-else>-</span>
</span>
<span class="tree-location-visibility">
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</span>
<div class="tree-location-actions table-action">
<router-link :to="locationEditRoute(location)" class="me-1" title="Edit">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<guarded-button tag="a" class="me-1" title="Delete"
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
@confirm="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash>
</guarded-button>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)" class="me-1">
<b-icon-link></b-icon-link>
</router-link>
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)" title="Print Label">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</template>
</TreeView>
</div>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2 d-flex" v-for="location in locations"
@ -131,6 +168,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import GuardedButton from "@/components/GuardedButton.vue";
import TreeView from "@/components/TreeView.vue";
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
@ -145,6 +183,7 @@ export default {
components: {
BaseLayout,
GuardedButton,
TreeView,
...BIcons
},
computed: {
@ -178,6 +217,21 @@ export default {
locations() {
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
},
// See docs/implementation.md#category-tree-reconstruction-from-flat-path-strings.
locationTree() {
const nodesById = {}
for (const location of this.locations) {
nodesById[location.id] = {...location, children: []}
}
const roots = []
for (const location of [...this.locations].sort((a, b) => a.name.localeCompare(b.name))) {
const node = nodesById[location.id]
// Falls back to root if the parent isn't in this owner's own location list.
const parent = location.parent != null ? nodesById[location.parent] : null
;(parent ? parent.children : roots).push(node)
}
return roots
},
addLocationRoute() {
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
return {name: 'locations-new', params}
@ -240,6 +294,29 @@ export default {
font-size: 0.8rem;
}
.tree-location-row {
display: grid;
/* Category/Visibility match the table's 20% th widths; actions is max-content so it never wraps. */
grid-template-columns: minmax(0, 1fr) 20% 20% auto;
align-items: center;
column-gap: 0.5rem;
width: 100%;
}
.tree-location-name {
min-width: 0;
}
.tree-location-actions {
display: flex;
align-items: center;
}
/* The th widths above leave Actions only the leftover 5% - without this it wraps mid-icon. */
.table-action {
white-space: nowrap;
}
.btn-group.mt-auto {
width: 100%;
}

BIN
testdata/avatars/avatar-2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
testdata/avatars/avatar-3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/avatars/avatar-4.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
testdata/avatars/avatar-5.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

BIN
testdata/avatars/avatar-6.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/avatars/avatar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -17,6 +17,7 @@ from nacl.signing import SigningKey
from nacl.encoding import HexEncoder
IMGS_DIR = os.path.join(os.path.dirname(__file__), 'imgs')
AVATARS_DIR = os.path.join(os.path.dirname(__file__), 'avatars')
IMAGE_ATTACHMENT_PROBABILITY = 0.9
@ -254,11 +255,11 @@ def load_shared_data():
return all_tags, all_categories, all_properties
def load_available_images():
"""Read every file in imgs/ and return a list of (data, mime_type) tuples."""
def _load_images_from_dir(directory):
"""Read every image file in the given directory and return a list of (data, mime_type) tuples."""
images = []
for filename in sorted(os.listdir(IMGS_DIR)):
filepath = os.path.join(IMGS_DIR, filename)
for filename in sorted(os.listdir(directory)):
filepath = os.path.join(directory, filename)
if not os.path.isfile(filepath):
continue
mime_type, _ = mimetypes.guess_type(filename)
@ -269,6 +270,16 @@ def load_available_images():
return images
def load_available_images():
"""Read every file in imgs/ and return a list of (data, mime_type) tuples."""
return _load_images_from_dir(IMGS_DIR)
def load_available_avatars():
"""Read every file in avatars/ and return a list of (data, mime_type) tuples."""
return _load_images_from_dir(AVATARS_DIR)
def generate_keypair():
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
signing_key = SigningKey.generate()
@ -277,6 +288,18 @@ def generate_keypair():
return private_hex, public_hex
def load_or_generate_keypair(key_path):
"""Return (hex_private_key, hex_public_key), reusing the key at key_path if it already
exists instead of generating (and thereby invalidating) a new identity."""
if os.path.exists(key_path):
with open(key_path, 'r') as f:
private_hex = f.read().strip()
signing_key = SigningKey(private_hex.encode('utf-8'), encoder=HexEncoder)
public_hex = signing_key.verify_key.encode(encoder=HexEncoder).decode('utf-8')
return private_hex, public_hex
return generate_keypair()
def generate_locations_csv(count):
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
@ -442,6 +465,24 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
return output.getvalue().encode('utf-8'), files
def generate_profile_data(username, domain, first_name, last_name, avatar_data, avatar_mime_type):
"""Build profile.json content and the (arcname, data) for its picture, matching
`toolshed.offlinedata.profile_data()`/`profile_picture_files()`'s 'files/<hash><ext>' convention.
"""
extension = mimetypes.guess_extension(avatar_mime_type) or ''
arcname = f'files/{sha256(avatar_data).hexdigest()}{extension}'
profile = {
'username': username,
'domain': domain,
'email': f'{username}@{domain}',
'first_name': first_name,
'last_name': last_name,
'profile_picture': arcname,
}
return json.dumps(profile, indent=2).encode('utf-8'), arcname
def generate_sample_files():
"""Generate sample files for the 'files/' folder in the zip."""
files = {}
@ -458,7 +499,8 @@ def generate_sample_files():
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
num_locations=10, num_items=150, item_range=None):
num_locations=10, num_items=150, item_range=None,
first_name='', last_name='', avatar=None):
"""Generate a zip file (bytes) like user_data() produces.
Args:
@ -470,6 +512,9 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
num_locations: Number of locations to generate
num_items: Number of items to generate
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
first_name: First name for profile.json
last_name: Last name for profile.json
avatar: (data, mime_type) tuple for the profile picture, or None to skip profile.json
"""
num_locations = max(5, min(20, num_locations))
num_items = max(100, min(200, num_items))
@ -495,6 +540,13 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
for arcname, content in sample_files.items():
zip_file.writestr(arcname, content)
if avatar:
avatar_data, avatar_mime_type = avatar
profile_json, avatar_arcname = generate_profile_data(
username, domain, first_name, last_name, avatar_data, avatar_mime_type)
zip_file.writestr('profile.json', profile_json)
zip_file.writestr(avatar_arcname, avatar_data)
return zip_buffer.getvalue()
@ -506,20 +558,30 @@ def main():
all_tags, all_categories, all_properties = load_shared_data()
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
print("\nGenerating keypairs...")
user_a_private, user_a_public = generate_keypair()
user_b_private, user_b_public = generate_keypair()
print("\nLoading/generating keypairs...")
user_a_key_path = os.path.join(script_dir, 'user-a.key')
user_b_key_path = os.path.join(script_dir, 'user-b.key')
user_a_existed = os.path.exists(user_a_key_path)
user_b_existed = os.path.exists(user_b_key_path)
user_a_private, user_a_public = load_or_generate_keypair(user_a_key_path)
user_b_private, user_b_public = load_or_generate_keypair(user_b_key_path)
print(f" user-a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
print(f" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
print(f" test_a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
print(f" test_b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
print("\nWriting key files...")
with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
if user_a_existed:
print(" ✓ user-a.key (reused existing)")
else:
with open(user_a_key_path, 'w') as f:
f.write(user_a_private)
with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
print(" ✓ user-a.key (generated)")
if user_b_existed:
print(" ✓ user-b.key (reused existing)")
else:
with open(user_b_key_path, 'w') as f:
f.write(user_b_private)
print(" ✓ user-a.key")
print(" ✓ user-b.key")
print(" ✓ user-b.key (generated)")
print("\nGenerating realistic export zips with partitioned items...")
# Partition the ITEMS_DATABASE randomly
@ -531,13 +593,21 @@ def main():
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
available_avatars = load_available_avatars()
if len(available_avatars) >= 2:
user_a_avatar, user_b_avatar = random.sample(available_avatars, 2)
else:
user_a_avatar = user_b_avatar = available_avatars[0] if available_avatars else None
user_a_zip = generate_export_zip(
'user-a', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
num_locations=12, num_items=175, item_range=user_a_item_range
'test_a', 'a.localhost', 'test_b', 'b.localhost', user_b_public,
num_locations=12, num_items=175, item_range=user_a_item_range,
first_name='Alice', last_name='Anderson', avatar=user_a_avatar
)
user_b_zip = generate_export_zip(
'user-b', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
num_locations=8, num_items=140, item_range=user_b_item_range
'test_b', 'b.localhost', 'test_a', 'a.localhost', user_a_public,
num_locations=8, num_items=140, item_range=user_b_item_range,
first_name='Bob', last_name='Baker', avatar=user_b_avatar
)
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:

BIN
testdata/imgs/unsplash-1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/imgs/unsplash-2.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

BIN
testdata/imgs/unsplash-3.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

2
testdata/user-a.key vendored
View file

@ -1 +1 @@
982abc6680c1358c9d4d2436c96c00aebe36e9dd50fe184c12f1ac58fc7f8565
53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b

BIN
testdata/user-a.zip vendored

Binary file not shown.

2
testdata/user-b.key vendored
View file

@ -1 +1 @@
2e81e04093250c647cb2c364e39017be5aeac0303e138d6ded2e2cc7c3cdcdde
8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9

BIN
testdata/user-b.zip vendored

Binary file not shown.