Persist station CRUD across restarts via JSON config

Station CRUD via the API previously mutated the scraper's in-memory
station_mapping only, so changes were lost on restart (and the systemd
service auto-restarts).

- Extract the 130-line hardcoded station_mapping into bundled defaults at
  src/data/stations.json; the scraper loads from a runtime-writable config
  file (STATION_CONFIG_PATH, default stations.json) and falls back to the
  bundled defaults to seed it.
- Add scraper.save_stations() with an atomic temp-file + os.replace write.
- create/update/delete station endpoints now persist and roll back the
  in-memory change if the write fails; re-raise HTTPException so persistence
  errors surface as real 500s instead of being swallowed.
- Backend-agnostic (works for the VictoriaMetrics deployment, which has no
  relational stations table). Runtime stations.json is gitignored.

Also clears pre-existing flake8 debt in water_scraper_v3.py (unused imports,
long lines, duplicate logging import) and dedupes the User-Agent to
Config.USER_AGENT.
This commit is contained in:
2026-07-22 12:51:29 +07:00
parent 6e78225d00
commit 4bc3d82773
5 changed files with 362 additions and 261 deletions
+22 -2
View File
@@ -353,8 +353,9 @@ async def create_station(station: StationCreateModel):
existing_ids = [int(sid) for sid in scraper.station_mapping.keys()]
new_station_id = max(existing_ids) + 1 if existing_ids else 1
# Add to station mapping
scraper.station_mapping[str(new_station_id)] = {
# Add to station mapping and persist
new_key = str(new_station_id)
scraper.station_mapping[new_key] = {
"code": station.station_code,
"thai_name": station.thai_name,
"english_name": station.english_name,
@@ -362,6 +363,9 @@ async def create_station(station: StationCreateModel):
"longitude": station.longitude,
"geohash": station.geohash,
}
if not scraper.save_stations():
scraper.station_mapping.pop(new_key, None)
raise HTTPException(status_code=500, detail="Failed to persist new station")
logger.info(f"Created new station: {station.station_code} ({station.english_name})")
@@ -376,6 +380,8 @@ async def create_station(station: StationCreateModel):
status=station.status,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating station: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -396,6 +402,7 @@ async def update_station(station_id: int, updates: StationUpdateModel):
try:
station_info = scraper.station_mapping[station_key]
original = dict(station_info) # snapshot for rollback if persistence fails
# Update fields if provided
if updates.thai_name is not None:
@@ -409,6 +416,10 @@ async def update_station(station_id: int, updates: StationUpdateModel):
if updates.geohash is not None:
station_info["geohash"] = updates.geohash
if not scraper.save_stations():
scraper.station_mapping[station_key] = original
raise HTTPException(status_code=500, detail="Failed to persist station update")
logger.info(f"Updated station {station_id}: {station_info['code']}")
return StationResponse(
@@ -422,6 +433,8 @@ async def update_station(station_id: int, updates: StationUpdateModel):
status=updates.status or "active",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating station {station_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@@ -442,10 +455,17 @@ async def delete_station(station_id: int):
try:
station_info = scraper.station_mapping.pop(station_key)
if not scraper.save_stations():
scraper.station_mapping[station_key] = station_info # restore
raise HTTPException(status_code=500, detail="Failed to persist station deletion")
logger.info(f"Deleted station {station_id}: {station_info['code']}")
return {"message": f"Station {station_info['code']} deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting station {station_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))