Add implementation plans and remove stray Word temp file
Plans record the decisions behind each feature and, for the protocol and licensing work, what remains unvalidated against real hardware. ~WRL0003.tmp was a Word autosave artifact committed by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,107 @@
|
||||
# Plan: Move local dev ports (backend 8000 → 8002, frontend 3000 → 5172)
|
||||
|
||||
## User Request
|
||||
|
||||
> ok, so, server is set to run on 8000, but I want it to be on 8002, also the frontend -
|
||||
> what port is it? I want it to be on 5172 (for the local npm rundev)
|
||||
|
||||
## Plan
|
||||
|
||||
### Context
|
||||
|
||||
The local development ports collide with other things running on this machine. The user
|
||||
wants the Django backend on **8002** instead of 8000, and the React frontend on **5172**
|
||||
instead of 3000.
|
||||
|
||||
Two findings shaped this plan:
|
||||
|
||||
1. **The frontend is Create React App (react-scripts 5.0.1), not Vite.** There is no
|
||||
`dev` script and no `vite.config`. The user asked for "npm run dev" on 5172 — a
|
||||
Vite-shaped request. Rather than migrate the build tool (out of scope), we set CRA's
|
||||
`PORT` env var to 5172 and add a `dev` script alias so `npm run dev` works as expected.
|
||||
2. **Changing the ports is not just two edits.** Three other places hardcode the old
|
||||
ports and would silently break: the frontend's API base URL, CRA's dev proxy, and the
|
||||
backend's CORS allow-list (which would reject the frontend's new origin).
|
||||
|
||||
Scope is **local dev only**. Docker (`backend/Dockerfile`, `docker-compose.yml`) keeps
|
||||
port 8000 — it is container-internal there and the host mapping `8101:8000` is what you
|
||||
actually connect to. Docs (README/SETUP/CLAUDE.md) are left as-is per the user's choice.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. Backend port → 8002
|
||||
|
||||
**[run_server.bat:4](../../run_server.bat#L4)** — the only place the local server port is set:
|
||||
|
||||
```
|
||||
uvicorn scalesapp.asgi:application --host 127.0.0.1 --port 8002 --reload
|
||||
```
|
||||
|
||||
#### 2. Frontend port → 5172 and API URL → 8002
|
||||
|
||||
**`frontend/.env`** (currently one line, `REACT_APP_API_URL=http://localhost:8000`):
|
||||
|
||||
```
|
||||
PORT=5172
|
||||
REACT_APP_API_URL=http://localhost:8002
|
||||
```
|
||||
|
||||
`PORT` is the standard CRA dev-server port variable — no config file needed. Note CRA
|
||||
only reads `.env` at dev-server start, so the server must be restarted, not just reloaded.
|
||||
|
||||
**`frontend/.env.example:1`** — mirror the same two lines so the template stays accurate.
|
||||
|
||||
#### 3. `npm run dev` alias
|
||||
|
||||
**[frontend/package.json:13-18](../../frontend/package.json#L13-L18)** — add a `dev`
|
||||
script next to the existing `start`:
|
||||
|
||||
```json
|
||||
"start": "react-scripts start",
|
||||
"dev": "react-scripts start",
|
||||
```
|
||||
|
||||
#### 4. CRA dev proxy → 8002
|
||||
|
||||
**[frontend/package.json:36](../../frontend/package.json#L36)**:
|
||||
|
||||
```json
|
||||
"proxy": "http://localhost:8002"
|
||||
```
|
||||
|
||||
#### 5. CORS allow-list → add 5172
|
||||
|
||||
**[backend/scalesapp/settings.py:108](../../backend/scalesapp/settings.py#L108)** —
|
||||
without this the browser blocks every API call from the new origin:
|
||||
|
||||
```python
|
||||
_cors_default = 'http://localhost:5172,http://127.0.0.1:5172,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174'
|
||||
```
|
||||
|
||||
Existing 3000/5174 entries are kept — they cost nothing and avoid breaking any other
|
||||
local setup. This is only the *default*; an explicit `CORS_ALLOWED_ORIGINS` in
|
||||
`backend/.env` would override it, and there is currently none, so the default applies.
|
||||
|
||||
### Not changed (deliberately)
|
||||
|
||||
- `backend/Dockerfile`, `docker-compose.yml` — container-internal 8000, host port 8101.
|
||||
- `frontend/src/services/api.js:3`, `contexts/NomenclatureContext.jsx:3` — their
|
||||
`http://localhost:8000` is a fallback only, superseded by `REACT_APP_API_URL`.
|
||||
- `serial_bridge/` — runs on 5000, talks to Django only via `BACKEND_URL`; unaffected
|
||||
unless the bridge needs to reach the backend (it currently pushes nothing on startup).
|
||||
- README.md, SETUP.md, CLAUDE.md port references.
|
||||
|
||||
### Verification
|
||||
|
||||
1. `run_server.bat` → console shows `Uvicorn running on http://127.0.0.1:8002`.
|
||||
Confirm `http://127.0.0.1:8002/api/` responds (401/403 is fine — it proves it's up).
|
||||
2. `cd frontend && npm run dev` → console shows `On Your Network: http://localhost:5172`
|
||||
and the browser opens there.
|
||||
3. Log in through the UI. This is the real end-to-end check: it exercises
|
||||
`POST /api/token/` cross-origin from `localhost:5172` to `localhost:8002`, so it
|
||||
validates the API URL **and** the CORS entry together.
|
||||
4. Open DevTools → Network: no CORS errors, and the `/sse-connect/` EventSource shows
|
||||
status 200 and stays open (SSE is authenticated via query-param token and is the most
|
||||
likely thing to break silently if a port was missed).
|
||||
5. Confirm nothing still calls 8000: with the app running, check the Network tab for any
|
||||
request to `localhost:8000`.
|
||||
@@ -0,0 +1,321 @@
|
||||
# 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 `<th>` per lookup nomenclature (`nom.name`) plus a narrow actions column; rows call `onApply(combo.data)`; each row has a `×` delete button with `e.stopPropagation()` and a `window.confirm`. Cells resolve via `getDisplayValue(nom.code, entryId)`, rendering `#<id>` in muted italic when an id no longer resolves. Returns `null` if there are no lookup nomenclatures.
|
||||
|
||||
CSS follows the `nui-mgmt-*` table conventions from [NomenclatureManagementOverlay.css](frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css), scaled down for inline use: sticky header on `#f8f9fa`, `1px solid #dee2e6` row borders, `#f0f0ff` hover, `max-height: 180px` + `overflow-y: auto`, `overflow-x: auto` with `white-space: nowrap`, uppercase 0.75rem headers, 6px radii, `#dc3545` delete hover.
|
||||
|
||||
### 6. `Main.jsx` — apply handler + placement
|
||||
|
||||
```jsx
|
||||
// Apply a saved combination - merge so non-lookup field values are preserved
|
||||
const handleApplyCombination = (data) => {
|
||||
setExtraData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
```
|
||||
|
||||
**Merge, not replace.** `data` only contains lookup codes (enforced server-side), so spreading over `prev` preserves every free-text/number/bool value the user already typed. Lookup fields absent from the combination keep their current value rather than being cleared — the safer default, never silently destroying input.
|
||||
|
||||
Placement inside the existing edit-form `.extra-section` at [Main.jsx:417-442](frontend/src/components/Main.jsx#L417-L442), directly above `.extra-fields`:
|
||||
|
||||
```jsx
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<div className="extra-fields">
|
||||
```
|
||||
|
||||
**Both forms.** The same table renders in the new-vehicle form and the edit form, each paired with its own state via a sibling handler:
|
||||
|
||||
```jsx
|
||||
const handleApplyCombinationNew = (data) => {
|
||||
setNewExtraData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour | Action |
|
||||
|---|---|---|
|
||||
| Entry **deactivated** after being saved in a combination | `getDisplayValue` searches all entries, so the label still renders. Save fails loudly with `Entry ID N not found`, caught by the existing `handleSaveExtra` error path. | None. Deactivation is soft and reversible; hiding rows would make them vanish and reappear. |
|
||||
| Entry **hard-deleted** | Cell flips live to muted italic `#<id>` (SSE removes it from the cache). | Render the marker so the broken row is visible and deletable — hiding it would remove the only way to clean it up. |
|
||||
| **Nomenclature deleted** | Its column disappears from the table; the orphan key stays in `data`, unrendered. Clicking such a row would fail validation on save with `Unknown nomenclature code`. | None. Admin-rare, self-resolving on the next fresh save. A cleanup signal would be speculative machinery. |
|
||||
| **Validation failure on click-apply** | Impossible — clicking is local `setState` only, no network. Errors surface at save via the existing `.vehicle-error` banner. | None. Don't special-case; this is how every other extra-data error already behaves. |
|
||||
| **Concurrent identical saves** | Handled by `get_or_create` (§3). | Done. |
|
||||
| **Empty projection** | Two guards in the recorder + serializer rejects `{}`. | Done. |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Backend on 8002 via `run_server.bat`; frontend via `npm start` in `frontend/` (port 5172).
|
||||
|
||||
Preflight: confirm `frontend/.env` sets `REACT_APP_API_URL=http://localhost:8002` — [NomenclatureContext.jsx:3](frontend/src/contexts/NomenclatureContext.jsx#L3) defaults to `:8000`, so a wrong value makes SSE silently dead while REST still works through the proxy. Confirm `http://localhost:5172` is in `CORS_ALLOWED_ORIGINS`.
|
||||
|
||||
```bat
|
||||
cd backend && ..\.venv\Scripts\activate
|
||||
python manage.py makemigrations vehicles && python manage.py migrate
|
||||
python manage.py showmigrations vehicles :: expect [X] 0005_extracombination
|
||||
```
|
||||
|
||||
Needs ≥2 vehicle lookup nomenclatures with active entries (e.g. `CARGO`, `SPEDITOR`) and one `kind='field'` text nomenclature (e.g. `NOTE`) for step 5.
|
||||
|
||||
1. **Empty state** — select a vehicle; "Previously Used" shows *No saved combinations yet* with correct columns.
|
||||
2. **First save creates a row** — Cargo=Coal, Speditor=ACME → Save Extra Data. Row appears with no page reload.
|
||||
3. **Dedup** — same combination on a *different* vehicle → still exactly one row. Verify:
|
||||
```bat
|
||||
python manage.py shell -c "from vehicles.models import ExtraCombination; [print(c.id, c.data, c.use_count, c.last_used) for c in ExtraCombination.objects.all()]"
|
||||
```
|
||||
Expect `use_count=2`, fresh `last_used`.
|
||||
4. **Click-apply** — clear both dropdowns, click the row → both populate. Save → one row, `use_count=3`.
|
||||
5. **Merge preserves free-text** *(most important regression check)* — type text into `NOTE`, click a combination row. **The note must survive**, and `NOTE` must have no column in the table.
|
||||
6. **Ordering** — save a second distinct combination; it appears above. Re-save the first → it returns to the top.
|
||||
7. **Delete over SSE** — open a second browser tab first, then delete a row via `×`. It must disappear in **both** tabs (this is the path `NomenclatureContext` doesn't natively support, so most likely to break).
|
||||
8. **New-vehicle path** — create a vehicle with extra data via "+ New Vehicle" → row created (verifies `perform_create`).
|
||||
9. **Empty projection** — create a vehicle with only `NOTE` filled → **no new row**; confirm count unchanged in the shell.
|
||||
10. **Stale id** — hard-delete a referenced entry via the `…` overlay. Cell flips to `#<id>` live; clicking + saving shows the `Entry ID N not found` error; `×` removes the row.
|
||||
11. **Key-order independence** —
|
||||
```bat
|
||||
python manage.py shell -c "from vehicles.models import combination_hash; print(combination_hash({'A':1,'B':2}) == combination_hash({'B':2,'A':1}))"
|
||||
```
|
||||
Expect `True`.
|
||||
12. **API surface** — `GET /api/extra-combinations/` returns the paginated envelope ordered by `-last_used`; `POST` returns **405**.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
**Backend**
|
||||
- `backend/vehicles/models.py` — imports, `combination_hash()`, `ExtraCombination`
|
||||
- `backend/vehicles/migrations/0005_extracombination.py` — generated
|
||||
- `backend/api/serializers.py` — extract `validate_vehicle_extra_data()`, slim `VehicleExtraSerializer.validate_data`, add `ExtraCombinationSerializer`
|
||||
- `backend/api/views.py` — imports, `_record_extra_combination()`, 1 line each in `perform_create`/`perform_update`, `ExtraCombinationViewSet`
|
||||
- `backend/api/urls.py` — 1 router line
|
||||
|
||||
**Frontend**
|
||||
- `frontend/src/components/ExtraCombinationsTable.jsx` — new
|
||||
- `frontend/src/components/ExtraCombinationsTable.css` — new
|
||||
- `frontend/src/components/Main.jsx` — import, `handleApplyCombination` + `handleApplyCombinationNew`, JSX insert in both forms
|
||||
- `frontend/src/contexts/NomenclatureContext.jsx` — 1 token at line 362
|
||||
|
||||
Untouched: `vehicles/views.py`, `vehicles/serializers.py`, `vehicles/urls.py` (dead code), `nomenclatures/`, `Main.css`.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Licensing: Server-Side Node-Locked with Station Limit
|
||||
|
||||
**Status: BUILT and verified, 2026-09-17.** This document is the record of what exists, not a proposal.
|
||||
|
||||
## Context
|
||||
|
||||
Customers buy ScalesApp for a fixed number of weighbridge stations. Without enforcement, nothing stops a customer copying the installer onto more machines. Sites are **air-gapped** (no internet on the scales server), so any online activation or phone-home scheme is impossible.
|
||||
|
||||
This licences the **server**, enforcing a maximum number of concurrently connected stations. Bridges themselves stay unlicensed.
|
||||
|
||||
**Decisions made with the user:**
|
||||
- **Licence the server, not each bridge** — one fingerprint, one key per customer, instead of N of each.
|
||||
- **Count = concurrently connected bridges.** Licence says 5; a 6th is declined. If one of the 5 stops, its slot frees automatically.
|
||||
- **Enforcement is per-station**: the 6th is refused, the working 5 are never interrupted.
|
||||
- **Tolerant fingerprint + 14-day grace period** on hardware change.
|
||||
- **Perpetual** — no expiry date (the signed payload carries an optional `expires` field so subscriptions can be added later without changing the format).
|
||||
- Slot refresh every 30s, released after 2 minutes (4 missed refreshes).
|
||||
|
||||
---
|
||||
|
||||
## Threat model — stated plainly
|
||||
|
||||
Client-side licensing **can always be defeated** by someone determined; the server is a Python app and bytecode is inspectable. The goal is not "unbreakable":
|
||||
|
||||
- Honest customers stay honest; the limit is a clear contractual line
|
||||
- Casual copying (operator copies the folder to 3 more PCs) is stopped
|
||||
- A determined pirate with a cracker is **not** stopped
|
||||
|
||||
For B2B sold to a port with a support contract, that is the right level. Do not invest in obfuscation — it raises effort, never to infinity, and costs debuggability.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Signing, not a shared secret
|
||||
|
||||
The critical design point: **the application only ever verifies; it can never generate.**
|
||||
|
||||
1. Server computes a **machine fingerprint** from stable hardware IDs
|
||||
2. Customer sends that string (email, phone, photo — it is short)
|
||||
3. Vendor runs `tools/license_tool.py sign`, which signs `{customer, stations, components}` with the **Ed25519 private key**
|
||||
4. Customer pastes the key back; the server verifies with the **public key** compiled into `api/licensing.py`
|
||||
|
||||
A shared-secret or same-algorithm-both-ways scheme would be worthless — anyone who unpacks the app could mint keys. With signatures, forging requires the private key, which never leaves the vendor's machine.
|
||||
|
||||
Blob format: `base64("<payload json>.<base64 signature>")`.
|
||||
|
||||
### Tolerant fingerprint
|
||||
|
||||
Four components are collected and hashed **separately** (not combined), so partial matching is possible: motherboard serial, disk serial, first physical MAC, machine GUID.
|
||||
|
||||
Validation passes when **at least 3 of 4** match (`MIN_COMPONENT_MATCHES`). A replaced disk or NIC keeps working; a wholly different PC fails.
|
||||
|
||||
**Windows uses PowerShell CIM, not `wmic`.** This matters: `wmic` is deprecated and **has been removed from recent Windows 11 builds**, where it fails silently and yields nothing. On this development machine the original `wmic` approach found only **1 of 4** components, which would have made every licence fail to validate. Each lookup falls back to `wmic` so older machines still work. Linux reads `/sys/class/dmi`, `lsblk`, `/sys/class/net` and `/etc/machine-id`.
|
||||
|
||||
Missing components return `None` rather than raising — a VM or restricted environment may not expose all four, and the 3-of-4 rule tolerates that.
|
||||
|
||||
### Grace period
|
||||
|
||||
If validation fails (fewer than 3 components match, or no licence at all), the server enters a **14-day grace period**: everything keeps working, a persistent non-dismissible banner appears for all users, and the admin licence screen explains it. After 14 days the limit drops to **1 station** (`UNLICENSED_STATIONS`).
|
||||
|
||||
Rationale: a licence check must never stop a weighbridge at 3am. Grace turns a hard outage into a support call.
|
||||
|
||||
Grace start is stored in the **database**, not a file — a file is trivially reset, and the DB is included in backups.
|
||||
|
||||
---
|
||||
|
||||
## Counting connected stations
|
||||
|
||||
The bridge **deliberately never talks to Django** — that independence is a design decision (see `bridge-is-transport-only` in project memory). Licensing does not change it.
|
||||
|
||||
**The browser reports instead.** It already talks to both. On load and every 30s it reads the bridge's `/status` and POSTs a heartbeat:
|
||||
|
||||
```
|
||||
POST /api/license/heartbeat/ {"station_key": "st-abc123", "com_port": "COM3", "bridge_version": "1.1.0"}
|
||||
-> 200 {"allowed": true, "stations_used": 3, "stations_licensed": 5}
|
||||
-> 403 {"allowed": false, "detail": "Licence limit reached (5 stations)"}
|
||||
```
|
||||
|
||||
`station_key` is a short stable hash of the bridge address plus its COM port, so **two browser tabs on the same station share one slot** rather than consuming two.
|
||||
|
||||
`StationSlot` rows are live if `last_seen` is within `SLOT_TIMEOUT_SECONDS` (120).
|
||||
|
||||
**A reconnecting station reclaims its own slot even at the limit** — matched on `station_key` in `_station_allowed()`. This is the single most important rule in the counting logic: without it, a station that blipped would be locked out by its own expiring ghost, which would look exactly like a licensing bug at the worst possible moment.
|
||||
|
||||
### What "declined" means
|
||||
|
||||
The 6th station's Measure button is disabled with the reason shown beneath it, and `POST /api/vehicles/{id}/measure/` returns 403 for that station key. Viewing and editing continue normally. The other five are entirely unaffected.
|
||||
|
||||
**The API check is the rule; the button state is only convenience.** Enforcement lives in `VehicleViewSet.measure`, not the UI.
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `api/licensing.py` | `machine_components()`, `machine_fingerprint()`, `verify_license()`, `license_status()`, `effective_station_limit()`, `LICENSE_PUBLIC_KEY` |
|
||||
| `api/models.py` | `License` (single row, enforced in `save()`), `StationSlot` |
|
||||
| `api/views.py` | `LicenseView` (GET status / POST install), `LicenseHeartbeatView`, `_station_allowed()`, licence check in `measure` |
|
||||
| `api/urls.py` | `license/`, `license/heartbeat/` |
|
||||
| `api/migrations/0008_license_stationslot.py` | generated |
|
||||
| `requirements.txt` | `cryptography==50.0.1` added (PyJWT alone only does HMAC — a shared secret, i.e. the worthless scheme) |
|
||||
|
||||
### Vendor tooling — `tools/license_tool.py`
|
||||
|
||||
```bash
|
||||
python tools/license_tool.py keygen # once; refuses to overwrite without --force
|
||||
python tools/license_tool.py sign --customer "Port of Varna" --stations 5 \
|
||||
--components '{"board":"...","disk":"...","mac":"...","guid":"..."}'
|
||||
```
|
||||
|
||||
`sign` refuses fewer than 3 components, since such a licence could never validate on the customer machine.
|
||||
|
||||
### Frontend
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `contexts/LicenseContext.jsx` | 30s heartbeat, derives `station_key` from the bridge's `/status` |
|
||||
| `components/License/LicenseManager.jsx` + `.css` | Admin screen: state, fingerprint with copy buttons, paste box, connected-station table. 🔑 in header |
|
||||
| `components/Main.jsx` | Measure gated on `licenseAllowed`, `station_key` sent with both measure calls, grace banner |
|
||||
| `App.jsx` | `LicenseProvider`, `/license` route (admin only) |
|
||||
|
||||
Design note: `LicenseContext` **assumes allowed until told otherwise**, so a slow first heartbeat never blocks a working station. Non-403 failures (server down) leave the previous state alone — a licence check must not stop a working weighbridge.
|
||||
|
||||
---
|
||||
|
||||
## Security: the private key
|
||||
|
||||
**`tools/license_private.key`** — gitignored via `*license*.key`, generated 2026-09-17. Verified not tracked.
|
||||
|
||||
- **If it leaks**, every licence ever issued becomes forgeable
|
||||
- **It cannot be rotated** without reissuing to every customer, since the public key is compiled into shipped binaries
|
||||
- **If it is lost**, no new licences can ever be signed for existing installs
|
||||
|
||||
Back it up outside the repo. Never ship it. The public key in `api/licensing.py` is safe to commit and must be correct in the build before shipping.
|
||||
|
||||
---
|
||||
|
||||
## Verification (all passing)
|
||||
|
||||
### Crypto — 6 checks
|
||||
|
||||
1. Genuine licence verifies; customer and station count read back correctly
|
||||
2. **Tampering rejected** — editing `"stations":5` to `"stations":500` in a real signed blob breaks the signature. *This is the property the entire scheme rests on.*
|
||||
3. Licence for a different machine rejected (1 of 4 match, 3 required)
|
||||
4. **3-of-4 tolerance works** — one component changed (simulated disk replacement) still validates
|
||||
5. Empty, garbage and malformed blobs rejected cleanly with distinct messages
|
||||
6. Expired licence rejected
|
||||
|
||||
### End to end — 10 checks
|
||||
|
||||
1. No licence → grace period, 14 days, still usable
|
||||
2. Install a 5-station licence → customer and count correct
|
||||
3. Five stations connect → `used=5 licensed=5`
|
||||
4. **Sixth declined** → 403, "Licence limit reached (5 stations)"
|
||||
5. The five keep working (refresh succeeds)
|
||||
6. **Reconnect at the limit reclaims its own slot** → 200, not locked out
|
||||
7. Offline station frees its slot after timeout → sixth then admitted
|
||||
8. `measure` with an unregistered station key → 403
|
||||
9. `measure` from a licensed station → 200
|
||||
10. Grace expiry (backdated 15 days) → limit drops to 1
|
||||
|
||||
Frontend builds clean; no warnings in any licensing file.
|
||||
|
||||
---
|
||||
|
||||
## Operating notes
|
||||
|
||||
**Currently in grace mode** — no licence installed, so everything works and the banner shows. Install one from the 🔑 screen to test the licensed path.
|
||||
|
||||
**To issue a licence:** customer opens 🔑 → copies the component JSON from "Full details" → sends it → run `license_tool.py sign` → customer pastes the result into the install box.
|
||||
|
||||
The fingerprint shown large (e.g. `C261-A181-3D8D-AC94`) is for reading over the phone to confirm identity. The component JSON underneath is what `sign` actually needs. Both are one-way hashes, so they carry no identifying hardware detail.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Expiry / subscriptions** — perpetual only; the payload field exists so this can be added without reissuing the format
|
||||
- **Licensing individual bridges** — rejected as N× the administration
|
||||
- **Obfuscation / anti-tamper** — see the threat model
|
||||
- **Named Station entities** — slots are identified by bridge hash; a Station model was deliberately skipped
|
||||
@@ -0,0 +1,224 @@
|
||||
# Multi-Protocol Scale Support
|
||||
|
||||
## Context
|
||||
|
||||
The port runs 10 scales from 6 manufacturers (Mettler-Toledo, Esit, Elikom, Libra, 3 no-name), each speaking a different serial protocol. ScalesApp currently assumes one format and parses it with a regex in `Main.jsx`.
|
||||
|
||||
The hard constraint is **operational, not technical**: installation and upgrades must need minimal involvement from the developer. So the design question is not "how do we parse 6 formats" but **"where does protocol knowledge live so that adding a scale never means visiting 10 PCs with a new binary."**
|
||||
|
||||
**Answer: the bridge knows nothing about protocols.** It is pure transport — open the port, optionally send a poll string on an interval, stream raw lines. Protocol definitions live in Django, are cached by the frontend, and parsing happens in the browser where the weight is displayed. A new scale type is a web-UI change; the bridge binary never changes for it.
|
||||
|
||||
**Decisions made with the user:**
|
||||
- Bridge = transport only. It learns its poll string at runtime from the UI, so a scale with a different command (`%p`, `SI`, `#w`) needs no bridge update.
|
||||
- Protocol definitions live in Django, cached in the frontend (same pattern as `NomenclatureDataContext`).
|
||||
- Live weight keeps going **bridge → UI directly** on localhost. Django stays out of the hot path, preserving the independence the project CLAUDE.md calls out.
|
||||
- Admin gets a COM monitor (raw text + hex) and a fixed-column protocol builder. Exotic protocols stay code.
|
||||
- Four protocols seeded: Simulator (`ST`/`US`), fixed-column `%p`, Toledo Continuous, Mettler-Toledo SICS.
|
||||
|
||||
---
|
||||
|
||||
## Why the browser, not the bridge, parses
|
||||
|
||||
The bridge is a PyInstaller binary on 10 station PCs. Anything it knows is expensive to change. The frontend is served from one place and updates on reload.
|
||||
|
||||
Polling is the exception that must stay in the bridge: a browser cannot be the heartbeat. Background tabs get timer-throttled by Chrome, a closed tab stops the scale, two open tabs double the rate. So the **bridge owns the poll loop, the UI owns the poll string.**
|
||||
|
||||
---
|
||||
|
||||
## Bridge changes (`serial_bridge/app.py`)
|
||||
|
||||
Note `serial_reader.py` and `backend_client.py` in that folder are dead code — nothing imports them. Leave them alone; all live code is in `app.py`.
|
||||
|
||||
### 1. Runtime poll configuration
|
||||
|
||||
```
|
||||
POST /poll {"command": "%p", "interval": 0.2} -> bridge stores and runs the loop
|
||||
POST /poll {"command": ""} -> continuous mode, no polling
|
||||
GET /poll -> current setting
|
||||
```
|
||||
|
||||
The UI pushes this on connect, from the station's protocol definition. The bridge writes the bytes verbatim — `%p` bare, **no terminator** (confirmed against the original Delphi, where `TComPort.WriteStr('%p')` appends nothing).
|
||||
|
||||
**Persist the last setting to a local file** (`poll_config.json` beside the executable) and restore it on startup. Otherwise a bridge restarted with no browser open sits idle in a confusing dead state.
|
||||
|
||||
### 2. One-off command endpoint — for the admin monitor
|
||||
|
||||
```
|
||||
POST /send {"command": "%p"}
|
||||
```
|
||||
|
||||
Writes once, does not change the poll loop. This is the probe tool for identifying an unknown scale.
|
||||
|
||||
### 3. Raw bytes alongside text
|
||||
|
||||
`read_data()` currently does `.decode('utf-8', errors='ignore')`, which **silently corrupts binary frames** — Toledo Continuous starts with `STX` (0x02) and packs status into bit fields. Add a `hex` field to every SSE payload:
|
||||
|
||||
```python
|
||||
{'port': ..., 'data': '<utf-8, lossy>', 'hex': '02 20 30 ...', 'timestamp': ...}
|
||||
```
|
||||
|
||||
The UI parses text protocols from `data` and binary ones from `hex`. Cheap now, painful to retrofit once something depends on `data`.
|
||||
|
||||
### 4. Polling loop
|
||||
|
||||
Extend `read_serial_data()`: when a poll command is set, write it, wait briefly, then read. Keep the existing buffer-drain behaviour for continuous mode (it discards stale lines and keeps the newest — added earlier today; essential for streaming scales, harmless for polled ones).
|
||||
|
||||
### 5. `.env` shrinks to machine-local hardware facts
|
||||
|
||||
```
|
||||
COM_PORT=COM3
|
||||
BAUD_RATE=9600
|
||||
TIMEOUT=1
|
||||
PORT=5000
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
No protocol, no poll command, no `STATION_ID`. COM port and baud must stay local — Django cannot know this PC's wiring.
|
||||
|
||||
### 6. Version reporting
|
||||
|
||||
Bridge reports its version in `/status`. Surfaced in the UI so stale stations are visible at a glance rather than discovered when one misbehaves.
|
||||
|
||||
---
|
||||
|
||||
## Backend: protocol definitions
|
||||
|
||||
### Model — `api/models.py`
|
||||
|
||||
```python
|
||||
class ScaleProtocol(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
kind = models.CharField(max_length=20, choices=[...]) # fixed_column | regex | toledo_continuous | sics
|
||||
poll_command = models.CharField(max_length=20, blank=True) # '' = continuous
|
||||
poll_interval = models.FloatField(default=0.2)
|
||||
config = models.JSONField(default=dict) # kind-specific parameters
|
||||
is_builtin = models.BooleanField(default=False) # seeded; not user-deletable
|
||||
```
|
||||
|
||||
`config` for `fixed_column` (the case the builder targets, and what covers the no-names):
|
||||
|
||||
```json
|
||||
{"weight_start": 4, "weight_length": 8,
|
||||
"stable_pos": 21, "stable_char": " ", "stable_means_balanced": true,
|
||||
"decimals": 0}
|
||||
```
|
||||
|
||||
**Offsets stored 0-based**, converted for display in the builder. The `%p` scale's "weight from char 5, length 8; stability last char" is 1-based Delphi — so `weight_start: 4`, `stable_pos: 21`.
|
||||
|
||||
Seed four via data migration: Simulator, `%p` fixed-column, Toledo Continuous, SICS. Marked `is_builtin`.
|
||||
|
||||
ViewSet + route `scale-protocols` in `api/urls.py`, following the existing router convention.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Protocol cache — `contexts/ScaleProtocolContext.jsx`
|
||||
|
||||
Fetch `/api/scale-protocols/` on mount, cache, subscribe to SSE updates. Mirrors `NomenclatureDataContext`, which already does exactly this; reuse its shape rather than inventing a new one.
|
||||
|
||||
### 2. Parsers — `services/scaleProtocols.js`
|
||||
|
||||
One function per kind, all returning the same shape:
|
||||
|
||||
```js
|
||||
parse(reading, protocol) -> { weight: number|null, balanced: boolean, error: string|null }
|
||||
```
|
||||
|
||||
- **`fixed_column`** — slice by offsets. Must **not** trim before reading `stable_pos`: in the `%p` format a *space* means balanced, so trailing-whitespace stripping would invert the signal and make every reading look unstable.
|
||||
- **`toledo_continuous`** — parse from `hex`. STX + SWA/SWB/SWC + 6 weight + 6 tare digits + CR. Stability is the **motion bit in SWB** (set = moving), decimal position from SWA.
|
||||
- **`sics`** — `S S 16480 kg` → stable; `S D` → dynamic; `S I` / `S +` / `S -` → error states.
|
||||
- **`simulator`** — trailing `ST`/`US`, the current behaviour.
|
||||
|
||||
**Return an explicit error rather than a plausible weight** when a frame doesn't match. Toledo and SICS are written from documented structure without hardware to test against, so they must fail loudly — a silently wrong weight on a weighbridge is worse than no weight.
|
||||
|
||||
### 3. `Main.jsx` — replace the inline regex
|
||||
|
||||
The current `currentWeight` / `isBalanced` memos parse the reading directly. Replace with a call to the selected protocol's parser. Everything downstream (the red/green colouring, the Measure button) already consumes `{weight, balanced}` and is unaffected.
|
||||
|
||||
The UI POSTs `/poll` to the bridge on connect with the station's protocol command and interval.
|
||||
|
||||
### 4. Admin: COM monitor + protocol builder
|
||||
|
||||
New page, gated on `is_admin`:
|
||||
- **Live raw view** — each incoming line as text and hex, scrolling, pause/capture to file. Hex matters: it's the only way to see `STX` or trailing spaces.
|
||||
- **Send command** — free-text box posting `/send`, for probing an unknown scale.
|
||||
- **Fixed-column builder** — form for weight start/length, stability position and character; **applied live against the captured lines** so the admin sees the parsed result before saving. This is what makes it usable without the developer.
|
||||
|
||||
---
|
||||
|
||||
## Which station uses which protocol
|
||||
|
||||
Deployment is flexible (one central Django at the port; single-machine elsewhere), so the browser identifies its station implicitly: **it talks to whichever bridge is on localhost:5000.** No cookie, no per-browser state — cookies were rejected because they are per-browser, wiped by clearing site data, and unreadable by the bridge.
|
||||
|
||||
The protocol selection for a station is held in Django and chosen in the UI. With one scale per PC this is a single selection; it does not require a full Station entity yet.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|---|---|
|
||||
| Frame doesn't match the protocol | Parser returns `error`; UI shows unparseable rather than a wrong number |
|
||||
| Binary frame through `data` | Use `hex`; `data` is lossy by design |
|
||||
| `%p` reply shorter than 22 chars | Guard offsets; return error, never slice past the end |
|
||||
| Bridge restarted, no browser open | Restores poll config from its local file and keeps polling |
|
||||
| Two tabs open | Both POST `/poll` with the same values — idempotent, loop stays single |
|
||||
| Django down | Live weight unaffected; UI uses cached protocol definitions |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Simulator unchanged** — existing test writer keeps working end to end; weight displays and colours as today. This is the regression guard for the whole refactor.
|
||||
2. **Fixed-column against a synthetic `%p`** — extend the test writer to emit a 22-char frame; confirm weight and the space-means-balanced rule, including that a trailing space survives.
|
||||
3. **Toledo/SICS** — unit-test the parsers against hand-built frames. Mark clearly as **unvalidated against real hardware** until a real scale confirms them.
|
||||
4. **Poll loop** — POST `/poll`, confirm the bridge writes on interval; empty command stops it; setting survives a bridge restart.
|
||||
5. **Hex field** — verify a frame containing 0x02 arrives intact in `hex` while `data` is lossy.
|
||||
6. **Builder** — capture lines from the simulator, build a fixed-column protocol in the UI, confirm the live preview parses correctly before save.
|
||||
7. **Offsets** — the `%p` 1-based → 0-based conversion, explicitly tested. One position off silently clips a digit.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
**Bridge** — `serial_bridge/app.py` (poll endpoints, `/send`, hex field, poll loop, version), `serial_bridge/.env`
|
||||
|
||||
**Backend** — `api/models.py`, `api/serializers.py`, `api/views.py`, `api/urls.py`, migration + seed data migration
|
||||
|
||||
**Frontend** — `contexts/ScaleProtocolContext.jsx`, `services/scaleProtocols.js`, `components/Main.jsx`, new admin monitor/builder component
|
||||
|
||||
**Not touched** — `serial_reader.py`, `backend_client.py` (dead code)
|
||||
|
||||
---
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
- **Station entity** — one scale per PC makes it unnecessary now; add it if a PC ever drives two scales.
|
||||
- **Bridge self-update** — version reporting only. Real update machinery is speculative until you see how often bridges actually change.
|
||||
- **Regex/delimited builder kinds** — the model supports the `kind` field, but only fixed-column gets builder UI. Add others if a real scale needs one.
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes (built 2026-09-17)
|
||||
|
||||
All three tiers built and verified. Deviations and details worth recording:
|
||||
|
||||
**Protocol selection is per-browser (`localStorage`), not a Station model.** With one scale per PC the browser talks to its own bridge on localhost, so a full Station entity was not needed. If a PC ever drives two scales, that changes.
|
||||
|
||||
**Bridge version 1.1.0** — reported in `/status` and shown in the monitor toolbar.
|
||||
|
||||
**`poll_config.json` sits beside the bridge executable** and is gitignored along with `serial_bridge.log`. Both are per-machine runtime state.
|
||||
|
||||
**Fallback protocol**: if none is chosen, the frontend uses the Simulator definition so a fresh install shows a weight rather than an error.
|
||||
|
||||
### Verified
|
||||
|
||||
- **27 parser unit tests pass** (scratchpad `test_protocols.mjs`), covering all four kinds plus error paths. Notably: space-means-balanced for `%p`, inverted stability, `decimals` scaling, Toledo motion bit and sign bit, SICS `I`/`+`/`-` error states, and `"16480 kg BEST"` correctly NOT read as stable.
|
||||
- **Backend API**: seeded 4 protocols; custom create/delete; built-ins protected from deletion; invalid offsets and non-positive poll intervals rejected.
|
||||
- **Bridge endpoints**: `/poll` get/set, persistence across restart, empty command = continuous, bad intervals rejected, `/send` 503 when disconnected, `/status` reports version and poll state.
|
||||
- **Byte handling**: a binary Toledo frame survives byte-exact in `hex` (STX intact); a 22-char `%p` line keeps its significant trailing space while CRLF is stripped; the backlog drain still keeps only the newest line.
|
||||
|
||||
### Still unvalidated
|
||||
|
||||
**Toledo Continuous and SICS have never seen real hardware.** Both are written from documented structure. They return explicit errors rather than guesses on malformed frames, and the monitor shows the raw hex needed to correct them. Expect the first real Toledo to need a tweak — most likely the SWA decimal-point table or a SWB bit position.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Plan: Drag-to-reorder nomenclatures, reflected in vehicle "Additional Data"
|
||||
|
||||
## User Request
|
||||
|
||||
> in nomenclatures overlay, make the rows in the list draggable, so they can be rearranged. then in vehicle, show the fields in this order
|
||||
|
||||
Clarified with screenshots: the target is the **left-hand list in the Nomenclatures overlay**
|
||||
(Агент, Товар, Товародател, № Контейнер, Корабособственик). The client wants to be able to move
|
||||
e.g. "Агент" to the bottom of the list, and the vehicle screen's "Additional Data" dropdowns
|
||||
should then appear in that same order.
|
||||
|
||||
## Plan
|
||||
|
||||
### Context
|
||||
|
||||
The backend already supports this fully: `Nomenclature.sort_order` exists
|
||||
(`backend/nomenclatures/models.py:29`), `Meta.ordering = ["sort_order", "code"]`
|
||||
(`models.py:32`), the viewset orders by it (`backend/api/views.py:260`), the serializer
|
||||
exposes it as writable (`backend/api/serializers.py:264`), and `Main.jsx:31` already sorts
|
||||
`vehicleNomenclatures` by it. **No backend change and no migration are needed.**
|
||||
|
||||
The reason the order looks fixed today is that every existing row has `sort_order = 0` — the
|
||||
model default — so the sort is a no-op and rows fall back to `code` order. Assigning real
|
||||
values on the first drag fixes that permanently.
|
||||
|
||||
Decisions taken with the user:
|
||||
- Persist by PATCHing each moved row (frontend-only; no new endpoint).
|
||||
- Save immediately on drop — no Save-button staging.
|
||||
- The whole row is draggable (no separate grip handle).
|
||||
|
||||
### Scope
|
||||
|
||||
Two files change. Both are frontend.
|
||||
|
||||
#### 1. `frontend/src/components/NomenclatureManager/NomenclatureManager.jsx`
|
||||
|
||||
Add native HTML5 drag-and-drop to the left list (lines 189–217). No library — `package.json`
|
||||
has no DnD dependency and adding one for a five-row list is not warranted.
|
||||
|
||||
**State:** two new pieces of local state — `dragIndex` (index of the row being dragged,
|
||||
`null` when idle) and `dragOverIndex` (index the row would drop into, for the drop-line
|
||||
indicator). Plus reuse of the existing `error` state for failure messages.
|
||||
|
||||
**Row markup** (`.nm-list-item`, line 191): add
|
||||
- `draggable={canEdit}` — reordering is an edit, so gate it on the same `canEdit`
|
||||
(`currentUser?.can_manage_entities`) that already gates the edit/add/delete buttons.
|
||||
- `onDragStart` → record `dragIndex`; call `e.dataTransfer.setData('text/plain', String(idx))`
|
||||
(Firefox refuses to start a drag without data set) and `e.dataTransfer.effectAllowed = 'move'`.
|
||||
- `onDragOver` → `e.preventDefault()` (required or no drop event fires) and set `dragOverIndex`.
|
||||
- `onDragEnd` → clear both indices (fires even on a cancelled drag, so it is the reliable reset).
|
||||
- `onDrop` → `e.preventDefault()`, then run the reorder below.
|
||||
- `className` gains conditional `nm-list-item--dragging` / `nm-list-item--drag-over` modifiers.
|
||||
|
||||
Change the `.map(nom => ...)` at line 190 to `.map((nom, idx) => ...)` so handlers have the
|
||||
index. The React `key={nom.id}` at line 192 is already stable and correct — leave it.
|
||||
|
||||
**Reorder handler** `handleDrop(targetIdx)`:
|
||||
1. Bail if `dragIndex === null || dragIndex === targetIdx`.
|
||||
2. Build the reordered array with a splice-out/splice-in on a copy of `nomenclatures`.
|
||||
3. Write `sort_order: i` onto every element of the new array, and `setNomenclatures(...)`
|
||||
immediately — optimistic update, so the list does not visibly snap back while the requests
|
||||
are in flight.
|
||||
4. Diff against the previous array and PATCH only rows whose `sort_order` actually changed:
|
||||
`api.patch('/api/nomenclatures/${id}/', { sort_order: i })`, all in one `Promise.all`.
|
||||
PATCH (not PUT) matters — `NomenclatureSerializer.update` deletes and recreates the whole
|
||||
`fields` collection (`backend/api/serializers.py:284-287`), so a PUT without a `fields` key
|
||||
would wipe the field definitions. A partial update leaves `fields` untouched.
|
||||
5. On rejection: `setError('Failed to save order')` and `await loadNomenclatures()` to resync
|
||||
from the server.
|
||||
|
||||
Note the first drag will PATCH nearly every row, because they all currently sit at
|
||||
`sort_order = 0` and the diff sees every position as changed. That is correct and self-healing —
|
||||
subsequent drags only touch the rows between the source and target.
|
||||
|
||||
**One caveat to handle:** each PATCH triggers `sse_broadcast_update('nomenclature', 'update', ...)`
|
||||
(`backend/api/views.py:268`), and `NomenclatureDataContext.jsx:82-85` responds to any
|
||||
`nomenclature` event with a full `loadAll()` refetch. For a 5-row reorder that is ~5 events
|
||||
collapsing into a few refetches of a small payload — acceptable, and it is what makes the
|
||||
vehicle screen update live without a page reload. No debounce needed at this scale; revisit
|
||||
only if the list ever grows large.
|
||||
|
||||
#### 2. `frontend/src/components/NomenclatureManager/NomenclatureManager.css`
|
||||
|
||||
- `.nm-list-item` (line 117) — the existing rule already has `cursor: pointer`; leave it. Add
|
||||
`user-select: none` so dragging does not paint a text selection across the row.
|
||||
- `.nm-list-item--dragging` — `opacity: 0.4`, to show which row is in flight.
|
||||
- `.nm-list-item--drag-over` — a drop indicator in the accent already used by
|
||||
`.nm-list-item--active` (line 132). Because `.nm-list-item` has a `border-bottom` (line 122),
|
||||
use an inset `box-shadow: inset 0 2px 0 #667eea` rather than a border so row height does not
|
||||
shift by 2px during the drag.
|
||||
|
||||
### Explicitly not in scope
|
||||
|
||||
- `NomenclatureField` rows (the "Fields" table on the right panel) — they have their own
|
||||
`sort_order` that the save payload currently drops, but the user asked about the left list
|
||||
only. Flagged to the user as a separate known gap, not fixed here.
|
||||
- `NomenclatureEntry` ordering — entries have no `sort_order` column at all; that would need a
|
||||
migration. Not requested.
|
||||
- `Main.jsx` — **no change required.** Line 31 already sorts by `sort_order`. Left as is.
|
||||
|
||||
### Verification
|
||||
|
||||
1. Start backend (`cd backend && uvicorn scalesapp.asgi:application --reload`) and frontend
|
||||
(`cd frontend && npm start`); log in as a user with `can_manage_entities`.
|
||||
2. Open the Nomenclatures overlay. Drag **Агент** from the top to the bottom of the left list.
|
||||
Verify: the row follows the cursor with a drop indicator, and lands last on release.
|
||||
3. Check the Network tab: several `PATCH /api/nomenclatures/{id}/` requests, each with a
|
||||
`{sort_order: N}` body only, all returning 200.
|
||||
4. Without reloading, look at the vehicle screen's "Additional Data" section — **АГЕНТ should
|
||||
now be the last dropdown**, moved there live by the SSE-driven `loadAll()`.
|
||||
5. Hard-reload the page (F5) and confirm both the overlay list and Additional Data still show
|
||||
Агент last — this proves it persisted rather than only living in local state.
|
||||
6. Re-open the overlay, click a nomenclature to edit it, and confirm its **Fields table still
|
||||
lists its fields** — this is the PUT-vs-PATCH regression guard from step 4 of the handler.
|
||||
7. Log in as a user without `can_manage_entities` and confirm rows are not draggable.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Single "Measure" Button + Manual Measurement
|
||||
|
||||
## Context
|
||||
|
||||
The weighing screen has two buttons, **Set Tare** and **Set Gross**. Operators regularly press the wrong one. Since the vehicle is on the scale either empty or loaded, the app can decide which is which — the operator shouldn't have to.
|
||||
|
||||
This replaces both with a single **Measure** button. The first press records a weighing; the second press records the other and finalizes the document. Assignment is **by weight**, not by order.
|
||||
|
||||
A second, related need: sometimes a weight must be entered by hand (scale offline, disputed reading). That must be recorded as manual, with who, when and why.
|
||||
|
||||
**Decisions made with the user:**
|
||||
- Site handles **both directions** — trucks may arrive loaded and leave empty. So order can't decide; **lighter = tare, heavier = gross**, regardless of which was taken first.
|
||||
- Pressing Measure on an already-finalized vehicle is **blocked** (button disabled), matching today's behaviour. No silent mutation of an issued document.
|
||||
- **No clear/redo.** Scales expose a "balanced" flag and measuring will later be gated on it. Not in this change.
|
||||
- Manual measurement is built **now**, extending the existing `tare_manual` / `gross_manual` booleans with user, date and note per measurement.
|
||||
|
||||
---
|
||||
|
||||
## Why weight-based assignment
|
||||
|
||||
`net = gross - tare`, so assigning the lighter reading to tare makes a negative net structurally impossible. Order-based assignment (first = tare) would produce a negative net for every outbound truck — which the user confirmed happens here.
|
||||
|
||||
The tradeoff to be aware of: for two weighings the rule is unambiguous, but it means **the first reading's role is not final until the second arrives**. A truck weighed at 15000 shows as tare; if the next weighing is 8000, the 15000 becomes gross and the 8000 becomes tare. The UI must not present the first reading as settled.
|
||||
|
||||
**Handling**: while only one weighing exists, display it in a single neutral card labelled **"First weighing"** rather than filling in the Tare card. Once the second arrives, both cards populate with their final roles. This avoids a value visibly jumping between cards, which would look like a bug.
|
||||
|
||||
**Equal weights** (`gross == tare`) yield `net = 0`. Physically it means nothing was loaded or unloaded. Treat as valid and let it finalize — rejecting it would strand the vehicle with no way forward, and a zero-net document is a legitimate record of a truck that came and went unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. Model — `backend/vehicles/models.py`
|
||||
|
||||
Extend the existing unused `tare_manual` / `gross_manual` booleans with companions. Keep them adjacent to the weight group they describe, matching the file's existing layout.
|
||||
|
||||
```python
|
||||
tare_manual = models.BooleanField(default=False)
|
||||
tare_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare_manual', on_delete=models.SET_NULL)
|
||||
tare_manual_date = models.DateTimeField(null=True, blank=True)
|
||||
tare_manual_note = models.CharField(max_length=255, null=True, blank=True)
|
||||
```
|
||||
|
||||
and the `gross_` equivalents (`related_name='user_vehicle_gross_manual'`).
|
||||
|
||||
`related_name` must not collide with the existing `user_vehicle_tare` / `user_vehicle_gross`.
|
||||
|
||||
Migration: `python manage.py makemigrations vehicles` → `0006_...` (latest is now `0005_extracombination`). All fields nullable with defaults, so no interactive prompt.
|
||||
|
||||
### 2. Assignment helper — `backend/api/views.py`
|
||||
|
||||
Add beside `_maybe_finalize`:
|
||||
|
||||
```python
|
||||
def _assign_weighing(vehicle, value, user, manual=False, note=None):
|
||||
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
|
||||
|
||||
First weighing is parked in tare; the second may swap it if it turns out lighter,
|
||||
since trucks may arrive either loaded (outbound) or empty (inbound).
|
||||
"""
|
||||
```
|
||||
|
||||
Logic:
|
||||
|
||||
- **Neither set** — park in `tare` (`tare_date`, `tare_user`, and the manual fields when manual). Role is provisional until the second weighing.
|
||||
- **One set (tare only)** — compare against the parked value. If the new reading is heavier or equal, it becomes `gross`. If lighter, it becomes `tare` and the **parked values move to gross** — carrying their own date, user and manual fields with them, so provenance follows the value rather than the slot. Then `_maybe_finalize`.
|
||||
- **Both set** — raise; the viewset returns 400. Should be unreachable from the UI (button disabled) but the API must not be trusted to be called only by the UI.
|
||||
|
||||
The swap must move **all** companion fields together (`*_date`, `*_user`, `*_manual`, `*_manual_user`, `*_manual_date`, `*_manual_note`). Missing one silently misattributes who took a reading — the most likely bug in this change, and invisible until someone audits a document.
|
||||
|
||||
### 3. Endpoint — `backend/api/views.py`
|
||||
|
||||
One new action on `VehicleViewSet`, replacing `set_tare` / `set_gross`:
|
||||
|
||||
```python
|
||||
@action(detail=True, methods=['post'], url_path='measure')
|
||||
def measure(self, request, pk=None):
|
||||
"""Record a weighing. The app assigns tare vs gross by weight."""
|
||||
```
|
||||
|
||||
- `value` required, coerced to `int` — same validation as the current `set_tare`.
|
||||
- `manual` (bool, default false) and `note` (string, optional) in the body.
|
||||
- **Permissions**: reject unless `request.user.can_measure`; additionally require `can_manually_measure` when `manual` is true. These flags exist on `User` and are editable in UserManager but are **currently enforced nowhere** — this is the first enforcement. 403 with a clear detail message.
|
||||
- **Manual requires a note** — reject blank. A manual override without a reason is not worth recording.
|
||||
- Returns the serialized vehicle and broadcasts `sse_broadcast_update('vehicle', 'updated', ...)`, matching the existing pattern.
|
||||
|
||||
**Delete `set_tare` and `set_gross`.** They bypass the assignment rule, so leaving them routed would leave the wrong-value path open — the exact problem being fixed. Nothing else calls them (verified: only `Main.jsx`).
|
||||
|
||||
### 4. Serializer — `backend/api/serializers.py`
|
||||
|
||||
Add the six new fields to `VehicleSerializer.fields` and to `read_only_fields` — they are set only through the `measure` action, never by direct PATCH.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### 5. `Main.jsx` — replace the two handlers
|
||||
|
||||
`handleSetTare` and `handleSetGross` (lines 122-140) collapse into:
|
||||
|
||||
```jsx
|
||||
const handleMeasure = async () => {
|
||||
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, { value: currentWeight });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to record measurement');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Note the current handlers swallow the server message (`catch { setError('Failed to set tare') }`). Surface `detail` instead, so permission and already-finalized errors are legible.
|
||||
|
||||
Manual entry: a small modal (weight, required note) posting the same endpoint with `manual: true`. Trigger shown only when `currentUser?.can_manually_measure`, using the `useAuth()` pattern from NomenclatureManager.
|
||||
|
||||
### 6. `Main.jsx` — weighing section JSX
|
||||
|
||||
Replace the two `weight-card` blocks (lines 362-401). Three states:
|
||||
|
||||
- **No weighings** — one neutral card, `---`, with the **Measure** button.
|
||||
- **One weighing** — one card headed **"First weighing"** showing value, timestamp, user, and a `manual` marker if applicable. Measure button still enabled. Deliberately *not* labelled Tare.
|
||||
- **Both** — Tare and Gross cards as today, each showing its own meta, plus the existing Net card. Measure button disabled.
|
||||
|
||||
Button disabled when: no live weight, or both weights set. Gate visibility on `can_measure`.
|
||||
|
||||
Reuse the existing `.weight-card`, `.weight-card-header`, `.weight-card-value`, `.weight-card-meta`, `.weight-set-btn` classes — no new CSS beyond a modifier for the manual marker.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|---|---|
|
||||
| Second reading **lighter** than the first | New value becomes tare; parked values move to gross with all their metadata. Net positive. |
|
||||
| Second reading **equal** | Becomes gross, `net = 0`, finalizes. Valid record. |
|
||||
| Measure on a **finalized** vehicle | Button disabled; API returns 400 if called directly. |
|
||||
| User lacks `can_measure` | 403. Button hidden. |
|
||||
| Manual without `can_manually_measure` | 403. Trigger hidden. |
|
||||
| Manual with blank note | 400. |
|
||||
| Two operators measuring the same vehicle at once | Last write wins, as today. Out of scope — flagged, not fixed. |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Backend on 8002 (`run_server.bat`), frontend on 5172 (`npm start`).
|
||||
|
||||
1. **Migration** — `python manage.py showmigrations vehicles` shows `[X] 0006_...`.
|
||||
2. **Inbound** — new vehicle, measure 8000 → "First weighing" card, not Tare. Measure 15000 → Tare 8000, Gross 15000, Net 7000.
|
||||
3. **Outbound (the case order-based logic breaks)** — measure 15000 first, then 8000 → Tare 8000, Gross 15000, Net **7000, positive**. Confirm the 15000's original date/user followed it to gross.
|
||||
4. **Equal** — 10000 twice → Net 0, finalized.
|
||||
5. **Third press blocked** — button disabled; direct POST returns 400.
|
||||
6. **Permissions** — user without `can_measure` gets 403 and no button; without `can_manually_measure`, no manual trigger.
|
||||
7. **Manual** — record manually with a note; confirm `*_manual`, `*_manual_user`, `*_manual_date`, `*_manual_note` in the shell. Blank note → 400.
|
||||
8. **Provenance after swap** — the shell check that matters:
|
||||
```
|
||||
python manage.py shell -c "from vehicles.models import Vehicle; v=Vehicle.objects.get(vehicle_number='X'); print(v.tare, v.tare_date, v.tare_user, v.tare_manual); print(v.gross, v.gross_date, v.gross_user, v.gross_manual)"
|
||||
```
|
||||
Each weight must carry the date/user of the press that produced it.
|
||||
9. **Old endpoints gone** — POST to `set-tare` returns 404.
|
||||
10. **Build** — `npm run build` compiles with no new warnings.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
**Backend**
|
||||
- `backend/vehicles/models.py` — 6 fields on `Vehicle`
|
||||
- `backend/vehicles/migrations/0006_...py` — generated
|
||||
- `backend/api/serializers.py` — fields on `VehicleSerializer`, read-only
|
||||
- `backend/api/views.py` — `_assign_weighing()`, `measure` action, delete `set_tare`/`set_gross`
|
||||
|
||||
**Frontend**
|
||||
- `frontend/src/components/Main.jsx` — `handleMeasure`, manual modal, rewritten weighing section
|
||||
- `frontend/src/components/Main.css` — manual marker style
|
||||
@@ -0,0 +1,164 @@
|
||||
# Auto-activate the Python venv in VS Code terminals
|
||||
|
||||
## Context
|
||||
|
||||
Opening a terminal in VS Code does not activate the project's Python venv. Two causes:
|
||||
|
||||
1. **The venv is not where VS Code looks.** It lives at `backend/venv`, but the Python
|
||||
extension's auto-discovery only scans for `.venv`/`venv` directly under the opened
|
||||
workspace folder (`C:\dev_projects\ScalesApp`) — one level above it.
|
||||
2. **No interpreter is configured.** There is no `.vscode/settings.json` in the project,
|
||||
and the user-level settings contain no `python.defaultInterpreterPath`. Without a known
|
||||
interpreter, the extension has nothing to activate.
|
||||
|
||||
Separately, none of the `run_*.bat` scripts activate the venv — they call `python` /
|
||||
`uvicorn` bare, so they only work when a venv happens to already be active.
|
||||
|
||||
**Outcome:** a single `.venv` at the project root that VS Code discovers automatically,
|
||||
plus batch scripts that activate it themselves and work from any shell state.
|
||||
|
||||
### Findings that shape the plan
|
||||
|
||||
- The existing `backend/venv` (Python 3.11.9, 138 MB) already has the dependencies from
|
||||
**all three** requirements files installed (Django + uvicorn, Flask + pyserial +
|
||||
pystray + PyInstaller). One shared root venv is therefore a faithful consolidation,
|
||||
not a new decision.
|
||||
- Base interpreter `C:\dev\Python\Python311\python.exe` (3.11.9) is present — verified.
|
||||
- `.gitignore` **already** ignores `.vscode/` and `venv/`, and has a `.venv` entry.
|
||||
The user chose to keep VS Code settings local, so **no `.gitignore` change is needed**.
|
||||
|
||||
### Decisions made
|
||||
|
||||
- Consolidate into one root `.venv` (standard layout, zero-config discovery).
|
||||
- Version conflicts between requirements files: **highest version wins** —
|
||||
install order chosen so the result is `python-dotenv==1.0.1` and `requests==2.32.5`.
|
||||
- Delete `backend/venv` only **after** the new venv is verified working.
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create the root venv
|
||||
|
||||
```bat
|
||||
C:\dev\Python\Python311\python.exe -m venv C:\dev_projects\ScalesApp\.venv
|
||||
```
|
||||
|
||||
→ **verify:** `.venv\Scripts\python.exe --version` prints `Python 3.11.9`.
|
||||
|
||||
### 2. Install dependencies (order matters)
|
||||
|
||||
Install so that the higher pin of each conflicting package is applied last:
|
||||
|
||||
```bat
|
||||
.venv\Scripts\python.exe -m pip install --upgrade pip
|
||||
.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
|
||||
.venv\Scripts\python.exe -m pip install -r test_comport_writer\requirements.txt
|
||||
.venv\Scripts\python.exe -m pip install -r serial_bridge\requirements.txt
|
||||
```
|
||||
|
||||
`serial_bridge` pins `requests==2.32.5` (higher) and `python-dotenv==1.0.0` (lower), so a
|
||||
final explicit pin is required to land on the intended "highest wins" result:
|
||||
|
||||
```bat
|
||||
.venv\Scripts\python.exe -m pip install "python-dotenv==1.0.1"
|
||||
```
|
||||
|
||||
→ **verify:** `pip freeze` shows `python-dotenv==1.0.1` and `requests==2.32.5`, and
|
||||
`pip check` reports no broken dependencies.
|
||||
|
||||
### 3. Create `.vscode/settings.json` (local only, already gitignored)
|
||||
|
||||
New file — `.vscode/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
|
||||
"python.terminal.activateEnvironment": true
|
||||
}
|
||||
```
|
||||
|
||||
`python.terminal.activateEnvironment` defaults to `true`, but is stated explicitly here so
|
||||
the intent is visible and survives a future user-settings change.
|
||||
|
||||
→ **verify:** reload the VS Code window, open a fresh terminal, and confirm the prompt is
|
||||
prefixed with `(.venv)`.
|
||||
|
||||
### 4. Make the batch scripts self-contained
|
||||
|
||||
Add a `call` to the venv activation script so each one works regardless of shell state.
|
||||
|
||||
- **`run_server.bat`** — currently `cd .\backend\` then `uvicorn ...` (also note: the file
|
||||
has no trailing newline). Activate from the root *before* the `cd`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
call "%~dp0.venv\Scripts\activate.bat"
|
||||
cd /d "%~dp0backend"
|
||||
uvicorn scalesapp.asgi:application --host 127.0.0.1 --port 8000 --reload
|
||||
```
|
||||
|
||||
- **`run_comport_reader.bat`** — currently `python .\serial_bridge\app.py`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
call "%~dp0.venv\Scripts\activate.bat"
|
||||
python "%~dp0serial_bridge\app.py"
|
||||
```
|
||||
|
||||
- **`run_test.bat`** — currently `python .\test_comport_writer\test_writer.py`:
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
call "%~dp0.venv\Scripts\activate.bat"
|
||||
python "%~dp0test_comport_writer\test_writer.py"
|
||||
```
|
||||
|
||||
`%~dp0` (the directory of the batch file itself, with trailing backslash) makes each
|
||||
script independent of the caller's working directory. `stop.bat` needs no change — it only
|
||||
calls `taskkill`.
|
||||
|
||||
### 5. Remove the old venv — only after step 6 passes
|
||||
|
||||
```bat
|
||||
rmdir /s /q C:\dev_projects\ScalesApp\backend\venv
|
||||
```
|
||||
|
||||
Reclaims 138 MB and prevents two interpreters drifting apart.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run in order; do not proceed to the deletion in step 5 until all of these pass.
|
||||
|
||||
1. **Interpreter** — `.venv\Scripts\python.exe --version` → `Python 3.11.9`.
|
||||
2. **Dependencies** — `.venv\Scripts\python.exe -m pip check` → no issues;
|
||||
`pip freeze` shows `python-dotenv==1.0.1`, `requests==2.32.5`.
|
||||
3. **Auto-activation** — reload the VS Code window, open a **new** terminal:
|
||||
prompt shows `(.venv)`; `where python` resolves to `...\ScalesApp\.venv\Scripts\python.exe`.
|
||||
4. **Django backend** — `run_server.bat` from a plain `cmd.exe` (no venv pre-activated):
|
||||
uvicorn starts and serves on `http://127.0.0.1:8000`.
|
||||
Also confirm `python manage.py check` in `backend/` reports no issues.
|
||||
5. **Serial bridge** — `run_comport_reader.bat` starts the Flask server on port 5000
|
||||
without an import error (`pyserial`, `flask`, `pystray` all resolve).
|
||||
6. **Test writer** — `run_test.bat` launches without an import error.
|
||||
|
||||
If step 4 or 5 fails on a missing package, the old `backend/venv` is still intact — install
|
||||
the missing package into `.venv` rather than rolling back.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `.venv/` | **new** — root venv (gitignored) |
|
||||
| `.vscode/settings.json` | **new** — interpreter path (gitignored, local only) |
|
||||
| `run_server.bat` | activate venv; use `%~dp0` for path independence |
|
||||
| `run_comport_reader.bat` | same |
|
||||
| `run_test.bat` | same |
|
||||
| `backend/venv/` | **deleted** after verification |
|
||||
|
||||
No application code changes. `.gitignore` needs no edit — `.vscode/`, `venv/` and `.venv`
|
||||
are all already covered.
|
||||
Reference in New Issue
Block a user