From 4df8571b45e56f7aed3b40c6e4e03ef1ba19557a Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 2 Aug 2026 16:59:17 -0700 Subject: [PATCH] fix(web): validate equipment and GPS form input at the API, not just the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equipment handlers parsed with bare float()/int() inside `except Exception: logger.error(...)` and then rendered the success template regardless, so an unreadable value reported "Eyepiece added" and saved nothing: POST /equipment/add_eyepiece/-1 focal_length_mm=7,5 -> HTTP 200 + "Eyepiece added, restart your PiFinder to use" -> eyepiece count unchanged /gps/update had no try/except at all, so the same input reached the user as an unhandled 500. A decimal comma is the easiest trigger — PiFinder ships de/es/fr/zh — but any unparseable value did it, including the blank instrument name from #569. Field rules now live in one table in equipment.py: the edit forms render them into their client-side check and the API re-checks them before anything reaches config. Measurements are floats throughout, so a 279.4mm aperture is enterable and a config carrying one is loadable (#291); whole millimetres still display as "1000", not "1000.0", via format_measurement. - equipment: measurements are validated floats; limits + name length live beside the dataclasses (ADR 0027) - server: parse_measurement/parse_name/*_from_form; failures re-render the edit form with the message and the values that were typed; route indexes are range-checked instead of raising IndexError as a 500; the DeepskyLog import skips records it can't read rather than writing them through - gps: parse everything before locking anything, so a bad clock entry can't half-apply a position; gps.html gets #536's normalizeDecimal, which never reached it, and locations.html's two decimal->DMS bypasses are fixed - config: an undecodable equipment section logs and falls back to the defaults instead of aborting main() before the UI comes up (#291) Covered at the request level (the Selenium suite runs en-US and structurally can't catch a decimal-comma bug): 68 new tests, and the equipment + locations web suites still pass against a live PiFinder. Fixes #569 Co-Authored-By: Claude Opus 5 (1M context) --- ...pment-measurements-are-validated-floats.md | 92 ++++ docs/ax/equipment.md | 53 ++- docs/ax/equipment/CONTEXT.md | 33 ++ python/PiFinder/config.py | 14 +- python/PiFinder/equipment.py | 63 ++- python/PiFinder/server.py | 441 +++++++++++++----- python/locale/de/LC_MESSAGES/messages.mo | Bin 35931 -> 37439 bytes python/locale/de/LC_MESSAGES/messages.po | 103 ++++ python/locale/es/LC_MESSAGES/messages.mo | Bin 36800 -> 38277 bytes python/locale/es/LC_MESSAGES/messages.po | 103 ++++ python/locale/fr/LC_MESSAGES/messages.mo | Bin 36710 -> 38206 bytes python/locale/fr/LC_MESSAGES/messages.po | 103 ++++ python/locale/zh/LC_MESSAGES/messages.mo | Bin 34044 -> 35397 bytes python/locale/zh/LC_MESSAGES/messages.po | 103 ++++ python/tests/test_config_equipment_load.py | 84 ++++ python/tests/test_equipment_validation.py | 224 +++++++++ python/tests/test_server_equipment_forms.py | 284 +++++++++++ python/tests/test_server_gps_update.py | 122 +++++ python/views/edit_eyepiece.html | 36 +- python/views/edit_instrument.html | 24 +- python/views/equipment.html | 12 +- python/views/equipment_validation.html | 109 +++++ python/views/gps.html | 90 ++-- python/views/locations.html | 4 +- 24 files changed, 1931 insertions(+), 166 deletions(-) create mode 100644 docs/adr/0027-equipment-measurements-are-validated-floats.md create mode 100644 python/tests/test_config_equipment_load.py create mode 100644 python/tests/test_equipment_validation.py create mode 100644 python/tests/test_server_equipment_forms.py create mode 100644 python/tests/test_server_gps_update.py create mode 100644 python/views/equipment_validation.html 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 3dd1147e26276b0182469c53981f1274dab11757..eec7487dbac24e718238c0d981276a76c37244b4 100644 GIT binary patch delta 12382 zcmb8!d3aPsy2tTD$U;aUfv~TKH7tPuvWpNPD*L_&vUSKw(j@7`?hd=Oh@hfuX5gY? z7`9OmWTe$mK?I_XAOZ>sBMOd!g9wNyq7LAEe>pWS_dd_ud;RBARi~D>-l}st%$!^u z@%qMy(5b4CD=q%p5n);NaBFqde*LdUYs;!e^)^hv5txV>*d7De2{&PXJc*j8S&C&< z#y*&Y!?7kh7>D;_v}J{?hbctSupA?CmGMdAAB~%^63@3_6?_?q**btsWW9?D@EB^o zGZ=&CP5TwBKs}<3YaB+hzE#aM)W+NB&=})!ifPYBO*9>~&^%NI7GhOgh04%otc1H! zfgD8j`xw>lA}Ro@t=lgaqgdanNkJ*8ZyvNnb?jo=(@gsy)P&&YT~x2 z6n90rn%@pk9bDs^5N0z$2(YK1KEW z0+pe!Q5%Tn)zCaisDSFC`X!?RX@{Ec){yDY2RqO(1oaxt#}@bwR>JR40Y=bC3&*1- zsEV4fHmYA!)WRK58R%~6{mt{?m`M9%)J8&uro(+$g@%Vv1E0XUxEbr?o2Y@GqXvG5 z3Lx?p_Y%gT&N{)=tD^2g4OBpNQJH9h8kg$ULsn-B>evUHyHOcBgv!tf)P_!>7QSri*HQCU?nt)u{x_jem4<$(o!^ZLU>Yj5Gf@jI zKrOJ?JpY4nJ(k^iQ-2W^P^qaOHU8bSe~sGE&ls=wKZft87OsX$aTBbD9Z?hYM+Gtt z6+kBTL?3FW+fd`fSO@o^j_ecE9r`EgQa0`E_G^!tCk;dTU<{z3+nIstm}}}Yjq~wV z+81LAzJ*HZx2TDJMgNgj)&|=h%)|mQs^L#Jr2oIw&bQ%@FCF9i)1xOYj$)*#Peuivi8`7B)c9Gb*E59be?Mxz&{7J?6rMx{Qi_W3 zAlAU6s3SO!x?ERKJE_#&owy#hrQR6z>p2{?<7LLRs0Fv9F85AUfCrHcgskHfw9}7K z0ep&z>`UYK#`4@iwI>?uV|D7SP~*~2N6;U&&;(l(FdXaxMY@`mq-rKzt9&F5mxv1A=Eo$fMQMdR{#vP~ycbj@CD&WJYKtD9~ zQ>YD|HGXTJm+RwR(%29Mb*O_%Wee2p?1T|G47Kn`)FqmXN@WhJ|4h_Q7ou+eGSux~ zkGhOu)DgdJ+CN5(yNJqE=qC!AAgZrBaRO?BhN!^Wp%(6G+V4aybQkIluhK{6GxKBk}m&Y%WdLQQZTwQy{@dpWD37H*1~pbM&hUo4Nq zQMY|GMqnoDu4LmKxDhAgSE%_1-mc5c`QJrB6Z%mR&OHaZM-N5*4Y*0(Y!D1a3hiBDk^u15v%3~InDsGaS_ zXnX^;z&pkhru{4`BNt5jWz_f^sDPvHbjR1lP&5sVDX2qB)DGLDcGwH`e5i2@>If#H zQe9{ap?0>^)K{a%KaDZ?7t}^xHy%M{>hE`we|>na(x8Q7`nhLa9b>2`qXI}p4d`sz z2cQ-lfx2`PP~)@B^CDCL_ZpX=GWrx&zzwMRxAr6d+Q|zv=m<(t0}rDXIE|Y4OY{7i zsh98XPE-XIU>#J-8==NG!&vNuTDX_-cGUBMn2dLaDCo==pw99k)PgHfDcfM$Uoh@P zEqKV(KfqY(pJ4^OfO>uvm6_{U3u6Yj8?;fcV+z*8&;SY=m~A=~n)+N*Ux*591uC!- zRK~WV?!r#g0*6rZ`~%zLWsJqf1Ksu%)c8)Q`TAl-z5l}~C)p)T7p z)4l;U(RNJ5moXmCp?(doVqHua?C!8NDns2+8R&<4ZAYNy8I77}5+>^XUrIq8*BUpY z76_aA9&AJX0H)y&$Y!l>LtGc40(cMg2h2s}m)L4L)cv)aij$}>LmMw4*TPC5ouPgd z#!=9vSdaRd+=J8bI6jGehFjJcJZ0=O!u_N^iVxF%6c^#dk#4HbU;_0ou@e4>+EC0W z_wRvfsMl}kDDtnJkEKCp?Li$yHflh?c%Nxsf;!8`QD zC+7KOQ!h8#U8mY;@~=p38q~2JR>r=@5vYuKPyx+E{X8#31^!3WnQlSdoiJ(xubSs? zp~f9S1@sAO{&VK}wGai}?sDW;sY)_7!nV{?F&`)47CeYeamHAV1=nD8JdOGQUB$AL z8)NQr1FDX?gpE*{YKjUh)X6mTF&##tCK_WLkLozdn2Xx!O!GX1ns|X}Uu5b_P=PEr zmY_1V6_vRck#QmGb<<%VDicRg0eprv@e9;zb`y0}wZ^#%wm=MR2P+zw%7%GVW>NW85G!zwF7mz;`v%6VSCgBx1$1@fZBNwbq5xpUcbet zj4d~=G(KTmi#ocEs3U#>b!Xn1MEJ2cB zdNOKU4r-nv^ZZ^^po>r$dlYNo`pF^pXK*JC+VKh0PA`}akyG5~^-;IBJL=5SO??<@ zXXB02O#5uqnLmKa*fP}oC8+T`P?vmnh=NkN4;9gVRHUDw7CeVK>no^3PrW17#Sy55e5iTnpw^j>1QfCsQBZ1@p(fg3-0pU;cA_%y1}f0^jh|py zhEU_KpaQ&!+G+GuHzNtCakiV)BX)= z!5gT+Dm(5@>tP-0%~0)W*b0YWTbzaZAU%T`zZ12AgIM;z{~x6gLBnw@j~`(qo)-N^HB3FLanphxF(zYtK%~?Xo4^* z^>3g8_!}y~bEpr;PpBOw=D5#m8(X0IbwLHtAN5@rYwFpkg^RE~&c|pBhbU;FS5dFa zer%3s@fM8tx)Y@thoN>d1@&G}GxY_ig_od?@JZAHYq2%{3ESfP7>nhmxtR&YQBZ1= zumZM5ol!^Ap&M4D-rG0`mGZHuK&PP=T7lZ(YSf+Bh?;MUF^uZB3l(^&+s?oLDd=A! zXHgSJ@-o!XOW4w$yn(L^$5trvqP|w&D)jtIlKv!&xJxqNvD)6bO@m`F_*{HxD#7cVq zA2kh6pdww1WvN3&97esKJ5dYnHXguK>K~%|Md!OIu8ew1s$)F1L*1?0P^lk>>hH%; z2MV((XrgVXNIx-NLM`|+>UPKZTr1;>WW1FzAKifj%lMGH|AtwFt}>rfec1ru=(DuDM;6Mur*&;@Lc*HHm9 z@w-RR8Z}RE)CNQ2D0HHbh5F#ELA@SFuos@iD%dRG4(NgkFdenf5Y&z*VMp|$0xUrV zvNdAYt+wn-6HqT>f11x z`b1R57NU-BF_s-MD$o-1d^5)B{eO;v7JLeT;f+N(@= zpWE1s_EgluqfzsXL(QLol`s!A?`$ml{a;K$XSoWi;nT)ls4v}ns3Z6awSya|i7L!+ z>ou?{^+u?`I-&v|fI8!GracQa-(0MLtFY|%e;Wl2cpl3x6>7rwuqmEFonh=uci}3i zdPCF#$*3K5G|$sffsVi=oQMkS9@J%h5Ve8gndD!oT1SH>*o6Ahy@+Y}G7iTdk$(bM z182Ddx1hdoFQWoDiaN4SQ7QidwSliqd&F#$G1LZYpfZp=oAcKMJ!w#i2jWPah}yyP zsN1{?6?rLYfy3AdPoTzEp5wOHMP;TbYUe4YJq@+7{;2r|p)x))WC|JP!9A#q%tS3X z7d7ESSO=G*F4ME9KwdQU-KhR=qcV96mEtp~ja)(9tsBOwbKL+#$rRM_R@6@Vqb3-G znlKAB&~JRuxCU#}{yeJRJE&jLK3eY)Xs*X&S<_<3&1lTaOMq5?@q zU8?q|4@wtQU_DUp`CwGP0P1$%i<-C?m6>&@Kz5qvhfwpKK?QtXb=J4Or=SS0qjnlY zqbAb-IjD*1nR;hzNj)9)TKZ6Tqu4xu5*5&9QxBu&-HXc1LDZ!@W$ITj)PRP_``iVa zV0VihqbABjMLOMhKPp2@O}zvaz!ucq*@1fgI;#CJDg(!HG@eJzn>ODKWWap#UzdhS zG-&6uQD;2gxEPhPV(g8Nqb4|M+RvK$SE%t%dQvMR^(!7BRmXr?X=bcH^nVb{ck~KtcPjuhgx_f-hqoy-<3L@{_3d{Zmv%uAh-Gki2F8lK=mJl3Ungs68p{b z6{h`JY)JbaRDhqN&i*{=sLDO6qvQN5Qi!9WdRYU}VI=hwjK}t<9rr}_y8|oY2-Ibp zh}wCfX%C@}ZZUES)~omu_Fm%tv-}6_Prb=fHpKc?J_S-?J&0Op2`1o5)J`{{F5ye4 z_xKg_{2fz2X`Ww1rT8*x1K(jh{)oyz^fGsyM2x0h14Ej)J_U8KQT0}+6tzL^u(PT6 z$FiNH7Mh4UDi0=LfoTt+Uh4&@BUp<%!q-vre}Fo=6U)fIQhJdFMf@Ww;+v?Q$1QhL zUeDOV*b((>n1%}cZcIc6HP3X^0&`FSE;p87W$IgvJC~FHS~MJ>L7&>sQ3G$F0;#&f zwISNnQ&5>1jJm~RP_NxgR6r|HJN+}N-&?2+9!6#EBv!#MuqOUcg@OV|e9Rr#^0DGN zt-6O=WO<#u%$5N=zbFv2GaS2Fi+s;aZ+=leZBuhRe$UjP;}6&ceml#V;rOj)|6@dk z6P)2VK8^JH%t((f^S3kcN4@R%f_|?Pu$jcM1G(P9LMPK6lHpAC6a^eR-Ej&7xw8fp zWZOBOOxssr7X>^Sd5%5ZlUKy3ECLATIQeZgl{Y|#pzYB^Z>F7JP~;2R!C8e)ctooz z<&!*q*0ResW*21H)4k4&@Z>hPm+x9ol&7hKcEB^;v7K2?q1Tz}bhr5@N8jH+^ZEio ze^Ea3=xOo#cHYLe_7wEyJHMoA9RIZ$Jh)AIx}DQKzk47Y-Tr9#;zJ$x$9nSYJja(E z%n28F`mcxx+ZV3awPLxpTGbP@+m-FcWBZ*frMJLu4RU^Yi?(A4=%o((mdR*XLN)eD4Lq{%dV{<1#ZKyTP^O8b|QQ;?PjfCZ$+lZ zpJnHizBDx_5OjQj-=3*G-tkV&;oS2)zHB?-czwUW8v%Rv3~!*U$8X~+`u)wY^Sn;B z!_a_zyO$eMx-r|aLo|$z@G0h~uB(_%bte>h@U2 zA9U3T@!PotJ|8Rk^wiFF0)=Hs@dtIy*+SXf z>i^4S`oDB9zB$F$jXRKOkIwPWcDzA5zjRk1z~;R9_8kTOd~VQxzj^lb0>7KcaF(Y+ zWbstzaK%34!$-=3PPl#6fr#+=oR;Of^>MtmvX*a;(B;eJ@^KyWJeuFq!+%$lZJ&=Y zGMrowTMSp7Rx7Ga`v2!T6t^r$srw&}zIaJN&EiWFYJ~F&Mnt5VeEvI;StNjvev`W2 QKPi6go*Uuk{M%~(3p&v4ng9R* delta 11005 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 DqrM;% 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 d4aa3c2b2c682ee7e41f63e03f728a211c730003..f4831b9d36edfc710978e8e98dd98cf0e149468f 100644 GIT binary patch delta 12351 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
X&MkHo=IE$-wlBLl&s7*IssDMxU+MOR zN-Nz}(rti?kBg7ZF17Pq_Cy~sDhwhIWu0Xy|9oP!|D)tybKO?4><9Pd*bEQZ_GWun z%9riR3w}A_LRfH{r&EP?8SY7LYA%!=vhi2dnFoH4J~z&i?eqGw-RsIHMYbjO|Np(0 zohwMGb7KwOc)?|+{hm*2gl~ zB(%3h`*pXS^`pMw!7b>7cVa5e!X#XX2KES=;^pWB>oFBKqZxZQcnF(P{|X&nxpCY- z6P-T?Q?V5$YEbAz!39PjW1`Wa!*X5G}m{%uCLl>-sPFM>KBp03U%J94$ zwxHe}^KmBD$M-P}zfVvw!hfR+CpU{HNJS^CfOf2bF5C#yu~n#d3D0|AIobzdSsaJ< zzYo0|51`|gV>x^g?U&d@!HC~OFUxN9Z1;!yC+OK9MkhRm?)(fo?uStS1?^Y7c^q&G z`qorJ$K|3aZxQO34_z$+nl$LDN_rDtjC+LZpI0UQXH1x78M;CeSt{FcYt#JqH`%a5SS!FyTZ`QE=kb=tLXQjBF3SgYMuxERXx~3OtRbw)B;8O3R^l zs0Ny$+QD3Oz7|*&TZej|D>;96a3c*BaXe<=1Lz%CjxMkQ9rywk1%@v44tkb5(Etyk z8Tca9zd<+jPqg2E(D}<;6>p^aRpj4>W;EELGkRuy(2R^g1DF_`hE6ye4dh|0iHp&I zUPS}nfCjQT_%7OiKbrbOp?)$!!N|{|NAV*%!SCp67`2WEB%u?Q#d?^929k#c*b6J; z05tV?qIYQux{;;myw711d=dRMOngGY9hbX0UML&A#f{Lwu0 z!KuOfgG;dz{hmR`ZA95@Yjhah zaoM(U|0?KFW}!RJMekM{bVI$-zz1O|){pL>P=SVt=)igCz{k+ctisFj_0YZxi(Wr8 zGiT6=zedOZjNYkYymKB+1vJ1+^ayIB^Vi1`zW=Q$cy{g3iTk4g-GWXuIXEx)I9@^f zTJ%Wvp#gq^rusW{hsE2)nJSBX8lsNq^QlOT(IQN^!}lo|`TwB-oC)>w=ngMoBlO0& zqsC~zR_M;!BVD3y$Qu^TL;tSWhK~On&Cqdl+}G%af4Y|ZyOYv<53{ftW@0a_jbqTa zU@^MWrReLkEVvR~;FVC{fCl&`8rZv`z6;&p!QkJ+^YiV=zqkEjc#zy7PF+PbkXmQ} z?a+lgptraOnz;dJsz;$an~dJ>ndrPr(aZNddZe2|`$y=wBMAzo<|}l93+Tj`&|i;0QD$6VRQ{3GK_#l&(hBh_;{${f6Fwl3n5*rlLEpj7_ix8bB{B;rl;`f+-q? z1~3X8a1R#8+319G(FNuQmxuNjFp2hcp?y6%ej6I_4s`ry!4u*6SxjU7=sbm@GY=ig zreqAU4#;9?rw(KG18 zFNF?;q5cj!(Oxvb!AcL}>pS9e)9x?@u(q)b4Qxs-W$OEDG+t0h+qD z=$+_@eXt*zvKP?+)?pQV6O-{%Ou?gA3Qwc`zeRtZe+=yn^5gkipgX@B8J~#qD0ta= zga^aXiSERvI2Da_4f-p$5v$>Dbcg5A3|++1SmOHlwM|Fo$w24HL^pap+HXjaod4|< zTwr2&Fbf+~e;7OBChUP1gPo`tz$54%CNCpDAJIu{h&6liEx{g`jjtfFMZ1w?M3s8Q zcVZY;_WhqlVH_6VGx!JIge!W-UO?_o)cpqjDF`3Mhw*aG$5gMvviK6F;bu(5o#^lU z0rZH<^o@630X^#Km?%Rbhk^r|2HTSqGvuB9e6jE!+XQ?g?JhDrRW`bIy_$= z>Tjd-9Y6#9BD8pQz?;!;`ZMSUXd@P- zJh&4L=pg#raU9LmNi?tvq5emBUN&)KJW=^zWwc{vupYY8R^jF?;>D9ufE$LZ)! zGef;$uywEt`kLPm>LZbjCZdTHoM;wS#QV{mJP}-jF8Dv_LOZY$?#0S@1`X_YG;?VK z<(69mR_#t|E4utwKG@vihg?|c`x;YNG2Hszc zJAwwZc~BhC9<=}8(NteVpQj9tKjAg7==Z-h1<$xE`g-+5Gtw_OFgP@LE4tG=(21v@ zNAwUn&jR!)9!1~#Rl#*=hF?cF`aUKs?4#g;PIiHe=`a{(BPS8-x5dG5bf9sy?kBK z%QFCdou*+!oQnpwKD2K`Gr1ED=p*!K_Mr3cM*}>H26ieD3g4g+UO+F+f6!D_;FrYI zHAXLCXY`KrK~s1WI`MEcBjZAS4%&Yq+V4p;&{b&0UPIrS#M`060d!|af@jc)&!ZFn zhDMw^Jg!$n1F4TL&;&EFHM)_0;rTE$gQL)er-b@EWWGeSjDnG^z&5xRN8>5aaWoST8qYJ)(F7z7O?=AH5eSr2mj%MU6n$e#z)%U;Dt?^DPp#!qffLfy| z%0nX`i0*VWdbX3$vz;31)6uh^iT0a|j$eod{46@pi)iKwvFQEZMxiDRAEG-rher4d zIz~mH|3Fii zJTh)Chwi8v8c-c{TtjriHfUfyL;JAMeiu4l0^Rw1^sRal-Qc>BVswW~vFLk(b*ZmKGrBkU8JgKsSjPAN z5(O_|%Gfw{bCs_3Rjs|)W4LoUFyu(sx;Hl`HsSwOTGuBdB zKgy%v1UI4+jzj~Pfu{KWU;%n&FQG@X73<(0ER7d1A1@&b0?u`l)SuoSkx zD-P^BEc)mF-V}^*1ZLwTG?kBGDn5so;VbA}*ogW;G#SSnx{PMsXrTcEeNH`c;2=xeno zxD*X^8K&bZ^fGQhH?{>0D6yM@3x103=p>fKZ_t$g3!OOW?s%e=4lFw*4yXZoncAQecg1qp2kn1r zsLwz*v>mAHpLi|{5i^}Ena97T8f3mRClX>lMaSdn@K z^i1nxC2ShnyJ8LMgV8`|ps(9}9D-}m3`Ns9e@EcGn0W; zsMkWDw?X@NL^E=IaCmSkdPf#wWn2;5h>rW{Uh-d_!Y4FT#k1%{f1m>@%!qfAiw4*R z4d_NRQzOv+W6=rcpgUiHrua#;|C;c819}&>1$QSXn1SQyglB`_qZ9rbEOuWUXc;th zHPD4}(FNL|N0Nu`_-1tcSTyBRLi==d`~vh&C6-e#fEDNjtFa+&3GJuROZg8pQ@^AA z(q_i(wb0BoM~|cznvntMhQ^|S+#R0JLIa$KSFnDxoPs-f6C2|$%*Nl)ftSyU7tBE? zY>fue5xw>OgJaPnOJF8Gf&Q#-MCad+ZSe#a%{$wGIsY6APMnKQ*fN-prf4|&8r>e+ zr=UAsh@RzN@Ge}9X0q&@IN&O1APv#}-OzPY4g&sH-y)>KA0q=(T$Dw{0 zo$xq%1izsHrZUM@SROs9e9Xc@SP!S6fv!X|_d0rKc4ET!b{_>JJBUs2D0)Vv=f;;M z6CH2`nu+FUN;_av?2UfW=HO7=fqk*Y{qg^SVG_2d{xn{XpJOx3d4T+L%+Y`c;fsI8Mo`n9^B(UiJ|M!#y8rB3~M^pb68u>mv}3$$M^OvQnrJ_^&RPe8w< zvx5cb!Y^Z#ojr5x$cYnXXZIh_so#Kt9t|55c4<^Lsc>x5PDuq{wisP_cguZA1wC5- zZ(-%D3yK$PYy0oQudX>%qTqDLng!oCsZyBNDY|w+@!+ow=!b? z?e`QW?&w;g;InaO3U}Rkp?KjZ6KWPKtTpM+k_Ek|H7@+?w3)>V`^-oxRyg9my4C&* DNMI)MRH0)nR>m8Hw+8PF-i5V!zZL7_BS_A%-AJI=i)ezc zqVYb&s`y#x{~K1LozX3_CRS$sSi{iK6i;SA8?1%1LVq3_Xd$}LrDz3KU<14nt4SN5d-2D*+81KosXwgH{E75yN}(Qz+eUEGHz@@I71 zL9{|&q8rHK(=bjpnox6eTt_sKo@l&5=`dh8_NHSz`WY?5PWT6`jfc?$GZ^H;wa@?! z(11+ol?j#@mtd^nyu0xOF=Fq+$ z4YUJIXeU~sy=aB@qZ@h$UHGfeK8D7v(}!yL{kP?z0Ue{!olip(xBxBfVsxP^&;?e9 z_v?Ziu;SK-_I5O(U7`I-@V(IgCAy)Xu$JF{RenERxFK5Nw%8E+pn=AqiJXTfkb^@o zAKmGF==^eQhR>i!_BMKlzDF-*yOW}Ez0f$rFzpYl<1#d$G zZ^9cmfIP?m;$?j=j!ANOfab!=it5j=+|*m!R`+LMwDTy1-`ah!3GVKY(5Eujs~F zoRW_IwL0>Y=u#xG4KJ3WpUWNS&Nra9`0n6C=z=>#dl#DUOK75h3hj5$4Sp2-I=rtm zJi4US(>yq!8CuFt=B>kB(oA?sNru`>#cB{|59jmZL}f`_TUu zI`0d#Qt9t_FhG?NQQ*30fL3T?J<)}Sg#Odeh0aCqzyh=q%g}%;(FLwU=dZ^WxDCCO zyU`8pLn@Vyy~TqGe27jsga$Zp%r*0^zTPYeGob3*mvkc zgIV3XF$Ue}1oVzf#qO*ho6Um>T!)qMcC3OM&;&N46COo(_B3YUf1?ZhA-F&Ee}q=# z^U(hlI{ybW;VP#^^P6Kji;gxtIN$_yhrQ4p4n^Oe89W<3g6U|f3xjENXKO^d~D^=QSmp?BeNbb-BSoR6^= zeudSs&FN8pS9E?~G~NiT;rBm*2P-fwyqJaVJP$41rRW`5juY@kbo|F?0-vM*_WKFl z`Elc-vu%kc)Bzpe1N}4JC-moG6~F(5Jk;dHQZ&F-=w-V$^lwB1-H$!+5v+xupnnaI zU~{Z{Mzq5&XoUu#6&Q_vwv*5}Q_wgwu%6%l8Xg>YM{o}-4|*mqL&lX^9-qW=|KiPI-XrT!4>(*6`{^pz z(BWAp(4&}#PACpu7W%J2&+=yU%-5sy{tN5j@51{Rk;@hPBYH>P4)4DT?MhRkbsA2g z{$?7d!+|}q4vq*;LMxI$6IzV^d0v4gekXdSThY5yj&9(w@cub;-aa&;x6$~Yg!kX1 zdGK~uqP~_YJJ=e#)9!(JI0Luh9&Cq;&f!?_Hf)6NqCY@Ku%hI_s^>-tH9{|8YqV1B z(8SVxL&xwiU@{u$?BG;%;Edn`bf=5M`!pK(iqO9@w68%ESsPrBR%#nsx$VfjbnMA6 z;2E?M`_Kg5$K&uI`kDQR9#xa`q6IslN7e-`=@9g8oPi#}By^`ULc1V%aqvpa_WLgj z9rvI+Ek^@Ag$?mpbSDRbpP&mKMHi|vH9CU&*qC-lG_fIQyou=9&%jogLMyQfTl@WA z&qEv^MnA*X(4Bvc4y-vX3eXU3w?Y%@gr5CL!EtE9v$3oacLbZ#t~ovGZ;w`FAoj!4 zF?})*SMb39#h&K>b--5qTDXH#(L~O}W_TgG02i zP3SsXF&iJ7nT|@hmkxLQAzH$3(2Q%%iuzlifqJ61w|{64M-v>2{y9A#J)$M(5nYZh zv^Ka2y%Ue12|k<^m*_wI`0^oNY3&F%5=oaI1mTn zP;}hI=-03ct=!${579&DhW20pa8M=|R=)8@=htUc=ize_g@)tbzCR%~s=~Ogf0Jf%MEOx|P^z7E5-|>^^LVv>Q zco40`S6B^eT@WSI2;F&Gw7)x!!hSdaFA4oS&^YO*crf$bq2ui^;A1qyuh5-;6Rf!) zTDTQsyQ64&I^{HU(GVC`0sx!c`)z|=nig2Gu@0<;9>Oh zd_45;#M-oXV>jH7o^@7!G`=ocp~h&uUg+<`VDxL5ipDL#wBPMQ9!|rDunv9~tX>c$ z(ggiMYKK;2Ai9Io(Vb5~1D=I0I4w9gcwum9@Jck^^##=5%s10vW)GqZJQsW&{a!!8 zw)h>ola__ikqyR*h0&kjX?Q-a$3FNyj=`Q6Miss&csbUifAxi&zYn+4Vac|krF|Yf zv)9m_zK`|sWAtdg4ecsLQU7u1{1ecfjYQ*2MaR!a%h0>_n{*iPU>NWO`kCxT z7km}1&_VQ4ei`0p6-NuzL&tRp_QwXa$6^PZjvmo+bpBQ7!grtvr61*?FAvY*U_6En z98ePdx7-O>m-cdWN9)i8H=qHwp-1p24#lU?TOTWp?m`xNx$EOtY>MMC2l>HE$I5xI z#IKQeG~S=`_P5oMdN&c#y^D4`z}~( zF~6*=A8W~j8TJm2!bY^u#U_}Kp4pXXLcc`=J{WugP2dGQ4&OnK<|rCBb4m2RF*c^% z1syjW)1K8-9t=DSomhxYyac^OYteB#!uu!CGvAFS@^bL)@cvWu(jG-O@)Npnql=>P zUD2cHe-ZUJ@Tqk88ID6sJRK)u5_50|TB+kNjso0<&1i2%OS}t>vlrdKt7t{u3;jpY z(jP-NT4!nWzU5M)y6}l~_{%pEU3fAY=q_|851~7K9KD>+V=sIe4VZaJlz28ezBy)N zyU^bsy_Cby%8fwR9h>ICQcMdkQt0I=L=z}ME3ypz_ra>rz86iX9Ie2UXu#d*(Y=Xo z=n$IF4`@ZQE{$xCCYbKYgF70DCNdt~(fMcsx#+|t=%rbMp6T7#1b>H)e+}EBf^U_(TV4wJIDzxLeF+J8gOm!7BtYN-~;Fx{|=3_7p=e> z;r%CQ+{2;2>Sb1jMq?f1JL`yde{ec>TmTWwB!P#g6Yl63+3*L=h&h6-e z`_OqGpox8h9#zINHpKd|Y#w~kBG?H%iWAY#<`i_oxoC+~*b=WsD{?Qo&~`N8-=pJS zMl14ucz+l@%Bq(~znsTox-}gGcreg3?1#zFz8$@-_XPhF4Y(bx)bnU%UPmkT7P|1K z=%xKGv@@?qFMRI~r&wPR8d#yV>$6;WlWcx}t#x zqL*_RT9LD{GNy1C=A%b+KQ_mm%c*}W9uCmqj=nskQ1T$!NKodIwdth&Lr?b)V z#pn?(Lo0DLTG5-a4{pS+m+EjRF@jSI zeXru@8mHoD+>UPGNAz=Sxr*No?1yy4Hsb{BwL0>097FpR+=S^C|3S7qJcCv6Od8(S zDQKxu=nfa-BwUHr@eQnjAD~Bf2pxX}txV->qDnME``ch;?1gbW30W^4JBJ4^!*r~Q z1?UgWBJ^%ti%s!9^vs?|kLm;T>-ZYI)iu^ciMK)%I}yFyL(urM!uuk011qtX-~U=3 z+`;YW_q-_#cmTabk6|r*5#8B=@cu91{SmB9f3<6)_l>bL?T&aIPQvH$L-a_ut>sh3 zcky`EkM(DirJsTZnuacvLKC_Oy@V@6|El0E=nu`k;r(-%N&AK1i)i96V_kd)t?(gq zy`z{m@G%}N*-vP@#`V$8Yh!)dajb^D(b5e<$BjZ09*y3av(W@DK#wXPJ;K#!oXzOG z$Iv5w;(F@ui`VEdqmQu;eu19tF|-2NWs$9dUC@O4p$QDfY@C2zvK;hIq|o=52Cqir z-xAte%Ba7e%?>)eWc$#CKSPh8(z>XG4bac56`IgcjN|F(CCd%2L3jQDI&M#JKU%>L zv0{T*vBCP`A^V1C2c2&yYu0&Sy3^cLGB@Xh;&@(ZaY=l3GTy#ZUSe@7uQZRoIr9@m zi8&?7qT+Z#QG9N4QL-r3{$FOyPL?c6Ci9({$`3OW`8mIi6l-4`Pv(~trIN*Qf+XX` z3sQxJ$(;E3*~vMH(&A)%WHMP;ykN<=f_d@ziJW+TLAu#u2F#}5C316mi;&}z7`6cm^C56fINuBFwW+#eRD_*g&c){HG!c=lm`OI#kGW!>l z<{G*rUYuB%j3<{Q3scEC$$|0q#jz3p{4SMWTvAk;M;zalZRnY56L(J~sl4RRr8y^y1y?tJ&p2&^oCiCZ&%r7tN`c4%D(E_ zp}gmitc-YZ>74mL?>aWVAnN#Kt7W~1?JxgvSdFH?+=~2UJf|R45-&^?$8&aVf#PJ* zuTM5Ur{aq2+FX<@{?*;6n6zta2}eDrAg{296Z8BluGi0(Bw6z7NdLGayS60L+&4~e z*Oro9TjHsC`2|IEavc*zzrH8=9TN)^|H!T4q9jYXsih^U+*HNgDUKHwlorL4xw*;W zihGmaG07!&FQxev_a&agX|hx)nN{4SWJMO`HK$I>tTSSMBCjx>C@ieFPvz%N|9$4< zF?odr#lii<)?ayu5xDa}o!2vl*O{>{K&3SKd^tZm|=LE{qfoJ0wC zvV=(U*+)g)7kZDQDke(3Gff4OzHB{o$q>pJ)CtWyi-ByyAG)#g>pEU$Dyqe^|p zB~lgVwd+pqTtzKPInZ4GUr9+a_D^q%Q}I;(oPzxP((*0Qmmz5SDEuUFZwfX-5k#Oi_ delta 11005 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 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 0977f0bba07f62655269ab9475899384d2607251..5aa5b9fbe075e515c1fbbcbeb4f5f416330119a6 100644 GIT binary patch delta 12252 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 IOj#NAZ<;0uh5!Hn delta 11004 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 ArvLx| 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 %}