# Quick-Fill Combinations for Vehicle Additional Data ## Context Trucks frequently arrive one after another carrying the same cargo, from the same speditor, via the same agent. Today the operator re-selects every dropdown in the "Additional Data" section of the vehicle edit form for each truck, even when nothing changed from the previous one. This adds a helper table above those fields listing previously-used *combinations* of the lookup values. One click fills all the dropdowns. Rows are created automatically when a vehicle's extra data is saved, and the user deletes rows they no longer need. **Decisions made with the user:** - A "combination" = only `kind='lookup'` nomenclature values. Free-text/number/bool fields are excluded so one-off notes don't pollute the table. - Re-using an existing combination bumps its `last_used` and increments `use_count`. No duplicate rows. - Rows are shared by all users (global, not per-user). - Rows persist indefinitely — no cap, no auto-trim. Manual delete only. - Table renders inline, always visible, directly above the existing fields, in **both** the new-vehicle and edit forms. --- ## Backend ### 1. Model — `backend/vehicles/models.py` Lives in `vehicles/` because it's a usage history of `VehicleExtra.data`, a domain model. (`api/` holds the API layer; `vehicles/views.py|serializers.py|urls.py` are dead, broken, unrouted code — do not touch them.) Dedup uses a canonical hash, not JSONField equality: on SQLite a `JSONField` exact-match compares the serialized string, so `{"A":1,"B":2}` ≠ `{"B":2,"A":1}`. A `unique=True` SHA-256 column is backend-agnostic and O(1). Add `import hashlib` / `import json` at the top, then: ```python def combination_hash(data): """Order-independent canonical hash of a {nomenclature_code: entry_id} dict.""" canonical = json.dumps(data, sort_keys=True, separators=(',', ':')) return hashlib.sha256(canonical.encode('utf-8')).hexdigest() class ExtraCombination(models.Model): """A previously-used combination of lookup nomenclature values for a vehicle's extra data.""" data = models.JSONField(default=dict) data_hash = models.CharField(max_length=64, unique=True, editable=False) use_count = models.IntegerField(default=1) last_used = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-last_used"] def save(self, *args, **kwargs): self.data_hash = combination_hash(self.data) super().save(*args, **kwargs) def __str__(self): return f"{self.data} (x{self.use_count})" ``` Computing the hash inside `save()` means it can never drift from `data`. `auto_now_add` (not `auto_now`) + an explicit bump in the recorder keeps the timestamp meaning "last used", not "last saved". Migration: `python manage.py makemigrations vehicles` → `0005_extracombination.py` (latest is currently `0004_`). ### 2. Serializer — `backend/api/serializers.py` Extract the existing validation at [api/serializers.py:85-123](backend/api/serializers.py#L85-L123) into a module-level function with a `lookup_only` flag, so both serializers share it. Behaviour for the existing path is bit-identical. ```python def validate_vehicle_extra_data(value, lookup_only=False): """Validate a {nomenclature_code: value} dict against vehicle nomenclatures. lookup_only=True rejects any key whose nomenclature is not kind='lookup'. """ ``` Body is the current `validate_data` verbatim, with one insert in the `Nomenclature.FIELD` branch: ```python elif nom.kind == Nomenclature.FIELD: if lookup_only: errors[key] = "Only lookup nomenclatures are allowed here." continue field_def = nom.fields.first() # ...rest unchanged ``` `VehicleExtraSerializer.validate_data` becomes `return validate_vehicle_extra_data(value)`. New serializer (after `VehicleSerializer`): ```python class ExtraCombinationSerializer(serializers.ModelSerializer): class Meta: model = ExtraCombination fields = ['id', 'data', 'use_count', 'last_used'] read_only_fields = ['id', 'use_count', 'last_used'] def validate_data(self, value): if not value: raise serializers.ValidationError("Combination cannot be empty.") return validate_vehicle_extra_data(value, lookup_only=True) ``` **Returns raw `{code: entry_id}` — no denormalized labels.** The frontend already has `getDisplayValue(code, entryId)` backed by an SSE-live cache, so labels update in realtime when an entry is renamed. Server-side labels would be a stale snapshot requiring re-broadcast of every row on every entry edit. ### 3. Recorder + ViewSet — `backend/api/views.py` Imports: add `mixins` to the DRF import, `ExtraCombination` and `combination_hash` to the `vehicles.models` import, `ExtraCombinationSerializer` to the serializer import. Helper above `VehicleViewSet` (next to `_maybe_finalize`, matching file convention): ```python def _record_extra_combination(extra_data): """Record the lookup-only projection of a vehicle's extra data as a reusable combination.""" if not extra_data: return lookup_codes = set( Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP) .values_list('code', flat=True) ) combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None} if not combo: return existing, created = ExtraCombination.objects.get_or_create( data_hash=combination_hash(combo), defaults={'data': combo} ) if not created: existing.use_count += 1 existing.last_used = timezone.now() existing.save() action = 'created' if created else 'updated' sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data) ``` `get_or_create` (rather than `filter().first()` + `create()`) closes the race where two users save the same brand-new combination simultaneously — otherwise one gets an `IntegrityError` 500 *after* the vehicle was already saved. Two guards matter: no extra at all, and extra containing only `kind='field'` values (the common "user typed a one-off note" case). Neither should create a row. ViewSet after `VehicleViewSet`: ```python class ExtraCombinationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet): """ API endpoint for previously-used combinations of vehicle extra (lookup) data. Rows are created/bumped automatically when a vehicle's extra data is saved. """ queryset = ExtraCombination.objects.all() serializer_class = ExtraCombinationSerializer permission_classes = [IsAuthenticated] ordering = ['-last_used'] def perform_destroy(self, instance): data = ExtraCombinationSerializer(instance).data instance.delete() sse_broadcast_update('extra_combination', 'deleted', data) ``` No create/update endpoints — rows only ever appear as a side effect of saving a vehicle. Route in [api/urls.py](backend/api/urls.py): ```python router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination') ``` ### 4. Where recording fires — `VehicleViewSet` **Backend, in `perform_create`/`perform_update`.** This is the layer that already owns side effects of a successful vehicle mutation (`sse_broadcast_update`), it covers both required paths (create-with-extra, and the `handleSaveExtra` PATCH which routes through `partial_update` → `perform_update`), and it can't be forgotten by frontend code. Rejected: a frontend POST (two round-trips, fails independently, must be duplicated, wrong trust boundary); a `post_save` signal (would fire on fixtures, migrations, shell saves and admin edits, broadcasting SSE with no request context — and the project has zero signals today). One line added to each, at [api/views.py:161-169](backend/api/views.py#L161-L169): ```python _record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None) ``` `hasattr` matches the existing style at [api/serializers.py:162](backend/api/serializers.py#L162); `extra` is a reverse OneToOne that raises `DoesNotExist` when absent. `set_tare`/`set_gross` bypass `perform_update` and don't touch extra data — leave them alone. --- ## Frontend ### 5. New component — `frontend/src/components/ExtraCombinationsTable.jsx` + `.css` Sits in `components/` next to `Main.jsx` (it's a Main-screen helper, not a nomenclature UI primitive). Props: `{ nomenclatures, onApply }` — `nomenclatures` is the already-sorted `vehicleNomenclatures` from [Main.jsx:28](frontend/src/components/Main.jsx#L28) so columns line up with the dropdowns below; `onApply(data)` gets the raw dict. **Data + realtime: local state + fetch on mount + `subscribeToUpdates`.** Extending `NOMENCLATURES_CONFIG` was rejected — that machinery sorts by `name`/`id` (not `-last_used`) and its handler has no `delete` branch, so it'd mean modifying shared load-bearing code for one consumer. This needs **one token** added to [NomenclatureContext.jsx:362](frontend/src/contexts/NomenclatureContext.jsx#L362), since unknown objects currently hit `console.warn` and get dropped: ```jsx if (object === 'nomenclature' || object === 'nomenclature_entry' || object === 'extra_combination') { ``` Strictly additive — existing subscribers already filter on `message.object` before acting. **Stale-closure hazard:** `subscribeToUpdates` is not `useCallback`'d ([NomenclatureContext.jsx:344](frontend/src/contexts/NomenclatureContext.jsx#L344)), so a `useEffect` depending on it re-subscribes every provider render. Hold it in a ref and run the effect with `[]` deps. Leave a one-line comment so nobody "fixes" it back. Key logic: ```jsx const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup'); const load = useCallback(async () => { const res = await api.get('/api/extra-combinations/'); setCombinations(res.data.results || res.data); // PAGE_SIZE=100 envelope }, []); // SSE if (message.object !== 'extra_combination') return; setCombinations(prev => operation === 'delete' ? prev.filter(c => c.id !== data.id) : [data, ...prev.filter(c => c.id !== data.id)]); // most recent first ``` Table: one `