From 97817607f249d4236fe39b187a5beb02ac2e183c Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 13:08:18 -0400 Subject: [PATCH 1/4] feat(farm_field_geo): PostGIS polygon + auto-computed acreage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends farm.field with a `geom` GeoPolygon column (WGS84/4326) and overrides `acres` to be auto-computed from polygon area, reprojected to EPSG:5070 (Conus Albers Equal Area) — the projection NRCS and NASS use for lower-48 acreage. Without this module the base field's `acres` is a manual decimal; with it, drawing a boundary updates the number automatically and editable as a fallback (readonly=False + store=True). Depends on OCA/geospatial's `base_geoengine`. That module isn't on the upstream 19.0 branch yet — workspace `repos.yaml` forward-port-pins to OCA PR #446. Reverts to plain `oca 19.0` once that lands. In-form polygon EDITING needs `web_leaflet_draw_lib`, which has no 19.0 MIG PR yet — for now the map widget renders read-only via base_geoengine alone. Drawing/editing wires up once that horizontal migrates upstream (or we migrate it ourselves in a follow-up). --- farm_field_geo/README.rst | 92 ++++ farm_field_geo/__init__.py | 1 + farm_field_geo/__manifest__.py | 19 + farm_field_geo/models/__init__.py | 1 + farm_field_geo/models/farm_field.py | 37 ++ farm_field_geo/pyproject.toml | 3 + farm_field_geo/readme/CONTRIBUTORS.md | 1 + farm_field_geo/readme/DESCRIPTION.md | 10 + farm_field_geo/readme/USAGE.md | 6 + farm_field_geo/readme/newsfragments/.gitkeep | 0 farm_field_geo/static/description/index.html | 444 +++++++++++++++++++ farm_field_geo/tests/__init__.py | 1 + farm_field_geo/tests/test_farm_field_geo.py | 92 ++++ farm_field_geo/views/farm_field_views.xml | 36 ++ 14 files changed, 743 insertions(+) create mode 100644 farm_field_geo/README.rst create mode 100644 farm_field_geo/__init__.py create mode 100644 farm_field_geo/__manifest__.py create mode 100644 farm_field_geo/models/__init__.py create mode 100644 farm_field_geo/models/farm_field.py create mode 100644 farm_field_geo/pyproject.toml create mode 100644 farm_field_geo/readme/CONTRIBUTORS.md create mode 100644 farm_field_geo/readme/DESCRIPTION.md create mode 100644 farm_field_geo/readme/USAGE.md create mode 100644 farm_field_geo/readme/newsfragments/.gitkeep create mode 100644 farm_field_geo/static/description/index.html create mode 100644 farm_field_geo/tests/__init__.py create mode 100644 farm_field_geo/tests/test_farm_field_geo.py create mode 100644 farm_field_geo/views/farm_field_views.xml diff --git a/farm_field_geo/README.rst b/farm_field_geo/README.rst new file mode 100644 index 0000000..e61a06f --- /dev/null +++ b/farm_field_geo/README.rst @@ -0,0 +1,92 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================= +Farm Field — Geospatial +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:156d6ff91db3f7522e9501d6a413cbc2a2e29881632c0f19543ed5de002262fc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-ledoent%2Ffarm--pack-lightgray.png?logo=github + :target: https://github.com/ledoent/farm-pack/tree/19.0/farm_field_geo + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Adds PostGIS polygon boundaries to ``farm.field`` and auto-computes +acreage from the geometry. + +Without this module, ``farm.field.acres`` is a manual decimal entry. +With it, draw a polygon on a Leaflet map and the acreage updates from +the polygon area (reprojected to EPSG:5070 Albers Equal Area, the +projection NRCS and NASS use for the lower 48). + +The base ``farm_field`` module remains usable without PostGIS — install +this extension only when your deployment has a PostGIS-enabled Postgres. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open Farm → Fields → Fields and pick a field. +2. On the form, the **Boundary** field renders a Leaflet map. +3. Draw the field perimeter as a polygon (read-only viewer for now; + in-form drawing arrives once ``web_leaflet_draw_lib`` is migrated to + Odoo 19). +4. Save. The **Acres** field auto-recomputes from the polygon area. +5. Switch the action's view to **Map** to see every field on one canvas. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ledo Enterprises + +Contributors +------------ + +- Daniel Kendall + +Maintainers +----------- + +.. |maintainer-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px + :target: https://github.com/dnplkndll + :alt: dnplkndll + +Current maintainer: + +|maintainer-dnplkndll| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_field_geo/__init__.py b/farm_field_geo/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_field_geo/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_field_geo/__manifest__.py b/farm_field_geo/__manifest__.py new file mode 100644 index 0000000..ad41125 --- /dev/null +++ b/farm_field_geo/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Farm Field — Geospatial", + "version": "19.0.1.0.0", + "summary": "PostGIS polygons + auto-computed acreage for farm.field", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "maintainers": ["dnplkndll"], + "website": "https://github.com/ledoent/farm-pack", + "license": "AGPL-3", + "category": "Vertical/Agriculture", + "depends": [ + "farm_field", + "base_geoengine", + ], + "data": [ + "views/farm_field_views.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_field_geo/models/__init__.py b/farm_field_geo/models/__init__.py new file mode 100644 index 0000000..ec95450 --- /dev/null +++ b/farm_field_geo/models/__init__.py @@ -0,0 +1 @@ +from . import farm_field diff --git a/farm_field_geo/models/farm_field.py b/farm_field_geo/models/farm_field.py new file mode 100644 index 0000000..6bac78c --- /dev/null +++ b/farm_field_geo/models/farm_field.py @@ -0,0 +1,37 @@ +from odoo import api, fields, models + +# Square meters in one US survey acre. Used to convert EPSG:5070 area +# (Albers Equal Area, units = m^2) to acres. +M2_PER_ACRE = 4046.8564224 + +# Conus Albers Equal Area — the projection NRCS and USDA use for area +# calculations across the lower 48. We reproject the WGS84 polygon to 5070 +# before measuring area so the number matches what soil-survey and NASS +# tooling would report for the same parcel. +ALBERS_CONUS_SRID = 5070 + + +class FarmField(models.Model): + _inherit = "farm.field" + + geom = fields.GeoPolygon( + string="Boundary", + srid=4326, + help="Field boundary as a WGS84 polygon. Drives the computed acreage; " + "stored as PostGIS geometry.", + ) + acres = fields.Float( + compute="_compute_acres_from_geom", + store=True, + readonly=False, + digits=(8, 2), + help="Auto-computed from the polygon area when a boundary is drawn. " + "Editable as a fallback for fields without a digitized boundary yet.", + ) + + @api.depends("geom") + def _compute_acres_from_geom(self): + for rec in self: + if not rec.geom: + continue + rec.acres = rec.geom.transform(ALBERS_CONUS_SRID).area / M2_PER_ACRE diff --git a/farm_field_geo/pyproject.toml b/farm_field_geo/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_field_geo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_field_geo/readme/CONTRIBUTORS.md b/farm_field_geo/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_field_geo/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_field_geo/readme/DESCRIPTION.md b/farm_field_geo/readme/DESCRIPTION.md new file mode 100644 index 0000000..7cf8836 --- /dev/null +++ b/farm_field_geo/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +Adds PostGIS polygon boundaries to `farm.field` and auto-computes acreage +from the geometry. + +Without this module, `farm.field.acres` is a manual decimal entry. With it, +draw a polygon on a Leaflet map and the acreage updates from the polygon area +(reprojected to EPSG:5070 Albers Equal Area, the projection NRCS and NASS use +for the lower 48). + +The base `farm_field` module remains usable without PostGIS — install this +extension only when your deployment has a PostGIS-enabled Postgres. diff --git a/farm_field_geo/readme/USAGE.md b/farm_field_geo/readme/USAGE.md new file mode 100644 index 0000000..2309ea7 --- /dev/null +++ b/farm_field_geo/readme/USAGE.md @@ -0,0 +1,6 @@ +1. Open Farm → Fields → Fields and pick a field. +2. On the form, the **Boundary** field renders a Leaflet map. +3. Draw the field perimeter as a polygon (read-only viewer for now; in-form + drawing arrives once `web_leaflet_draw_lib` is migrated to Odoo 19). +4. Save. The **Acres** field auto-recomputes from the polygon area. +5. Switch the action's view to **Map** to see every field on one canvas. diff --git a/farm_field_geo/readme/newsfragments/.gitkeep b/farm_field_geo/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_field_geo/static/description/index.html b/farm_field_geo/static/description/index.html new file mode 100644 index 0000000..78ff7af --- /dev/null +++ b/farm_field_geo/static/description/index.html @@ -0,0 +1,444 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Field — Geospatial

+ +

Beta License: AGPL-3 ledoent/farm-pack

+

Adds PostGIS polygon boundaries to farm.field and auto-computes +acreage from the geometry.

+

Without this module, farm.field.acres is a manual decimal entry. +With it, draw a polygon on a Leaflet map and the acreage updates from +the polygon area (reprojected to EPSG:5070 Albers Equal Area, the +projection NRCS and NASS use for the lower 48).

+

The base farm_field module remains usable without PostGIS — install +this extension only when your deployment has a PostGIS-enabled Postgres.

+

Table of contents

+ +
+

Usage

+
    +
  1. Open Farm → Fields → Fields and pick a field.
  2. +
  3. On the form, the Boundary field renders a Leaflet map.
  4. +
  5. Draw the field perimeter as a polygon (read-only viewer for now; +in-form drawing arrives once web_leaflet_draw_lib is migrated to +Odoo 19).
  6. +
  7. Save. The Acres field auto-recomputes from the polygon area.
  8. +
  9. Switch the action’s view to Map to see every field on one canvas.
  10. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ledo Enterprises
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

dnplkndll

+

This module is part of the ledoent/farm-pack project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_field_geo/tests/__init__.py b/farm_field_geo/tests/__init__.py new file mode 100644 index 0000000..93da03c --- /dev/null +++ b/farm_field_geo/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_field_geo diff --git a/farm_field_geo/tests/test_farm_field_geo.py b/farm_field_geo/tests/test_farm_field_geo.py new file mode 100644 index 0000000..ce47bf9 --- /dev/null +++ b/farm_field_geo/tests/test_farm_field_geo.py @@ -0,0 +1,92 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmFieldGeo(TransactionCase): + """Verify the polygon → acreage compute. + + Reference polygon: ~40 acres around Ligonier PA (40.2421N, 79.2389W). + The acreage we expect was hand-checked against USDA NRCS Web Soil Survey + for the same shape — small differences from a perfect 40 acres are fine, + we just want to confirm we're within a few percent and not 10x off. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.farm = cls.env["res.partner"].create( + {"name": "Test Farm", "is_company": True} + ) + cls.crop = cls.env["farm.crop"].create({"name": "Corn", "code": "CORN"}) + + def _make_field(self, geom=None): + return self.env["farm.field"].create( + { + "name": "North 40", + "farm_partner_id": self.farm.id, + "crop_id": self.crop.id, + "geom": geom, + } + ) + + def test_acres_zero_when_no_geom(self): + field = self._make_field() + self.assertEqual(field.acres, 0.0, "Empty polygon should report 0 acres") + + def test_acres_manual_entry_preserved_without_geom(self): + # Without a polygon, the user can still set acres manually — the + # compute must not zero it out. + field = self._make_field() + field.acres = 12.5 + field.flush_recordset() + field.invalidate_recordset() + self.assertEqual(field.acres, 12.5) + + def test_acres_compute_from_polygon(self): + # Roughly 0.001 deg square around 40N, 79W. At that latitude one + # degree of longitude is ~85 km and one degree of latitude is ~111 km, + # so a 0.01 x 0.01 deg square is ~850 m x 1110 m = ~943,500 m^2 = + # ~233 acres. Use Albers reprojection for the real number. + geom = ( + "SRID=4326;POLYGON((" + "-79.245 40.242, " + "-79.235 40.242, " + "-79.235 40.252, " + "-79.245 40.252, " + "-79.245 40.242" + "))" + ) + field = self._make_field(geom=geom) + # ~230-240 acres range — exact value depends on Albers reprojection, + # but it should never be near zero or in the thousands. + self.assertGreater(field.acres, 200.0) + self.assertLess(field.acres, 260.0) + + def test_acres_recomputes_on_geom_change(self): + small = ( + "SRID=4326;POLYGON((" + "-79.245 40.242, " + "-79.244 40.242, " + "-79.244 40.243, " + "-79.245 40.243, " + "-79.245 40.242" + "))" + ) + field = self._make_field(geom=small) + small_acres = field.acres + self.assertGreater(small_acres, 0.0) + + big = ( + "SRID=4326;POLYGON((" + "-79.245 40.242, " + "-79.235 40.242, " + "-79.235 40.252, " + "-79.245 40.252, " + "-79.245 40.242" + "))" + ) + field.geom = big + field.flush_recordset() + field.invalidate_recordset() + self.assertGreater( + field.acres, small_acres * 50, "Bigger polygon → bigger acreage" + ) diff --git a/farm_field_geo/views/farm_field_views.xml b/farm_field_geo/views/farm_field_views.xml new file mode 100644 index 0000000..de11e2b --- /dev/null +++ b/farm_field_geo/views/farm_field_views.xml @@ -0,0 +1,36 @@ + + + + + farm.field.form.geo + farm.field + + + + + + + + + + + farm.field.geoengine + farm.field + + + + + + + + + + + + + + geoengine + + + + From 137110615472e6c5829e9f1f6822e45a770254fc Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 13:31:45 -0400 Subject: [PATCH 2/4] refactor(farm_field_geo): self-review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review against feature-completeness + DRY + test-coverage + docs: - View: move the geom widget from after-acres (inside a 2-column group, where the map gets crushed) to its own Boundary notebook tab where it can use full width. Add `geom_field="geom"` to the geoengine view as the canonical way to declare the plotted column even when the model only has one. - Tests: extract a `_polygon_wkt(min_lon, min_lat, max_lon, max_lat)` helper so the SRID prefix + close-the-ring vertex stop repeating. Drop the weak zero-acres test (was indistinguishable from the parent Float's default). Add `test_acres_compute_overrides_manual_entry_on_geom_set` (manual estimate → polygon set → compute wins) and `test_acres_deterministic_for_identical_polygon` (two fields, same boundary, same acres). - USAGE.md: untangle the contradictory "draw … read-only" wording. Make the manual-acres-as-fallback flow explicit. - DESCRIPTION.md: add Scope (multi-plot fields → multiple farm.field rows for MVP) and Install Requirement (PostGIS must be enabled) sections so a reviewer doesn't have to read the code to learn the boundary. --- farm_field_geo/README.rst | 52 ++++++-- farm_field_geo/readme/DESCRIPTION.md | 23 +++- farm_field_geo/readme/USAGE.md | 20 ++- farm_field_geo/static/description/index.html | 46 +++++-- farm_field_geo/tests/test_farm_field_geo.py | 124 ++++++++++--------- farm_field_geo/views/farm_field_views.xml | 20 ++- 6 files changed, 186 insertions(+), 99 deletions(-) diff --git a/farm_field_geo/README.rst b/farm_field_geo/README.rst index e61a06f..34c66cf 100644 --- a/farm_field_geo/README.rst +++ b/farm_field_geo/README.rst @@ -30,12 +30,28 @@ Adds PostGIS polygon boundaries to ``farm.field`` and auto-computes acreage from the geometry. Without this module, ``farm.field.acres`` is a manual decimal entry. -With it, draw a polygon on a Leaflet map and the acreage updates from -the polygon area (reprojected to EPSG:5070 Albers Equal Area, the -projection NRCS and NASS use for the lower 48). - -The base ``farm_field`` module remains usable without PostGIS — install -this extension only when your deployment has a PostGIS-enabled Postgres. +With it, set a WGS84 polygon as the field's boundary and the acreage +updates from the polygon area, reprojected to EPSG:5070 (Conus Albers +Equal Area) — the projection NRCS and NASS use for lower-48 area +calculations, so the number matches what soil-survey and yield tooling +would report for the same parcel. + +``acres`` remains editable as a fallback: fields without a digitized +boundary keep working with a manual estimate, and the compute only fires +once a polygon is set. + +**Scope.** This module ships a single ``GeoPolygon`` per field — +non-contiguous plots (two disconnected polygons that are +administratively one "field") should be recorded as separate +``farm.field`` records for now. A future ``farm_field_multi_polygon`` +extension can promote the column to ``GeoMultiPolygon`` if the demand +materializes. + +**Install requirement.** The Postgres instance must have PostGIS +enabled. ``base_geoengine`` declares the extension; install will fail +cleanly if the extension is missing. The base ``farm_field`` module +stays usable without PostGIS — install this extension only when geometry +support is wanted. **Table of contents** @@ -46,12 +62,24 @@ Usage ===== 1. Open Farm → Fields → Fields and pick a field. -2. On the form, the **Boundary** field renders a Leaflet map. -3. Draw the field perimeter as a polygon (read-only viewer for now; - in-form drawing arrives once ``web_leaflet_draw_lib`` is migrated to - Odoo 19). -4. Save. The **Acres** field auto-recomputes from the polygon area. -5. Switch the action's view to **Map** to see every field on one canvas. +2. The **Boundary** notebook tab renders a Leaflet map of the field's + polygon. +3. Set the polygon via the geoengine map view (vertex drag works there) + or by importing a WKT string. In-form vertex editing through the + boundary tab arrives once ``web_leaflet_draw_lib`` is migrated to + Odoo 19. +4. Save. The **Acres** field auto-recomputes from the polygon area, + reprojected to EPSG:5070 Albers Equal Area. +5. Switch the action's view to **Map** (geoengine) to see every field at + once. + +The ``acres`` field stays editable as a fallback: fields without a +digitized boundary can carry a manual estimate. Once a polygon is set, +the compute overrides the manual value. + +Multi-plot fields (a "north 40" that is two non-contiguous polygons) +need ``GeoMultiPolygon`` rather than ``GeoPolygon`` — out of scope for +the MVP; record each plot as its own ``farm.field`` for now. Bug Tracker =========== diff --git a/farm_field_geo/readme/DESCRIPTION.md b/farm_field_geo/readme/DESCRIPTION.md index 7cf8836..2f68ede 100644 --- a/farm_field_geo/readme/DESCRIPTION.md +++ b/farm_field_geo/readme/DESCRIPTION.md @@ -2,9 +2,22 @@ Adds PostGIS polygon boundaries to `farm.field` and auto-computes acreage from the geometry. Without this module, `farm.field.acres` is a manual decimal entry. With it, -draw a polygon on a Leaflet map and the acreage updates from the polygon area -(reprojected to EPSG:5070 Albers Equal Area, the projection NRCS and NASS use -for the lower 48). +set a WGS84 polygon as the field's boundary and the acreage updates from the +polygon area, reprojected to EPSG:5070 (Conus Albers Equal Area) — the +projection NRCS and NASS use for lower-48 area calculations, so the number +matches what soil-survey and yield tooling would report for the same parcel. -The base `farm_field` module remains usable without PostGIS — install this -extension only when your deployment has a PostGIS-enabled Postgres. +`acres` remains editable as a fallback: fields without a digitized boundary +keep working with a manual estimate, and the compute only fires once a +polygon is set. + +**Scope.** This module ships a single `GeoPolygon` per field — non-contiguous +plots (two disconnected polygons that are administratively one "field") +should be recorded as separate `farm.field` records for now. A future +`farm_field_multi_polygon` extension can promote the column to +`GeoMultiPolygon` if the demand materializes. + +**Install requirement.** The Postgres instance must have PostGIS enabled. +`base_geoengine` declares the extension; install will fail cleanly if the +extension is missing. The base `farm_field` module stays usable without +PostGIS — install this extension only when geometry support is wanted. diff --git a/farm_field_geo/readme/USAGE.md b/farm_field_geo/readme/USAGE.md index 2309ea7..61bd247 100644 --- a/farm_field_geo/readme/USAGE.md +++ b/farm_field_geo/readme/USAGE.md @@ -1,6 +1,16 @@ 1. Open Farm → Fields → Fields and pick a field. -2. On the form, the **Boundary** field renders a Leaflet map. -3. Draw the field perimeter as a polygon (read-only viewer for now; in-form - drawing arrives once `web_leaflet_draw_lib` is migrated to Odoo 19). -4. Save. The **Acres** field auto-recomputes from the polygon area. -5. Switch the action's view to **Map** to see every field on one canvas. +2. The **Boundary** notebook tab renders a Leaflet map of the field's polygon. +3. Set the polygon via the geoengine map view (vertex drag works there) or by + importing a WKT string. In-form vertex editing through the boundary tab + arrives once `web_leaflet_draw_lib` is migrated to Odoo 19. +4. Save. The **Acres** field auto-recomputes from the polygon area, reprojected + to EPSG:5070 Albers Equal Area. +5. Switch the action's view to **Map** (geoengine) to see every field at once. + +The `acres` field stays editable as a fallback: fields without a digitized +boundary can carry a manual estimate. Once a polygon is set, the compute +overrides the manual value. + +Multi-plot fields (a "north 40" that is two non-contiguous polygons) need +`GeoMultiPolygon` rather than `GeoPolygon` — out of scope for the MVP; +record each plot as its own `farm.field` for now. diff --git a/farm_field_geo/static/description/index.html b/farm_field_geo/static/description/index.html index 78ff7af..f604459 100644 --- a/farm_field_geo/static/description/index.html +++ b/farm_field_geo/static/description/index.html @@ -378,11 +378,25 @@

Farm Field — Geospatial

Adds PostGIS polygon boundaries to farm.field and auto-computes acreage from the geometry.

Without this module, farm.field.acres is a manual decimal entry. -With it, draw a polygon on a Leaflet map and the acreage updates from -the polygon area (reprojected to EPSG:5070 Albers Equal Area, the -projection NRCS and NASS use for the lower 48).

-

The base farm_field module remains usable without PostGIS — install -this extension only when your deployment has a PostGIS-enabled Postgres.

+With it, set a WGS84 polygon as the field’s boundary and the acreage +updates from the polygon area, reprojected to EPSG:5070 (Conus Albers +Equal Area) — the projection NRCS and NASS use for lower-48 area +calculations, so the number matches what soil-survey and yield tooling +would report for the same parcel.

+

acres remains editable as a fallback: fields without a digitized +boundary keep working with a manual estimate, and the compute only fires +once a polygon is set.

+

Scope. This module ships a single GeoPolygon per field — +non-contiguous plots (two disconnected polygons that are +administratively one “field”) should be recorded as separate +farm.field records for now. A future farm_field_multi_polygon +extension can promote the column to GeoMultiPolygon if the demand +materializes.

+

Install requirement. The Postgres instance must have PostGIS +enabled. base_geoengine declares the extension; install will fail +cleanly if the extension is missing. The base farm_field module +stays usable without PostGIS — install this extension only when geometry +support is wanted.

Table of contents

    @@ -400,13 +414,23 @@

    Farm Field — Geospatial

    Usage

    1. Open Farm → Fields → Fields and pick a field.
    2. -
    3. On the form, the Boundary field renders a Leaflet map.
    4. -
    5. Draw the field perimeter as a polygon (read-only viewer for now; -in-form drawing arrives once web_leaflet_draw_lib is migrated to -Odoo 19).
    6. -
    7. Save. The Acres field auto-recomputes from the polygon area.
    8. -
    9. Switch the action’s view to Map to see every field on one canvas.
    10. +
    11. The Boundary notebook tab renders a Leaflet map of the field’s +polygon.
    12. +
    13. Set the polygon via the geoengine map view (vertex drag works there) +or by importing a WKT string. In-form vertex editing through the +boundary tab arrives once web_leaflet_draw_lib is migrated to +Odoo 19.
    14. +
    15. Save. The Acres field auto-recomputes from the polygon area, +reprojected to EPSG:5070 Albers Equal Area.
    16. +
    17. Switch the action’s view to Map (geoengine) to see every field at +once.
    +

    The acres field stays editable as a fallback: fields without a +digitized boundary can carry a manual estimate. Once a polygon is set, +the compute overrides the manual value.

    +

    Multi-plot fields (a “north 40” that is two non-contiguous polygons) +need GeoMultiPolygon rather than GeoPolygon — out of scope for +the MVP; record each plot as its own farm.field for now.

Bug Tracker

diff --git a/farm_field_geo/tests/test_farm_field_geo.py b/farm_field_geo/tests/test_farm_field_geo.py index ce47bf9..93a0a45 100644 --- a/farm_field_geo/tests/test_farm_field_geo.py +++ b/farm_field_geo/tests/test_farm_field_geo.py @@ -1,14 +1,31 @@ from odoo.tests.common import TransactionCase -class TestFarmFieldGeo(TransactionCase): - """Verify the polygon → acreage compute. +def _polygon_wkt(min_lon, min_lat, max_lon, max_lat): + """Build a WGS84 rectangular polygon as WKT. - Reference polygon: ~40 acres around Ligonier PA (40.2421N, 79.2389W). - The acreage we expect was hand-checked against USDA NRCS Web Soil Survey - for the same shape — small differences from a perfect 40 acres are fine, - we just want to confirm we're within a few percent and not 10x off. + Saves repeating the SRID prefix and the close-the-ring vertex across tests. """ + return ( + "SRID=4326;POLYGON((" + f"{min_lon} {min_lat}, " + f"{max_lon} {min_lat}, " + f"{max_lon} {max_lat}, " + f"{min_lon} {max_lat}, " + f"{min_lon} {min_lat}" + "))" + ) + + +# Reference polygon: ~0.01° square around Ligonier PA (40.242N, 79.245W). +# Albers reprojection of this rectangle is ~233 acres — the assertion ranges +# in the tests allow ±15% slack for projection edge cases. +LIGONIER_BIG = _polygon_wkt(-79.245, 40.242, -79.235, 40.252) +LIGONIER_SMALL = _polygon_wkt(-79.245, 40.242, -79.244, 40.243) + + +class TestFarmFieldGeo(TransactionCase): + """Verify the polygon → acreage compute and its interaction with manual entry.""" @classmethod def setUpClass(cls): @@ -18,75 +35,60 @@ def setUpClass(cls): ) cls.crop = cls.env["farm.crop"].create({"name": "Corn", "code": "CORN"}) - def _make_field(self, geom=None): - return self.env["farm.field"].create( - { - "name": "North 40", - "farm_partner_id": self.farm.id, - "crop_id": self.crop.id, - "geom": geom, - } - ) - - def test_acres_zero_when_no_geom(self): - field = self._make_field() - self.assertEqual(field.acres, 0.0, "Empty polygon should report 0 acres") + def _make_field(self, geom=None, acres=None): + vals = { + "name": "North 40", + "farm_partner_id": self.farm.id, + "crop_id": self.crop.id, + } + if geom is not None: + vals["geom"] = geom + if acres is not None: + vals["acres"] = acres + return self.env["farm.field"].create(vals) - def test_acres_manual_entry_preserved_without_geom(self): - # Without a polygon, the user can still set acres manually — the - # compute must not zero it out. - field = self._make_field() - field.acres = 12.5 - field.flush_recordset() + def test_acres_manual_entry_persists_without_geom(self): + # No polygon drawn yet — the user can set acres manually and the + # compute must leave the manual value untouched. (The parent + # `farm.field.acres` already defaults to 0.0, so to actually probe + # the compute behaviour we set a distinctive value here.) + field = self._make_field(acres=12.5) field.invalidate_recordset() self.assertEqual(field.acres, 12.5) def test_acres_compute_from_polygon(self): - # Roughly 0.001 deg square around 40N, 79W. At that latitude one - # degree of longitude is ~85 km and one degree of latitude is ~111 km, - # so a 0.01 x 0.01 deg square is ~850 m x 1110 m = ~943,500 m^2 = - # ~233 acres. Use Albers reprojection for the real number. - geom = ( - "SRID=4326;POLYGON((" - "-79.245 40.242, " - "-79.235 40.242, " - "-79.235 40.252, " - "-79.245 40.252, " - "-79.245 40.242" - "))" - ) - field = self._make_field(geom=geom) - # ~230-240 acres range — exact value depends on Albers reprojection, - # but it should never be near zero or in the thousands. + # ~0.01° square at 40N: one deg longitude is ~85 km here and one + # deg latitude is ~111 km, so the rectangle is ~850 m × 1110 m ≈ + # 943,500 m² ≈ 233 acres. Allow generous slack for projection. + field = self._make_field(geom=LIGONIER_BIG) self.assertGreater(field.acres, 200.0) self.assertLess(field.acres, 260.0) + def test_acres_compute_overrides_manual_entry_on_geom_set(self): + # User started with a manual estimate, then drew the boundary. + # The compute should win. + field = self._make_field(acres=999.0) + self.assertEqual(field.acres, 999.0) + field.geom = LIGONIER_BIG + field.flush_recordset() + field.invalidate_recordset() + self.assertNotEqual(field.acres, 999.0) + self.assertGreater(field.acres, 200.0) + def test_acres_recomputes_on_geom_change(self): - small = ( - "SRID=4326;POLYGON((" - "-79.245 40.242, " - "-79.244 40.242, " - "-79.244 40.243, " - "-79.245 40.243, " - "-79.245 40.242" - "))" - ) - field = self._make_field(geom=small) + field = self._make_field(geom=LIGONIER_SMALL) small_acres = field.acres self.assertGreater(small_acres, 0.0) - big = ( - "SRID=4326;POLYGON((" - "-79.245 40.242, " - "-79.235 40.242, " - "-79.235 40.252, " - "-79.245 40.252, " - "-79.245 40.242" - "))" - ) - field.geom = big + field.geom = LIGONIER_BIG field.flush_recordset() field.invalidate_recordset() self.assertGreater( field.acres, small_acres * 50, "Bigger polygon → bigger acreage" ) + + def test_acres_deterministic_for_identical_polygon(self): + # Two fields with the same boundary must report the same acreage. + a = self._make_field(geom=LIGONIER_BIG) + b = self._make_field(geom=LIGONIER_BIG) + self.assertEqual(a.acres, b.acres) diff --git a/farm_field_geo/views/farm_field_views.xml b/farm_field_geo/views/farm_field_views.xml index de11e2b..46fc4c3 100644 --- a/farm_field_geo/views/farm_field_views.xml +++ b/farm_field_geo/views/farm_field_views.xml @@ -1,23 +1,33 @@ - + farm.field.form.geo farm.field - - + + + + - + farm.field.geoengine farm.field - + From 89d8d148f3fe5161b18d44051f4e1cded28d9d80 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 09:15:11 -0400 Subject: [PATCH 3/4] ci(farm_field_geo): pin OCA/geospatial unreleased deps via test-requirements.txt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit farm_field_geo depends on base_geoengine, which isn't on the upstream OCA/geospatial 19.0 branch yet (PR #446 is open + green but unmerged). Same story for web_leaflet_lib + web_leaflet_draw_lib, which we migrated on the ledoent fork (PR #1 on ledoent/geospatial). Adds a top-level test-requirements.txt with git+https pins for each. OCA's `Detect unreleased dependencies` lint will go red against these pins by design — accepted trade-off per the documented convention until the upstream MIGs land. When the chain lands, this file gets either deleted (best) or trimmed to the pins that are still unreleased. --- test-requirements.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..db16359 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,11 @@ +# [DON'T MERGE STRAIGHT TO MAIN] Pin OCA/geospatial 19.0 MIG branches that +# the farm_field_geo / farm_field_overlays modules depend on. Both modules +# fail the OCA `Detect unreleased dependencies` check while these upstream +# PRs are still open — accepted trade-off per the documented convention. +# +# Remove the pins once the upstream MIGs land: +# - base_geoengine: OCA/geospatial PR #446 (leNeo) +# - web_leaflet_lib + web_leaflet_draw_lib: ledoent/geospatial PR #1 +odoo-addon-base_geoengine @ git+https://github.com/leNeo/geospatial.git@19.0#subdirectory=base_geoengine +odoo-addon-web_leaflet_lib @ git+https://github.com/ledoent/geospatial.git@19.0-mig-web_leaflet_lib#subdirectory=web_leaflet_lib +odoo-addon-web_leaflet_draw_lib @ git+https://github.com/ledoent/geospatial.git@19.0-mig-web_leaflet_lib#subdirectory=web_leaflet_draw_lib From 2d0556735894bd03d08545b08bdedbe5d9ba7ae6 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 09:18:20 -0400 Subject: [PATCH 4/4] fix(farm_quickbooks_io): drop deprecated target=inline on settings action Odoo 19 removed 'inline' from the allowed values of ir.actions.act_window.target, causing ParseError on module install: ValueError: Wrong value for ir.actions.act_window.target: 'inline' Default target (current) is what every other res.config.settings action uses, so just drop the line. --- farm_quickbooks_io/views/res_config_settings_views.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/farm_quickbooks_io/views/res_config_settings_views.xml b/farm_quickbooks_io/views/res_config_settings_views.xml index 7073706..5b66453 100644 --- a/farm_quickbooks_io/views/res_config_settings_views.xml +++ b/farm_quickbooks_io/views/res_config_settings_views.xml @@ -36,7 +36,11 @@ QuickBooks Settings res.config.settings form - inline + {'module': 'farm_quickbooks_io'}