diff --git a/landa/water_body_management/doctype/stocking_measure/stocking_measure.js b/landa/water_body_management/doctype/stocking_measure/stocking_measure.js index 84a248d5..0a21f825 100644 --- a/landa/water_body_management/doctype/stocking_measure/stocking_measure.js +++ b/landa/water_body_management/doctype/stocking_measure/stocking_measure.js @@ -6,3 +6,16 @@ landa.water_body_management.StockingMeasure = class StockingMeasure extends ( ) {}; cur_frm.script_manager.make(landa.water_body_management.StockingMeasure); + +frappe.ui.form.on("Stocking Measure", { + setup(frm) { + frm.set_query("stocking_site", (doc, cdt, cdn) => { + return { + filters: [ + ["Stocking Site", "water_body", "=", doc.water_body], + ["Fish Species Table", "fish_species", "=", doc.fish_species], + ] + }; + }); + }, +}); \ No newline at end of file diff --git a/landa/water_body_management/doctype/stocking_measure/stocking_measure.json b/landa/water_body_management/doctype/stocking_measure/stocking_measure.json index 947df4c7..8daba9db 100644 --- a/landa/water_body_management/doctype/stocking_measure/stocking_measure.json +++ b/landa/water_body_management/doctype/stocking_measure/stocking_measure.json @@ -28,6 +28,7 @@ "weight", "weight_per_water_body_size", "unit_of_weight_per_water_body_size", + "stocking_site", "data_14", "quantity", "quantity_per_water_body_size", @@ -238,16 +239,23 @@ "fieldtype": "Data", "label": "Water Body Title", "read_only": 1 + }, + { + "fieldname": "stocking_site", + "fieldtype": "Link", + "label": "Stocking Site", + "options": "Stocking Site", + "search_index": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-12-04 18:55:19.951039", + "modified": "2026-07-11 17:54:40.151120", "modified_by": "Administrator", "module": "Water Body Management", "name": "Stocking Measure", - "naming_rule": "Expression", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { diff --git a/landa/water_body_management/doctype/stocking_measure/stocking_measure.py b/landa/water_body_management/doctype/stocking_measure/stocking_measure.py index 8648f306..09e0651f 100644 --- a/landa/water_body_management/doctype/stocking_measure/stocking_measure.py +++ b/landa/water_body_management/doctype/stocking_measure/stocking_measure.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import frappe +from frappe import _ from landa.water_body_management.stocking_controller import StockingController @@ -26,6 +27,7 @@ class StockingMeasure(StockingController): quantity: DF.Float quantity_per_water_body_size: DF.Float status: DF.Literal["In Progress", "Completed"] + stocking_site: DF.Link | None stocking_target: DF.Link | None supplier: DF.Link | None unit_of_quantity_per_water_body_size: DF.Data | None @@ -39,6 +41,27 @@ class StockingMeasure(StockingController): year: DF.Int # end: auto-generated types + def validate(self): + super().validate() + self.validate_stocking_site() + + def validate_stocking_site(self): + if not self.stocking_site: + return + + if frappe.db.get_value("Stocking Site", self.stocking_site, "water_body") != self.water_body: + frappe.throw(_("Stocking Site must belong to the selected Water Body.")) + + if not frappe.db.exists( + "Fish Species Table", + { + "parent": self.stocking_site, + "parenttype": "Stocking Site", + "fish_species": self.fish_species, + }, + ): + frappe.throw(_("Stocking Site does not support the selected Fish Species.")) + def on_change(self): self.update_stocking_target() diff --git a/landa/water_body_management/doctype/stocking_site/__init__.py b/landa/water_body_management/doctype/stocking_site/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/landa/water_body_management/doctype/stocking_site/stocking_site.js b/landa/water_body_management/doctype/stocking_site/stocking_site.js new file mode 100644 index 00000000..5b7773b3 --- /dev/null +++ b/landa/water_body_management/doctype/stocking_site/stocking_site.js @@ -0,0 +1,180 @@ +// Copyright (c) 2026, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Stocking Site", { + setup(frm) { + const field = frm.fields_dict.location; + field.get_leaflet_controls = () => get_marker_controls(field); + field.bind_leaflet_event_listeners = () => bind_map_events(field); + }, + + refresh(frm) { + frm.trigger("show_water_body"); + }, + + water_body(frm) { + frm.trigger("show_water_body"); + }, + + location(frm) { + frm.trigger("show_water_body"); + }, + + show_water_body(frm) { + const field = frm.fields_dict.location; + if (!field.map) { + return; + } + + remove_water_body_layer(field); + if (!frm.doc.water_body) { + return; + } + + const water_body = frm.doc.water_body; + frappe + .xcall( + "landa.water_body_management.doctype.stocking_site.stocking_site.get_water_body_map_data", + { water_body }, + ) + .then(({ location, margin_meters }) => { + if (frm.doc.water_body !== water_body || !location) { + return; + } + + field.water_body_margin_meters = margin_meters; + field.water_body_geojson = JSON.parse(location); + field.water_body_layer = window.L.geoJSON(field.water_body_geojson, { + filter: ({ geometry }) => ["Polygon", "MultiPolygon"].includes(geometry.type), + style: ({ properties }) => ({ + color: properties.is_restricted_area ? "red" : frappe.ui.color.get("blue"), + fillOpacity: 0.08, + }), + }).addTo(field.map); + + const bounds = field.water_body_layer.getBounds(); + if (!bounds.isValid()) { + return; + } + + const max_bounds = window.L.latLngBounds( + bounds.getSouthWest().toBounds(margin_meters * 2), + ).extend(bounds.getNorthEast().toBounds(margin_meters * 2)); + field.map.invalidateSize(); + field.map.setMaxBounds(max_bounds); + field.map.options.maxBoundsViscosity = 1; + field.map.setMinZoom( + Math.min(field.map.getBoundsZoom(max_bounds), field.map.getMaxZoom()), + ); + field.map.fitBounds(bounds, { padding: [30, 30] }); + }); + }, + + validate(frm) { + get_location_point(frm.doc.location); + }, +}); + +function get_marker_controls(field) { + return new window.L.Control.Draw({ + position: "topleft", + draw: { + polyline: false, + polygon: false, + rectangle: false, + circle: false, + circlemarker: false, + marker: { repeatMode: false }, + }, + edit: { + featureGroup: field.editableLayers, + edit: false, + remove: true, + }, + }); +} + +function bind_map_events(field) { + field.map.on("draw:created", async ({ layer, layerType }) => { + if (layerType !== "marker") { + return; + } + if (field.editableLayers.getLayers().length) { + frappe.msgprint(__("Only one location marker is allowed.")); + return; + } + + const water_body = field.frm.doc.water_body; + if (!water_body) { + frappe.msgprint(__("Select a Water Body before placing a marker.")); + return; + } + + const latlng = layer.getLatLng(); + const is_valid = await frappe.xcall( + "landa.water_body_management.doctype.stocking_site.stocking_site.is_point_near_water_body", + { + water_body, + longitude: latlng.lng, + latitude: latlng.lat, + }, + ); + if (field.frm.doc.water_body !== water_body) { + return; + } + if (!is_valid) { + frappe.msgprint( + __("The location marker must be within {0} m of the Water Body.", [ + field.water_body_margin_meters, + ]), + ); + return; + } + if (field.editableLayers.getLayers().length) { + frappe.msgprint(__("Only one location marker is allowed.")); + return; + } + + field.editableLayers.addLayer(layer); + set_location_value(field); + }); + + field.map.on("draw:deleted", () => set_location_value(field)); +} + +function set_location_value(field) { + field.set_value(JSON.stringify(field.editableLayers.toGeoJSON())); +} + +function remove_water_body_layer(field) { + if (!field.map) { + return; + } + if (field.water_body_layer) { + field.map.removeLayer(field.water_body_layer); + delete field.water_body_layer; + } + delete field.water_body_geojson; + delete field.water_body_margin_meters; + field.map.setMaxBounds(null); + field.map.setMinZoom(0); +} + +function get_location_point(location) { + if (!location) { + return null; + } + + const geojson = JSON.parse(location); + if (geojson.type === "FeatureCollection" && geojson.features.length === 0) { + return null; + } + if ( + geojson.type !== "FeatureCollection" || + geojson.features.length !== 1 || + geojson.features[0].geometry.type !== "Point" + ) { + frappe.throw(__("At most one location marker is allowed.")); + } + return geojson.features[0].geometry.coordinates; +} diff --git a/landa/water_body_management/doctype/stocking_site/stocking_site.json b/landa/water_body_management/doctype/stocking_site/stocking_site.json new file mode 100644 index 00000000..dfc15ac0 --- /dev/null +++ b/landa/water_body_management/doctype/stocking_site/stocking_site.json @@ -0,0 +1,125 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-07-11 17:06:07.098612", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "water_body", + "column_break_tlgo", + "disabled", + "section_break_klnp", + "location", + "section_break_oobz", + "description", + "fish_species" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "water_body", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Water Body", + "options": "Water Body", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "column_break_tlgo", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disabled", + "fieldtype": "Check", + "label": "Disabled" + }, + { + "fieldname": "section_break_klnp", + "fieldtype": "Section Break" + }, + { + "depends_on": "water_body", + "fieldname": "location", + "fieldtype": "Geolocation", + "label": "Location" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + }, + { + "fieldname": "fish_species", + "fieldtype": "Table", + "label": "Fish Species", + "options": "Fish Species Table" + }, + { + "fieldname": "section_break_oobz", + "fieldtype": "Section Break" + } + ], + "grid_page_length": 50, + "links": [], + "modified": "2026-07-11 17:21:24.745985", + "modified_by": "Administrator", + "module": "Water Body Management", + "name": "Stocking Site", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LANDA Regional Water Body Management", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "LANDA Local Water Body Management", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} \ No newline at end of file diff --git a/landa/water_body_management/doctype/stocking_site/stocking_site.py b/landa/water_body_management/doctype/stocking_site/stocking_site.py new file mode 100644 index 00000000..df137844 --- /dev/null +++ b/landa/water_body_management/doctype/stocking_site/stocking_site.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026, ALYF GmbH and contributors +# For license information, please see license.txt + +import json +from collections.abc import Sequence +from math import cos, hypot, pi +from typing import Any, TypeAlias + +import frappe +from frappe.model.document import Document + +Coordinate: TypeAlias = Sequence[float] +Ring: TypeAlias = Sequence[Coordinate] +Polygon: TypeAlias = Sequence[Ring] +GeoJSON: TypeAlias = dict[str, Any] +WATER_BODY_MARGIN_METERS = 500 + + +class StockingSite(Document): + """A potential stocking location associated with a Water Body.""" + + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from landa.water_body_management.doctype.fish_species_table.fish_species_table import FishSpeciesTable + + description: DF.SmallText | None + disabled: DF.Check + fish_species: DF.Table[FishSpeciesTable] + title: DF.Data + water_body: DF.Link + # end: auto-generated types + + def validate(self) -> None: + """Validate that the optional marker lies within the allowed area.""" + point = get_location_point(self.location) + if not point: + return + + water_body_location = frappe.db.get_value("Water Body", self.water_body, "location") + if not water_body_location: + frappe.throw(frappe._("The selected Water Body has no location boundary.")) + + if not point_near_water_body(point, json.loads(water_body_location)): + frappe.throw( + frappe._("The location marker must be within {0} m of the Water Body.").format( + WATER_BODY_MARGIN_METERS + ) + ) + + +@frappe.whitelist() +def get_water_body_map_data(water_body: str) -> dict[str, str | int | None]: + """Return the permitted Water Body geometry and marker margin.""" + return { + "location": get_permitted_water_body_location(water_body), + "margin_meters": WATER_BODY_MARGIN_METERS, + } + + +@frappe.whitelist() +def is_point_near_water_body(water_body: str, longitude: float, latitude: float) -> bool: + """Return whether a point lies within the permitted Water Body area.""" + location = get_permitted_water_body_location(water_body) + return bool( + location + and point_near_water_body( + (float(longitude), float(latitude)), + json.loads(location), + ) + ) + + +def get_permitted_water_body_location(water_body: str) -> str | None: + """Return a Water Body location after checking read permission.""" + water_body_doc = frappe.get_doc("Water Body", water_body) + water_body_doc.check_permission("read") + return water_body_doc.location + + +def get_location_point(location: str | None) -> Coordinate | None: + """Return the sole Point coordinates from a Stocking Site GeoJSON value.""" + if not location: + return None + + try: + geojson = json.loads(location) + features = geojson["features"] + if geojson.get("type") == "FeatureCollection" and not features: + return None + geometry = features[0]["geometry"] + coordinates = geometry["coordinates"] + except (json.JSONDecodeError, TypeError, KeyError, IndexError): + frappe.throw(frappe._("At most one location marker is allowed.")) + + if ( + geojson.get("type") != "FeatureCollection" + or len(features) != 1 + or geometry.get("type") != "Point" + or len(coordinates) < 2 + ): + frappe.throw(frappe._("At most one location marker is allowed.")) + + return coordinates[:2] + + +def point_near_water_body(point: Coordinate, geojson: GeoJSON) -> bool: + """Return whether a point is within the allowed margin of the Water Body.""" + for feature in geojson.get("features", []): + geometry = feature.get("geometry", {}) + if geometry.get("type") == "Polygon" and point_near_polygon(point, geometry["coordinates"]): + return True + if geometry.get("type") == "MultiPolygon" and any( + point_near_polygon(point, polygon) for polygon in geometry["coordinates"] + ): + return True + return False + + +def point_near_polygon(point: Coordinate, rings: Polygon) -> bool: + """Return whether a point is within the allowed margin of a polygon.""" + return point_in_polygon(point, rings) or any( + distance_to_segment(point, coordinate, ring[(index + 1) % len(ring)]) <= WATER_BODY_MARGIN_METERS + for ring in rings + for index, coordinate in enumerate(ring) + ) + + +def point_in_polygon(point: Coordinate, rings: Polygon) -> bool: + """Return whether a point is inside a polygon and outside its holes.""" + return point_in_ring(point, rings[0]) and not any(point_in_ring(point, ring) for ring in rings[1:]) + + +def point_in_ring(point: Coordinate, ring: Ring) -> bool: + """Return whether a point is inside a linear ring using ray casting.""" + x, y = point + inside = False + j = len(ring) - 1 + for i, (xi, yi) in enumerate(ring): + xj, yj = ring[j] + if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi: + inside = not inside + j = i + return inside + + +def distance_to_segment(point: Coordinate, start: Coordinate, end: Coordinate) -> float: + """Return the approximate distance in metres from a point to a segment.""" + earth_radius = 6371000 + latitude = point[1] * pi / 180 + + def project(coordinate: Coordinate) -> tuple[float, float]: + """Project a coordinate to a local Cartesian plane around the point.""" + return ( + earth_radius * (coordinate[0] - point[0]) * pi / 180 * cos(latitude), + earth_radius * (coordinate[1] - point[1]) * pi / 180, + ) + + start_x, start_y = project(start) + end_x, end_y = project(end) + length_squared = (end_x - start_x) ** 2 + (end_y - start_y) ** 2 + ratio = ( + max( + 0, + min( + 1, + (-start_x * (end_x - start_x) - start_y * (end_y - start_y)) / length_squared, + ), + ) + if length_squared + else 0 + ) + return hypot( + start_x + ratio * (end_x - start_x), + start_y + ratio * (end_y - start_y), + ) diff --git a/landa/water_body_management/doctype/stocking_site/test_stocking_site.py b/landa/water_body_management/doctype/stocking_site/test_stocking_site.py new file mode 100644 index 00000000..87b02137 --- /dev/null +++ b/landa/water_body_management/doctype/stocking_site/test_stocking_site.py @@ -0,0 +1,104 @@ +# Copyright (c) 2026, ALYF GmbH and Contributors +# See license.txt + +import json +from math import pi + +import frappe +from frappe.tests.utils import FrappeTestCase + +from landa.water_body_management.doctype.stocking_site.stocking_site import ( + WATER_BODY_MARGIN_METERS, + distance_to_segment, + get_location_point, + point_in_polygon, + point_in_ring, + point_near_polygon, +) + +EARTH_RADIUS_METERS = 6371000 +OUTER_RING = [[-0.01, -0.01], [0.01, -0.01], [0.01, 0.01], [-0.01, 0.01], [-0.01, -0.01]] + + +class TestStockingSite(FrappeTestCase): + def test_point_in_ring(self): + self.assertTrue(point_in_ring([0, 0], OUTER_RING)) + self.assertFalse(point_in_ring([0.02, 0], OUTER_RING)) + + def test_point_inside_polygon_hole(self): + hole = [[-0.001, -0.001], [0.001, -0.001], [0.001, 0.001], [-0.001, 0.001], [-0.001, -0.001]] + + self.assertFalse(point_in_polygon([0, 0], [OUTER_RING, hole])) + self.assertTrue(point_in_polygon([0.005, 0], [OUTER_RING, hole])) + + def test_distance_to_segment(self): + longitude = self.longitude_for_distance(400) + + self.assertAlmostEqual( + distance_to_segment([longitude, 0], [0, -0.01], [0, 0.01]), + 400, + delta=0.01, + ) + + def test_point_within_and_beyond_margin(self): + polygon = [[[0, -0.01], [0, 0.01], [-0.01, 0.01], [-0.01, -0.01], [0, -0.01]]] + + self.assertTrue( + point_near_polygon( + [self.longitude_for_distance(WATER_BODY_MARGIN_METERS - 1), 0], + polygon, + ) + ) + self.assertFalse( + point_near_polygon( + [self.longitude_for_distance(WATER_BODY_MARGIN_METERS + 1), 0], + polygon, + ) + ) + + def test_get_location_point(self): + self.assertIsNone(get_location_point(None)) + self.assertIsNone(get_location_point(json.dumps({"type": "FeatureCollection", "features": []}))) + self.assertEqual( + get_location_point( + json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Point", "coordinates": [12, 51]}, + } + ], + } + ) + ), + [12, 51], + ) + + def test_get_location_point_rejects_malformed_geojson(self): + invalid_locations = [ + "{", + json.dumps({"type": "FeatureCollection", "features": [{}]}), + json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Polygon", "coordinates": [OUTER_RING]}, + } + ], + } + ), + ] + + for location in invalid_locations: + with self.subTest(location=location), self.assertRaises(frappe.ValidationError): + get_location_point(location) + + @staticmethod + def longitude_for_distance(distance: float) -> float: + return distance / EARTH_RADIUS_METERS * 180 / pi diff --git a/landa/water_body_management/doctype/water_body/water_body.json b/landa/water_body_management/doctype/water_body/water_body.json index d9ba50b9..43680f7a 100644 --- a/landa/water_body_management/doctype/water_body/water_body.json +++ b/landa/water_body_management/doctype/water_body/water_body.json @@ -300,6 +300,10 @@ "link_doctype": "Water Body Management Local Organization", "link_fieldname": "water_body" }, + { + "link_doctype": "Stocking Site", + "link_fieldname": "water_body" + }, { "link_doctype": "Lease Contract", "link_fieldname": "water_body" @@ -309,7 +313,7 @@ "link_fieldname": "water_body" } ], - "modified": "2026-07-03 17:16:55.918967", + "modified": "2026-07-11 17:12:18.047485", "modified_by": "Administrator", "module": "Water Body Management", "name": "Water Body",