From 4c8921c46ef3b46361af09883a1d007a36d7d10c Mon Sep 17 00:00:00 2001 From: Mike Wills Date: Tue, 29 Sep 2026 23:36:27 -0500 Subject: [PATCH] feat: replace a race's line or a place's position from an updated file Import only ever added, so a revised route became a second course with none of the first one's settings and a moved stop a second place. The review screen's assign box now has Replace: a staged line replaces an existing course's geometry (re-turned to its stated start; refused with nothing changed if its ends miss it) and one staged point moves an existing place, listed nearest first. Everything the club set stays; the screen says how far a place moved, since its What3Words names the old spot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QkbtsMnQEe8HZ7xyxXHYHB --- CHANGELOG.md | 22 ++++ CLAUDE.md | 11 +- docs/RUNBOOK.md | 9 ++ src/courseops/admin.py | 22 ++++ src/courseops/guides/setup.md | 22 ++++ src/courseops/importer.py | 107 +++++++++++++-- src/courseops/static/setup.html | 6 + src/courseops/static/setup.js | 84 +++++++++++- tests/test_replace.py | 224 ++++++++++++++++++++++++++++++++ 9 files changed, 487 insertions(+), 20 deletions(-) create mode 100644 tests/test_replace.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb0a24..9074a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ month, PATCH counting releases in that month from 0. Before that they were ## [Unreleased] +### Added + +- **Replace a race's line or a place's position from an updated file.** The + Import review's assign box has a **Replace** choice. Lines: pick a race, and + its geometry is replaced while its id, name, colours, start time, *Starts + at* / *Finishes at*, the races ticked on its stops and its lead runner + reports stay; the new line is re-turned to the stated start, and a line + whose ends miss that start is refused with nothing changed. One point: the + places are listed nearest first with the distance, and the chosen place + moves there, keeping its name, layer, order, races, pin label, notes, + What3Words and posted operator - the screen then says how far it moved, + because the What3Words square still names the old spot. The replaced + features are discarded rather than left assigned, so deleting the course + later does not put both versions back in review. Why it mattered: import + only ever added, so an organizer's revised Half became a second "Half" with + none of the first one's settings, a moved stop a second "A" with no ticks, + order, What3Words or operator - and the originals could not be deleted once + anything referred to them. Two weeks before the Mankato Marathon a revised + file is expected. Checked in the browser by replacing the demo Half with the + real organizer Half (same course id, start time kept, 14.6 -> 13.1 mi) and + moving a stop 72 m. + ## [2026.9.19] - 2026-09-29 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 2b286dd..ddb19aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ python -m venv .venv ./.venv/Scripts/python.exe -m pip install -e ".[dev]" # Windows cp .env.example .env # then set APRS_CALLSIGN -./.venv/Scripts/python.exe -m pytest -q # 882 tests, no network +./.venv/Scripts/python.exe -m pytest -q # 892 tests, no network courseops init-db courseops add-event marathon2026 "Spring Marathon 2026" --lat 34.73 --lon -86.58 @@ -383,6 +383,13 @@ usability, not style preferences. - **Import never writes directly to `course` or `poi`.** Files stage as `pending` in `import_feature`; a human assigns each one. `suggest()` is advisory and must stay conservative — better unassigned than a parking lot filed as an aid station. + A human may also assign a staged line or point to REPLACE an existing course's + line or place's position (`importer.replace_course_line` / + `replace_poi_position`): the row keeps its id and everything the club set, + only geometry changes, and a replaced line is re-turned to its stated start. + Never match an updated feature to an existing one automatically - a revised + file names stops "WATER (ALL)" as often as "A"; the UI lists places nearest + first and a person picks. - **KML is untrusted third-party input.** It comes from the race organizer and will arrive by web upload. Parse with `defusedxml`, keep the KMZ decompression and size guards, and never swap back to stdlib `ElementTree.fromstring`. @@ -1198,6 +1205,7 @@ Rules that keep this honest: Last 10 entries; full record in `CHANGELOG.md`. +- **2026-09-29** Replace from an updated file: Import's assign box can put a staged line INTO an existing race or ONE point INTO an existing place (nearest first); only geometry changes, the line is re-turned to its start. - **2026-09-29** Fixed: a place's mile is measured on a race it serves (not the nearest line), at the pass the club's order means; lead runner pace/ETA measured on the leader's own race. - **2026-09-29** Starts at / Finishes at per race on the Courses tab: naming either turns the stored line to run from the start (the Mankato Half and 10K were drawn finish-first). - **2026-09-29** Fixed: the NCS stations list sorted posted stations by mile across races (10K mile 1.7 above Full 2.2); it now follows the club's place order, movers grouped by race. @@ -1207,4 +1215,3 @@ Last 10 entries; full record in `CHANGELOG.md`. - **2026-09-27** Pre-event beacon check-in is optional when operators are not local; race morning's Needs attention and a glance per row at net check-in cover it. First real event recorded in `docs/PLAN.md`. - **2026-09-27** Archive an event (#4): tracking off, positions deleted, links 404, hidden behind Show archived; Download / `courseops export` writes one event to a SQLite file, `serve --db` reads it. - **2026-09-27** Runbook: a station whose age keeps climbing - check the phone app's send path (APRS-IS/TNC switches), aprs.fi raw, then the server log. -- **2026-09-20** The role page's browser tab reads " | Course Ops" and setup " | Course Ops Setup", set beside the heading. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 57e087b..e7ceef7 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -115,6 +115,15 @@ courseops discard mankato2026 8 9 # parking, porta-johns, junk 13, segments are missing or one belongs to a different route. If `assign-course` warns about a gap, look at the course on the map before the event. +**A revised file from the organizer** (a changed route, a moved stop): upload +it in `/setup` and use **Replace** in the assign box, not a plain assign. A +line replaces a race's line and keeps everything set on the race; ONE point +moves an existing place (listed nearest first) and keeps everything set on +it. A plain assign makes a second "Half" or a second "A" with none of the +settings. After a moved stop, re-check its What3Words - the app says how far +it moved. After a replaced line, check the miles on the Places tab. The CLI +has no replace; this is browser-only. + ### 4. Set colours and draw order `/setup` -> Courses: each race's name and line colour, and the draw order. diff --git a/src/courseops/admin.py b/src/courseops/admin.py index 7d1c400..a3ea489 100644 --- a/src/courseops/admin.py +++ b/src/courseops/admin.py @@ -357,6 +357,28 @@ def assign_features(conn: sqlite3.Connection, event_id: int, payload: dict) -> d if not ids: raise ValueError("Select at least one feature.") + if kind == "course" and payload.get("replace_course_id") not in (None, ""): + course_id = _ids([payload["replace_course_id"]], "course")[0] + distance_m, warnings = importer.replace_course_line( + conn, event_id, course_id, ids, + reverse=bool(payload.get("reverse"))) + # Turned to the race's stated start, like a Starts at save: the + # revision is drawn whichever way the organizer drew it this time. + # Refused here rolls the whole replacement back. + course = conn.execute("SELECT * FROM course WHERE id = ?", + (course_id,)).fetchone() + if course["start_poi_id"] or course["finish_poi_id"]: + _set_course_ends(conn, event_id, course, {}) + return {"course_id": course_id, "distance_m": distance_m, + "warnings": warnings, "replaced": True} + + if kind == "poi" and payload.get("replace_poi_id") not in (None, ""): + poi_id = _ids([payload["replace_poi_id"]], "place")[0] + if len(ids) != 1: + raise ValueError("Select one point to move a place to.") + moved_m = importer.replace_poi_position(conn, event_id, poi_id, ids[0]) + return {"poi_ids": [poi_id], "moved_m": moved_m, "replaced": True} + if kind == "course": name = _text(payload, "name") if not name: diff --git a/src/courseops/guides/setup.md b/src/courseops/guides/setup.md index 7e52e5b..7b2874c 100644 --- a/src/courseops/guides/setup.md +++ b/src/courseops/guides/setup.md @@ -105,6 +105,28 @@ The screenshot above is a real export's defects: three placemarks, all named segments, a stray line miles from the route, or a start marker in the wrong car park is obvious there and invisible in a list of names. +### An updated file from the organizer + +Routes and stops get revised in the weeks before a race. Upload the new file the +same way, then use **Replace** in the assign box rather than making something +new - otherwise you get a second "Half" and a second "A" with none of your +settings on them. + +- **A revised route:** select its line (or all its segments) and pick the race + under **Replace**. The race keeps its name, colours, start time, *Starts at* / + *Finishes at*, the races ticked on its stops and any lead runner reports; only + the line changes, and it is turned to run from the race's start again. If the + new line's ends are nowhere near the start you chose, it is refused and + nothing changes. Check the miles on the Places tab afterwards. +- **A moved stop:** select ONE point. **Replace** lists your places nearest + first, with the distance - a point a few metres from "A" is almost certainly + A. The place keeps its name, layer, order, races, pin label, What3Words and + whoever is posted there; only its position moves. **Its What3Words still + names the old spot**, so the app tells you how far it moved: re-check the + address if it moved more than a few metres. +- A point that matches none of your places (a new stop) is assigned the + ordinary way, and anything unchanged can simply be discarded. + ## 3. Courses: colours and draw order ![The Courses tab: name, distance, line colour, bib colour, drag to reorder](images/setup-courses.png) diff --git a/src/courseops/importer.py b/src/courseops/importer.py index 044198f..45db974 100644 --- a/src/courseops/importer.py +++ b/src/courseops/importer.py @@ -276,20 +276,9 @@ def set_course_style( @db.transactional -def assign_course( - conn: sqlite3.Connection, - event_id: int, - feature_ids: list[int], - name: str, - color: str | None = None, - reverse: bool = False, - dash: str | None = None, -) -> tuple[int, float, list[str]]: - """Build one course from one or more staged line features. - - Several features are stitched end-to-end, since a course routinely arrives - split across segments. Returns (course_id, distance_m, warnings). - """ +def _stitched(conn: sqlite3.Connection, event_id: int, feature_ids: list[int], + reverse: bool = False) -> tuple[list[LonLat], list[str]]: + """One line from one or more staged line features, and any warnings.""" rows = [get_feature(conn, event_id, fid) for fid in feature_ids] missing = [fid for fid, row in zip(feature_ids, rows) if row is None] if missing: @@ -312,7 +301,97 @@ def assign_course( coords = geo.reverse(coords) if len(coords) < 2: raise ValueError("Result has fewer than two points; nothing to draw.") + return coords, warnings + + +def replace_course_line( + conn: sqlite3.Connection, + event_id: int, + course_id: int, + feature_ids: list[int], + reverse: bool = False, +) -> tuple[float, list[str]]: + """Give an existing course a new line; everything else about it stays. + + An organizer's revised route used to arrive as a SECOND course with + none of the first one's settings, and the first could not be deleted + once a leader had been reported on it. Here the row, and so its id, name, + colours, start time, start and finish, the races ticked on its stops and + its sightings, stay put. The features it was built from are discarded + rather than left `assigned`: deleting the course later would otherwise + put both versions back in review. The caller re-turns the line to its + stated start (`admin._set_course_ends`). Returns (distance_m, warnings). + """ + exists = conn.execute( + "SELECT 1 FROM course WHERE id = ? AND event_id = ?", (course_id, event_id) + ).fetchone() + if exists is None: + raise ValueError(f"No course with id {course_id} in this event.") + coords, warnings = _stitched(conn, event_id, feature_ids, reverse) + distance_m = geo.line_length_m(coords) + conn.execute( + "UPDATE course SET geojson = ?, distance_m = ? WHERE id = ?", + (json.dumps(geo.to_geojson_linestring(coords)), distance_m, course_id)) + conn.execute( + "UPDATE import_feature SET status = 'discarded'" + " WHERE event_id = ? AND course_id = ?", (event_id, course_id)) + conn.executemany( + "UPDATE import_feature SET status = 'assigned', course_id = ?" + " WHERE id = ? AND event_id = ?", + [(course_id, fid, event_id) for fid in feature_ids], + ) + return distance_m, warnings + + +def replace_poi_position( + conn: sqlite3.Connection, + event_id: int, + poi_id: int, + feature_id: int, +) -> float: + """Move an existing place to a staged point; everything else stays. + + Name, layer, order, races, pin label, notes, What3Words and whoever is + posted there are the club's and stay. Only the position is the file's. + Returns how far it moved, because the What3Words square is where the + place USED to be and someone has to look at it. + """ + place = conn.execute( + "SELECT lat, lon FROM poi WHERE id = ? AND event_id = ?", (poi_id, event_id) + ).fetchone() + if place is None: + raise ValueError(f"No place with id {poi_id} in this event.") + row = get_feature(conn, event_id, feature_id) + if row is None: + raise ValueError(f"No staged feature with id {feature_id} in this event.") + if row["geom_type"] != "point": + raise ValueError("A place can only be moved to a point, not a line.") + lon, lat = _coords_of(row)[0] + conn.execute("UPDATE poi SET lat = ?, lon = ? WHERE id = ?", (lat, lon, poi_id)) + conn.execute( + "UPDATE import_feature SET status = 'discarded'" + " WHERE event_id = ? AND poi_id = ?", (event_id, poi_id)) + conn.execute( + "UPDATE import_feature SET status = 'assigned', poi_id = ?" + " WHERE id = ? AND event_id = ?", (poi_id, feature_id, event_id)) + return geo.haversine_m((place["lon"], place["lat"]), (lon, lat)) + + +def assign_course( + conn: sqlite3.Connection, + event_id: int, + feature_ids: list[int], + name: str, + color: str | None = None, + reverse: bool = False, + dash: str | None = None, +) -> tuple[int, float, list[str]]: + """Build one course from one or more staged line features. + Several features are stitched end-to-end, since a course routinely arrives + split across segments. Returns (course_id, distance_m, warnings). + """ + coords, warnings = _stitched(conn, event_id, feature_ids, reverse) distance_m = geo.line_length_m(coords) # A new course takes the next unused palette color. Lines are solid unless a diff --git a/src/courseops/static/setup.html b/src/courseops/static/setup.html index 858d195..d3e32c0 100644 --- a/src/courseops/static/setup.html +++ b/src/courseops/static/setup.html @@ -268,6 +268,12 @@

Review

+ +
diff --git a/src/courseops/static/setup.js b/src/courseops/static/setup.js index ebc0c98..42cfc75 100644 --- a/src/courseops/static/setup.js +++ b/src/courseops/static/setup.js @@ -1068,6 +1068,12 @@ async function fillAssignTypes() { async function loadStaged() { await fillAssignTypes(); + // What a revised line or point can replace. Kept apart from S.courses and + // S.pois, which other tabs load with their own filters (the roster's are + // staffed places only). + const existing = await api(`/api/setup/events/${S.eventId}/courses`); + S.reviewCourses = [...existing.courses].reverse(); // Courses tab order + S.reviewPois = existing.pois; const data = await api(`/api/setup/events/${S.eventId}/staged`); renderReview(data.features); } @@ -1235,19 +1241,89 @@ function togglePick(id) { if (picked.length === 1 && !$('assign-name').value) { $('assign-name').value = picked[0].name.replace(/\s*\[\d+\]$/, ''); } + fillReplace(picked, allLines); +} + +/* What an updated file's line or point can go INTO. Lines: the races. + One point: the places, nearest first with the distance, because the + question being answered is "which of our stops is this" and an + organizer's revised file names them "WATER (ALL)" as often as "A". */ +function fillReplace(picked, allLines) { + const onePoint = picked.length === 1 && picked[0].geom_type === 'point'; + const field = $('assign-replace-field'); + field.hidden = !(allLines || onePoint); + if (field.hidden) { $('assign-replace').value = ''; syncReplace(); return; } + if (allLines) { + $('assign-replace-hint').textContent = + 'keeps its name, colours, start, stops and sightings'; + $('assign-replace').innerHTML = '' + + (S.reviewCourses || []).map((c) => + ``).join(''); + } else { + const [lon, lat] = picked[0].geojson.coordinates; + const away = (p) => metresBetween(lat, lon, p.lat, p.lon); + const places = [...(S.reviewPois || [])].sort((a, b) => away(a) - away(b)); + $('assign-replace-hint').textContent = + 'moves it here; keeps its name, order, races, W3W and who is posted'; + $('assign-replace').innerHTML = '' + + places.map((p) => ``).join(''); + } + $('assign-replace').value = ''; + syncReplace(); +} + +// Replacing keeps the existing name and layer, so those boxes say nothing. +function syncReplace() { + const replacing = !!$('assign-replace').value; + $('assign-name').disabled = replacing; + $('assign-type').disabled = replacing; +} +$('assign-replace').addEventListener('change', syncReplace); + +function metresBetween(lat1, lon1, lat2, lon2) { + const rad = Math.PI / 180; + const x = (lon2 - lon1) * rad * Math.cos(((lat1 + lat2) / 2) * rad); + const y = (lat2 - lat1) * rad; + return Math.hypot(x, y) * 6371000; +} + +function distanceText(m) { + return m < 1000 ? `${Math.round(m)} m` : `${(m / 1609.344).toFixed(1)} mi`; } $('assign-go').addEventListener('click', async () => { const type = $('assign-type').value; const ids = [...S.picked]; + const replace = $('assign-replace-field').hidden ? '' : $('assign-replace').value; + const lines = S.staged.filter((f) => S.picked.has(f.id)) + .every((f) => f.geom_type !== 'point'); try { - const body = type === 'course' - ? {kind: 'course', ids, name: $('assign-name').value, - reverse: $('assign-reverse').checked} - : {kind: 'poi', ids, poi_type: type, name: $('assign-name').value}; + let body; + if (replace && lines) { + body = {kind: 'course', ids, replace_course_id: Number(replace), + reverse: $('assign-reverse').checked}; + } else if (replace) { + body = {kind: 'poi', ids, replace_poi_id: Number(replace)}; + } else { + body = type === 'course' + ? {kind: 'course', ids, name: $('assign-name').value, + reverse: $('assign-reverse').checked} + : {kind: 'poi', ids, poi_type: type, name: $('assign-name').value}; + } const result = await post(`/api/setup/events/${S.eventId}/assign`, body); if (result.warnings && result.warnings.length) { banner(result.warnings.join(' '), true); + } else if (result.replaced && result.moved_m != null) { + // The What3Words square is where the place WAS: say so when it moved + // far enough to be a different square. + banner(result.moved_m >= 3 + ? `Place moved ${distanceText(result.moved_m)}. Check its What3Words - ` + + 'it still names the old spot.' + : 'Place position updated (moved less than 3 m).', result.moved_m >= 3); + } else if (result.replaced) { + banner(`Course line replaced (${miles(result.distance_m)}). Check the miles ` + + 'on the Places tab.'); } else { banner(type === 'course' ? `Course created (${miles(result.distance_m)}).` diff --git a/tests/test_replace.py b/tests/test_replace.py new file mode 100644 index 0000000..71a8604 --- /dev/null +++ b/tests/test_replace.py @@ -0,0 +1,224 @@ +"""An updated file replaces a race's line or a place's position in place. + +Import only ever added: an organizer's revised Half became a SECOND +"Half" with none of the first one's settings, and a moved water stop a +second "A" with no ticks, no order, no What3Words and nobody posted - +while deleting the originals was refused once anything referred to them. +Two weeks before a race, a revised route is normal. So a staged line can +REPLACE an existing course's geometry, and a staged point an existing +place's position: everything the club set stays, only where it is moves. +""" +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from courseops import access, admin, db, geo, importer, leaders, users, web +from courseops.config import Settings +from courseops.web import create_app + +STADIUM, DOWNTOWN = (-94.0, 44.162), (-94.0, 44.18) +# The original line, drawn downtown -> stadium, 2 km. +OLD = [(-94.0, 44.18 - i * 0.0018) for i in range(11)] +# The revision: the same ends, a 300 m detour east in the middle - and drawn +# backwards again, as the organizer draws it. +NEW = [(-94.0, 44.18), (-94.0, 44.175), (-93.996, 44.172), (-94.0, 44.169), + (-94.0, 44.162)] + + +def _stage(conn, event_id, name, geometry): + batch = conn.execute( + "INSERT INTO import_batch (event_id, filename, source_kind)" + " VALUES (?, 'rev.kml', 'kml')", + (event_id,)).lastrowid + return conn.execute( + "INSERT INTO import_feature (batch_id, event_id, name, geom_type, geojson)" + " VALUES (?, ?, ?, ?, ?)", + (batch, event_id, name, geometry["type"].lower(), json.dumps(geometry)) + ).lastrowid + + +def _line(coords): + return geo.to_geojson_linestring(coords) + + +def _point(lonlat): + return {"type": "Point", "coordinates": list(lonlat)} + + +@pytest.fixture +def event(tmp_path): + db_path = tmp_path / "t.sqlite3" + conn = db.connect(db_path) + db.init_schema(conn) + event_id = db.create_event(conn, "m2026", "Spring Marathon") + first = _stage(conn, event_id, "Half", _line(OLD)) + course_id, _, _ = importer.assign_course(conn, event_id, [first], name="Half") + start = conn.execute( + "INSERT INTO poi (event_id, name, poi_type, lat, lon) VALUES (?, 'Stadium'," + " 'start', ?, ?)", (event_id, STADIUM[1], STADIUM[0])).lastrowid + water = conn.execute( + "INSERT INTO poi (event_id, name, poi_type, lat, lon, sort_order, what3words)" + " VALUES (?, 'A', 'aid_station', 44.171, -94.0, 3, 'filled.count.soap')", + (event_id,)).lastrowid + admin.update_course(conn, event_id, course_id, { + "start_poi_id": start, "start_time": "07:30", "color": "#1a5fa5", + "bib_color": "#6633cc", "bib_color_name": "Purple"}) + admin.set_poi_courses(conn, event_id, water, [course_id]) + db.upsert_roster_entry(conn, event_id, "KD0AAA", "WA", "aid_station", + expects_aprs=False) + db.assign_station_to_poi(conn, event_id, "KD0AAA", water) + leaders.record_sighting(conn, event_id, course_id, "male", water) + return conn, db_path, event_id, course_id, water, first + + +def _course(conn, course_id): + return conn.execute("SELECT * FROM course WHERE id = ?", (course_id,)).fetchone() + + +# --- a line ------------------------------------------------------------------- + +def test_a_new_line_replaces_the_geometry_and_keeps_everything_else(event): + conn, _, event_id, course_id, water, _ = event + before = dict(_course(conn, course_id)) + fid = _stage(conn, event_id, "Half v2", _line(NEW)) + result = admin.assign_features(conn, event_id, { + "kind": "course", "ids": [fid], "replace_course_id": course_id}) + after = dict(_course(conn, course_id)) + + assert result["course_id"] == course_id + assert conn.execute("SELECT COUNT(*) FROM course").fetchone()[0] == 1 + for kept in ("name", "color", "bib_color", "bib_color_name", "start_time", + "start_poi_id", "sort_order"): + assert after[kept] == before[kept], kept + assert after["geojson"] != before["geojson"] + assert after["distance_m"] == pytest.approx(result["distance_m"]) + # The ticks and the report of the leader passing A are the course's. + assert conn.execute("SELECT COUNT(*) FROM poi_course WHERE course_id = ?", + (course_id,)).fetchone()[0] == 1 + assert len(leaders.sightings(conn, event_id, course_id, "male")) == 1 + + +def test_the_new_line_is_turned_to_run_from_the_races_start(event): + """The revision is drawn downtown-first like the original; the race's + Starts at is still the stadium, so the new line is turned too.""" + conn, _, event_id, course_id, _, _ = event + fid = _stage(conn, event_id, "Half v2", _line(NEW)) + admin.assign_features(conn, event_id, { + "kind": "course", "ids": [fid], "replace_course_id": course_id}) + first = json.loads(_course(conn, course_id)["geojson"])["coordinates"][0] + assert tuple(first) == pytest.approx(STADIUM) + + +def test_a_line_whose_ends_miss_the_start_is_refused_and_nothing_changes(event): + conn, _, event_id, course_id, _, _ = event + before = _course(conn, course_id)["geojson"] + elsewhere = [(-93.9, 44.3), (-93.9, 44.32)] + fid = _stage(conn, event_id, "Wrong file", _line(elsewhere)) + with pytest.raises(ValueError, match="Stadium"): + admin.assign_features(conn, event_id, { + "kind": "course", "ids": [fid], "replace_course_id": course_id}) + assert _course(conn, course_id)["geojson"] == before + status = conn.execute("SELECT status FROM import_feature WHERE id = ?", + (fid,)).fetchone()["status"] + assert status == "pending" + + +def test_the_replaced_features_leave_the_review_for_good(event): + """The old line's staged features were `assigned` to this course; left + so, deleting the course later would put BOTH versions back in review.""" + conn, _, event_id, course_id, _, first = event + fid = _stage(conn, event_id, "Half v2", _line(NEW)) + admin.assign_features(conn, event_id, { + "kind": "course", "ids": [fid], "replace_course_id": course_id}) + status = {r["id"]: (r["status"], r["course_id"]) for r in conn.execute( + "SELECT id, status, course_id FROM import_feature")} + assert status[fid] == ("assigned", course_id) + assert status[first][0] == "discarded" + + +def test_a_course_from_another_event_cannot_be_replaced(event): + conn, _, event_id, _, _, _ = event + other = db.create_event(conn, "other", "Other") + theirs, _, _ = importer.assign_course( + conn, other, [_stage(conn, other, "Theirs", _line(OLD))], name="Theirs") + fid = _stage(conn, event_id, "Half v2", _line(NEW)) + with pytest.raises(ValueError): + admin.assign_features(conn, event_id, { + "kind": "course", "ids": [fid], "replace_course_id": theirs}) + + +# --- a stop ------------------------------------------------------------------- + +def test_a_point_moves_a_place_and_keeps_everything_else(event): + conn, _, event_id, course_id, water, _ = event + before = dict(conn.execute("SELECT * FROM poi WHERE id = ?", (water,)).fetchone()) + moved_to = (-94.0, 44.1719) # 100 m north + fid = _stage(conn, event_id, "WATER A (ALL)", _point(moved_to)) + result = admin.assign_features(conn, event_id, { + "kind": "poi", "ids": [fid], "replace_poi_id": water}) + after = dict(conn.execute("SELECT * FROM poi WHERE id = ?", (water,)).fetchone()) + + assert result["poi_ids"] == [water] + assert (after["lon"], after["lat"]) == pytest.approx(moved_to) + for kept in ("name", "poi_type", "sort_order", "what3words", "label", "notes"): + assert after[kept] == before[kept], kept + assert conn.execute("SELECT poi_id FROM roster WHERE station_key = 'KD0AAA'" + ).fetchone()["poi_id"] == water + assert conn.execute("SELECT COUNT(*) FROM poi_course WHERE poi_id = ?", + (water,)).fetchone()[0] == 1 + # How far it moved: its What3Words square is where it USED to be. + assert result["moved_m"] == pytest.approx(100, abs=3) + + +def test_one_point_replaces_one_place(event): + conn, _, event_id, _, water, _ = event + a = _stage(conn, event_id, "A", _point((-94.0, 44.17))) + b = _stage(conn, event_id, "B", _point((-94.0, 44.171))) + with pytest.raises(ValueError): + admin.assign_features(conn, event_id, { + "kind": "poi", "ids": [a, b], "replace_poi_id": water}) + + +def test_a_line_cannot_replace_a_place(event): + conn, _, event_id, _, water, _ = event + fid = _stage(conn, event_id, "Half v2", _line(NEW)) + with pytest.raises(ValueError): + admin.assign_features(conn, event_id, { + "kind": "poi", "ids": [fid], "replace_poi_id": water}) + + +def test_setup_replaces_through_the_assign_route(event): + conn, db_path, event_id, course_id, water, _ = event + line = _stage(conn, event_id, "Half v2", _line(NEW)) + point = _stage(conn, event_id, "A", _point((-94.0, 44.1719))) + users.create_user(conn, "mike", "a-long-enough-password", "system_admin") + conn.close() + app = create_app(Settings(callsign="KI4TST", passcode="-1", host="h", port=1, + db_path=db_path, log_level="WARNING")) + url = f"/api/setup/events/{event_id}/assign" + with TestClient(app) as client: + client.post("/api/setup/login", + json={"username": "mike", "password": "a-long-enough-password"}) + a = client.post(url, json={"kind": "course", "ids": [line], + "replace_course_id": str(course_id)}) + b = client.post(url, json={"kind": "poi", "ids": [point], + "replace_poi_id": water}) + assert a.status_code == 200, a.text + assert b.status_code == 200, b.text + assert b.json()["moved_m"] == pytest.approx(100, abs=3) + + +# --- the client --------------------------------------------------------------- + +SETUP_HTML = (web.STATIC_DIR / "setup.html").read_text(encoding="utf-8") +SETUP_JS = (web.STATIC_DIR / "setup.js").read_text(encoding="utf-8") + + +def test_the_review_screen_offers_a_replacement(): + assert 'id="assign-replace"' in SETUP_HTML + go = SETUP_JS[SETUP_JS.index("$('assign-go').addEventListener"):] + go = go[:go.index("\n});\n")] + assert "replace_course_id" in go and "replace_poi_id" in go