diff --git a/docs/adr/0027-equipment-measurements-are-validated-floats.md b/docs/adr/0027-equipment-measurements-are-validated-floats.md new file mode 100644 index 000000000..61f40e599 --- /dev/null +++ b/docs/adr/0027-equipment-measurements-are-validated-floats.md @@ -0,0 +1,92 @@ +# Equipment measurements are validated floats, and a rejected entry never reports success + +Every numeric field on a `Telescope` or an `Eyepiece` is a **float**, and every +value entering one is range-checked against a single table of limits in +`equipment.py` before it reaches config. A value that fails re-renders the form +with the reason; it never renders the success banner. + +## Context + +Two defects with one root. `equipment_add_eyepiece` / `equipment_add_instrument` +parsed with bare `float()` / `int()` inside `except Exception: logger.error(...)` +and then fell through to the success template regardless +([#569](https://github.com/brickbots/PiFinder/issues/569)): + +``` +POST /equipment/add_eyepiece/-1 focal_length_mm=7,5 + -> HTTP 200 + "Eyepiece added, restart your PiFinder to use" + -> eyepiece count unchanged. Nothing was saved. +``` + +And the types themselves rejected real gear. `aperture_mm` and +`focal_length_mm` were `int` on `Telescope`, so an 11" SCT (279.4mm) could not +be entered — and an older release that wrote the string `"279.5"` into config +made the PiFinder **unbootable**: `Equipment.from_dict` raised +`invalid literal for int()` inside `main()` before the UI came up +([#291](https://github.com/brickbots/PiFinder/issues/291)). Meanwhile +`Eyepiece.focal_length_mm` *was* a float, so the same field name carried two +types. + +A decimal comma is the easiest way to trigger the parse failure — PiFinder ships +de/es/fr/zh, and a comma-locale keyboard offers a comma — but any unreadable +value did it, including the blank instrument name from #569's description. + +## Decision + +1. **Measurements are floats.** `aperture_mm`, `focal_length_mm` and `afov` + join `obstruction_perc` and `field_stop`. Optics are fractional: 279.4mm of + aperture, 1280.2mm behind a reducer, a 3.5mm Nagler. Whole millimetres + still *display* as whole millimetres — `format_measurement()` drops the + `.0`, so the tables read "1000", not "1000.0". Loading only gets more + tolerant: an int, a float or a numeric string all decode. + +2. **One table of limits, two enforcement points.** `TELESCOPE_LIMITS` and + `EYEPIECE_LIMITS` live in `equipment.py`. The edit forms render them into + their client-side check; the API re-checks them in `telescope_from_form` / + `eyepiece_from_form`. The client's job is fast feedback, the API's job is + deciding what reaches config — the ranges are shared so the two can't drift. + The ranges themselves are documented in + [`docs/ax/equipment/CONTEXT.md`](../ax/equipment/CONTEXT.md). + +3. **A failed save re-renders the form, never the success banner.** With the + message and the values the user typed, so one bad field doesn't cost them + the whole entry. + +4. **A blank required field is an error, not a zero.** Only `obstruction_perc` + and `field_stop` have a meaning for zero ("refractor", "unknown"), and only + those default when left blank. The old handlers' `request.form.get(x) or "0"` + turned every empty field into a valid-looking record. + +## Considered options + +- **Keep `Telescope.focal_length_mm` as an int and reject decimals with a clear + message.** Honest, and the smallest change. Rejected: it makes the API refuse + values that physically exist, and it leaves the same field name carrying two + types across the two records. The display concern that motivated `int` is a + formatting concern, and `format_measurement()` answers it directly. +- **Validate in the dataclasses' `__post_init__`.** Rejected: the records are + also built by `from_dict` at boot, and raising there re-creates #291's + unbootable device. Validation belongs at the write boundary — the API — with + the loader staying permissive and falling back to defaults. +- **Client-side validation only.** Rejected outright: it is exactly what the + system already had, and #569 is a report of it being bypassed. A `POST` from + a script, a stale page, or any browser quirk reaches the same handler. + +## Consequences + +- **The config loader no longer aborts the boot.** `config.py` catches a + malformed equipment section, logs it and falls back to the shipped defaults. + A PiFinder with a hand-edited config comes up usable instead of not at all. +- **The DeepskyLog import obeys the same limits.** A record it can't make sense + of is skipped and counted in the result message rather than written through + and discovered at the next boot. +- **`Eyepiece.__str__` formats through `format_measurement()`**, so the eyepiece + label on the object-detail screen reads "25mm Plossl" rather than + "25.0mm Plossl". +- **The bounds are judgement calls, not physics.** 2000mm of aperture and 180° + of AFOV are past anything an amateur owns; they exist to catch a typo or a + mis-parse, not to police gear. Widen them if someone's real equipment doesn't + fit — that is a bug in the limit, not in the user's telescope. +- **Existing configs are untouched.** No migration: the stored values are + already numbers the float fields read, and nothing rewrites them until the + user edits that record. diff --git a/docs/ax/equipment.md b/docs/ax/equipment.md index bda8bbe1e..e1c4dd6b4 100644 --- a/docs/ax/equipment.md +++ b/docs/ax/equipment.md @@ -55,8 +55,8 @@ for round-tripping through `config.json`; the nested `Telescope` / | --- | --- | --- | | `make` | str | Manufacturer, free text. | | `name` | str | Model / instrument name. | -| `aperture_mm` | int | Clear aperture in mm. | -| `focal_length_mm` | int | Focal length in mm. Numerator of `calc_magnification()`. | +| `aperture_mm` | float | Clear aperture in mm. | +| `focal_length_mm` | float | Focal length in mm. Numerator of `calc_magnification()`. | | `obstruction_perc` | float | Central obstruction as a percentage (0 for a refractor). Informational; not used by the optics calcs here. | | `mount_type` | str | `"alt/az"` or `"equatorial"`. | | `flip_image` | bool | Top-to-bottom (vertical) mirror of the object image. See §6. | @@ -74,11 +74,31 @@ the glossary's "Flagged ambiguities." | `make` | str | Manufacturer, free text. | | `name` | str | Model name. | | `focal_length_mm` | float | Focal length in mm. Denominator of `calc_magnification()`; also the eyepiece sort key. | -| `afov` | int | Apparent field of view (AFOV) in degrees — a property of the eyepiece alone. | +| `afov` | float | Apparent field of view (AFOV) in degrees — a property of the eyepiece alone. | | `field_stop` | float | Field-stop diameter in mm; default `0`. When non-zero it gives a more accurate TFOV (see §5). | -`Eyepiece.__str__` renders as `"{focal_length_mm}mm {name}"`, which is the -string the object-detail screen burns into the image as the eyepiece label. +`Eyepiece.__str__` renders as `"{focal_length_mm}mm {name}"` — through +`format_measurement()`, so a whole-millimetre eyepiece reads "25mm Plossl" +rather than "25.0mm Plossl". That is the string the object-detail screen +burns into the image as the eyepiece label. + +### 2.5 Field rules (`TELESCOPE_LIMITS` / `EYEPIECE_LIMITS`) + +Every measurement is a **float** and carries an inclusive `Limits(minimum, +maximum)` pair declared alongside the dataclasses. The ranges themselves are +tabulated in [`equipment/CONTEXT.md`](./equipment/CONTEXT.md); the rationale for +floats-everywhere is [ADR 0027](../adr/0027-equipment-measurements-are-validated-floats.md). + +Two properties matter more than the numbers: + +- **One table, two enforcement points.** The edit forms render the limits into + their client-side check (`views/equipment_validation.html`) and the API + re-checks them (`server.py`, `telescope_from_form` / `eyepiece_from_form`). + The client is feedback; the API decides what reaches config. +- **The records themselves don't validate.** `__post_init__` only sorts. Raising + in the dataclasses would re-create #291 — an unbootable device — because + `from_dict` builds the same records at load. Validation lives at the write + boundary; the loader stays permissive and falls back to defaults (§3.1). ### 2.3 `Equipment` (`equipment.py:33`) @@ -122,6 +142,11 @@ always reads the repo-root `default_config.json` into very wrong"), Equipment is built empty: `Equipment(telescopes=[], eyepieces=[])`. - Otherwise the section is validated (§3.3) and `Equipment.from_dict(eq_config)` builds the object. +- If `from_dict` **can't** decode the section, the failure is logged and the + shipped defaults are used instead. This used to be an uncaught raise inside + `main()`, so a config an older release had written with a string measurement + produced a PiFinder that booted to nothing (#291); measurements are floats + now, and the fallback covers whatever else a hand edit can produce. ### 3.2 When a save is actually triggered — the freeze nuance @@ -328,7 +353,7 @@ list/table page) and `views/edit_instrument.html` / `views/edit_eyepiece.html` | `GET /equipment` | List telescopes + eyepieces, show active radios, import button. | | `GET /equipment/set_active_instrument/` | Set active telescope, save. | | `GET /equipment/set_active_eyepiece/` | Set active eyepiece, save. | -| `GET /equipment/edit_instrument/` | Edit form (id `< 0` = add new, blank `Telescope`). | +| `GET /equipment/edit_instrument/` | Edit form (id `< 0` = add new, blank fields). | | `POST /equipment/add_instrument/` | Create or update a telescope, save. | | `GET /equipment/delete_instrument/` | Remove a telescope, save. | | `GET /equipment/edit_eyepiece/` | Edit form (id `< 0` = add new). | @@ -336,6 +361,18 @@ list/table page) and `views/edit_instrument.html` / `views/edit_eyepiece.html` | `GET /equipment/delete_eyepiece/` | Remove an eyepiece, save. | | `POST /equipment/import_from_deepskylog` | Bulk import from DeepskyLog (see below). | +Every route that takes an `` range-checks it and re-renders the list page +with "No such instrument / eyepiece" rather than letting a stale or hand-edited +URL raise `IndexError` as a 500. + +The two `add_*` handlers build their record through `telescope_from_form` / +`eyepiece_from_form` (§2.5). On a `ValueError` they re-render the **edit form** +with the message and the values that were submitted; only a record that +validated reaches `save_equipment()`. Before #569 both handlers swallowed the +exception and rendered the success banner regardless, so an unparseable value — +a decimal comma being the easiest way to produce one — reported "Eyepiece added" +and saved nothing. + The instrument form (`edit_instrument.html`) exposes the orientation flags directly as checkboxes — labelled "Flip image (upside down)" and "Flop image (left right)" — plus "Reverse Arrow A/B". The POST handler @@ -362,6 +399,10 @@ eyepieces: `flip_image` / `flop_image` straight from DeepskyLog). `reverse_arrow_*` default to `False`. HTML entities in names are unescaped. - Eyepieces map `focalLength`, `apparentFOV` → `afov`, and `field_stop_mm`. +- Each record is re-checked against the same limits as the forms + (`check_equipment_limits`). One DeepskyLog can't supply usable values for is + skipped and counted in the result message, rather than written through and + discovered at the next boot. - Each new record is appended only if not already present (dedup via `list.index(...)` raising `ValueError`), then `save_equipment()`. diff --git a/docs/ax/equipment/CONTEXT.md b/docs/ax/equipment/CONTEXT.md index c79552b3c..b1e1c9c8a 100644 --- a/docs/ax/equipment/CONTEXT.md +++ b/docs/ax/equipment/CONTEXT.md @@ -62,6 +62,39 @@ _Avoid_: FOV (unqualified). Per-telescope flags that invert push-to chart arrow directions to match how the observer reads their eyepiece/finder. These orient the *arrows*, never the *image*. _Avoid_: flip arrows, mirror arrows. +### Field rules + +**Measurement**: +Any numeric field on a telescope or eyepiece — aperture, focal length, obstruction, AFOV, field stop. All measurements are **floats**: real optics are fractional (a 11" SCT is 279.4mm, a focal reducer turns 2032mm into 1280.2mm). Rendered for display through `format_measurement()`, which drops a meaningless `.0`. +_Avoid_: dimension, spec, number. + +**Limits**: +The inclusive `(minimum, maximum)` range a measurement may take, declared once in `equipment.py` (`TELESCOPE_LIMITS`, `EYEPIECE_LIMITS`). The edit form renders them into its client-side check and the API re-checks them; neither is the sole authority, but the API is the one that decides what reaches config. +_Avoid_: bounds, constraints, validation rules (as a name for the table). + +The rules the two forms and the API enforce: + +| Record | Field | Required | Range | Notes | +| --- | --- | --- | --- | --- | +| Telescope | `make` | no | ≤ 64 chars | Free text, stripped. | +| | `name` | **yes** | ≤ 64 chars | Blank names read as an empty row in the menu and the tables. | +| | `aperture_mm` | yes | 1 – 2000 | | +| | `focal_length_mm` | yes | 1 – 20000 | Zero would make magnification zero. | +| | `obstruction_perc` | no (0) | 0 – 100 | A percentage; a refractor is 0. | +| | `mount_type` | yes | `alt/az` \| `equatorial` | Anything else has no meaning. | +| Eyepiece | `make` | no | ≤ 64 chars | | +| | `name` | **yes** | ≤ 64 chars | | +| | `focal_length_mm` | yes | 0.1 – 100 | `calc_magnification` divides by it, so never 0. | +| | `afov` | yes | 1 – 180 | Degrees. | +| | `field_stop` | no (0) | 0 – 100 | 0 means unknown — TFOV falls back to AFOV ÷ magnification. | + +Two rules that are not about ranges: + +- **A blank field is not a zero.** An empty required measurement is an error, never silently `0`. Only `obstruction_perc` and `field_stop` have a documented zero meaning, and only those default when left blank. +- **A rejected entry never reports success.** The handler re-renders the form with the message and the values the user typed. This is the defect [#569](https://github.com/brickbots/PiFinder/issues/569) was raised for: the old handlers logged the failure and rendered "Eyepiece added" anyway. + +Recorded in [ADR 0027](../../adr/0027-equipment-measurements-are-validated-floats.md). + ### Boundary terms - **Roll** — the camera roll from the latest plate-solve, owned by [Positioning](../positioning/CONTEXT.md); the object-image baseline rotation consumes it. diff --git a/python/PiFinder/config.py b/python/PiFinder/config.py index c002fbad3..eba097101 100644 --- a/python/PiFinder/config.py +++ b/python/PiFinder/config.py @@ -90,7 +90,19 @@ def load_config(self): ): eq_config["active_eyepiece_index"] = 0 - self.equipment = equipment.Equipment.from_dict(eq_config) + try: + self.equipment = equipment.Equipment.from_dict(eq_config) + except (ValueError, TypeError, KeyError): + # A value the equipment dataclasses can't decode used to + # abort main() before the UI came up — an unusable PiFinder + # (#291). Fall back to the defaults and keep booting; the + # web forms validate everything they write, so a config + # that lands here was hand-edited or written by an older + # release. + logger.exception( + "Could not load saved equipment; falling back to defaults" + ) + self.equipment = equipment.Equipment.from_dict(default_eq) # Load the locations config loc_config = self.get_option("locations") diff --git a/python/PiFinder/equipment.py b/python/PiFinder/equipment.py index 1d22c7a52..212c51c20 100644 --- a/python/PiFinder/equipment.py +++ b/python/PiFinder/equipment.py @@ -1,7 +1,60 @@ from dataclasses import dataclass from dataclasses_json import dataclass_json from operator import attrgetter -from typing import Union +from typing import NamedTuple, Union + + +class Limits(NamedTuple): + """The inclusive range a user-entered measurement may take.""" + + minimum: float + maximum: float + + +# Every measurement below is a float: real optics are fractional (a 11" +# SCT is 279.4mm of aperture, a focal reducer turns 2032mm into 1280.2mm, +# a Nagler is 3.5mm) and an int field made those values unenterable — or, +# once written to config as a string, unbootable (#291). See +# docs/adr/0027-equipment-measurements-are-validated-floats.md. +# +# These limits are the single source of the validation rules: the edit +# forms render them into their inputs and their client-side check, and the +# API handlers re-check them before anything reaches config. Documented +# in prose in docs/ax/equipment/CONTEXT.md. +TELESCOPE_LIMITS = { + "aperture_mm": Limits(1, 2000), + "focal_length_mm": Limits(1, 20000), + "obstruction_perc": Limits(0, 100), +} + +EYEPIECE_LIMITS = { + "focal_length_mm": Limits(0.1, 100), + "afov": Limits(1, 180), + "field_stop": Limits(0, 100), +} + +# The mount types the instrument form offers. Not consumed at runtime — +# push-to arrows read the global ``mount_type`` option, not this one — but +# a value outside this set has no meaning, so the form rejects it. +MOUNT_TYPES = ("alt/az", "equatorial") + +# Names longer than this overflow the on-device menu and the web tables. +NAME_MAX_LENGTH = 64 + + +def format_measurement(value) -> str: + """Render a measurement for display, dropping a meaningless ``.0``. + + Focal lengths and apertures are stored as floats but are usually whole + millimetres; ``1000.0mm`` reads worse than ``1000mm``. + """ + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + if number == int(number): + return str(int(number)) + return str(number) @dataclass @@ -9,19 +62,19 @@ class Eyepiece: make: str name: str focal_length_mm: float - afov: int + afov: float field_stop: float = 0 def __str__(self): - return f"{self.focal_length_mm}mm {self.name}" + return f"{format_measurement(self.focal_length_mm)}mm {self.name}" @dataclass class Telescope: make: str name: str - aperture_mm: int - focal_length_mm: int + aperture_mm: float + focal_length_mm: float obstruction_perc: float mount_type: str flip_image: bool diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index 431af27f1..84a28f4b6 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -16,7 +16,15 @@ from PiFinder.db.observations_db import ( ObservationsDatabase, ) -from PiFinder.equipment import Telescope, Eyepiece +from PiFinder.equipment import ( + EYEPIECE_LIMITS, + MOUNT_TYPES, + NAME_MAX_LENGTH, + TELESCOPE_LIMITS, + Eyepiece, + Telescope, + format_measurement, +) from PiFinder.keyboard_interface import KeyboardInterface from PiFinder.multiproclogging import MultiprocLogging @@ -48,6 +56,13 @@ class SignedIntConverter(IntegerConverter): SESSION_SECRET = str(uuid.uuid4()) +# Bounds for the location fields the GPS form writes. The /locations +# handlers enforce the same ranges inline. +LATITUDE_LIMITS = (-90.0, 90.0) +LONGITUDE_LIMITS = (-180.0, 180.0) +ALTITUDE_LIMITS = (-1000.0, 10000.0) + + def parse_coordinate(value, field_name): """Parse a coordinate/measurement field, accepting comma or period decimals.""" if value is None: @@ -58,6 +73,149 @@ def parse_coordinate(value, field_name): raise ValueError(_("%s must be a number") % field_name) +def parse_measurement(value, field_name, limits, default=None): + """Parse a numeric field and range-check it against ``limits``. + + ``limits`` is any (minimum, maximum) pair — an equipment ``Limits`` + or one of the location tuples above. A blank field falls back to + ``default`` when one is given, and is an error otherwise: a value the + user left empty must not silently become zero. + """ + if default is not None and (value is None or str(value).strip() == ""): + return default + + number = parse_coordinate(value, field_name) + minimum, maximum = limits + if not minimum <= number <= maximum: + raise ValueError( + _("%(field)s must be between %(minimum)s and %(maximum)s") + % { + "field": field_name, + "minimum": format_measurement(minimum), + "maximum": format_measurement(maximum), + } + ) + return number + + +def parse_name(value, field_name, required=True): + """Parse and length-check a free-text field, returning it stripped.""" + text = (value or "").strip() + if required and not text: + raise ValueError(_("%s is required") % field_name) + if len(text) > NAME_MAX_LENGTH: + raise ValueError( + _("%(field)s must be %(maximum)s characters or fewer") + % {"field": field_name, "maximum": NAME_MAX_LENGTH} + ) + return text + + +def check_equipment_limits(record, limits): + """Re-check an equipment record's measurements against ``limits``. + + For records that never pass through the edit form — the DeepskyLog + import — so an upstream value out of range is caught before it is + written into config rather than at the next boot. + """ + for field, limit in limits.items(): + parse_measurement(getattr(record, field), field, limit) + if not record.name.strip(): + raise ValueError(_("%s is required") % _("Name")) + + +def submitted_eyepiece(form): + """The raw submitted eyepiece values, keyed as the edit template reads + them, so a rejected form comes back with what the user typed still in it. + """ + return { + "make": form.get("make", ""), + "name": form.get("name", ""), + "focal_length_mm": form.get("focal_length_mm", ""), + "afov": form.get("afov", ""), + "field_stop": form.get("field_stop", ""), + } + + +def submitted_telescope(form): + """The raw submitted instrument values, keyed as the edit template reads + them, so a rejected form comes back with what the user typed still in it. + """ + return { + "make": form.get("make", ""), + "name": form.get("name", ""), + "aperture_mm": form.get("aperture", ""), + "focal_length_mm": form.get("focal_length_mm", ""), + "obstruction_perc": form.get("obstruction_perc", ""), + "mount_type": form.get("mount_type", ""), + "flip_image": bool(form.get("flip")), + "flop_image": bool(form.get("flop")), + "reverse_arrow_a": bool(form.get("reverse_arrow_a")), + "reverse_arrow_b": bool(form.get("reverse_arrow_b")), + } + + +def eyepiece_from_form(form) -> Eyepiece: + """Build an Eyepiece from submitted form values. + + Raises ValueError — with a message meant for the user — if any field + is missing, unparseable or out of range. + """ + return Eyepiece( + make=parse_name(form.get("make"), _("Make"), required=False), + name=parse_name(form.get("name"), _("Name")), + focal_length_mm=parse_measurement( + form.get("focal_length_mm"), + _("Focal length"), + EYEPIECE_LIMITS["focal_length_mm"], + ), + afov=parse_measurement( + form.get("afov"), _("Apparent field of view"), EYEPIECE_LIMITS["afov"] + ), + field_stop=parse_measurement( + form.get("field_stop"), + _("Field stop"), + EYEPIECE_LIMITS["field_stop"], + default=0.0, + ), + ) + + +def telescope_from_form(form) -> Telescope: + """Build a Telescope from submitted form values. + + Raises ValueError — with a message meant for the user — if any field + is missing, unparseable or out of range. + """ + mount_type = (form.get("mount_type") or MOUNT_TYPES[0]).strip().lower() + if mount_type not in MOUNT_TYPES: + raise ValueError(_("%s is not a valid mount type") % mount_type) + + return Telescope( + make=parse_name(form.get("make"), _("Make"), required=False), + name=parse_name(form.get("name"), _("Instrument name")), + aperture_mm=parse_measurement( + form.get("aperture"), _("Aperture"), TELESCOPE_LIMITS["aperture_mm"] + ), + focal_length_mm=parse_measurement( + form.get("focal_length_mm"), + _("Focal length"), + TELESCOPE_LIMITS["focal_length_mm"], + ), + obstruction_perc=parse_measurement( + form.get("obstruction_perc"), + _("Obstruction"), + TELESCOPE_LIMITS["obstruction_perc"], + default=0.0, + ), + mount_type=mount_type, + flip_image=bool(form.get("flip")), + flop_image=bool(form.get("flop")), + reverse_arrow_a=bool(form.get("reverse_arrow_a")), + reverse_arrow_b=bool(form.get("reverse_arrow_b")), + ) + + def auth_required(func): def auth_wrapper(*args, **kwargs): # check for and validate session @@ -172,6 +330,11 @@ def __init__( app.jinja_env.globals["_"] = builtins._ + # Equipment measurements are floats; render 1000.0 as "1000" so the + # tables and edit forms read the way the user typed them. + app.jinja_env.filters["measurement"] = format_measurement + app.jinja_env.globals["name_max_length"] = NAME_MAX_LENGTH + # # Create a simple gettext function for templates that works without translation files # def simple_gettext(text): # return text @@ -340,11 +503,33 @@ def gps_update(): altitude = request.form.get("altitude") date_req = request.form.get("date") time_req = request.form.get("time") - gps_lock(float(lat), float(lon), float(altitude)) - if time_req and date_req: - datetime_str = f"{date_req} {time_req}" - datetime_obj = timez.parse(datetime_str, "%Y-%m-%d %H:%M:%S") - datetime_utc = datetime_obj.replace(tzinfo=timezone.utc) + + try: + latitude = parse_measurement(lat, _("Latitude"), LATITUDE_LIMITS) + longitude = parse_measurement(lon, _("Longitude"), LONGITUDE_LIMITS) + height = parse_measurement(altitude, _("Altitude"), ALTITUDE_LIMITS) + datetime_utc = None + if time_req and date_req: + try: + datetime_obj = timez.parse( + f"{date_req} {time_req}", "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + raise ValueError(_("Date and time must be YYYY-MM-DD h:m:s")) + datetime_utc = datetime_obj.replace(tzinfo=timezone.utc) + except ValueError as e: + # Re-render with what was typed, the way /locations does. + return app.jinja_env.get_template("gps.html").render( + title=_("GPS"), + show_new_form=0, + lat=lat, + lon=lon, + altitude=altitude, + error_message=str(e), + ) + + gps_lock(latitude, longitude, height) + if datetime_utc is not None: time_lock(datetime_utc) logger.debug( "GPS update: %s, %s, %s, %s, %s", lat, lon, altitude, date_req, time_req @@ -592,10 +777,24 @@ def equipment(): title=_("Equipment"), equipment=config.Config().equipment ) + def equipment_page_error(message): + """Render the equipment page with an error instead of raising. + + A hand-edited or stale URL carrying an index nobody owns used + to reach the list and raise IndexError as a 500. + """ + return app.jinja_env.get_template("equipment.html").render( + title=_("Equipment"), + equipment=config.Config().equipment, + error_message=message, + ) + @app.route("/equipment/set_active_instrument/") @auth_required def set_active_instrument(instrument_id: int): cfg = config.Config() + if not 0 <= instrument_id < len(cfg.equipment.telescopes): + return equipment_page_error(_("No such instrument")) cfg.equipment.set_active_telescope(cfg.equipment.telescopes[instrument_id]) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -613,6 +812,8 @@ def set_active_instrument(instrument_id: int): @auth_required def set_active_eyepiece(eyepiece_id: int): cfg = config.Config() + if not 0 <= eyepiece_id < len(cfg.equipment.eyepieces): + return equipment_page_error(_("No such eyepiece")) cfg.equipment.set_active_eyepiece(cfg.equipment.eyepieces[eyepiece_id]) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -631,6 +832,7 @@ def set_active_eyepiece(eyepiece_id: int): def equipment_import(): username = request.form.get("dsl_name") cfg = config.Config() + skipped = 0 if username: instruments = pds.dsl_instruments(username) for instrument in instruments: @@ -638,34 +840,43 @@ def equipment_import(): # Skip the naked eye continue - make = instrument["instrument_make"]["name"] + try: + make = instrument["instrument_make"]["name"] + + obstruction_perc = instrument["obstruction_perc"] + if obstruction_perc is None: + obstruction_perc = 0 + + # Convert the html special characters (ampersand, quote, ...) in instrument["name"] + # to the corresponding character + instrument["name"] = instrument["name"].replace("&", "&") + instrument["name"] = instrument["name"].replace(""", '"') + instrument["name"] = instrument["name"].replace("'", "'") + instrument["name"] = instrument["name"].replace("<", "<") + instrument["name"] = instrument["name"].replace(">", ">") + + new_instrument = Telescope( + make=make, + name=instrument["name"], + aperture_mm=float(instrument["diameter"]), + focal_length_mm=float( + instrument["diameter"] * instrument["fd"] + ), + obstruction_perc=float(obstruction_perc), + mount_type=instrument["mount_type"]["name"].lower(), + flip_image=bool(instrument["flip_image"]), + flop_image=bool(instrument["flop_image"]), + reverse_arrow_a=False, + reverse_arrow_b=False, + ) + check_equipment_limits(new_instrument, TELESCOPE_LIMITS) + except (ValueError, TypeError, KeyError) as e: + # An upstream record we can't make sense of is + # skipped, not written through into config. + logger.warning("Skipping DeepskyLog instrument: %s", e) + skipped += 1 + continue - obstruction_perc = instrument["obstruction_perc"] - if obstruction_perc is None: - obstruction_perc = 0 - else: - obstruction_perc = float(obstruction_perc) - - # Convert the html special characters (ampersand, quote, ...) in instrument["name"] - # to the corresponding character - instrument["name"] = instrument["name"].replace("&", "&") - instrument["name"] = instrument["name"].replace(""", '"') - instrument["name"] = instrument["name"].replace("'", "'") - instrument["name"] = instrument["name"].replace("<", "<") - instrument["name"] = instrument["name"].replace(">", ">") - - new_instrument = Telescope( - make=make, - name=instrument["name"], - aperture_mm=int(instrument["diameter"]), - focal_length_mm=int(instrument["diameter"] * instrument["fd"]), - obstruction_perc=obstruction_perc, - mount_type=instrument["mount_type"]["name"].lower(), - flip_image=bool(instrument["flip_image"]), - flop_image=bool(instrument["flop_image"]), - reverse_arrow_a=False, - reverse_arrow_b=False, - ) try: cfg.equipment.telescopes.index(new_instrument) except ValueError: @@ -674,23 +885,30 @@ def equipment_import(): # Add the eyepieces from deepskylog eyepieces = pds.dsl_eyepieces(username) for eyepiece in eyepieces: - # Convert the html special characters (ampersand, quote, ...) in eyepiece["name"] - # to the corresponding character - eyepiece["name"] = eyepiece["name"].replace("&", "&") - eyepiece["name"] = eyepiece["name"].replace(""", '"') - eyepiece["name"] = eyepiece["name"].replace("'", "'") - eyepiece["name"] = eyepiece["name"].replace("<", "<") - eyepiece["name"] = eyepiece["name"].replace(">", ">") - - make = eyepiece["eyepiece_make"]["name"] - - new_eyepiece = Eyepiece( - make=make, - name=eyepiece["name"], - focal_length_mm=float(eyepiece["focalLength"]), - afov=int(eyepiece["apparentFOV"]), - field_stop=float(eyepiece["field_stop_mm"]), - ) + try: + # Convert the html special characters (ampersand, quote, ...) in eyepiece["name"] + # to the corresponding character + eyepiece["name"] = eyepiece["name"].replace("&", "&") + eyepiece["name"] = eyepiece["name"].replace(""", '"') + eyepiece["name"] = eyepiece["name"].replace("'", "'") + eyepiece["name"] = eyepiece["name"].replace("<", "<") + eyepiece["name"] = eyepiece["name"].replace(">", ">") + + make = eyepiece["eyepiece_make"]["name"] + + new_eyepiece = Eyepiece( + make=make, + name=eyepiece["name"], + focal_length_mm=float(eyepiece["focalLength"]), + afov=float(eyepiece["apparentFOV"]), + field_stop=float(eyepiece["field_stop_mm"]), + ) + check_equipment_limits(new_eyepiece, EYEPIECE_LIMITS) + except (ValueError, TypeError, KeyError) as e: + logger.warning("Skipping DeepskyLog eyepiece: %s", e) + skipped += 1 + continue + try: cfg.equipment.eyepieces.index(new_eyepiece) except ValueError: @@ -698,26 +916,38 @@ def equipment_import(): cfg.save_equipment() self.ui_queue.put("reload_config") + + success_message = _( + "Equipment Imported, restart your PiFinder to use this new data" + ) + if skipped: + success_message += " " + _( + "%s entries were skipped because DeepskyLog had no usable values for them." + ) % str(skipped) return app.jinja_env.get_template("equipment.html").render( title=_("Equipment"), equipment=config.Config().equipment, - success_message=_( - "Equipment Imported, restart your PiFinder to use this new data" - ), + success_message=success_message, ) @app.route("/equipment/edit_eyepiece/") @auth_required def edit_eyepiece(eyepiece_id: int): + eyepieces = config.Config().equipment.eyepieces if eyepiece_id >= 0: - eyepiece = config.Config().equipment.eyepieces[eyepiece_id] + if eyepiece_id >= len(eyepieces): + return equipment_page_error(_("No such eyepiece")) + eyepiece = eyepieces[eyepiece_id] else: - eyepiece = Eyepiece( - make="", name="", focal_length_mm=0, afov=0, field_stop=0 - ) + # A new eyepiece starts blank rather than pre-filled with + # zeros, which are not values any eyepiece may keep. + eyepiece = submitted_eyepiece({}) return app.jinja_env.get_template("edit_eyepiece.html").render( - title=_("Edit Eyepiece"), eyepiece=eyepiece, eyepiece_id=eyepiece_id + title=_("Edit Eyepiece"), + eyepiece=eyepiece, + eyepiece_id=eyepiece_id, + limits=EYEPIECE_LIMITS, ) @app.route("/equipment/add_eyepiece/", methods=["POST"]) @@ -726,25 +956,24 @@ def equipment_add_eyepiece(eyepiece_id: int): cfg = config.Config() try: - make = request.form.get("make") or "" - name = request.form.get("name") or "" - focal_length_str = request.form.get("focal_length_mm") or "0" - afov_str = request.form.get("afov") or "0" - field_stop_str = request.form.get("field_stop") or "0" - - eyepiece = Eyepiece( - make=make, - name=name, - focal_length_mm=float(focal_length_str), - afov=int(afov_str), - field_stop=float(field_stop_str), + eyepiece = eyepiece_from_form(request.form) + except ValueError as e: + # Hand the form back with the message and the values the + # user typed, rather than claiming the save worked. + return app.jinja_env.get_template("edit_eyepiece.html").render( + title=_("Edit Eyepiece"), + eyepiece=submitted_eyepiece(request.form), + eyepiece_id=eyepiece_id, + limits=EYEPIECE_LIMITS, + error_message=str(e), ) + try: if eyepiece_id >= 0: cfg.equipment.update_eyepiece(eyepiece_id, eyepiece) else: try: - index = cfg.equipment.telescopes.index(eyepiece) + index = cfg.equipment.eyepieces.index(eyepiece) cfg.equipment.update_eyepiece(index, eyepiece) except ValueError: cfg.equipment.add_eyepiece(eyepiece) @@ -752,7 +981,12 @@ def equipment_add_eyepiece(eyepiece_id: int): cfg.save_equipment() self.ui_queue.put("reload_config") except Exception as e: - logger.error(f"Error adding eyepiece: {e}") + logger.exception("Error adding eyepiece") + return app.jinja_env.get_template("equipment.html").render( + title=_("Equipment"), + equipment=config.Config().equipment, + error_message=_("Could not save eyepiece: %s") % e, + ) return app.jinja_env.get_template("equipment.html").render( title=_("Equipment"), @@ -764,6 +998,8 @@ def equipment_add_eyepiece(eyepiece_id: int): @auth_required def equipment_delete_eyepiece(eyepiece_id: int): cfg = config.Config() + if not 0 <= eyepiece_id < len(cfg.equipment.eyepieces): + return equipment_page_error(_("No such eyepiece")) cfg.equipment.eyepieces.pop(eyepiece_id) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -778,26 +1014,21 @@ def equipment_delete_eyepiece(eyepiece_id: int): @app.route("/equipment/edit_instrument/") @auth_required def edit_instrument(instrument_id: int): + telescopes = config.Config().equipment.telescopes if instrument_id >= 0: - telescope = config.Config().equipment.telescopes[instrument_id] + if instrument_id >= len(telescopes): + return equipment_page_error(_("No such instrument")) + telescope = telescopes[instrument_id] else: - telescope = Telescope( - make="", - name="", - aperture_mm=0, - focal_length_mm=0, - obstruction_perc=0, - mount_type="", - flip_image=False, - flop_image=False, - reverse_arrow_a=False, - reverse_arrow_b=False, - ) + # A new instrument starts blank rather than pre-filled with + # zeros, which are not values any instrument may keep. + telescope = submitted_telescope({"mount_type": MOUNT_TYPES[0]}) return app.jinja_env.get_template("edit_instrument.html").render( title=_("Edit Instrument"), telescope=telescope, instrument_id=instrument_id, + limits=TELESCOPE_LIMITS, ) @app.route( @@ -808,25 +1039,19 @@ def equipment_add_instrument(instrument_id: int): cfg = config.Config() try: - make = request.form.get("make") or "" - name = request.form.get("name") or "" - aperture_str = request.form.get("aperture") or "0" - focal_length_str = request.form.get("focal_length_mm") or "0" - obstruction_str = request.form.get("obstruction_perc") or "0" - mount_type = request.form.get("mount_type") or "" - - instrument = Telescope( - make=make, - name=name, - aperture_mm=int(aperture_str), - focal_length_mm=int(focal_length_str), - obstruction_perc=float(obstruction_str), - mount_type=mount_type, - flip_image=bool(request.form.get("flip")), - flop_image=bool(request.form.get("flop")), - reverse_arrow_a=bool(request.form.get("reverse_arrow_a")), - reverse_arrow_b=bool(request.form.get("reverse_arrow_b")), + instrument = telescope_from_form(request.form) + except ValueError as e: + # Hand the form back with the message and the values the + # user typed, rather than claiming the save worked. + return app.jinja_env.get_template("edit_instrument.html").render( + title=_("Edit Instrument"), + telescope=submitted_telescope(request.form), + instrument_id=instrument_id, + limits=TELESCOPE_LIMITS, + error_message=str(e), ) + + try: if instrument_id >= 0: cfg.equipment.telescopes[instrument_id] = instrument else: @@ -839,7 +1064,13 @@ def equipment_add_instrument(instrument_id: int): cfg.save_equipment() self.ui_queue.put("reload_config") except Exception as e: - logger.error(f"Error adding instrument: {e}") + logger.exception("Error adding instrument") + return app.jinja_env.get_template("equipment.html").render( + title=_("Equipment"), + equipment=config.Config().equipment, + error_message=_("Could not save instrument: %s") % e, + ) + return app.jinja_env.get_template("equipment.html").render( title=_("Equipment"), equipment=config.Config().equipment, @@ -850,6 +1081,8 @@ def equipment_add_instrument(instrument_id: int): @auth_required def equipment_delete_instrument(instrument_id: int): cfg = config.Config() + if not 0 <= instrument_id < len(cfg.equipment.telescopes): + return equipment_page_error(_("No such instrument")) cfg.equipment.telescopes.pop(instrument_id) cfg.save_equipment() self.ui_queue.put("reload_config") diff --git a/python/locale/de/LC_MESSAGES/messages.mo b/python/locale/de/LC_MESSAGES/messages.mo index 3dd1147e2..eec7487db 100644 Binary files a/python/locale/de/LC_MESSAGES/messages.mo and b/python/locale/de/LC_MESSAGES/messages.mo differ diff --git a/python/locale/de/LC_MESSAGES/messages.po b/python/locale/de/LC_MESSAGES/messages.po index 8dbb775f1..f4efa7dbf 100644 --- a/python/locale/de/LC_MESSAGES/messages.po +++ b/python/locale/de/LC_MESSAGES/messages.po @@ -3317,3 +3317,106 @@ msgstr "" #~ msgid "Download: {}MB" #~ msgstr "" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:91 +#, python-format +msgid "%(field)s must be between %(minimum)s and %(maximum)s" +msgstr "%(field)s muss zwischen %(minimum)s und %(maximum)s liegen" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:108 +#, python-format +msgid "%(field)s must be %(maximum)s characters or fewer" +msgstr "%(field)s darf höchstens %(maximum)s Zeichen lang sein" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:169 +#: PiFinder/server.py:202 +msgid "Focal length" +msgstr "Brennweite" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:173 +msgid "Apparent field of view" +msgstr "Scheinbares Gesichtsfeld" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:177 +msgid "Field stop" +msgstr "Feldblende" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:192 +#, python-format +msgid "%s is not a valid mount type" +msgstr "%s ist keine gültige Montierungsart" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:196 +msgid "Instrument name" +msgstr "Instrumentenname" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:207 +msgid "Obstruction" +msgstr "Obstruktion" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:518 +msgid "Date and time must be YYYY-MM-DD h:m:s" +msgstr "Datum und Uhrzeit müssen im Format YYYY-MM-DD h:m:s vorliegen" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:797 +#: PiFinder/server.py:1020 +#: PiFinder/server.py:1085 +msgid "No such instrument" +msgstr "Instrument nicht gefunden" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:816 +#: PiFinder/server.py:939 +#: PiFinder/server.py:1002 +msgid "No such eyepiece" +msgstr "Okular nicht gefunden" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:924 +#, python-format +msgid "%s entries were skipped because DeepskyLog had no usable values for them." +msgstr "%s Einträge wurden übersprungen, weil DeepskyLog keine verwendbaren Werte dafür hatte." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:988 +#, python-format +msgid "Could not save eyepiece: %s" +msgstr "Okular konnte nicht gespeichert werden: %s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:1071 +#, python-format +msgid "Could not save instrument: %s" +msgstr "Instrument konnte nicht gespeichert werden: %s" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_eyepiece.html:55 +msgid "Leave at 0 if unknown" +msgstr "Bei 0 lassen, wenn unbekannt" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_instrument.html:56 +msgid "Leave at 0 for a refractor" +msgstr "Bei einem Refraktor 0 lassen" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:10 +#, python-format +msgid "Must be between %(minimum)s and %(maximum)s" +msgstr "Muss zwischen %(minimum)s und %(maximum)s liegen" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:11 +#, python-format +msgid "Must be %(maximum)s characters or fewer" +msgstr "Darf höchstens %(maximum)s Zeichen lang sein" diff --git a/python/locale/es/LC_MESSAGES/messages.mo b/python/locale/es/LC_MESSAGES/messages.mo index d4aa3c2b2..f4831b9d3 100644 Binary files a/python/locale/es/LC_MESSAGES/messages.mo and b/python/locale/es/LC_MESSAGES/messages.mo differ diff --git a/python/locale/es/LC_MESSAGES/messages.po b/python/locale/es/LC_MESSAGES/messages.po index 385468c3e..1297eedcf 100644 --- a/python/locale/es/LC_MESSAGES/messages.po +++ b/python/locale/es/LC_MESSAGES/messages.po @@ -3419,3 +3419,106 @@ msgstr "" #~ msgid "Download: {}MB" #~ msgstr "" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:91 +#, python-format +msgid "%(field)s must be between %(minimum)s and %(maximum)s" +msgstr "%(field)s debe estar entre %(minimum)s y %(maximum)s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:108 +#, python-format +msgid "%(field)s must be %(maximum)s characters or fewer" +msgstr "%(field)s no puede tener más de %(maximum)s caracteres" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:169 +#: PiFinder/server.py:202 +msgid "Focal length" +msgstr "Distancia focal" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:173 +msgid "Apparent field of view" +msgstr "Campo de visión aparente" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:177 +msgid "Field stop" +msgstr "Diafragma de campo" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:192 +#, python-format +msgid "%s is not a valid mount type" +msgstr "%s no es un tipo de montura válido" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:196 +msgid "Instrument name" +msgstr "Nombre del instrumento" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:207 +msgid "Obstruction" +msgstr "Obstrucción" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:518 +msgid "Date and time must be YYYY-MM-DD h:m:s" +msgstr "La fecha y la hora deben tener el formato YYYY-MM-DD h:m:s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:797 +#: PiFinder/server.py:1020 +#: PiFinder/server.py:1085 +msgid "No such instrument" +msgstr "No existe ese instrumento" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:816 +#: PiFinder/server.py:939 +#: PiFinder/server.py:1002 +msgid "No such eyepiece" +msgstr "No existe ese ocular" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:924 +#, python-format +msgid "%s entries were skipped because DeepskyLog had no usable values for them." +msgstr "Se omitieron %s entradas porque DeepskyLog no tenía valores utilizables para ellas." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:988 +#, python-format +msgid "Could not save eyepiece: %s" +msgstr "No se pudo guardar el ocular: %s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:1071 +#, python-format +msgid "Could not save instrument: %s" +msgstr "No se pudo guardar el instrumento: %s" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_eyepiece.html:55 +msgid "Leave at 0 if unknown" +msgstr "Deje 0 si no se conoce" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_instrument.html:56 +msgid "Leave at 0 for a refractor" +msgstr "Deje 0 para un refractor" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:10 +#, python-format +msgid "Must be between %(minimum)s and %(maximum)s" +msgstr "Debe estar entre %(minimum)s y %(maximum)s" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:11 +#, python-format +msgid "Must be %(maximum)s characters or fewer" +msgstr "No puede tener más de %(maximum)s caracteres" diff --git a/python/locale/fr/LC_MESSAGES/messages.mo b/python/locale/fr/LC_MESSAGES/messages.mo index 883ebd9f9..8f656497b 100644 Binary files a/python/locale/fr/LC_MESSAGES/messages.mo and b/python/locale/fr/LC_MESSAGES/messages.mo differ diff --git a/python/locale/fr/LC_MESSAGES/messages.po b/python/locale/fr/LC_MESSAGES/messages.po index cefe8d8b7..d33df5468 100644 --- a/python/locale/fr/LC_MESSAGES/messages.po +++ b/python/locale/fr/LC_MESSAGES/messages.po @@ -3497,3 +3497,106 @@ msgstr "" #~ msgid "Download: {}MB" #~ msgstr "" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:91 +#, python-format +msgid "%(field)s must be between %(minimum)s and %(maximum)s" +msgstr "%(field)s doit être compris entre %(minimum)s et %(maximum)s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:108 +#, python-format +msgid "%(field)s must be %(maximum)s characters or fewer" +msgstr "%(field)s ne doit pas dépasser %(maximum)s caractères" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:169 +#: PiFinder/server.py:202 +msgid "Focal length" +msgstr "Focale" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:173 +msgid "Apparent field of view" +msgstr "Champ apparent" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:177 +msgid "Field stop" +msgstr "Diaphragme de champ" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:192 +#, python-format +msgid "%s is not a valid mount type" +msgstr "%s n'est pas un type de monture valide" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:196 +msgid "Instrument name" +msgstr "Nom instrument" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:207 +msgid "Obstruction" +msgstr "Obstruction" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:518 +msgid "Date and time must be YYYY-MM-DD h:m:s" +msgstr "La date et l'heure doivent être au format YYYY-MM-DD h:m:s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:797 +#: PiFinder/server.py:1020 +#: PiFinder/server.py:1085 +msgid "No such instrument" +msgstr "Instrument introuvable" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:816 +#: PiFinder/server.py:939 +#: PiFinder/server.py:1002 +msgid "No such eyepiece" +msgstr "Oculaire introuvable" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:924 +#, python-format +msgid "%s entries were skipped because DeepskyLog had no usable values for them." +msgstr "%s entrées ont été ignorées car DeepskyLog n'avait pas de valeurs utilisables pour elles." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:988 +#, python-format +msgid "Could not save eyepiece: %s" +msgstr "Impossible d'enregistrer l'oculaire : %s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:1071 +#, python-format +msgid "Could not save instrument: %s" +msgstr "Impossible d'enregistrer l'instrument : %s" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_eyepiece.html:55 +msgid "Leave at 0 if unknown" +msgstr "Laisser à 0 si inconnu" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_instrument.html:56 +msgid "Leave at 0 for a refractor" +msgstr "Laisser à 0 pour une lunette" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:10 +#, python-format +msgid "Must be between %(minimum)s and %(maximum)s" +msgstr "Doit être compris entre %(minimum)s et %(maximum)s" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:11 +#, python-format +msgid "Must be %(maximum)s characters or fewer" +msgstr "Ne doit pas dépasser %(maximum)s caractères" diff --git a/python/locale/zh/LC_MESSAGES/messages.mo b/python/locale/zh/LC_MESSAGES/messages.mo index 0977f0bba..5aa5b9fbe 100644 Binary files a/python/locale/zh/LC_MESSAGES/messages.mo and b/python/locale/zh/LC_MESSAGES/messages.mo differ diff --git a/python/locale/zh/LC_MESSAGES/messages.po b/python/locale/zh/LC_MESSAGES/messages.po index fc5959e80..55a98b9b3 100644 --- a/python/locale/zh/LC_MESSAGES/messages.po +++ b/python/locale/zh/LC_MESSAGES/messages.po @@ -3526,3 +3526,106 @@ msgstr "这将使用所提供的文件恢复你的用户数据,并覆盖现有 #~ msgid "Download: {}MB" #~ msgstr "" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:91 +#, python-format +msgid "%(field)s must be between %(minimum)s and %(maximum)s" +msgstr "%(field)s 必须在 %(minimum)s 和 %(maximum)s 之间" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:108 +#, python-format +msgid "%(field)s must be %(maximum)s characters or fewer" +msgstr "%(field)s 不能超过 %(maximum)s 个字符" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:169 +#: PiFinder/server.py:202 +msgid "Focal length" +msgstr "焦距" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:173 +msgid "Apparent field of view" +msgstr "视场角" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:177 +msgid "Field stop" +msgstr "视场光阑" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:192 +#, python-format +msgid "%s is not a valid mount type" +msgstr "%s 不是有效的望远镜类型" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:196 +msgid "Instrument name" +msgstr "器材名称" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:207 +msgid "Obstruction" +msgstr "遮挡率" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:518 +msgid "Date and time must be YYYY-MM-DD h:m:s" +msgstr "日期和时间必须为 YYYY-MM-DD h:m:s 格式" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:797 +#: PiFinder/server.py:1020 +#: PiFinder/server.py:1085 +msgid "No such instrument" +msgstr "找不到该器材" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:816 +#: PiFinder/server.py:939 +#: PiFinder/server.py:1002 +msgid "No such eyepiece" +msgstr "找不到该目镜" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:924 +#, python-format +msgid "%s entries were skipped because DeepskyLog had no usable values for them." +msgstr "%s 条记录被跳过,因为 DeepskyLog 没有可用的数值。" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:988 +#, python-format +msgid "Could not save eyepiece: %s" +msgstr "无法保存目镜:%s" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:1071 +#, python-format +msgid "Could not save instrument: %s" +msgstr "无法保存器材:%s" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_eyepiece.html:55 +msgid "Leave at 0 if unknown" +msgstr "未知时请保持为 0" + +# AI-TRANSLATED (claude): needs human review +#: views/edit_instrument.html:56 +msgid "Leave at 0 for a refractor" +msgstr "折射镜请保持为 0" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:10 +#, python-format +msgid "Must be between %(minimum)s and %(maximum)s" +msgstr "必须在 %(minimum)s 和 %(maximum)s 之间" + +# AI-TRANSLATED (claude): needs human review +#: views/equipment_validation.html:11 +#, python-format +msgid "Must be %(maximum)s characters or fewer" +msgstr "不能超过 %(maximum)s 个字符" diff --git a/python/tests/test_config_equipment_load.py b/python/tests/test_config_equipment_load.py new file mode 100644 index 000000000..bb991b0a9 --- /dev/null +++ b/python/tests/test_config_equipment_load.py @@ -0,0 +1,84 @@ +"""Config must survive an equipment section it cannot decode (#291). + +A telescope written with a string aperture aborted ``main()`` at +``Equipment.from_dict`` before the UI came up — the PiFinder booted to +nothing until someone ssh'd in and hand-edited config.json. The web forms +validate everything they write now, but a config from an older release (or +a hand edit) must still boot. +""" + +import json + +import pytest + +from PiFinder import config + + +@pytest.fixture +def config_dir(tmp_path, monkeypatch): + monkeypatch.setattr(config.utils, "data_dir", tmp_path) + return tmp_path + + +def write_config(config_dir, equipment): + (config_dir / "config.json").write_text(json.dumps({"equipment": equipment})) + + +@pytest.mark.unit +def test_undecodable_equipment_falls_back_to_defaults(config_dir, caplog): + write_config( + config_dir, + { + "telescopes": [ + { + "make": "Celestron", + "name": "Deep Space", + "aperture_mm": "not a number", + "focal_length_mm": "1960", + "obstruction_perc": 13.0, + "mount_type": "equatorial", + "flip_image": True, + "flop_image": True, + "reverse_arrow_a": True, + "reverse_arrow_b": True, + } + ], + "eyepieces": [], + }, + ) + + cfg = config.Config() + + assert cfg.equipment.telescopes # the defaults, not an aborted boot + assert "Could not load saved equipment" in caplog.text + + +@pytest.mark.unit +def test_decimal_aperture_written_as_a_string_still_loads(config_dir): + """The exact config from #291: measurements are floats, so "279.5" is + now a value the dataclasses can read rather than one that aborts.""" + write_config( + config_dir, + { + "telescopes": [ + { + "make": "Celestron", + "name": "Deep Space", + "aperture_mm": "279.5", + "focal_length_mm": "1960", + "obstruction_perc": 13.0, + "mount_type": "equatorial", + "flip_image": True, + "flop_image": True, + "reverse_arrow_a": True, + "reverse_arrow_b": True, + } + ], + "eyepieces": [], + }, + ) + + cfg = config.Config() + + assert cfg.equipment.telescopes[0].aperture_mm == 279.5 + assert cfg.equipment.telescopes[0].name == "Deep Space" diff --git a/python/tests/test_equipment_validation.py b/python/tests/test_equipment_validation.py new file mode 100644 index 000000000..6391673a8 --- /dev/null +++ b/python/tests/test_equipment_validation.py @@ -0,0 +1,224 @@ +"""Unit tests for the equipment field rules (#569). + +The equipment forms built their records with bare ``float()``/``int()`` +inside a ``try/except`` that logged the failure and rendered the success +banner anyway, so a comma decimal, a blank name or an out-of-range value +reported "Eyepiece added" and saved nothing. These tests pin the rules +the API enforces now; ``test_server_equipment_forms.py`` drives the same +rules through the routes. +""" + +import pytest + +from PiFinder.equipment import ( + EYEPIECE_LIMITS, + TELESCOPE_LIMITS, + format_measurement, +) +from PiFinder.server import ( + eyepiece_from_form, + parse_measurement, + parse_name, + telescope_from_form, +) + + +def eyepiece_form(**overrides): + form = { + "make": "TeleVue", + "name": "Ethos", + "focal_length_mm": "13", + "afov": "100", + "field_stop": "0", + } + form.update(overrides) + return form + + +def instrument_form(**overrides): + form = { + "make": "Celestron", + "name": "C11", + "aperture": "279.4", + "focal_length_mm": "2800", + "obstruction_perc": "34", + "mount_type": "alt/az", + } + form.update(overrides) + return form + + +# ── parse_measurement ────────────────────────────────────────────── + + +@pytest.mark.unit +@pytest.mark.parametrize("raw, expected", [("7.5", 7.5), ("7,5", 7.5), (" 7 ", 7.0)]) +def test_parse_measurement_accepts_both_separators(raw, expected): + assert parse_measurement(raw, "Focal length", (0.1, 100)) == expected + + +@pytest.mark.unit +@pytest.mark.parametrize("raw", ["0.05", "101"]) +def test_parse_measurement_rejects_out_of_range(raw): + with pytest.raises(ValueError, match="between"): + parse_measurement(raw, "Focal length", (0.1, 100)) + + +@pytest.mark.unit +def test_parse_measurement_accepts_the_bounds_themselves(): + assert parse_measurement("0.1", "Focal length", (0.1, 100)) == 0.1 + assert parse_measurement("100", "Focal length", (0.1, 100)) == 100 + + +@pytest.mark.unit +def test_parse_measurement_blank_uses_default_when_given(): + assert parse_measurement("", "Field stop", (0, 100), default=0.0) == 0.0 + + +@pytest.mark.unit +def test_parse_measurement_blank_without_default_is_an_error(): + """A field the user left empty must not silently become zero.""" + with pytest.raises(ValueError): + parse_measurement("", "Focal length", (0.1, 100)) + + +# ── parse_name ───────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_parse_name_strips_surrounding_space(): + assert parse_name(" Ethos ", "Name") == "Ethos" + + +@pytest.mark.unit +def test_parse_name_required_rejects_blank(): + with pytest.raises(ValueError, match="required"): + parse_name(" ", "Name") + + +@pytest.mark.unit +def test_parse_name_optional_allows_blank(): + assert parse_name("", "Make", required=False) == "" + + +@pytest.mark.unit +def test_parse_name_rejects_overlong_value(): + with pytest.raises(ValueError, match="characters"): + parse_name("E" * 65, "Name") + + +# ── eyepiece_from_form ───────────────────────────────────────────── + + +@pytest.mark.unit +def test_eyepiece_accepts_comma_decimal(): + eyepiece = eyepiece_from_form( + eyepiece_form(focal_length_mm="7,5", field_stop="8,0") + ) + assert eyepiece.focal_length_mm == 7.5 + assert eyepiece.field_stop == 8.0 + + +@pytest.mark.unit +def test_eyepiece_blank_field_stop_means_unknown(): + assert eyepiece_from_form(eyepiece_form(field_stop="")).field_stop == 0.0 + + +@pytest.mark.unit +def test_eyepiece_requires_a_name(): + with pytest.raises(ValueError, match="required"): + eyepiece_from_form(eyepiece_form(name=" ")) + + +@pytest.mark.unit +def test_eyepiece_make_is_optional(): + assert eyepiece_from_form(eyepiece_form(make="")).make == "" + + +@pytest.mark.unit +def test_eyepiece_rejects_zero_focal_length(): + """calc_magnification divides by it — zero used to be storable.""" + with pytest.raises(ValueError): + eyepiece_from_form(eyepiece_form(focal_length_mm="0")) + + +@pytest.mark.unit +@pytest.mark.parametrize("afov", ["0", "360", "wide"]) +def test_eyepiece_rejects_impossible_afov(afov): + with pytest.raises(ValueError): + eyepiece_from_form(eyepiece_form(afov=afov)) + + +@pytest.mark.unit +def test_eyepiece_keeps_a_fractional_afov(): + assert eyepiece_from_form(eyepiece_form(afov="68.5")).afov == 68.5 + + +# ── telescope_from_form ──────────────────────────────────────────── + + +@pytest.mark.unit +def test_instrument_accepts_fractional_aperture(): + """An 11" SCT is 279.4mm; int(aperture) made that unenterable (#291).""" + assert telescope_from_form(instrument_form()).aperture_mm == 279.4 + + +@pytest.mark.unit +def test_instrument_accepts_comma_decimal(): + instrument = telescope_from_form( + instrument_form(aperture="279,4", focal_length_mm="1280,2") + ) + assert instrument.aperture_mm == 279.4 + assert instrument.focal_length_mm == 1280.2 + + +@pytest.mark.unit +def test_instrument_requires_a_name(): + with pytest.raises(ValueError, match="required"): + telescope_from_form(instrument_form(name="")) + + +@pytest.mark.unit +@pytest.mark.parametrize("obstruction", ["-1", "101"]) +def test_instrument_rejects_impossible_obstruction(obstruction): + with pytest.raises(ValueError): + telescope_from_form(instrument_form(obstruction_perc=obstruction)) + + +@pytest.mark.unit +def test_instrument_blank_obstruction_means_none(): + assert ( + telescope_from_form(instrument_form(obstruction_perc="")).obstruction_perc == 0 + ) + + +@pytest.mark.unit +def test_instrument_rejects_unknown_mount_type(): + with pytest.raises(ValueError, match="mount type"): + telescope_from_form(instrument_form(mount_type="dobsonian")) + + +@pytest.mark.unit +def test_instrument_flags_come_from_the_checkboxes(): + instrument = telescope_from_form(instrument_form(flip="on", reverse_arrow_b="on")) + assert (instrument.flip_image, instrument.flop_image) == (True, False) + assert (instrument.reverse_arrow_a, instrument.reverse_arrow_b) == (False, True) + + +# ── limits and display ───────────────────────────────────────────── + + +@pytest.mark.unit +def test_limits_are_ordered(): + for limits in (TELESCOPE_LIMITS, EYEPIECE_LIMITS): + for field, limit in limits.items(): + assert limit.minimum < limit.maximum, field + + +@pytest.mark.unit +@pytest.mark.parametrize( + "value, expected", + [(1000.0, "1000"), (7.5, "7.5"), (0, "0"), ("", ""), ("51,3", "51,3")], +) +def test_format_measurement(value, expected): + assert format_measurement(value) == expected diff --git a/python/tests/test_server_equipment_forms.py b/python/tests/test_server_equipment_forms.py new file mode 100644 index 000000000..d8bb64a89 --- /dev/null +++ b/python/tests/test_server_equipment_forms.py @@ -0,0 +1,284 @@ +"""Request-level regression tests for the equipment forms (#569). + +Reproduced against a live PiFinder during the 2.6.1 test pass:: + + POST /equipment/add_eyepiece/-1 focal_length_mm=7,5 field_stop=8,0 + -> HTTP 200 + "Eyepiece added, restart your PiFinder to use" + -> eyepiece count unchanged. Nothing was saved. + +The handlers caught the parse error, logged it and rendered the success +template regardless. The Selenium suite runs en-US and structurally +cannot catch a decimal-comma bug, so cover it here: these drive the real +routes through Flask's test client and run in CI. +""" + +import pytest + +from PiFinder import server as server_module +from PiFinder.equipment import Equipment, Eyepiece, Telescope + +SUCCESS_EYEPIECE = "Eyepiece added" +SUCCESS_INSTRUMENT = "Instrument Added" + + +def a_telescope(name="Dobsonian"): + return Telescope( + make="Generic", + name=name, + aperture_mm=200, + focal_length_mm=1000, + obstruction_perc=17.0, + mount_type="alt/az", + flip_image=False, + flop_image=False, + reverse_arrow_a=False, + reverse_arrow_b=False, + ) + + +def an_eyepiece(name="Plossl", focal_length_mm=25): + return Eyepiece( + make="Generic", + name=name, + focal_length_mm=focal_length_mm, + afov=50, + field_stop=21.2, + ) + + +class FakeConfig: + """Stands in for config.Config() so no real config file is touched.""" + + def __init__(self): + self.equipment = Equipment( + telescopes=[a_telescope()], eyepieces=[an_eyepiece()] + ) + self.saved = False + + def save_equipment(self): + self.saved = True + + +@pytest.fixture +def equipment_client(monkeypatch): + cfg = FakeConfig() + monkeypatch.setattr(server_module.config, "Config", lambda: cfg) + + server = server_module.Server() + server.app.testing = True + client = server.app.test_client() + with client.session_transaction() as session: + session["authenticated"] = True + return client, cfg + + +def eyepiece_form(**overrides): + form = { + "make": "TeleVue", + "name": "Nagler", + "focal_length_mm": "7.5", + "afov": "82", + "field_stop": "8.0", + } + form.update(overrides) + return form + + +def instrument_form(**overrides): + form = { + "make": "Celestron", + "name": "C11", + "aperture": "279.4", + "focal_length_mm": "2800", + "obstruction_perc": "34", + "mount_type": "alt/az", + } + form.update(overrides) + return form + + +# ── the reported failure: a comma decimal saved nothing ──────────── + + +@pytest.mark.unit +def test_eyepiece_with_comma_decimal_is_saved(equipment_client): + client, cfg = equipment_client + + response = client.post( + "/equipment/add_eyepiece/-1", + data=eyepiece_form(focal_length_mm="7,5", field_stop="8,0"), + ) + + assert response.status_code == 200 + assert SUCCESS_EYEPIECE in response.text + added = [ep for ep in cfg.equipment.eyepieces if ep.name == "Nagler"] + assert len(added) == 1 + assert added[0].focal_length_mm == 7.5 + assert added[0].field_stop == 8.0 + assert cfg.saved + + +@pytest.mark.unit +def test_instrument_with_comma_decimal_is_saved(equipment_client): + client, cfg = equipment_client + + response = client.post( + "/equipment/add_instrument/-1", data=instrument_form(aperture="279,4") + ) + + assert response.status_code == 200 + assert SUCCESS_INSTRUMENT in response.text + added = [t for t in cfg.equipment.telescopes if t.name == "C11"] + assert len(added) == 1 + assert added[0].aperture_mm == 279.4 + assert cfg.saved + + +# ── a rejected entry must not report success ─────────────────────── + + +@pytest.mark.unit +@pytest.mark.parametrize( + "overrides", + [ + {"focal_length_mm": "not a number"}, + {"focal_length_mm": "0"}, + {"name": ""}, + {"afov": "400"}, + {"field_stop": "-1"}, + ], + ids=[ + "garbage", + "zero-focal-length", + "blank-name", + "afov-too-wide", + "negative-stop", + ], +) +def test_invalid_eyepiece_is_rejected_not_reported_as_added( + equipment_client, overrides +): + client, cfg = equipment_client + before = list(cfg.equipment.eyepieces) + + response = client.post( + "/equipment/add_eyepiece/-1", data=eyepiece_form(**overrides) + ) + + assert response.status_code == 200 + assert SUCCESS_EYEPIECE not in response.text + assert cfg.equipment.eyepieces == before + assert not cfg.saved + + +@pytest.mark.unit +@pytest.mark.parametrize( + "overrides", + [ + {"name": ""}, + {"aperture": "abc"}, + {"obstruction_perc": "120"}, + {"focal_length_mm": ""}, + {"mount_type": "hammock"}, + ], + ids=[ + "blank-name", + "garbage", + "obstruction-over-100", + "blank-focal-length", + "bad-mount", + ], +) +def test_invalid_instrument_is_rejected_not_reported_as_added( + equipment_client, overrides +): + client, cfg = equipment_client + before = list(cfg.equipment.telescopes) + + response = client.post( + "/equipment/add_instrument/-1", data=instrument_form(**overrides) + ) + + assert response.status_code == 200 + assert SUCCESS_INSTRUMENT not in response.text + assert cfg.equipment.telescopes == before + assert not cfg.saved + + +@pytest.mark.unit +def test_rejected_eyepiece_comes_back_with_the_typed_values(equipment_client): + """The form is re-rendered so the user can fix one field, not retype all.""" + client, _ = equipment_client + + response = client.post( + "/equipment/add_eyepiece/-1", + data=eyepiece_form(name="Nagler", focal_length_mm="seven"), + ) + + assert 'action="/equipment/add_eyepiece/-1"' in response.text + assert 'value="Nagler"' in response.text + assert 'value="seven"' in response.text + assert "must be a number" in response.text + + +@pytest.mark.unit +def test_editing_an_eyepiece_with_a_bad_value_leaves_it_untouched(equipment_client): + client, cfg = equipment_client + original = cfg.equipment.eyepieces[0] + + response = client.post( + "/equipment/add_eyepiece/0", data=eyepiece_form(focal_length_mm="") + ) + + assert response.status_code == 200 + assert cfg.equipment.eyepieces[0] == original + assert not cfg.saved + + +# ── indices nobody owns used to raise IndexError as a 500 ────────── + + +@pytest.mark.unit +@pytest.mark.parametrize( + "path", + [ + "/equipment/edit_eyepiece/99", + "/equipment/edit_instrument/99", + "/equipment/delete_eyepiece/99", + "/equipment/delete_instrument/99", + "/equipment/set_active_eyepiece/99", + "/equipment/set_active_instrument/99", + ], +) +def test_out_of_range_index_does_not_crash(equipment_client, path): + client, cfg = equipment_client + + response = client.get(path) + + assert response.status_code == 200 + assert len(cfg.equipment.eyepieces) == 1 + assert len(cfg.equipment.telescopes) == 1 + assert not cfg.saved + + +@pytest.mark.unit +def test_new_eyepiece_form_starts_blank(equipment_client): + """Zeros are not values an eyepiece may keep, so don't pre-fill them.""" + client, _ = equipment_client + + response = client.get("/equipment/edit_eyepiece/-1") + + assert response.status_code == 200 + assert 'id="focal_length_mm" type="text" inputmode="decimal"' in response.text + assert 'value=""' in response.text + + +@pytest.mark.unit +def test_stored_whole_millimetres_render_without_a_trailing_zero(equipment_client): + """focal_length_mm is a float now; the table must still read "1000".""" + client, _ = equipment_client + + response = client.get("/equipment") + + assert "1000" in response.text + assert "1000.0" not in response.text diff --git a/python/tests/test_server_gps_update.py b/python/tests/test_server_gps_update.py new file mode 100644 index 000000000..8a429af0d --- /dev/null +++ b/python/tests/test_server_gps_update.py @@ -0,0 +1,122 @@ +"""Request-level regression tests for /gps/update (#569). + +``gps_update()`` had no try/except at all, so a value ``float()`` could not +read reached the user as a 500:: + + POST /gps/update latitudeDecimal=51,3 -> 500 (location unchanged) + POST /gps/update latitudeDecimal=51.5 -> 302 (same value, saved) + +#536 fixed this class for /locations but its helper never reached the GPS +page. These tests pin the tolerant parse, the range checks, and that a +rejected form locks nothing at all. +""" + +import pytest + +from PiFinder import server as server_module + + +class RecordingQueue: + """Captures what the route would hand to the GPS process.""" + + def __init__(self): + self.messages = [] + + def put(self, message): + self.messages.append(message) + + +@pytest.fixture +def gps_client(monkeypatch): + # The route sleeps a second to let the GPS thread catch up + monkeypatch.setattr(server_module.time, "sleep", lambda seconds: None) + + gps_queue = RecordingQueue() + server = server_module.Server(gps_queue=gps_queue) + server.app.testing = True + client = server.app.test_client() + with client.session_transaction() as session: + session["authenticated"] = True + return client, gps_queue + + +def gps_form(**overrides): + form = { + "latitudeDecimal": "51.5", + "longitudeDecimal": "3.2", + "altitude": "10", + "date": "2026-08-02", + "time": "21:30:00", + } + form.update(overrides) + return form + + +def fixes(queue): + return [message for kind, message in queue.messages if kind == "fix"] + + +@pytest.mark.unit +def test_comma_decimal_is_accepted(gps_client): + client, queue = gps_client + + response = client.post("/gps/update", data=gps_form(latitudeDecimal="51,3")) + + assert response.status_code == 302 + assert fixes(queue)[0]["lat"] == 51.3 + + +@pytest.mark.unit +def test_period_decimal_still_works(gps_client): + client, queue = gps_client + + response = client.post("/gps/update", data=gps_form()) + + assert response.status_code == 302 + fix = fixes(queue)[0] + assert (fix["lat"], fix["lon"], fix["altitude"]) == (51.5, 3.2, 10.0) + assert [kind for kind, _ in queue.messages] == ["fix", "time"] + + +@pytest.mark.unit +@pytest.mark.parametrize( + "overrides", + [ + {"latitudeDecimal": "not-a-number"}, + {"latitudeDecimal": ""}, + {"latitudeDecimal": "91"}, + {"longitudeDecimal": "-181"}, + {"altitude": "99999"}, + ], + ids=["garbage", "blank", "lat-too-high", "lon-too-low", "altitude-too-high"], +) +def test_invalid_position_is_reported_and_locks_nothing(gps_client, overrides): + client, queue = gps_client + + response = client.post("/gps/update", data=gps_form(**overrides)) + + assert response.status_code == 200 + assert queue.messages == [] + + +@pytest.mark.unit +def test_rejected_form_comes_back_with_the_typed_values(gps_client): + client, _ = gps_client + + response = client.post("/gps/update", data=gps_form(latitudeDecimal="ninety")) + + assert 'action="/gps/update"' in response.text + assert 'value="ninety"' in response.text + assert "must be a number" in response.text + + +@pytest.mark.unit +def test_unreadable_time_does_not_lock_a_partial_update(gps_client): + """Position and time are parsed before either is sent, so a bad clock + entry doesn't leave the location half-applied.""" + client, queue = gps_client + + response = client.post("/gps/update", data=gps_form(time="half past nine")) + + assert response.status_code == 200 + assert queue.messages == [] diff --git a/python/views/edit_eyepiece.html b/python/views/edit_eyepiece.html index f4a9ea607..57998c6bb 100644 --- a/python/views/edit_eyepiece.html +++ b/python/views/edit_eyepiece.html @@ -11,39 +11,52 @@

{{ _('Edit eyepiece') }}

+{% if error_message %} +
+
+

{{ error_message }}

+
+
+{% endif %} +
+
+
- + +
- + +
- + + {{ _('Leave at 0 if unknown') }}
- {% if eyepiece_id < 0 %} {{ _('Add eyepiece!') }} @@ -55,4 +68,17 @@

{{ _('Edit eyepiece') }}



-{% endblock %} \ No newline at end of file +{% endblock %} + +{% block scripts %} +{% include "equipment_validation.html" %} + +{% endblock %} diff --git a/python/views/edit_instrument.html b/python/views/edit_instrument.html index 89ecb0e80..f0355fc59 100644 --- a/python/views/edit_instrument.html +++ b/python/views/edit_instrument.html @@ -25,30 +25,35 @@

{{ _('Edit instrument') }}

+
+
- + +
- + +
- + + {{ _('Leave at 0 for a refractor') }}
@@ -97,7 +102,7 @@

{{ _('Edit instrument') }}

-
{% if instrument_id < 0 %} {{ _('Add instrument!') }} @@ -112,10 +117,19 @@

{{ _('Edit instrument') }}

{% endblock %} {% block scripts %} +{% include "equipment_validation.html" %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/python/views/equipment.html b/python/views/equipment.html index c2198e3c3..c291ffb8a 100644 --- a/python/views/equipment.html +++ b/python/views/equipment.html @@ -82,9 +82,9 @@
{{ _('Instruments') }}
{{ instrument.make }} {{ instrument.name }} - {{ instrument.aperture_mm }} - {{ instrument.focal_length_mm }} - {{ instrument.obstruction_perc }} + {{ instrument.aperture_mm|measurement }} + {{ instrument.focal_length_mm|measurement }} + {{ instrument.obstruction_perc|measurement }} {{ instrument.mount_type }} {{ instrument.flip_image }} {{ instrument.flop_image }} @@ -126,9 +126,9 @@
{{ _('Eyepieces') }}
{{ eyepiece.make }} {{ eyepiece.name }} - {{ eyepiece.focal_length_mm }} - {{ eyepiece.afov }} - {{ eyepiece.field_stop }} + {{ eyepiece.focal_length_mm|measurement }} + {{ eyepiece.afov|measurement }} + {{ eyepiece.field_stop|measurement }}
diff --git a/python/views/equipment_validation.html b/python/views/equipment_validation.html new file mode 100644 index 000000000..642777397 --- /dev/null +++ b/python/views/equipment_validation.html @@ -0,0 +1,109 @@ +{# Client-side validation shared by the two equipment edit forms. + + Each form registers its field rules with registerEquipmentForm(); the + ranges in those rules are rendered from PiFinder.equipment, which is + also what the API re-checks, so the two can't drift. This is feedback, + not enforcement — every rule here is applied again server side. #} + diff --git a/python/views/gps.html b/python/views/gps.html index 6a429017d..70816b49d 100644 --- a/python/views/gps.html +++ b/python/views/gps.html @@ -6,6 +6,13 @@
{{ _('GPS Settings') }}
+{% if error_message %} +
+
+

{{ error_message }}

+
+
+{% endif %}
@@ -19,11 +26,11 @@
{{ _('GPS Settings') }}
- +
- +
@@ -55,7 +62,7 @@
{{ _('GPS Settings') }}
- +
@@ -84,6 +91,18 @@
{{ _('GPS Settings') }}
{% block scripts %}