From ed04d98bf18deace78aea36a518ed15875fc0c7d Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 24 Aug 2026 15:57:17 +0200 Subject: [PATCH 1/4] stash --- backend/authentication/api.py | 24 +- backend/authentication/models.py | 13 +- backend/authentication/signature_auth.py | 14 +- backend/backend/asgi.py | 9 - backend/backend/settings.py | 45 +-- backend/backend/urls.py | 15 - backend/backend/wsgi.py | 9 - backend/configure.py | 7 +- backend/files/media_urls.py | 50 +-- backend/files/models.py | 10 +- backend/files/tests.py | 28 +- backend/toolshed/api/files.py | 10 +- backend/toolshed/api/inventory.py | 27 +- backend/toolshed/models.py | 15 +- backend/toolshed/offlinedata.py | 131 +------- backend/toolshed/serializers.py | 6 +- backend/toolshed/tests/test_friend.py | 16 +- backend/toolshed/tests/test_inventory.py | 26 +- backend/toolshed/tests/test_offlinedata.py | 10 +- deploy/dev/Dockerfile.proxy | 13 - deploy/dev/docker-compose.yml | 10 + docs/index.md | 1 + frontend/src/cameraManager.js | 6 +- .../src/components/AuthenticatedImage.vue | 5 +- .../src/components/LabelLayoutPreview.vue | 94 ++++-- .../components/inputs/CameraFileSource.vue | 4 +- .../components/inputs/DragDropFileSource.vue | 4 +- .../src/components/inputs/FsFileSource.vue | 4 +- .../components/inputs/WebcamFileSource.vue | 4 +- .../workflows/BulkItemImportWorkflow.vue | 31 +- .../workflows/FotoFirstBulkImportWorkflow.vue | 26 +- .../FotoFirstBulkImportWorkflow2.vue | 15 +- frontend/src/federation.js | 8 +- frontend/src/fileCache.js | 16 +- frontend/src/handle-url.js | 8 +- frontend/src/label-layouts.js | 74 ++--- frontend/src/label.js | 311 +++++------------- frontend/src/router.js | 26 +- frontend/src/scss/_pixel-fonts.scss | 13 +- frontend/src/scss/toolshed.scss | 1 + frontend/src/store.js | 63 ++-- frontend/src/tests/short-id.js | 4 +- frontend/src/views/Inventory.vue | 14 +- frontend/src/views/InventoryDetail.vue | 9 +- frontend/src/views/InventoryEdit.vue | 6 +- frontend/src/views/Print.vue | 256 +++++--------- frontend/src/views/Scan.vue | 266 ++++++++++++--- frontend/src/views/Search.vue | 6 +- frontend/src/views/ShortId.vue | 10 +- frontend/src/views/StorageLocation.vue | 7 +- frontend/src/views/Swatch.vue | 27 +- frontend/src/views/WorkflowDetail.vue | 4 +- frontend/src/views/Workflows.vue | 4 - frontend/src/workflows.js | 60 +--- 54 files changed, 661 insertions(+), 1214 deletions(-) diff --git a/backend/authentication/api.py b/backend/authentication/api.py index 1e3f09c..599fea5 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -18,9 +18,8 @@ from hostadmin.models import Domain router = routers.SimpleRouter() -# Schema for the account-level preferences a client may store on the server (see -# AccountPreference). Device-level preferences are never sent here - they stay in the -# browser's local storage since they describe the device, not the account. +# Schema for account-level preferences a client may store server-side (see AccountPreference); +# device-level preferences stay in the browser's local storage instead. PREFERENCE_DEFINITIONS = [ { 'key': 'ui.compact_mode', @@ -102,9 +101,8 @@ class UserViewSet(viewsets.ModelViewSet): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def getUserInfo(request): - """Get or update the authenticated local user's own account info. Only usable by the - account owner on their own home server - see getUserProfile for viewing another (friend) - user's public profile.""" + """Get or update the authenticated local user's own account info; only the account owner may + call this on their own home server (see getUserProfile for viewing a friend's public profile).""" user = request.user if request.method == 'PATCH': old_file = user.profile_picture @@ -147,9 +145,9 @@ def getUserInfo(request): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def getUserProfile(request, handle): - """Get another local user's public profile by handle (username@domain), e.g. so a friend - can look up someone's avatar. The caller must be a friend of that user (or the user - itself, signing with their own known identity rather than their local credentials).""" + """Get another local user's public profile by handle, e.g. so a friend can look up an avatar; + caller must be a friend of that user (or the user itself, signing with their own known + identity rather than local credentials).""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: @@ -213,11 +211,9 @@ def preference_definitions(request): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) def account_preferences(request): - """Get or bulk-upsert the authenticated user's account-level preferences. - - GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value} - dict of one or more preferences to set/overwrite; unspecified keys are left untouched. - """ + """Get or bulk-upsert the authenticated user's account-level preferences: GET returns the + current preferences as {key: value}; PUT sets/overwrites one or more, leaving unspecified + keys untouched.""" if request.method == 'PUT': if not isinstance(request.data, dict): return Response({'detail': 'Expected an object of key/value pairs.'}, status=400) diff --git a/backend/authentication/models.py b/backend/authentication/models.py index e120ce8..a19e759 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -114,11 +114,8 @@ class ToolshedUser(AbstractUser): class AccountPreference(models.Model): - """A single account-level (server-synced, cross-device) user preference, stored as a key/value pair. - - Device-level preferences are intentionally *not* stored here - they stay in the browser's - local storage since they describe the device, not the account. - """ + """A single account-level (server-synced, cross-device) preference as a key/value pair; + device-level preferences are intentionally *not* stored here.""" user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences') key = models.CharField(max_length=255) value = models.JSONField() @@ -165,9 +162,9 @@ class Group(models.Model): class GroupInvite(models.Model): - """A pending invite tracked on the group's own home backend, checked when the invitee's - accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own - backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance).""" + """A pending invite tracked on the group's own home backend, checked when the invitee's accept + request arrives (mirror: GroupInviteIncoming on the invitee's backend; see + docs/design-in-progress/groups-mvp.md).""" secret = models.CharField(max_length=255) group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites') invitee_username = models.CharField(max_length=255) diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index 2be90d3..1d2d247 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -81,12 +81,8 @@ def verify_incoming_friend_request(request, raw_request_body): def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field): - """Self-certifying verifier for the two legs of the group invite/accept dance that land on a - backend which doesn't have the caller cached as a KnownIdentity yet (see - docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own - backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the - group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors - verify_incoming_friend_request exactly, just with configurable field names.""" + """Self-certifying verifier for the group invite/accept dance. See + docs/implementation.md#group-invite-and-accept-self-certifying-verification.""" try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) except ValueError: @@ -143,10 +139,8 @@ def authenticate_request_against_local_users(request, raw_request_body): class SignatureAuthentication(authentication.BaseAuthentication): def authenticate(self, request): identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8')) - # Returning a bare None (rather than a (None, None) tuple) tells DRF this - # authenticator doesn't apply, so it moves on to the next authenticator in the - # authentication_classes list instead of treating the request as authenticated - # with an empty user. + # Bare None (not a (None, None) tuple) tells DRF to try the next authenticator, instead + # of treating the request as authenticated with an empty user. if identity is None: return None return identity, None diff --git a/backend/backend/asgi.py b/backend/backend/asgi.py index f4518a1..c6fe45e 100644 --- a/backend/backend/asgi.py +++ b/backend/backend/asgi.py @@ -1,12 +1,3 @@ -""" -ASGI config for backend project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ -""" - import os from django.core.asgi import get_asgi_application diff --git a/backend/backend/settings.py b/backend/backend/settings.py index e56e4ab..db7893c 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -1,14 +1,3 @@ -""" -Django settings for backend project. - -Generated by 'django-admin startproject' using Django 4.2.2. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.1/ref/settings/ -""" import os import subprocess import dotenv @@ -19,13 +8,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent def _git_commit(): - # deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at - # /git (separate from BASE_DIR, which only has the backend/ subtree) - - # point git at it explicitly there. Bare-metal dev has no such mount, but - # BASE_DIR sits inside the real checkout so plain rev-parse finds it by - # walking up. In prod, the build context is backend/ alone with no .git - # anywhere, so both fail and we fall back to the GIT_COMMIT build-arg/env - # var (see deploy/prod/Dockerfile.backend and playbook.yml). + # Docker dev bind-mounts the real .git dir at /git (see docker-compose.yml); bare-metal dev + # finds it by walking up from BASE_DIR instead. Prod has no .git at all, so both fail and we + # fall back to the GIT_COMMIT build-arg/env var (see Dockerfile.backend/playbook.yml). cmd = ['git', '--git-dir=/git'] if os.path.isdir('/git') else ['git'] try: return subprocess.check_output( @@ -36,9 +21,6 @@ def _git_commit(): dotenv.load_dotenv(BASE_DIR / '.env') -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ - SECRET_KEY = os.environ.get('SECRET_KEY', None) if SECRET_KEY is None: raise Exception('environment variable SECRET_KEY not set. try running `configure.py` or setting it manually') @@ -127,9 +109,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'backend.wsgi.application' -# Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -139,9 +118,6 @@ DATABASES = { AUTH_USER_MODEL = 'authentication.ToolshedUser' -# Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -157,9 +133,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -# Internationalization -# https://docs.djangoproject.com/en/4.1/topics/i18n/ - LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' @@ -168,26 +141,18 @@ USE_I18N = True USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.1/howto/static-files/ - STATIC_ROOT = 'staticfiles' STATIC_URL = '/static/' MEDIA_ROOT = os.environ.get('TOOLSHED_USERFILES_PATH', 'userfiles') MEDIA_URL = '/media/' -# In prod, nginx (running as www-data) reads these files directly - see +# Pinned explicitly (rather than left to the backend process's ambient umask) so group-read +# is guaranteed for nginx/www-data regardless of how the container is started - see # SERVE_X_ACCEL_REDIRECT and playbook.yml's `location /redirect_media/`. -# Pinned explicitly rather than left to the backend process's ambient umask, -# so group-read (www-data is added to the backend's system group) is -# guaranteed regardless of how the container is started. FILE_UPLOAD_PERMISSIONS = 0o640 FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o750 -# Default primary key field type -# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' STORAGES = { diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 495aac1..105a175 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -1,18 +1,3 @@ -"""backend URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin from django.urls import path, include from drf_yasg import openapi diff --git a/backend/backend/wsgi.py b/backend/backend/wsgi.py index c4aa324..21d383b 100644 --- a/backend/backend/wsgi.py +++ b/backend/backend/wsgi.py @@ -1,12 +1,3 @@ -""" -WSGI config for backend project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ -""" - import os from django.core.wsgi import get_wsgi_application diff --git a/backend/configure.py b/backend/configure.py index cb04c8c..dde345c 100755 --- a/backend/configure.py +++ b/backend/configure.py @@ -32,11 +32,8 @@ def yesno(prompt, default=False): def configure(): - # Keys this function may generate/update, tracked so that if .env turns - # out to be unwritable (e.g. a prod container running as an unprivileged - # user, whose real config comes from --env-file instead) we can still - # print the resulting configuration for the operator to apply manually, - # rather than silently discarding it or crashing. + # Keys this function may generate/update; tracked so an unwritable .env (e.g. a prod + # container configured via --env-file) can still print them for the operator to apply manually. tracked_keys = ['SECRET_KEY', 'ALLOWED_HOSTS'] unwritable = False diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index 7c5e2ff..fe8bdc7 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -24,9 +24,8 @@ THUMBNAIL_SIZES = (32, 64, 256) def _accessible_files(request): - # Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see - # what - a file is visible if the requester is friends-or-self with whatever currently - # references it (an inventory item, a profile picture) or it's their own staged photo. + # Shared by media_urls and thumbnail_urls: a file is visible if the requester is + # friends-or-self with whatever references it (item, profile picture), or it's their own staged photo. return File.objects.filter( Q(connected_items__owner__in=request.user.friends_or_self()) | Q(profile_picture_users__in=request.user.friends_or_self()) | @@ -35,8 +34,7 @@ def _accessible_files(request): def _cache_headers(etag): - # Content is addressed by its own hash and can never change under a given URL, so caches - # (and the conditional-GET checks in both views below) can treat it as immutable forever. + # Content is hash-addressed and can never change under a given URL, so it's cacheable forever. return { 'ETag': etag, 'Cache-Control': 'max-age=31536000, private, immutable', @@ -49,23 +47,16 @@ def _cache_headers(etag): @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def media_urls(request, hash_path): - # Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware, - # configured in settings.py) adds them to every Django response automatically, so - # setting them manually on these responses would just be redundant. The one exception - # is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it - # follows the X-Accel-Redirect and serves the file itself, so the CORS header for that - # case has to be configured in nginx's `location /redirect_media/` block instead. + # CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's + # /redirect_media/ block must set it instead. # - # Looked up by the derived storage path (not by raw hash) because FileSerializer.name - # (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g. - # AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and - # the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too. + # Looked up by the derived storage path, not the raw hash, to match FileSerializer.name + # (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase). try: file = _accessible_files(request).get(file=hash_path) - # The access-control lookup above must happen before this check - otherwise a bare - # hash + If-None-Match would let anyone probe "does a file with this hash exist" for - # files they can't actually see. + # Must run before this check, else a bare hash + If-None-Match would let anyone probe + # file existence for files they can't see. if request.META.get('HTTP_IF_NONE_MATCH') == file.hash: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) @@ -79,9 +70,7 @@ def media_urls(request, hash_path): **cache_headers, }) else: - # Read via the FieldFile itself (works against whatever storage backend is - # actually configured) rather than assuming file.file.path is a real filesystem - # path - the test suite swaps in an in-memory backend where that isn't true. + # Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend. with file.file.open('rb') as fh: content = fh.read() return HttpResponse(status=status.HTTP_200_OK, @@ -94,9 +83,7 @@ def media_urls(request, hash_path): def _thumbnail_rel_path(file_hash, size): - # Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails//` - # subtree - reachable through the same nginx `/redirect_media/` alias as originals when - # served from real disk, no separate nginx location needed. + # Mirrors hash_upload()'s sharding under thumbnails//, reachable via the same nginx alias as originals. return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6], file_hash[6:] + '.jpg') @@ -116,22 +103,15 @@ def thumbnail_urls(request, size, hash_path): if request.META.get('HTTP_IF_NONE_MATCH') == etag: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) - # Read/write through the default storage backend, same as File.file itself, rather - # than a hand-rolled filesystem path - correct regardless of storage backend (real - # disk in production, in-memory under the test runner) and keeps the cache in the - # same place originals live. + # Read/write via default_storage, not a hand-rolled path, to work with both real-disk + # and in-memory test storage. rel_path = _thumbnail_rel_path(file.hash, size) if not default_storage.exists(rel_path): - # Thumbnails are always re-encoded as JPEG regardless of the original format - - # smaller and simpler than preserving e.g. PNG transparency at this scale. + # Always re-encoded as JPEG regardless of original format - simpler than preserving transparency at this scale. with file.file.open('rb') as fh: image = Image.open(fh) image.thumbnail((size, size)) - # Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha, - # palette-with-transparency, RGBA) store meaningless color/luminance data under - # fully transparent pixels (often zeroed out, i.e. black). Converting straight - # to RGB reveals that instead of "nothing there"; compositing onto an opaque - # background first shows what the image is actually supposed to look like. + # Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels. rgba = image.convert('RGBA') flattened = Image.new('RGB', rgba.size, (255, 255, 255)) flattened.paste(rgba, mask=rgba.getchannel('A')) diff --git a/backend/files/models.py b/backend/files/models.py index bff9121..a55f794 100644 --- a/backend/files/models.py +++ b/backend/files/models.py @@ -43,15 +43,7 @@ class FileManager(models.Manager): else: raise ValueError('data must be a base64 encoded string or file and hash must be provided') if not self.filter(hash=kwargs['hash']).exists(): - # The upload path is derived entirely from the hash (hash_upload, above), and hash - # is DB-unique - so if no File row owns this hash yet, anything already sitting at - # its computed path is necessarily a stale orphan (e.g. left behind by a bug in - # cleanup code that deleted a File row without removing its stored bytes, or a - # crashed upload). Clear it before saving instead of letting Django's storage layer - # invent an alternate filename to avoid the "collision" - a suffixed name would - # silently break every part of the app that derives this file's URL purely from its - # hash (media serving, thumbnail generation, item/avatar attachment), and no future - # caller could ever discover it again. + # Clears a stale orphan already at this hash's canonical path before saving. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path. expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '') if default_storage.exists(expected_path): default_storage.delete(expected_path) diff --git a/backend/files/tests.py b/backend/files/tests.py index 05ecb38..8c160e9 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -113,12 +113,7 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase): self.assertEqual(countdir(DefaultStorage(), ''), 3) def test_file_upload_reclaims_stale_orphan_at_canonical_path(self): - # Reproduces a real incident: a File row gets deleted without its underlying stored - # bytes being removed (e.g. a bug in some cleanup call site), leaving an orphan sitting - # at the exact path hash_upload() would compute for that content. A later upload of the - # same content must land back on that canonical path - not get silently suffixed by - # Django's default collision-avoidance, which would make it unreachable to every part - # of the app that derives a file's URL purely from its hash. + # Regression test for a stale orphan at the canonical hash path. See docs/implementation.md#stale-orphan-cleanup-at-the-canonical-hash-path. expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}" default_storage.save(expected_path, ContentFile(self.f['test_content4'])) self.assertTrue(default_storage.exists(expected_path)) @@ -231,11 +226,8 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To self.prepare_properties() self.prepare_inventory() - # Each test method gets its own distinct image content (and therefore its own content - # hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within - # a run, so sharing one fixed image across methods risks one test's cached (or, in - # test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted) - # thumbnail leaking into another test's assertions. + # Each test uses a distinct seeded image (own hash/cache path) since InMemoryStorage + # isn't reset between test methods, so a shared image risks one test's cached thumbnail leaking into another's assertions. seed = zlib.crc32(self._testMethodName.encode()) % 256 buffer = io.BytesIO() Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG') @@ -254,8 +246,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg') def test_thumbnail_sizes_available(self): - # Documents the fixed size allow-list this test suite exercises against - update both - # if files/media_urls.py's THUMBNAIL_SIZES ever changes. + # Fixed size allow-list this suite exercises - update both if media_urls.py's THUMBNAIL_SIZES changes. self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256)) @override_settings(SERVE_X_ACCEL_REDIRECT=False) @@ -274,10 +265,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To @override_settings(SERVE_X_ACCEL_REDIRECT=False) def test_thumbnail_flattens_transparency_instead_of_going_black(self): - # Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent - # region has zeroed-out luminance underneath, as many image tools produce. Converting - # straight to RGB (dropping alpha without compositing) reveals that zeroed data - the - # whole thumbnail comes out solid black even though the visible (opaque) content isn't. + # Regression test for an 'LA' source with zeroed transparent-region luminance. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels. half_transparent = Image.new('LA', (200, 200)) pixels = half_transparent.load() for x in range(200): @@ -310,8 +298,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To with default_storage.open(rel_path, 'rb') as f: cached_bytes = f.read() - # Overwrite the cached file with a marker so a correct implementation must serve this - # exact content back rather than regenerating it from the original. + # Overwrites the cache with a marker so a correct implementation must serve it back, not regenerate. default_storage.delete(rel_path) default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER')) @@ -333,8 +320,7 @@ class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, To self.assertEqual(reply.status_code, 403) def test_thumbnail_not_friend(self): - # local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the - # denied case needs a stranger to that friendship instead. + # local_user1/local_user2 are friends here (see prepare_inventory), so the denied case needs a stranger instead. reply = client.get(self._thumb_url(64), self.f['ext_user1']) self.assertEqual(reply.status_code, 404) self.assertFalse(default_storage.exists(self._thumb_rel_path(64))) diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index 9b3b8d0..03b51d9 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -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) diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index c0c119c..40f2b97 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -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// item URL (see docs/design-in-progress/items-labels.md) or the - /inventory/shared// 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// or + /inventory/shared//. See docs/implementation.md#get-shared-item-looks-up-by-owner.""" try: username, domain = split_userhandle_or_throw(handle) except ValueError: diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index d02de2b..6fdb9ff 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -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) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 10cde6e..1175e63 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -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 diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 3cf9a03..4c864f3 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -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: diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py index fba1a42..202030b 100644 --- a/backend/toolshed/tests/test_friend.py +++ b/backend/toolshed/tests/test_friend.py @@ -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): diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py index dc608ba..eba0c12 100644 --- a/backend/toolshed/tests/test_inventory.py +++ b/backend/toolshed/tests/test_inventory.py @@ -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() diff --git a/backend/toolshed/tests/test_offlinedata.py b/backend/toolshed/tests/test_offlinedata.py index 655cd38..ac5b55f 100644 --- a/backend/toolshed/tests/test_offlinedata.py +++ b/backend/toolshed/tests/test_offlinedata.py @@ -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() diff --git a/deploy/dev/Dockerfile.proxy b/deploy/dev/Dockerfile.proxy index 095c80f..ececb93 100644 --- a/deploy/dev/Dockerfile.proxy +++ b/deploy/dev/Dockerfile.proxy @@ -1,14 +1 @@ FROM nginx:bookworm - -# snakeoil for localhost - -RUN apt-get update && \ - apt-get install -y openssl && \ - openssl genrsa -des3 -passout pass:x -out server.pass.key 2048 && \ - openssl rsa -passin pass:x -in server.pass.key -out server.key && \ - rm server.pass.key && \ - openssl req -new -key server.key -out server.csr \ - -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=localhost" && \ - openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt &&\ - mv server.crt /etc/nginx/nginx.crt && \ - mv server.key /etc/nginx/nginx.key \ diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index 30ed12a..473ea58 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -72,6 +72,14 @@ services: - ./instance_a/dns.json:/var/www/dns.json:ro - ./instance_a/domains.json:/var/www/domains.json:ro - ./instance_a/userfiles:/var/www/userfiles:ro + # A stable, CA-signed cert (see frontend/.local/make_localhost.sh) covering localhost plus + # every loopback IP a dev proxy binds to below, instead of Dockerfile.proxy generating a + # fresh throwaway self-signed one on every image build - that regenerated cert invalidated + # any trust exception you'd added in your browser on the previous build. Trust + # frontend/.local/RootCA.crt once (see docs/development.md) and it keeps working across + # rebuilds. + - ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro + - ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro ports: - "127.0.0.1:8080:8080" - "127.0.0.3:5353:5353" @@ -83,6 +91,8 @@ services: volumes: - ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - ./instance_b/userfiles:/var/www/userfiles:ro + - ../../frontend/.local/localhost.crt:/etc/nginx/nginx.crt:ro + - ../../frontend/.local/localhost.key:/etc/nginx/nginx.key:ro ports: - "127.0.0.2:8080:8080" diff --git a/docs/index.md b/docs/index.md index 756dc7e..cb46256 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,5 +10,6 @@ This is the documentation for the Toolshed project. It is a work in progress. - [Development Setup](development.md) - [About Federation](federation.md) - [Handles and Short IDs](handles-and-shortids.md) + - [Implementation Notes](implementation.md) diff --git a/frontend/src/cameraManager.js b/frontend/src/cameraManager.js index b2a3471..5420a8b 100644 --- a/frontend/src/cameraManager.js +++ b/frontend/src/cameraManager.js @@ -1,7 +1,5 @@ -// Shared camera stream manager used by any component that needs webcam access -// (WebcamFileSource, Scan). Centralizing this means every consumer gets device -// enumeration, preferred-camera memory, stream reuse and disconnect/reconnect -// handling for free instead of re-implementing it per component. +// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device +// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling. class CameraManager { constructor() { this.availableCameras = []; diff --git a/frontend/src/components/AuthenticatedImage.vue b/frontend/src/components/AuthenticatedImage.vue index 4aae7d5..75bec1f 100644 --- a/frontend/src/components/AuthenticatedImage.vue +++ b/frontend/src/components/AuthenticatedImage.vue @@ -73,9 +73,8 @@ export default { return; } try { - // Cached by src alone (content is hash-addressed and immutable - see - // fileCache.js) so every AuthenticatedImage instance showing the same - // file, across the whole app, shares one fetch and one decoded blob. + // Cached by src alone: content is hash-addressed and immutable (see fileCache.js), + // so every instance showing the same file shares one fetch and blob. const url = await fileCache.get(this.src, async () => { this.servers = await this.getFriendServers({username: this.owner}); const response = await this.servers.getRaw(this.signAuth, this.src); diff --git a/frontend/src/components/LabelLayoutPreview.vue b/frontend/src/components/LabelLayoutPreview.vue index 17a6645..5faf433 100644 --- a/frontend/src/components/LabelLayoutPreview.vue +++ b/frontend/src/components/LabelLayoutPreview.vue @@ -6,9 +6,9 @@
+ :class="{'template-option-disabled': !isSelectable(t)}" + :title="unavailableReason(t)" + role="button" @click="isSelectable(t) && $emit('input', t.id)"> @@ -24,8 +24,7 @@ + + diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 40505db..51af3bd 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -34,10 +34,13 @@ {{ c.text }}
@@ -53,63 +56,7 @@
Scan from camera
-

- This browser has no camera API available. -

- - -
- Drag & drop an image here, paste one, or use the file picker above. -
-
- -
-
    -
  • - No codes found. -
  • -
  • - {{ r.type }} - {{ r.metaText }} -
    - {{ r.text }} - - -
    -
  • -
+
@@ -121,10 +68,8 @@ From 0c025db7998bf0ccec1b4ad2ccc6f2454a3b5c7b Mon Sep 17 00:00:00 2001 From: jedi Date: Mon, 24 Aug 2026 21:35:38 +0200 Subject: [PATCH 4/4] stash --- backend/toolshed/api/inventory.py | 4 + backend/toolshed/models.py | 33 ++ backend/toolshed/offlinedata.py | 18 +- backend/toolshed/serializers.py | 43 ++- backend/toolshed/tests/fixtures.py | 12 +- .../assets/fonts/label/Inter-Regular.woff2 | Bin 0 -> 21564 bytes frontend/src/components/CameraScanner.vue | 359 +++++++++++++++++- .../src/components/LabelLayoutPreview.vue | 23 +- frontend/src/label-layouts.js | 67 +++- frontend/src/label.js | 57 +-- frontend/src/router.js | 4 +- frontend/src/scss/toolshed.scss | 1 + frontend/src/views/Inventory.vue | 2 +- frontend/src/views/InventoryDetail.vue | 2 +- frontend/src/views/Print.vue | 33 +- frontend/src/views/StorageLocation.vue | 2 +- 16 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 frontend/src/assets/fonts/label/Inter-Regular.woff2 diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 40f2b97..833c00e 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet): serializer_class = StorageLocationSerializer authentication_classes = [SignatureAuthentication] permission_classes = [IsAuthenticated] + # Detail routes address a location 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): if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 6fdb9ff..6136a86 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -169,7 +169,27 @@ class ItemTag(models.Model): inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) +class OwnerStorageLocationSequence(models.Model): + """Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation + (see StorageLocation.create_for_owner).""" + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True) + last_id = models.PositiveIntegerField(default=0) + + @classmethod + def allocate(cls, *, owner): + with transaction.atomic(): + seq, _ = cls.objects.select_for_update().get_or_create(owner=owner) + seq.last_id += 1 + seq.save(update_fields=['last_id']) + return seq.last_id + + class StorageLocation(models.Model): + internal_id = models.AutoField(primary_key=True) + # Externally visible id, sequential/gapless within the owner's own locations (see + # OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not + # .objects.create(). + id = models.PositiveIntegerField(editable=False) name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, @@ -177,10 +197,23 @@ class StorageLocation(models.Model): parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations') + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'), + ] + def __str__(self): parent = str(self.parent) + "/" if self.parent else "" return parent + self.name + @classmethod + def create_for_owner(cls, *, owner, **kwargs): + """The only supported way to create a StorageLocation: atomically allocates the next id + for this owner's scope.""" + with transaction.atomic(): + next_id = OwnerStorageLocationSequence.allocate(owner=owner) + return cls.objects.create(owner=owner, id=next_id, **kwargs) + class WorkflowInstance(models.Model): slug = models.CharField(max_length=255) diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 1175e63..ee9f383 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -339,13 +339,17 @@ def import_locations(user, data): if category_path: category = get_or_create_category(category_path) - location, _ = StorageLocation.objects.update_or_create( - owner=user, name=name, parent=parent, - defaults={ - 'description': row.get('description', '') or '', - 'category': category, - }, - ) + defaults = { + 'description': row.get('description', '') or '', + 'category': category, + } + try: + location = StorageLocation.objects.get(owner=user, name=name, parent=parent) + for field, value in defaults.items(): + setattr(location, field, value) + location.save(update_fields=list(defaults.keys())) + except StorageLocation.DoesNotExist: + location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults) resolved_by_path[path] = location imported += 1 except Exception as error: diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 4c864f3..369222f 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.serializers import OwnerSerializer, GroupOwnerSerializer @@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer): return resolve_category_handle(data.split("/")[-1]) +class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + """Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to + the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly + visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not + internal_id.""" + + def use_pk_only_optimization(self): + # False: to_representation needs the owner-scoped `id`, not just the internal pk that the + # PKOnlyObject optimization would otherwise limit us to. + return False + + def get_queryset(self): + queryset = super().get_queryset() + request = self.context.get('request') + if request is not None and type(request.user) == KnownIdentity and request.user.user.exists(): + return queryset.filter(owner=request.user.user.get()) + return queryset.none() + + def to_internal_value(self, data): + queryset = self.get_queryset() + try: + if isinstance(data, bool): + raise TypeError + return queryset.get(id=data) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=data) + except (TypeError, ValueError): + self.fail('incorrect_type', data_type=type(data).__name__) + + def to_representation(self, value): + return value.id + + class StorageLocationSerializer(serializers.ModelSerializer): owner = OwnerSerializer(read_only=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True) + parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) path = serializers.SerializerMethodField() class Meta: model = StorageLocation fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent'] - read_only_fields = ['path'] + read_only_fields = ['id', 'path'] @staticmethod def get_path(obj): @@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer): return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name return obj.name + def create(self, validated_data): + return StorageLocation.create_for_owner(**validated_data) + class ItemPropertySerializer(serializers.ModelSerializer): property = PropertySerializer(read_only=True) @@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') category = CategorySerializer(required=False, allow_null=True) files = FileSerializer(many=True, read_only=True) + storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False, + allow_null=True) class Meta: model = InventoryItem diff --git a/backend/toolshed/tests/fixtures.py b/backend/toolshed/tests/fixtures.py index 942de7e..69c2e42 100644 --- a/backend/toolshed/tests/fixtures.py +++ b/backend/toolshed/tests/fixtures.py @@ -49,12 +49,12 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin): class LocationTestMixin: def prepare_locations(self): - self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1']) - self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'], - category=self.f['cat1']) - self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) - self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], - category=self.f['cat1']) + self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1']) + self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'], + category=self.f['cat1']) + self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', 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']) class WorkflowTestMixin: diff --git a/frontend/src/assets/fonts/label/Inter-Regular.woff2 b/frontend/src/assets/fonts/label/Inter-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d228a4af2fcc51e12c8a744e6c35d2363bd4b98a GIT binary patch literal 21564 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/CameraScanner.vue b/frontend/src/components/CameraScanner.vue index a6cb277..a786981 100644 --- a/frontend/src/components/CameraScanner.vue +++ b/frontend/src/components/CameraScanner.vue @@ -32,13 +32,156 @@ diff --git a/frontend/src/components/LabelLayoutPreview.vue b/frontend/src/components/LabelLayoutPreview.vue index d697178..e0a68b3 100644 --- a/frontend/src/components/LabelLayoutPreview.vue +++ b/frontend/src/components/LabelLayoutPreview.vue @@ -90,12 +90,12 @@ background: #fff; } -/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still - failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this - tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than - just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not - shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason) - since that's already conveyed by the greyed-out thumbnail. */ +/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger + code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent + throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch). + Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the + reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the + greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */ .template-thumb-warning { position: absolute; top: .35rem; @@ -276,17 +276,8 @@ export default { if (!canvas) { continue; } - const content = templateContent(t, this.fields); - if (!this.isAvailable(t) || !content) { - canvas.width = 1; - canvas.height = 1; - delete this.failed[t.id]; - delete this.warned[t.id]; - delete this.qrInfo[t.id]; - delete this.textInfo[t.id]; - continue; - } try { + const content = templateContent(t, this.fields); const {warnings, qrInfo, textInfo} = this.tape ? drawLabel(canvas, this.tape, content, "along") : drawFallbackLabel(canvas, content, "along"); diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 018b69d..84dc502 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [ }, { id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortUrl","itemId"], tags: ["external"], - layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] + required_vars: ["shortUrl", "itemId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] }, { id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", - required_vars: ["shortId","itemId"], tags: ["internal"], - layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] - },...QR_ONLY_TEMPLATES, + required_vars: ["shortId", "itemId"], tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.itemId?.toString().padStart(4, "0") + }] + }, + { + id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", + required_vars: ["shortUrl", "locationId"], tags: ["external"], + layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, + { + id: "location-mqr-tokem-id", + name: "rMQR Token", + description: "The code with the encoded text printed next to it.", + required_vars: ["shortId", "locationId"], + tags: ["internal"], + layout: [{type: "mqr", content: c => c.shortId}, GAP, { + type: "text", + content: c => c.locationId?.toString().padStart(4, "0") + }] + }, ...QR_ONLY_TEMPLATES, { id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", @@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [ id: "id-qr-text-vertical", name: "ID + QR code + text below", description: "The code with the encoded text printed below it.", required_vars: ["itemId", "text", "userHandle"], - layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { + layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, { type: "qr", content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] @@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [ required_vars: ["userHandle", "itemId"], tags: ["internal"], layout: [{type: "text", content: c => [c.userHandle, c.itemId]}] }, + { + id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.", + required_vars: ["locationId"], tags: ["internal"], + layout: [{type: "text", content: c => c.locationId}] + }, + { + id: "owner-id-text-location", name: "Owner + location ID", + description: "The owner's handle and the location id, as two lines of text - no code.", + required_vars: ["userHandle", "locationId"], tags: ["internal"], + layout: [{type: "text", content: c => [c.userHandle, c.locationId]}] + }, { id: "item-url-qr-handle", name: "Item URL + handle", description: "Scannable item URL, with the item's compact handle printed alongside.", @@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) { } // Resolves t's content leaves against fields into a tree for label.js's -// drawLabel/drawFallbackLabel, or null if nothing to render yet. +// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own +// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a +// leaf isn't resolved yet, rather than returning null, so a caller's single catch around +// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check. export function templateContent(t, fields) { - const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); - let hasContent = false; - walkLeaves(tree, leaf => { - if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) { - hasContent = true; - } - }); - return hasContent ? tree : null; + if (!templateIsAvailable(t, fields)) { + const err = new Error("This layout needs more fields filled in before it can be drawn."); + err.short = "missing data"; + throw err; + } + return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); } diff --git a/frontend/src/label.js b/frontend/src/label.js index e2ec350..ac26a18 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a // Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why -// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. -const PIXEL_FONT_TIERS = [ - {belowPx: 8, family: "Tom Thumb", scale: 3.2}, - {belowPx: 10, family: "Silkscreen"}, +// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names +// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic +// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the +// same label print differently depending on where it was rendered from. +const FONT_TIERS = [ + {belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true}, + {belowPx: 10, family: "Silkscreen", pixel: true}, + {belowPx: Infinity, family: "Inter"}, ]; function fontFamilyFor(fontPx) { - return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"}; + return FONT_TIERS.find(t => fontPx < t.belowPx); } // Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility @@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) { } } -// Always measures in sans-serif at the reference size, since the eventual font (see -// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the -// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures. +// Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't +// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when +// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still +// catches real failures. function measureTextBlock(ctx, lines, referencePx) { - ctx.font = `${referencePx}px sans-serif`; + ctx.font = `${referencePx}px "Inter"`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const height = referencePx * 1.15 * lines.length; return {width, height}; @@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) { // there's no "too small to draw" case left to special-case here. function drawTextLeaf(ctx, node, referencePx) { const fontPx = effectiveFontPx(node, referencePx); - const {family, scale = 1} = fontFamilyFor(fontPx); - const isPixelFont = family !== "sans-serif"; + const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx); // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts - // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is + // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is // left exact since anti-aliasing handles fractional positions fine. const snap = isPixelFont ? Math.round : (v) => v; - // `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font - // for its real ink; fontPx itself stays the logical size used for centering/stacking math. + // `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its + // real ink; fontPx itself stays the logical size used for centering/stacking math. ctx.font = `${fontPx * scale}px "${family}"`; // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM - // text. See docs/implementation.md#canvas-font-loading. - if (isPixelFont) { - document.fonts.load(ctx.font); - } + // text. See docs/implementation.md#canvas-font-loading. Every tier now names a real, + // self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts. + document.fonts.load(ctx.font); ctx.textAlign = "center"; const centerX = snap(node.box.x + node.box.width / 2); const lineHeight = node.box.height / node.lines.length; @@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") { // print label should show/encode; keyed by `kind` so each kind's format is defined in one place. export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just - // the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store + // the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store // lookup, so it stays a separate field/template rather than being baked in here. - "item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`, + "item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`, // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank; // the short link and any future location template still work via the base vars below. }; @@ -542,7 +545,7 @@ export function buildLabelContent(prefill) { return build ? build(prefill.components) : ""; } -// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way +// Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way // store.js's lookupServer does), tagging on whichever id field the resource's templates key // required_vars by. function splitUserHandle(userHandle) { @@ -560,19 +563,19 @@ function splitUserHandle(userHandle) { // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field // (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. const LABEL_FIELD_BUILDERS = { - "item": ({userHandle, id}) => { + "item": ({userHandle, item}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !item) { return {}; } - return {...split, itemId: String(id)}; + return {...split, itemId: String(item)}; }, - "storage-location": ({userHandle, id}) => { + "storage-location": ({userHandle, location}) => { const split = splitUserHandle(userHandle); - if (!split || !id) { + if (!split || !location) { return {}; } - return {...split, locationId: String(id)}; + return {...split, locationId: String(location)}; }, }; diff --git a/frontend/src/router.js b/frontend/src/router.js index d3f3597..9198495 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { meta: {requiresAuth: true}, props: route => { const {kind, ...components} = route.query; - return {prefill: kind ? {kind, components} : null}; + // queryFields lets any base var (e.g. ?text=...) be set directly, without going through + // a `kind` builder - see Print.vue's varValues. + return {prefill: kind ? {kind, components} : null, queryFields: components}; } }, { path: '/scan', diff --git a/frontend/src/scss/toolshed.scss b/frontend/src/scss/toolshed.scss index 22ce7f3..5be483d 100644 --- a/frontend/src/scss/toolshed.scss +++ b/frontend/src/scss/toolshed.scss @@ -94,6 +94,7 @@ $body-color: $gray-700; @import "dropdown"; @import "pixel-fonts"; @import "pixel-fonts-candidates"; +@import "label-fonts"; #root, body, html { height: 100%; diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index fd2808d..5adca48 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -154,7 +154,7 @@ export default { // See docs/implementation.md#print-link-shape-for-personal-items. printLinkFor(item) { if (!item.owner) return null - return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}} + return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}} }, }, async mounted() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 6268ae2..0685771 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -51,7 +51,7 @@ Delete diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index 9fa4d78..21195bc 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -57,7 +57,7 @@

- Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" + Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen were. CodersCrux and 712Serif fail to load as web fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below @@ -86,7 +86,7 @@

Label preview
-
+
@@ -144,7 +144,7 @@ Connect a printer to preview and print a label.