From dc8515b46b87589070858c3e05ff1ce3feb0ac17 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:51:04 -0400 Subject: [PATCH 01/17] feat(farm_base): add farm.mixin AbstractModel for color/notes/mail.thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates ~12 lines × 8+ farm models. All SMB-tier models and existing farm modules can _inherit = ["farm.mixin"] to pick up the shared bundle in one line. --- farm_base/models/__init__.py | 1 + farm_base/models/farm_mixin.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 farm_base/models/farm_mixin.py diff --git a/farm_base/models/__init__.py b/farm_base/models/__init__.py index 06bc3a5..ceaef8f 100644 --- a/farm_base/models/__init__.py +++ b/farm_base/models/__init__.py @@ -1,3 +1,4 @@ from . import farm_season +from . import farm_mixin from . import farm_measurement_mixin from . import farm_gps_point_mixin diff --git a/farm_base/models/farm_mixin.py b/farm_base/models/farm_mixin.py new file mode 100644 index 0000000..28b3855 --- /dev/null +++ b/farm_base/models/farm_mixin.py @@ -0,0 +1,23 @@ +from odoo import fields, models + + +class FarmMixin(models.AbstractModel): + """Shared field bundle for farm models. + + Pulls in mail.thread + activity, a kanban color, an html notes field, and an + active flag — the things 8+ farm models all want. Inherit alongside any model + that needs them: + + class FarmField(models.Model): + _name = "farm.field" + _inherit = ["farm.mixin"] + ... + """ + + _name = "farm.mixin" + _description = "Farm Common Mixin" + _inherit = ["mail.thread", "mail.activity.mixin"] + + notes = fields.Html() + color = fields.Integer(string="Kanban Color") + active = fields.Boolean(default=True) From eb65779e31c8522dfa5648927c152130db822060 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:51:44 -0400 Subject: [PATCH 02/17] feat(farm_crop): catalog of farm crops with 18 seeded entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit farm.crop is the canonical crop identity referenced by farm.field, farm.planting, and farm.harvest. Ships with 18 common US small-farm crops under noupdate=1 so farmers can edit freely. Fields: - name, latin_name, family (Solanaceae, Brassicaceae, etc. — useful for rotation) - category: vegetable/fruit/grain/legume/herb/cover/forage/flower/other - days_to_maturity (consumed by farm.planting._compute_expected_harvest_date) - image (auto-sized 128px variant for kanban) 5 tests. --- farm_crop/README.rst | 96 +++++ farm_crop/__init__.py | 1 + farm_crop/__manifest__.py | 26 ++ farm_crop/data/farm_crop_data.xml | 149 ++++++++ farm_crop/demo/farm_crop_demo.xml | 12 + farm_crop/models/__init__.py | 1 + farm_crop/models/farm_crop.py | 40 +++ farm_crop/pyproject.toml | 3 + farm_crop/readme/CONTRIBUTORS.md | 1 + farm_crop/readme/DESCRIPTION.md | 6 + farm_crop/readme/USAGE.md | 6 + farm_crop/security/ir.model.access.csv | 3 + farm_crop/static/description/index.html | 447 ++++++++++++++++++++++++ farm_crop/tests/__init__.py | 1 + farm_crop/tests/test_farm_crop.py | 28 ++ farm_crop/views/farm_crop_menu.xml | 10 + farm_crop/views/farm_crop_views.xml | 119 +++++++ 17 files changed, 949 insertions(+) create mode 100644 farm_crop/README.rst create mode 100644 farm_crop/__init__.py create mode 100644 farm_crop/__manifest__.py create mode 100644 farm_crop/data/farm_crop_data.xml create mode 100644 farm_crop/demo/farm_crop_demo.xml create mode 100644 farm_crop/models/__init__.py create mode 100644 farm_crop/models/farm_crop.py create mode 100644 farm_crop/pyproject.toml create mode 100644 farm_crop/readme/CONTRIBUTORS.md create mode 100644 farm_crop/readme/DESCRIPTION.md create mode 100644 farm_crop/readme/USAGE.md create mode 100644 farm_crop/security/ir.model.access.csv create mode 100644 farm_crop/static/description/index.html create mode 100644 farm_crop/tests/__init__.py create mode 100644 farm_crop/tests/test_farm_crop.py create mode 100644 farm_crop/views/farm_crop_menu.xml create mode 100644 farm_crop/views/farm_crop_views.xml diff --git a/farm_crop/README.rst b/farm_crop/README.rst new file mode 100644 index 0000000..fdcc66b --- /dev/null +++ b/farm_crop/README.rst @@ -0,0 +1,96 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Farm Crop Catalog +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8ce9b2e903043ec55455074998784487c6c2d87477b863472b98ee98983bc0c8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_crop + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Catalog of crops grown on the farm. Seeds with 18 common US small-farm +crops at install (tomato, lettuce, kale, sweet corn, etc.) and lets +farmers add their own. + +Used by ``farm_field`` (current crop on a field), ``farm_planting`` +(what's being planted), and ``farm_harvest`` (what's being harvested) — +but those modules don't ship together; this catalog is the single source +of crop identity. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Browse the seeded catalog at **Farm → Configuration → Crops**. To add a +custom crop: hit *New*, fill name + category (vegetable, fruit, grain, +etc.), optionally latin name + botanical family (helps with rotation +planning) + days to maturity. + +Crops referenced from ``farm.field``, ``farm.planting``, and +``farm.harvest``. Archive unused entries via the Active toggle rather +than deleting. + +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 +------------ + +- Don Kendall dkendall@ledoweb.com + +Maintainers +----------- + +.. |maintainer-dkendall| image:: https://github.com/dkendall.png?size=40px + :target: https://github.com/dkendall + :alt: dkendall + +Current maintainer: + +|maintainer-dkendall| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_crop/__init__.py b/farm_crop/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_crop/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_crop/__manifest__.py b/farm_crop/__manifest__.py new file mode 100644 index 0000000..2a224f9 --- /dev/null +++ b/farm_crop/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Farm Crop Catalog", + "summary": "Catalog of crops grown on the farm (lettuce, tomato, corn, etc.)", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "website": "https://github.com/ledoent/farm-pack", + "category": "Vertical/Agriculture", + "depends": [ + "farm_base", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "data/farm_crop_data.xml", + "views/farm_crop_views.xml", + "views/farm_crop_menu.xml", + ], + "demo": [ + "demo/farm_crop_demo.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", + "maintainers": ["dkendall"], +} diff --git a/farm_crop/data/farm_crop_data.xml b/farm_crop/data/farm_crop_data.xml new file mode 100644 index 0000000..ebf532a --- /dev/null +++ b/farm_crop/data/farm_crop_data.xml @@ -0,0 +1,149 @@ + + + + + + Tomato + Solanum lycopersicum + vegetable + Solanaceae + 75 + + + + Lettuce + Lactuca sativa + vegetable + Asteraceae + 55 + + + + Carrot + Daucus carota + vegetable + Apiaceae + 70 + + + + Kale + Brassica oleracea + vegetable + Brassicaceae + 55 + + + + Sweet Corn + Zea mays + grain + Poaceae + 80 + + + + Potato + Solanum tuberosum + vegetable + Solanaceae + 90 + + + + Garlic + Allium sativum + vegetable + Amaryllidaceae + 240 + + + + Strawberry + Fragaria x ananassa + fruit + Rosaceae + 90 + + + + Blueberry + Vaccinium corymbosum + fruit + Ericaceae + 730 + + + + Basil + Ocimum basilicum + herb + Lamiaceae + 60 + + + + Summer Squash + Cucurbita pepo + vegetable + Cucurbitaceae + 50 + + + + Winter Squash + Cucurbita maxima + vegetable + Cucurbitaceae + 100 + + + + Pepper + Capsicum annuum + vegetable + Solanaceae + 75 + + + + Bush Bean + Phaseolus vulgaris + legume + Fabaceae + 55 + + + + Pea + Pisum sativum + legume + Fabaceae + 60 + + + + Winter Rye (Cover) + Secale cereale + cover + Poaceae + 0 + + + + Crimson Clover (Cover) + Trifolium incarnatum + cover + Fabaceae + 0 + + + + Alfalfa + Medicago sativa + forage + Fabaceae + 60 + + diff --git a/farm_crop/demo/farm_crop_demo.xml b/farm_crop/demo/farm_crop_demo.xml new file mode 100644 index 0000000..02dd47e --- /dev/null +++ b/farm_crop/demo/farm_crop_demo.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/farm_crop/models/__init__.py b/farm_crop/models/__init__.py new file mode 100644 index 0000000..a409daf --- /dev/null +++ b/farm_crop/models/__init__.py @@ -0,0 +1 @@ +from . import farm_crop diff --git a/farm_crop/models/farm_crop.py b/farm_crop/models/farm_crop.py new file mode 100644 index 0000000..d034996 --- /dev/null +++ b/farm_crop/models/farm_crop.py @@ -0,0 +1,40 @@ +from odoo import fields, models + + +class FarmCrop(models.Model): + _name = "farm.crop" + _description = "Farm Crop" + _inherit = ["farm.mixin"] + _order = "name" + + name = fields.Char(required=True, tracking=True) + latin_name = fields.Char( + string="Scientific Name", + help="Latin binomial, e.g. 'Solanum lycopersicum' for tomato.", + ) + category = fields.Selection( + [ + ("vegetable", "Vegetable"), + ("fruit", "Fruit"), + ("grain", "Grain"), + ("legume", "Legume"), + ("herb", "Herb"), + ("cover", "Cover Crop"), + ("forage", "Forage"), + ("flower", "Cut Flower"), + ("other", "Other"), + ], + default="vegetable", + required=True, + tracking=True, + ) + family = fields.Char( + string="Botanical Family", + help="e.g. 'Solanaceae' (nightshades), 'Brassicaceae' (brassicas) — " + "useful for crop rotation planning.", + ) + days_to_maturity = fields.Integer( + help="Typical days from planting to first harvest under your conditions.", + ) + image_1920 = fields.Image(max_width=1920, max_height=1920) + image_128 = fields.Image(related="image_1920", max_width=128, max_height=128) diff --git a/farm_crop/pyproject.toml b/farm_crop/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_crop/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_crop/readme/CONTRIBUTORS.md b/farm_crop/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..9e17a5f --- /dev/null +++ b/farm_crop/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Don Kendall diff --git a/farm_crop/readme/DESCRIPTION.md b/farm_crop/readme/DESCRIPTION.md new file mode 100644 index 0000000..f5161c1 --- /dev/null +++ b/farm_crop/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Catalog of crops grown on the farm. Seeds with 18 common US small-farm crops at +install (tomato, lettuce, kale, sweet corn, etc.) and lets farmers add their own. + +Used by `farm_field` (current crop on a field), `farm_planting` (what's being +planted), and `farm_harvest` (what's being harvested) — but those modules don't +ship together; this catalog is the single source of crop identity. diff --git a/farm_crop/readme/USAGE.md b/farm_crop/readme/USAGE.md new file mode 100644 index 0000000..79628f1 --- /dev/null +++ b/farm_crop/readme/USAGE.md @@ -0,0 +1,6 @@ +Browse the seeded catalog at **Farm → Configuration → Crops**. To add a custom +crop: hit *New*, fill name + category (vegetable, fruit, grain, etc.), optionally +latin name + botanical family (helps with rotation planning) + days to maturity. + +Crops referenced from `farm.field`, `farm.planting`, and `farm.harvest`. Archive +unused entries via the Active toggle rather than deleting. diff --git a/farm_crop/security/ir.model.access.csv b/farm_crop/security/ir.model.access.csv new file mode 100644 index 0000000..e133234 --- /dev/null +++ b/farm_crop/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_crop_user,farm.crop user,model_farm_crop,farm_base.group_farm_user,1,0,0,0 +access_farm_crop_manager,farm.crop manager,model_farm_crop,farm_base.group_farm_manager,1,1,1,1 diff --git a/farm_crop/static/description/index.html b/farm_crop/static/description/index.html new file mode 100644 index 0000000..fe3646b --- /dev/null +++ b/farm_crop/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Crop Catalog

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Catalog of crops grown on the farm. Seeds with 18 common US small-farm +crops at install (tomato, lettuce, kale, sweet corn, etc.) and lets +farmers add their own.

+

Used by farm_field (current crop on a field), farm_planting +(what’s being planted), and farm_harvest (what’s being harvested) — +but those modules don’t ship together; this catalog is the single source +of crop identity.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

Browse the seeded catalog at Farm → Configuration → Crops. To add a +custom crop: hit New, fill name + category (vegetable, fruit, grain, +etc.), optionally latin name + botanical family (helps with rotation +planning) + days to maturity.

+

Crops referenced from farm.field, farm.planting, and +farm.harvest. Archive unused entries via the Active toggle rather +than deleting.

+
+
+

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:

+

dkendall

+

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

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_crop/tests/__init__.py b/farm_crop/tests/__init__.py new file mode 100644 index 0000000..5bcc368 --- /dev/null +++ b/farm_crop/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_crop diff --git a/farm_crop/tests/test_farm_crop.py b/farm_crop/tests/test_farm_crop.py new file mode 100644 index 0000000..a5fd4e6 --- /dev/null +++ b/farm_crop/tests/test_farm_crop.py @@ -0,0 +1,28 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmCrop(TransactionCase): + def test_seeded_catalog_loaded(self): + """Seed catalog from data/farm_crop_data.xml should land at install.""" + tomato = self.env.ref("farm_crop.crop_tomato") + self.assertEqual(tomato.name, "Tomato") + self.assertEqual(tomato.category, "vegetable") + self.assertEqual(tomato.family, "Solanaceae") + + def test_create_custom_crop(self): + """Farmers can add their own crops.""" + crop = self.env["farm.crop"].create( + {"name": "Heirloom Cherokee Purple", "category": "vegetable"} + ) + self.assertEqual(crop.name, "Heirloom Cherokee Purple") + self.assertTrue(crop.active) + + def test_image_thumbnail_stored(self): + """image_128 should be computed/stored from image_1920.""" + # 1×1 transparent PNG, base64 + png = ( + b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA" + b"AAYAAjCB0C8AAAAASUVORK5CYII=" + ) + crop = self.env["farm.crop"].create({"name": "Picture Crop", "image_1920": png}) + self.assertTrue(crop.image_128) diff --git a/farm_crop/views/farm_crop_menu.xml b/farm_crop/views/farm_crop_menu.xml new file mode 100644 index 0000000..c144ff4 --- /dev/null +++ b/farm_crop/views/farm_crop_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_crop/views/farm_crop_views.xml b/farm_crop/views/farm_crop_views.xml new file mode 100644 index 0000000..3afea3c --- /dev/null +++ b/farm_crop/views/farm_crop_views.xml @@ -0,0 +1,119 @@ + + + + farm.crop.list + farm.crop + + + + + + + + + + + + + farm.crop.kanban + farm.crop + + + + +
+ +
+
+ + + +
+ + + +
+
+ days to maturity +
+
+
+
+
+
+
+ + + farm.crop.form + farm.crop + +
+ + +
+

+ +

+ +
+ + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.crop.search + farm.crop + + + + + + + + + + + + + + + + + + Crops + farm.crop + kanban,list,form + +
From 09c237b61146b1cb07c9a77a65615829ed64d5b6 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:52:21 -0400 Subject: [PATCH 03/17] feat(farm_field): fields/plots with multi-company isolation farm.field models a plot of land. Inherits farm.mixin (color/notes/mail.thread). Fields: - name, code (used on signage), farm_partner_id, company_id - acres (manual; farm_field_geo will compute from polygon) - crop_id (current crop), season_id, organic_certified (tracked) Multi-company: - _check_company_auto = True - ir.rule scopes [company_id IN env.company_ids] globally - security covered by test (user in company A blocked from company B's field) 6 tests including multi-company isolation. --- farm_field/README.rst | 95 +++++ farm_field/__init__.py | 1 + farm_field/__manifest__.py | 27 ++ farm_field/demo/farm_field_demo.xml | 29 ++ farm_field/models/__init__.py | 1 + farm_field/models/farm_field.py | 42 ++ farm_field/pyproject.toml | 3 + farm_field/readme/CONTRIBUTORS.md | 1 + farm_field/readme/DESCRIPTION.md | 6 + farm_field/readme/USAGE.md | 6 + farm_field/security/farm_field_security.xml | 9 + farm_field/security/ir.model.access.csv | 3 + farm_field/static/description/index.html | 446 ++++++++++++++++++++ farm_field/tests/__init__.py | 1 + farm_field/tests/test_farm_field.py | 47 +++ farm_field/views/farm_field_menu.xml | 10 + farm_field/views/farm_field_views.xml | 141 +++++++ 17 files changed, 868 insertions(+) create mode 100644 farm_field/README.rst create mode 100644 farm_field/__init__.py create mode 100644 farm_field/__manifest__.py create mode 100644 farm_field/demo/farm_field_demo.xml create mode 100644 farm_field/models/__init__.py create mode 100644 farm_field/models/farm_field.py create mode 100644 farm_field/pyproject.toml create mode 100644 farm_field/readme/CONTRIBUTORS.md create mode 100644 farm_field/readme/DESCRIPTION.md create mode 100644 farm_field/readme/USAGE.md create mode 100644 farm_field/security/farm_field_security.xml create mode 100644 farm_field/security/ir.model.access.csv create mode 100644 farm_field/static/description/index.html create mode 100644 farm_field/tests/__init__.py create mode 100644 farm_field/tests/test_farm_field.py create mode 100644 farm_field/views/farm_field_menu.xml create mode 100644 farm_field/views/farm_field_views.xml diff --git a/farm_field/README.rst b/farm_field/README.rst new file mode 100644 index 0000000..df39d97 --- /dev/null +++ b/farm_field/README.rst @@ -0,0 +1,95 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========== +Farm Field +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:20df860857e1f42c1cfe864b30cf1605207c0bd16e45f642d58d663d2bcf4958 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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 + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Fields / plots where crops grow. Each field has a name, code, acres +(manual entry for the SMB tier — ``farm_field_geo`` adds polygon-based +compute), current crop, crop year, organic-certified flag, and free-form +notes. + +Used as the location reference for ``farm_planting`` (what's planted +where) and ``farm_harvest`` (what's harvested where). + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Farm → Fields** lists every plot. *New* to add: name, code (used on +signage), acres (manual; the future ``farm_field_geo`` will compute from +polygon), current crop. The Organic flag is tracked in the chatter for +audit trail. + +For multi-season records, link ``farm.planting`` entries to the field. +The kanban view supports per-card color so you can flag plots that need +attention. + +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 +------------ + +- Don Kendall dkendall@ledoweb.com + +Maintainers +----------- + +.. |maintainer-dkendall| image:: https://github.com/dkendall.png?size=40px + :target: https://github.com/dkendall + :alt: dkendall + +Current maintainer: + +|maintainer-dkendall| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_field/__init__.py b/farm_field/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_field/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_field/__manifest__.py b/farm_field/__manifest__.py new file mode 100644 index 0000000..17ae948 --- /dev/null +++ b/farm_field/__manifest__.py @@ -0,0 +1,27 @@ +{ + "name": "Farm Field", + "summary": "Fields / plots where crops grow — name, acres, current crop, organic", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "website": "https://github.com/ledoent/farm-pack", + "category": "Vertical/Agriculture", + "depends": [ + "farm_base", + "farm_crop", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "security/farm_field_security.xml", + "views/farm_field_views.xml", + "views/farm_field_menu.xml", + ], + "demo": [ + "demo/farm_field_demo.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", + "maintainers": ["dkendall"], +} diff --git a/farm_field/demo/farm_field_demo.xml b/farm_field/demo/farm_field_demo.xml new file mode 100644 index 0000000..e119d06 --- /dev/null +++ b/farm_field/demo/farm_field_demo.xml @@ -0,0 +1,29 @@ + + + + North 12-acre + N-12 + 12.0 + + + + + South Back + S-BK + 3.5 + + + + + Hoophouse A + HH-A + 0.1 + + + + East Orchard + E-ORC + 4.0 + + + diff --git a/farm_field/models/__init__.py b/farm_field/models/__init__.py new file mode 100644 index 0000000..ec95450 --- /dev/null +++ b/farm_field/models/__init__.py @@ -0,0 +1 @@ +from . import farm_field diff --git a/farm_field/models/farm_field.py b/farm_field/models/farm_field.py new file mode 100644 index 0000000..f0bbcc8 --- /dev/null +++ b/farm_field/models/farm_field.py @@ -0,0 +1,42 @@ +from odoo import fields, models + + +class FarmField(models.Model): + _name = "farm.field" + _description = "Farm Field" + _inherit = ["farm.mixin"] + _order = "name" + _check_company_auto = True + + name = fields.Char(required=True, tracking=True) + code = fields.Char( + help="Short code used on signage, planting tags, harvest sheets " + "(e.g. 'N-12' for North 12-acre).", + ) + farm_partner_id = fields.Many2one( + "res.partner", + string="Farm", + domain="[('is_company','=',True)]", + ) + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company, + index=True, + ) + acres = fields.Float( + digits=(8, 2), + help="Manual entry until the GEO pack lands and recomputes from polygon.", + ) + crop_id = fields.Many2one( + "farm.crop", + string="Current Crop", + tracking=True, + help="What's planted on this field right now. Use farm.planting for " + "multi-season history.", + ) + season_id = fields.Many2one("farm.season") + organic_certified = fields.Boolean(tracking=True) + + # NOTE: GEO fields (geom, computed acres from polygon) land in farm_field_geo + # as an extension so this base lives without PostGIS. diff --git a/farm_field/pyproject.toml b/farm_field/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_field/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_field/readme/CONTRIBUTORS.md b/farm_field/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..9e17a5f --- /dev/null +++ b/farm_field/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Don Kendall diff --git a/farm_field/readme/DESCRIPTION.md b/farm_field/readme/DESCRIPTION.md new file mode 100644 index 0000000..d366637 --- /dev/null +++ b/farm_field/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Fields / plots where crops grow. Each field has a name, code, acres (manual entry +for the SMB tier — `farm_field_geo` adds polygon-based compute), current crop, +crop year, organic-certified flag, and free-form notes. + +Used as the location reference for `farm_planting` (what's planted where) and +`farm_harvest` (what's harvested where). diff --git a/farm_field/readme/USAGE.md b/farm_field/readme/USAGE.md new file mode 100644 index 0000000..90beb90 --- /dev/null +++ b/farm_field/readme/USAGE.md @@ -0,0 +1,6 @@ +**Farm → Fields** lists every plot. *New* to add: name, code (used on signage), +acres (manual; the future `farm_field_geo` will compute from polygon), current +crop. The Organic flag is tracked in the chatter for audit trail. + +For multi-season records, link `farm.planting` entries to the field. The kanban +view supports per-card color so you can flag plots that need attention. diff --git a/farm_field/security/farm_field_security.xml b/farm_field/security/farm_field_security.xml new file mode 100644 index 0000000..163b29d --- /dev/null +++ b/farm_field/security/farm_field_security.xml @@ -0,0 +1,9 @@ + + + + Farm Field: multi-company + + [('company_id', 'in', company_ids)] + + + diff --git a/farm_field/security/ir.model.access.csv b/farm_field/security/ir.model.access.csv new file mode 100644 index 0000000..638ae83 --- /dev/null +++ b/farm_field/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_field_user,farm.field user,model_farm_field,farm_base.group_farm_user,1,1,1,0 +access_farm_field_manager,farm.field manager,model_farm_field,farm_base.group_farm_manager,1,1,1,1 diff --git a/farm_field/static/description/index.html b/farm_field/static/description/index.html new file mode 100644 index 0000000..3ce88bc --- /dev/null +++ b/farm_field/static/description/index.html @@ -0,0 +1,446 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Field

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Fields / plots where crops grow. Each field has a name, code, acres +(manual entry for the SMB tier — farm_field_geo adds polygon-based +compute), current crop, crop year, organic-certified flag, and free-form +notes.

+

Used as the location reference for farm_planting (what’s planted +where) and farm_harvest (what’s harvested where).

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

Farm → Fields lists every plot. New to add: name, code (used on +signage), acres (manual; the future farm_field_geo will compute from +polygon), current crop. The Organic flag is tracked in the chatter for +audit trail.

+

For multi-season records, link farm.planting entries to the field. +The kanban view supports per-card color so you can flag plots that need +attention.

+
+
+

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:

+

dkendall

+

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

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_field/tests/__init__.py b/farm_field/tests/__init__.py new file mode 100644 index 0000000..c694a95 --- /dev/null +++ b/farm_field/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_field diff --git a/farm_field/tests/test_farm_field.py b/farm_field/tests/test_farm_field.py new file mode 100644 index 0000000..f8554d1 --- /dev/null +++ b/farm_field/tests/test_farm_field.py @@ -0,0 +1,47 @@ +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, new_test_user + + +class TestFarmField(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tomato = cls.env.ref("farm_crop.crop_tomato") + + def test_create_minimal(self): + field = self.env["farm.field"].create({"name": "Test Field 1"}) + self.assertEqual(field.name, "Test Field 1") + self.assertTrue(field.active) + self.assertEqual(field.acres, 0.0) + self.assertEqual(field.company_id, self.env.company) + + def test_create_with_crop(self): + field = self.env["farm.field"].create( + {"name": "Tomato Patch", "crop_id": self.tomato.id, "acres": 0.5} + ) + self.assertEqual(field.crop_id, self.tomato) + self.assertEqual(field.acres, 0.5) + + def test_organic_toggle(self): + field = self.env["farm.field"].create({"name": "Cert Field"}) + self.assertFalse(field.organic_certified) + field.organic_certified = True + self.assertTrue(field.organic_certified) + + def test_multi_company_isolation(self): + """A user in company A can't see fields in company B (ir.rule).""" + company_a = self.env["res.company"].create({"name": "Farm Co A"}) + company_b = self.env["res.company"].create({"name": "Farm Co B"}) + user_a = new_test_user( + self.env, + login="farm_user_a", + groups="farm_base.group_farm_user", + company_id=company_a.id, + company_ids=[(6, 0, [company_a.id])], + ) + field_b = self.env["farm.field"].create( + {"name": "B's Field", "company_id": company_b.id} + ) + # User A in company A can't read company B's field. + with self.assertRaises(AccessError): + field_b.with_user(user_a).read(["name"]) diff --git a/farm_field/views/farm_field_menu.xml b/farm_field/views/farm_field_menu.xml new file mode 100644 index 0000000..437ebea --- /dev/null +++ b/farm_field/views/farm_field_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_field/views/farm_field_views.xml b/farm_field/views/farm_field_views.xml new file mode 100644 index 0000000..923b637 --- /dev/null +++ b/farm_field/views/farm_field_views.xml @@ -0,0 +1,141 @@ + + + + farm.field.list + farm.field + + + + + + + + + + + + + + + + farm.field.kanban + farm.field + + + + +
+ + + +
+ + + +
+
+ acres + Organic +
+
+ + +
+
+
+
+
+
+
+ + + farm.field.form + farm.field + +
+ +
+

+ +

+ +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.field.search + farm.field + + + + + + + + + + + + + + + + + + + Fields + farm.field + kanban,list,form + +
From 7c39af9dcb01dd92f7008eb57b4fc31e7ac4bfd4 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:54:51 -0400 Subject: [PATCH 04/17] feat(farm_planting): planting events with state machine + multi-company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit farm.planting records a (field, crop, date) tuple. State machine walks through planted → growing → harvested|failed. Expected harvest date auto-computes from crop.days_to_maturity (overrideable). Multi-company: company_id is related to field_id.company_id, store=True + index=True + precompute=True (NOT NULL at INSERT so ir.rule binds correctly). ir.rule scoping by company_id. field_id is check_company=True. 9 tests including precompute and date-constraint checks. --- farm_planting/README.rst | 100 ++++ farm_planting/__init__.py | 1 + farm_planting/__manifest__.py | 28 ++ farm_planting/demo/farm_planting_demo.xml | 33 ++ farm_planting/models/__init__.py | 1 + farm_planting/models/farm_planting.py | 117 +++++ farm_planting/pyproject.toml | 3 + farm_planting/readme/CONTRIBUTORS.md | 1 + farm_planting/readme/DESCRIPTION.md | 6 + farm_planting/readme/USAGE.md | 13 + .../security/farm_planting_security.xml | 9 + farm_planting/security/ir.model.access.csv | 3 + farm_planting/static/description/index.html | 451 ++++++++++++++++++ farm_planting/tests/__init__.py | 1 + farm_planting/tests/test_farm_planting.py | 89 ++++ farm_planting/views/farm_planting_menu.xml | 10 + farm_planting/views/farm_planting_views.xml | 181 +++++++ 17 files changed, 1047 insertions(+) create mode 100644 farm_planting/README.rst create mode 100644 farm_planting/__init__.py create mode 100644 farm_planting/__manifest__.py create mode 100644 farm_planting/demo/farm_planting_demo.xml create mode 100644 farm_planting/models/__init__.py create mode 100644 farm_planting/models/farm_planting.py create mode 100644 farm_planting/pyproject.toml create mode 100644 farm_planting/readme/CONTRIBUTORS.md create mode 100644 farm_planting/readme/DESCRIPTION.md create mode 100644 farm_planting/readme/USAGE.md create mode 100644 farm_planting/security/farm_planting_security.xml create mode 100644 farm_planting/security/ir.model.access.csv create mode 100644 farm_planting/static/description/index.html create mode 100644 farm_planting/tests/__init__.py create mode 100644 farm_planting/tests/test_farm_planting.py create mode 100644 farm_planting/views/farm_planting_menu.xml create mode 100644 farm_planting/views/farm_planting_views.xml diff --git a/farm_planting/README.rst b/farm_planting/README.rst new file mode 100644 index 0000000..07513e4 --- /dev/null +++ b/farm_planting/README.rst @@ -0,0 +1,100 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +Farm Planting +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:153aee395d475116a7ebc3c1e14c99dc0ee2b6b8c8d23359e1172a9ec9879928 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_planting + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Planting events: a crop went into a field on a date. Records what was +planted, how much, expected harvest date (auto-computed from +``crop.days_to_maturity``), and tracks state through planning → planted +→ growing → harvested (or failed). + +Used by ``farm_harvest`` to link a harvest record back to the planting +that produced it (and indirectly: field + crop + season). + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Farm → Plantings** captures what got planted where. Pick a field, pick +a crop, set the date. Expected harvest auto-fills from +``crop.days_to_maturity`` (override freely). + +The state buttons walk a planting through its lifecycle: + +- **Mark Growing** — once seedlings emerge / direct seed germinates +- **Mark Harvested** (highlighted) — once the last pick is in + (``farm.harvest`` records track individual picks) +- **Mark Failed** — pest, weather, etc. + +Filter by state, group by field or crop, color-code attention items via +the kanban color. + +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 +------------ + +- Don Kendall dkendall@ledoweb.com + +Maintainers +----------- + +.. |maintainer-dkendall| image:: https://github.com/dkendall.png?size=40px + :target: https://github.com/dkendall + :alt: dkendall + +Current maintainer: + +|maintainer-dkendall| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_planting/__init__.py b/farm_planting/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_planting/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_planting/__manifest__.py b/farm_planting/__manifest__.py new file mode 100644 index 0000000..4af4c3d --- /dev/null +++ b/farm_planting/__manifest__.py @@ -0,0 +1,28 @@ +{ + "name": "Farm Planting", + "summary": "Planting events: which crop went into which field and when", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "website": "https://github.com/ledoent/farm-pack", + "category": "Vertical/Agriculture", + "depends": [ + "farm_field", + "farm_crop", + "mail", + "uom", + ], + "data": [ + "security/ir.model.access.csv", + "security/farm_planting_security.xml", + "views/farm_planting_views.xml", + "views/farm_planting_menu.xml", + ], + "demo": [ + "demo/farm_planting_demo.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", + "maintainers": ["dkendall"], +} diff --git a/farm_planting/demo/farm_planting_demo.xml b/farm_planting/demo/farm_planting_demo.xml new file mode 100644 index 0000000..110edd2 --- /dev/null +++ b/farm_planting/demo/farm_planting_demo.xml @@ -0,0 +1,33 @@ + + + + + + + 50.0 + growing + + + + + + 200.0 + growing + + + + + + 120.0 + planted + + diff --git a/farm_planting/models/__init__.py b/farm_planting/models/__init__.py new file mode 100644 index 0000000..9d0a25b --- /dev/null +++ b/farm_planting/models/__init__.py @@ -0,0 +1 @@ +from . import farm_planting diff --git a/farm_planting/models/farm_planting.py b/farm_planting/models/farm_planting.py new file mode 100644 index 0000000..c19360a --- /dev/null +++ b/farm_planting/models/farm_planting.py @@ -0,0 +1,117 @@ +from datetime import timedelta + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class FarmPlanting(models.Model): + _name = "farm.planting" + _description = "Farm Planting" + _inherit = ["farm.mixin"] + _order = "plant_date desc, id desc" + _check_company_auto = True + + name = fields.Char( + compute="_compute_name", + store=True, + precompute=True, + ) + field_id = fields.Many2one( + "farm.field", + string="Field", + required=True, + ondelete="restrict", + check_company=True, + tracking=True, + ) + crop_id = fields.Many2one( + "farm.crop", + string="Crop", + required=True, + ondelete="restrict", + tracking=True, + ) + company_id = fields.Many2one( + related="field_id.company_id", + store=True, + index=True, + precompute=True, + ) + plant_date = fields.Date( + required=True, + default=fields.Date.context_today, + tracking=True, + ) + expected_harvest_date = fields.Date( + compute="_compute_expected_harvest_date", + store=True, + readonly=False, + help="Defaults to plant_date + crop.days_to_maturity. Override freely.", + ) + qty_seeded = fields.Float( + digits=(12, 3), + help="Quantity seeded — units for transplants, lb/oz for seed weight.", + ) + qty_uom_id = fields.Many2one("uom.uom", string="UoM") + season_id = fields.Many2one( + "farm.season", + related="field_id.season_id", + store=True, + readonly=False, + ) + state = fields.Selection( + [ + ("planted", "Planted"), + ("growing", "Growing"), + ("harvested", "Harvested"), + ("failed", "Failed"), + ], + default="planted", + required=True, + tracking=True, + ) + + @api.depends("crop_id", "field_id", "plant_date") + def _compute_name(self): + for rec in self: + if rec.crop_id and rec.field_id and rec.plant_date: + rec.name = ( + f"{rec.crop_id.name} on {rec.field_id.name}" + f" ({rec.plant_date.isoformat()})" + ) + else: + rec.name = self.env._("New Planting") + + @api.depends("plant_date", "crop_id") + def _compute_expected_harvest_date(self): + for rec in self: + if rec.plant_date and rec.crop_id and rec.crop_id.days_to_maturity: + rec.expected_harvest_date = rec.plant_date + timedelta( + days=rec.crop_id.days_to_maturity + ) + + @api.constrains("plant_date", "expected_harvest_date") + def _check_dates(self): + for rec in self: + if ( + rec.expected_harvest_date + and rec.plant_date + and rec.expected_harvest_date < rec.plant_date + ): + raise ValidationError( + self.env._( + "Expected harvest date can't be before plant date " + "(%(p)s → %(h)s).", + p=rec.plant_date, + h=rec.expected_harvest_date, + ) + ) + + def action_mark_growing(self): + self.write({"state": "growing"}) + + def action_mark_harvested(self): + self.write({"state": "harvested"}) + + def action_mark_failed(self): + self.write({"state": "failed"}) diff --git a/farm_planting/pyproject.toml b/farm_planting/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_planting/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_planting/readme/CONTRIBUTORS.md b/farm_planting/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..9e17a5f --- /dev/null +++ b/farm_planting/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Don Kendall diff --git a/farm_planting/readme/DESCRIPTION.md b/farm_planting/readme/DESCRIPTION.md new file mode 100644 index 0000000..9b9ea9a --- /dev/null +++ b/farm_planting/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +Planting events: a crop went into a field on a date. Records what was planted, +how much, expected harvest date (auto-computed from `crop.days_to_maturity`), +and tracks state through planning → planted → growing → harvested (or failed). + +Used by `farm_harvest` to link a harvest record back to the planting that +produced it (and indirectly: field + crop + season). diff --git a/farm_planting/readme/USAGE.md b/farm_planting/readme/USAGE.md new file mode 100644 index 0000000..b07b6c8 --- /dev/null +++ b/farm_planting/readme/USAGE.md @@ -0,0 +1,13 @@ +**Farm → Plantings** captures what got planted where. Pick a field, pick a crop, +set the date. Expected harvest auto-fills from `crop.days_to_maturity` (override +freely). + +The state buttons walk a planting through its lifecycle: + +- **Mark Growing** — once seedlings emerge / direct seed germinates +- **Mark Harvested** (highlighted) — once the last pick is in (`farm.harvest` + records track individual picks) +- **Mark Failed** — pest, weather, etc. + +Filter by state, group by field or crop, color-code attention items via the +kanban color. diff --git a/farm_planting/security/farm_planting_security.xml b/farm_planting/security/farm_planting_security.xml new file mode 100644 index 0000000..f16d153 --- /dev/null +++ b/farm_planting/security/farm_planting_security.xml @@ -0,0 +1,9 @@ + + + + Farm Planting: multi-company + + [('company_id', 'in', company_ids)] + + + diff --git a/farm_planting/security/ir.model.access.csv b/farm_planting/security/ir.model.access.csv new file mode 100644 index 0000000..bc0443b --- /dev/null +++ b/farm_planting/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_planting_user,farm.planting user,model_farm_planting,farm_base.group_farm_user,1,1,1,0 +access_farm_planting_manager,farm.planting manager,model_farm_planting,farm_base.group_farm_manager,1,1,1,1 diff --git a/farm_planting/static/description/index.html b/farm_planting/static/description/index.html new file mode 100644 index 0000000..61fc443 --- /dev/null +++ b/farm_planting/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Planting

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Planting events: a crop went into a field on a date. Records what was +planted, how much, expected harvest date (auto-computed from +crop.days_to_maturity), and tracks state through planning → planted +→ growing → harvested (or failed).

+

Used by farm_harvest to link a harvest record back to the planting +that produced it (and indirectly: field + crop + season).

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

Farm → Plantings captures what got planted where. Pick a field, pick +a crop, set the date. Expected harvest auto-fills from +crop.days_to_maturity (override freely).

+

The state buttons walk a planting through its lifecycle:

+
    +
  • Mark Growing — once seedlings emerge / direct seed germinates
  • +
  • Mark Harvested (highlighted) — once the last pick is in +(farm.harvest records track individual picks)
  • +
  • Mark Failed — pest, weather, etc.
  • +
+

Filter by state, group by field or crop, color-code attention items via +the kanban color.

+
+
+

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:

+

dkendall

+

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

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_planting/tests/__init__.py b/farm_planting/tests/__init__.py new file mode 100644 index 0000000..45f514e --- /dev/null +++ b/farm_planting/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_planting diff --git a/farm_planting/tests/test_farm_planting.py b/farm_planting/tests/test_farm_planting.py new file mode 100644 index 0000000..38be609 --- /dev/null +++ b/farm_planting/tests/test_farm_planting.py @@ -0,0 +1,89 @@ +from datetime import date + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestFarmPlanting(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.field = cls.env["farm.field"].create({"name": "T-1"}) + cls.tomato = cls.env.ref("farm_crop.crop_tomato") # 75 days to maturity + + def test_create_planting(self): + p = self.env["farm.planting"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "plant_date": "2026-04-01", + } + ) + self.assertEqual(p.state, "planted") + self.assertIn("Tomato", p.name) + self.assertIn("T-1", p.name) + + def test_name_precomputed_on_create(self): + """`name` is required by no field but computed-stored-precompute, so + external reads right after create see a value.""" + p = self.env["farm.planting"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "plant_date": "2026-04-01", + } + ) + # Use read() to bypass any in-memory cache. + self.assertTrue(p.read(["name"])[0]["name"]) + + def test_expected_harvest_auto_computed(self): + p = self.env["farm.planting"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "plant_date": "2026-04-01", + } + ) + self.assertEqual(p.expected_harvest_date, date(2026, 6, 15)) + + def test_expected_harvest_override(self): + p = self.env["farm.planting"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "plant_date": "2026-04-01", + "expected_harvest_date": "2026-08-01", + } + ) + self.assertEqual(p.expected_harvest_date, date(2026, 8, 1)) + + def test_harvest_before_plant_rejected(self): + with self.assertRaises(ValidationError): + self.env["farm.planting"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "plant_date": "2026-04-01", + "expected_harvest_date": "2026-03-01", + } + ) + + def test_state_transitions(self): + p = self.env["farm.planting"].create( + {"field_id": self.field.id, "crop_id": self.tomato.id} + ) + self.assertEqual(p.state, "planted") + p.action_mark_growing() + self.assertEqual(p.state, "growing") + p.action_mark_harvested() + self.assertEqual(p.state, "harvested") + + def test_company_from_field(self): + """planting.company_id is populated from field.company_id at INSERT time + (precompute=True), so ir.rule binds correctly.""" + p = self.env["farm.planting"].create( + {"field_id": self.field.id, "crop_id": self.tomato.id} + ) + self.assertEqual( + p.read(["company_id"])[0]["company_id"][0], self.field.company_id.id + ) diff --git a/farm_planting/views/farm_planting_menu.xml b/farm_planting/views/farm_planting_menu.xml new file mode 100644 index 0000000..d17d395 --- /dev/null +++ b/farm_planting/views/farm_planting_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_planting/views/farm_planting_views.xml b/farm_planting/views/farm_planting_views.xml new file mode 100644 index 0000000..e0969ea --- /dev/null +++ b/farm_planting/views/farm_planting_views.xml @@ -0,0 +1,181 @@ + + + + farm.planting.list + farm.planting + + + + + + + + + + + + + + + farm.planting.kanban + farm.planting + + + + +
+ + + +
+ + +
+
+ + +
+
+ exp. harvest: +
+
+
+
+
+
+
+ + + farm.planting.form + farm.planting + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.planting.search + farm.planting + + + + + + + + + + + + + + + + + + + + + + + Plantings + farm.planting + kanban,list,form + +
From 3f30321133fce9d4373c4fcc5774315f00ae19c0 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:56:21 -0400 Subject: [PATCH 05/17] feat(farm_harvest): harvest events with auto-fill from planting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit farm.harvest logs quantity + UoM + date for each pick. Two flows: (1) from a planting — field/crop auto-fill via precompute=True compute, locked readonly in the form; (2) standalone — manual field/crop entry for retroactive records. Multi-company: company_id related to field.company_id, precompute=True. ir.rule scoping. check_company=True on field_id. Optional grade (A/B/C/compost) for tiered pricing downstream. 6 tests including stale field_id behavior when planting cleared. --- farm_harvest/README.rst | 99 ++++ farm_harvest/__init__.py | 1 + farm_harvest/__manifest__.py | 29 ++ farm_harvest/demo/farm_harvest_demo.xml | 23 + farm_harvest/models/__init__.py | 1 + farm_harvest/models/farm_harvest.py | 90 ++++ farm_harvest/pyproject.toml | 3 + farm_harvest/readme/CONTRIBUTORS.md | 1 + farm_harvest/readme/DESCRIPTION.md | 5 + farm_harvest/readme/USAGE.md | 9 + .../security/farm_harvest_security.xml | 9 + farm_harvest/security/ir.model.access.csv | 3 + farm_harvest/static/description/index.html | 450 ++++++++++++++++++ farm_harvest/tests/__init__.py | 1 + farm_harvest/tests/test_farm_harvest.py | 72 +++ farm_harvest/views/farm_harvest_menu.xml | 10 + farm_harvest/views/farm_harvest_views.xml | 151 ++++++ 17 files changed, 957 insertions(+) create mode 100644 farm_harvest/README.rst create mode 100644 farm_harvest/__init__.py create mode 100644 farm_harvest/__manifest__.py create mode 100644 farm_harvest/demo/farm_harvest_demo.xml create mode 100644 farm_harvest/models/__init__.py create mode 100644 farm_harvest/models/farm_harvest.py create mode 100644 farm_harvest/pyproject.toml create mode 100644 farm_harvest/readme/CONTRIBUTORS.md create mode 100644 farm_harvest/readme/DESCRIPTION.md create mode 100644 farm_harvest/readme/USAGE.md create mode 100644 farm_harvest/security/farm_harvest_security.xml create mode 100644 farm_harvest/security/ir.model.access.csv create mode 100644 farm_harvest/static/description/index.html create mode 100644 farm_harvest/tests/__init__.py create mode 100644 farm_harvest/tests/test_farm_harvest.py create mode 100644 farm_harvest/views/farm_harvest_menu.xml create mode 100644 farm_harvest/views/farm_harvest_views.xml diff --git a/farm_harvest/README.rst b/farm_harvest/README.rst new file mode 100644 index 0000000..c9e3980 --- /dev/null +++ b/farm_harvest/README.rst @@ -0,0 +1,99 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============ +Farm Harvest +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:02e62e911c221ce0c8658e5a7f56aac71e7d7a8d78b3a559885c7a64b29a4990 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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_harvest + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Harvest events: how much of which crop came off which field on which +date. + +Each record can either link back to a ``farm.planting`` (which +auto-populates field + +- crop) or stand alone (for retroactive entry / legacy data). Quantity + + UoM + optional grade for tier pricing. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Farm → Harvests** logs each pick. The fastest path: open a planting +and create a harvest from its smart links — field + crop auto-fill from +the planting. + +For standalone or retroactive entries, create directly: pick a field, a +crop, a date, the quantity + UoM (pound is the default for produce). +Optionally tag a quality grade (A / B / C / Compost) to feed downstream +pricing tiers. + +Group by crop or season to see yield totals across plantings; the list +view sums ``Total`` across selected rows. + +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 +------------ + +- Don Kendall dkendall@ledoweb.com + +Maintainers +----------- + +.. |maintainer-dkendall| image:: https://github.com/dkendall.png?size=40px + :target: https://github.com/dkendall + :alt: dkendall + +Current maintainer: + +|maintainer-dkendall| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_harvest/__init__.py b/farm_harvest/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_harvest/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_harvest/__manifest__.py b/farm_harvest/__manifest__.py new file mode 100644 index 0000000..6461639 --- /dev/null +++ b/farm_harvest/__manifest__.py @@ -0,0 +1,29 @@ +{ + "name": "Farm Harvest", + "summary": "Harvest events: how much of which crop came off which field", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "website": "https://github.com/ledoent/farm-pack", + "category": "Vertical/Agriculture", + "depends": [ + "farm_planting", + "farm_field", + "farm_crop", + "mail", + "uom", + ], + "data": [ + "security/ir.model.access.csv", + "security/farm_harvest_security.xml", + "views/farm_harvest_views.xml", + "views/farm_harvest_menu.xml", + ], + "demo": [ + "demo/farm_harvest_demo.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", + "maintainers": ["dkendall"], +} diff --git a/farm_harvest/demo/farm_harvest_demo.xml b/farm_harvest/demo/farm_harvest_demo.xml new file mode 100644 index 0000000..8fcb256 --- /dev/null +++ b/farm_harvest/demo/farm_harvest_demo.xml @@ -0,0 +1,23 @@ + + + + + + 35.0 + + a + + + + + 120.0 + + a + + diff --git a/farm_harvest/models/__init__.py b/farm_harvest/models/__init__.py new file mode 100644 index 0000000..3486f43 --- /dev/null +++ b/farm_harvest/models/__init__.py @@ -0,0 +1 @@ +from . import farm_harvest diff --git a/farm_harvest/models/farm_harvest.py b/farm_harvest/models/farm_harvest.py new file mode 100644 index 0000000..6a9ab18 --- /dev/null +++ b/farm_harvest/models/farm_harvest.py @@ -0,0 +1,90 @@ +from odoo import api, fields, models + + +class FarmHarvest(models.Model): + _name = "farm.harvest" + _description = "Farm Harvest" + _inherit = ["farm.mixin"] + _order = "harvest_date desc, id desc" + _check_company_auto = True + + name = fields.Char(compute="_compute_name", store=True, precompute=True) + planting_id = fields.Many2one( + "farm.planting", + string="Planting", + ondelete="set null", + help="Optional. When set, field + crop auto-fill from the planting on " + "create. Clearing it later does NOT clear field/crop (manual record).", + ) + field_id = fields.Many2one( + "farm.field", + string="Field", + required=True, + ondelete="restrict", + check_company=True, + compute="_compute_field_crop", + store=True, + readonly=False, + precompute=True, + tracking=True, + ) + crop_id = fields.Many2one( + "farm.crop", + string="Crop", + required=True, + ondelete="restrict", + compute="_compute_field_crop", + store=True, + readonly=False, + precompute=True, + tracking=True, + ) + company_id = fields.Many2one( + related="field_id.company_id", + store=True, + index=True, + precompute=True, + ) + harvest_date = fields.Date( + required=True, + default=fields.Date.context_today, + tracking=True, + ) + qty_harvested = fields.Float(digits=(12, 3), required=True, tracking=True) + qty_uom_id = fields.Many2one("uom.uom", string="UoM", required=True) + season_id = fields.Many2one( + "farm.season", + related="field_id.season_id", + store=True, + readonly=False, + ) + grade = fields.Selection( + [ + ("a", "A (premium)"), + ("b", "B (seconds)"), + ("c", "C (utility)"), + ("compost", "Compost"), + ], + help="Optional quality grade for tiered pricing.", + ) + + @api.depends("planting_id") + def _compute_field_crop(self): + for rec in self: + if rec.planting_id: + rec.field_id = rec.planting_id.field_id + rec.crop_id = rec.planting_id.crop_id + + @api.depends("qty_harvested", "qty_uom_id", "crop_id", "harvest_date") + def _compute_name(self): + for rec in self: + bits = [] + if rec.qty_harvested: + bits.append(f"{rec.qty_harvested:g}") + if rec.qty_uom_id: + bits.append(rec.qty_uom_id.name) + if rec.crop_id: + bits.append(rec.crop_id.name) + if rec.harvest_date: + bits.append(f"on {rec.harvest_date.isoformat()}") + rec.name = " ".join(bits) or self.env._("New Harvest") diff --git a/farm_harvest/pyproject.toml b/farm_harvest/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_harvest/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_harvest/readme/CONTRIBUTORS.md b/farm_harvest/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..9e17a5f --- /dev/null +++ b/farm_harvest/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Don Kendall diff --git a/farm_harvest/readme/DESCRIPTION.md b/farm_harvest/readme/DESCRIPTION.md new file mode 100644 index 0000000..1375f44 --- /dev/null +++ b/farm_harvest/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +Harvest events: how much of which crop came off which field on which date. + +Each record can either link back to a `farm.planting` (which auto-populates field ++ crop) or stand alone (for retroactive entry / legacy data). Quantity + UoM + +optional grade for tier pricing. diff --git a/farm_harvest/readme/USAGE.md b/farm_harvest/readme/USAGE.md new file mode 100644 index 0000000..0301845 --- /dev/null +++ b/farm_harvest/readme/USAGE.md @@ -0,0 +1,9 @@ +**Farm → Harvests** logs each pick. The fastest path: open a planting and create +a harvest from its smart links — field + crop auto-fill from the planting. + +For standalone or retroactive entries, create directly: pick a field, a crop, a +date, the quantity + UoM (pound is the default for produce). Optionally tag a +quality grade (A / B / C / Compost) to feed downstream pricing tiers. + +Group by crop or season to see yield totals across plantings; the list view sums +`Total` across selected rows. diff --git a/farm_harvest/security/farm_harvest_security.xml b/farm_harvest/security/farm_harvest_security.xml new file mode 100644 index 0000000..587b297 --- /dev/null +++ b/farm_harvest/security/farm_harvest_security.xml @@ -0,0 +1,9 @@ + + + + Farm Harvest: multi-company + + [('company_id', 'in', company_ids)] + + + diff --git a/farm_harvest/security/ir.model.access.csv b/farm_harvest/security/ir.model.access.csv new file mode 100644 index 0000000..fe63ec0 --- /dev/null +++ b/farm_harvest/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_harvest_user,farm.harvest user,model_farm_harvest,farm_base.group_farm_user,1,1,1,0 +access_farm_harvest_manager,farm.harvest manager,model_farm_harvest,farm_base.group_farm_manager,1,1,1,1 diff --git a/farm_harvest/static/description/index.html b/farm_harvest/static/description/index.html new file mode 100644 index 0000000..834cb57 --- /dev/null +++ b/farm_harvest/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Harvest

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Harvest events: how much of which crop came off which field on which +date.

+

Each record can either link back to a farm.planting (which +auto-populates field

+
    +
  • crop) or stand alone (for retroactive entry / legacy data). Quantity + +UoM + optional grade for tier pricing.
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+

Farm → Harvests logs each pick. The fastest path: open a planting +and create a harvest from its smart links — field + crop auto-fill from +the planting.

+

For standalone or retroactive entries, create directly: pick a field, a +crop, a date, the quantity + UoM (pound is the default for produce). +Optionally tag a quality grade (A / B / C / Compost) to feed downstream +pricing tiers.

+

Group by crop or season to see yield totals across plantings; the list +view sums Total across selected rows.

+
+
+

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:

+

dkendall

+

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

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_harvest/tests/__init__.py b/farm_harvest/tests/__init__.py new file mode 100644 index 0000000..556abf5 --- /dev/null +++ b/farm_harvest/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_harvest diff --git a/farm_harvest/tests/test_farm_harvest.py b/farm_harvest/tests/test_farm_harvest.py new file mode 100644 index 0000000..93b5c9d --- /dev/null +++ b/farm_harvest/tests/test_farm_harvest.py @@ -0,0 +1,72 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmHarvest(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.field = cls.env["farm.field"].create({"name": "H-1"}) + cls.tomato = cls.env.ref("farm_crop.crop_tomato") + cls.uom_lb = cls.env.ref("uom.product_uom_lb") + cls.planting = cls.env["farm.planting"].create( + { + "field_id": cls.field.id, + "crop_id": cls.tomato.id, + "plant_date": "2026-04-01", + } + ) + + def test_from_planting_inherits_field_crop(self): + h = self.env["farm.harvest"].create( + { + "planting_id": self.planting.id, + "harvest_date": "2026-07-15", + "qty_harvested": 30.0, + "qty_uom_id": self.uom_lb.id, + } + ) + self.assertEqual(h.field_id, self.field) + self.assertEqual(h.crop_id, self.tomato) + + def test_standalone_record(self): + h = self.env["farm.harvest"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "harvest_date": "2026-08-01", + "qty_harvested": 12.5, + "qty_uom_id": self.uom_lb.id, + } + ) + self.assertFalse(h.planting_id) + self.assertEqual(h.qty_harvested, 12.5) + + def test_name_auto_composed(self): + h = self.env["farm.harvest"].create( + { + "field_id": self.field.id, + "crop_id": self.tomato.id, + "harvest_date": "2026-08-01", + "qty_harvested": 7, + "qty_uom_id": self.uom_lb.id, + } + ) + self.assertIn("7", h.name) + self.assertIn("Tomato", h.name) + self.assertIn("2026-08-01", h.name) + + def test_clearing_planting_keeps_field_crop(self): + """Documented behavior: clearing planting_id does NOT reset field/crop + — the compute only fires when planting_id is set. Codified so a future + refactor doesn't silently break it.""" + h = self.env["farm.harvest"].create( + { + "planting_id": self.planting.id, + "harvest_date": "2026-07-15", + "qty_harvested": 30.0, + "qty_uom_id": self.uom_lb.id, + } + ) + original_field = h.field_id + h.planting_id = False + self.assertEqual(h.field_id, original_field) diff --git a/farm_harvest/views/farm_harvest_menu.xml b/farm_harvest/views/farm_harvest_menu.xml new file mode 100644 index 0000000..468a844 --- /dev/null +++ b/farm_harvest/views/farm_harvest_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_harvest/views/farm_harvest_views.xml b/farm_harvest/views/farm_harvest_views.xml new file mode 100644 index 0000000..4c48000 --- /dev/null +++ b/farm_harvest/views/farm_harvest_views.xml @@ -0,0 +1,151 @@ + + + + farm.harvest.list + farm.harvest + + + + + + + + + + + + + + + + farm.harvest.kanban + farm.harvest + + + + +
+ + + + + +
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + farm.harvest.form + farm.harvest + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.harvest.search + farm.harvest + + + + + + + + + + + + + + + + + + + + + + + Harvests + farm.harvest + kanban,list,form + +
From cf00cfa834061d89d9108db432d99e632f7efd4a Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 08:22:00 -0400 Subject: [PATCH 06/17] fix: kanban templates for Odoo 19 Card system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Card template introduced in Odoo 17+ doesn't support the legacy wrappers (`oe_kanban_details`, `o_kanban_image_fill_left`) or the `highlight_color` attribute, and the `t-if="record.X.raw_value"` conditional pattern that worked in v18 raises an OwlError at mount time. Rewrite all four kanbans in the simplified Card style (mirrors farm_egg_production): drop wrapper divs, drop `highlight_color`, replace conditional t-if with bare `` references that render empty when null. Verified against fresh 19.0 DB — 0 page errors, all records render. --- farm_crop/views/farm_crop_views.xml | 26 ++++++++-------- farm_field/views/farm_field_views.xml | 33 ++++++++++----------- farm_harvest/views/farm_harvest_views.xml | 23 +++++++------- farm_planting/views/farm_planting_views.xml | 27 +++++++---------- 4 files changed, 50 insertions(+), 59 deletions(-) diff --git a/farm_crop/views/farm_crop_views.xml b/farm_crop/views/farm_crop_views.xml index 3afea3c..77bf98e 100644 --- a/farm_crop/views/farm_crop_views.xml +++ b/farm_crop/views/farm_crop_views.xml @@ -18,24 +18,24 @@ farm.crop.kanban farm.crop - + -
- -
-
+
-
- - - -
-
- days to maturity -
+
+
+ +
+
+ + + +
+
+ days to maturity
diff --git a/farm_field/views/farm_field_views.xml b/farm_field/views/farm_field_views.xml index 923b637..ba54871 100644 --- a/farm_field/views/farm_field_views.xml +++ b/farm_field/views/farm_field_views.xml @@ -25,29 +25,26 @@ farm.field.kanban farm.field - + -
+
-
- - - -
-
- acres - Organic -
-
- - -
+
+
+ +
+
+ acres +
+
+ +
+
+ + Organic
diff --git a/farm_harvest/views/farm_harvest_views.xml b/farm_harvest/views/farm_harvest_views.xml index 4c48000..0a70f03 100644 --- a/farm_harvest/views/farm_harvest_views.xml +++ b/farm_harvest/views/farm_harvest_views.xml @@ -21,25 +21,24 @@ farm.harvest.kanban farm.harvest - + -
+
-
- - -
-
- -
-
- -
+
+
+ +
+
+ +
+
+
diff --git a/farm_planting/views/farm_planting_views.xml b/farm_planting/views/farm_planting_views.xml index e0969ea..004fa45 100644 --- a/farm_planting/views/farm_planting_views.xml +++ b/farm_planting/views/farm_planting_views.xml @@ -24,27 +24,22 @@ farm.planting.kanban farm.planting - + -
+
-
- - -
-
- - -
-
- exp. harvest: -
+
+
+ +
+
+ +
+
+ exp. harvest
From 97817607f249d4236fe39b187a5beb02ac2e183c Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 13:08:18 -0400 Subject: [PATCH 07/17] 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 08/17] 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 09/17] 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 10/17] 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'} From ed64be161dc458cae9112ac4af9f4c0ed66caca3 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:03:18 -0400 Subject: [PATCH 11/17] ci: trigger workflow on feat/farm-field-geo after retarget to 19.0 From 75dc514a8ceac72953c9c7ce0791ea7e8e3bab2c Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:10:30 -0400 Subject: [PATCH 12/17] fix(farm_field_geo): development_status=Alpha matches farm_field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCA's check-dev-status workflow gate rejects higher → lower dev-status deps. farm_field_geo defaulted to Beta but depends on farm_field which is Alpha. Set Alpha explicitly. --- farm_field_geo/README.rst | 9 +++++++-- farm_field_geo/__manifest__.py | 3 +++ farm_field_geo/static/description/index.html | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/farm_field_geo/README.rst b/farm_field_geo/README.rst index 34c66cf..f206374 100644 --- a/farm_field_geo/README.rst +++ b/farm_field_geo/README.rst @@ -14,9 +14,9 @@ Farm Field — Geospatial !! source digest: sha256:156d6ff91db3f7522e9501d6a413cbc2a2e29881632c0f19543ed5de002262fc !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status - :alt: Beta + :alt: Alpha .. |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 @@ -53,6 +53,11 @@ cleanly if the extension is missing. The base ``farm_field`` module stays usable without PostGIS — install this extension only when geometry support is wanted. +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + **Table of contents** .. contents:: diff --git a/farm_field_geo/__manifest__.py b/farm_field_geo/__manifest__.py index ad41125..0ce349f 100644 --- a/farm_field_geo/__manifest__.py +++ b/farm_field_geo/__manifest__.py @@ -7,6 +7,9 @@ "website": "https://github.com/ledoent/farm-pack", "license": "AGPL-3", "category": "Vertical/Agriculture", + # Alpha matches farm_field; OCA's check-dev-status job rejects higher + # dev-status modules depending on lower-status ones. + "development_status": "Alpha", "depends": [ "farm_field", "base_geoengine", diff --git a/farm_field_geo/static/description/index.html b/farm_field_geo/static/description/index.html index f604459..d80c7ec 100644 --- a/farm_field_geo/static/description/index.html +++ b/farm_field_geo/static/description/index.html @@ -374,7 +374,7 @@

Farm Field — Geospatial

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:156d6ff91db3f7522e9501d6a413cbc2a2e29881632c0f19543ed5de002262fc !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 ledoent/farm-pack

+

Alpha 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. @@ -397,6 +397,12 @@

Farm Field — Geospatial

cleanly if the extension is missing. The base farm_field module stays usable without PostGIS — install this extension only when geometry support is wanted.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+

Table of contents

    From 0480572b31a3247741839dd6b313fe9edf6e193d Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:15:26 -0400 Subject: [PATCH 13/17] =?UTF-8?q?ci:=20swap=20postgres:13=20=E2=86=92=20po?= =?UTF-8?q?stgis/postgis:13-3.4-alpine=20in=20tests=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCA's stock CI uses postgres:13 which doesn't have the PostGIS extension. base_geoengine (a dep of farm_field_geo) requires it — install crashed with: could not open extension control file "postgis.control": No such file postgis/postgis bundles the same Postgres major plus PostGIS. Drop the swap once farm-pack no longer ships geometry-bearing modules. --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79387eb..d8b7deb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,12 @@ jobs: name: test with OCB services: postgres: - image: postgres:13 + # postgis/postgis carries the same Postgres major version as the + # upstream OCA template (13) but bundles PostGIS so base_geoengine + # (and any farm.field geometry module) can `CREATE EXTENSION postgis`. + # Drop back to `postgres:13` when farm-pack no longer ships geo + # modules that need PostGIS. + image: postgis/postgis:13-3.4-alpine env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo From fb5d9d8e3eb1f5ada41d70868c78ed1a41898a58 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:21:13 -0400 Subject: [PATCH 14/17] fix(farm_field_geo): drop bogus code field from test crop fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit farm.crop on 19.0 has fields: name, latin_name, category, family, days_to_maturity — no 'code' column. The test was a copy-paste from an earlier design draft that included code. --- farm_field_geo/tests/test_farm_field_geo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/farm_field_geo/tests/test_farm_field_geo.py b/farm_field_geo/tests/test_farm_field_geo.py index 93a0a45..744501f 100644 --- a/farm_field_geo/tests/test_farm_field_geo.py +++ b/farm_field_geo/tests/test_farm_field_geo.py @@ -33,7 +33,7 @@ def setUpClass(cls): cls.farm = cls.env["res.partner"].create( {"name": "Test Farm", "is_company": True} ) - cls.crop = cls.env["farm.crop"].create({"name": "Corn", "code": "CORN"}) + cls.crop = cls.env["farm.crop"].create({"name": "Corn"}) def _make_field(self, geom=None, acres=None): vals = { From 7e17b0fa4624a677d8fe1869e59d2d17788266db Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:27:24 -0400 Subject: [PATCH 15/17] fix(farm_field_geo): use shapely.Polygon instead of EWKT strings in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base_geoengine.fields.GeoPolygon's serializer doesn't accept SRID=...;POLYGON(...) EWKT input on write — it expects either shapely geometry objects or EWKB hex. Switch to shapely.geometry.Polygon, which is the published Python API. --- farm_field_geo/tests/test_farm_field_geo.py | 36 +++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/farm_field_geo/tests/test_farm_field_geo.py b/farm_field_geo/tests/test_farm_field_geo.py index 744501f..672e71b 100644 --- a/farm_field_geo/tests/test_farm_field_geo.py +++ b/farm_field_geo/tests/test_farm_field_geo.py @@ -1,27 +1,29 @@ -from odoo.tests.common import TransactionCase +from shapely.geometry import Polygon +from odoo.tests.common import TransactionCase -def _polygon_wkt(min_lon, min_lat, max_lon, max_lat): - """Build a WGS84 rectangular polygon as WKT. - Saves repeating the SRID prefix and the close-the-ring vertex across tests. +def _polygon(min_lon, min_lat, max_lon, max_lat): + """Return a Shapely polygon — base_geoengine's GeoPolygon field accepts + shapely geometries via to_column_format(); EWKT strings raise hex-parse + errors because the field's serializer expects EWKB-hex on write paths. """ - 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}" - "))" + return Polygon( + [ + (min_lon, min_lat), + (max_lon, min_lat), + (max_lon, max_lat), + (min_lon, max_lat), + (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) +# Reference rectangle: ~0.01° square around Ligonier PA (40.242N, 79.245W). +# Albers reprojection of this size is ~233 acres — assertion ranges below +# leave generous slack for projection edge cases. +LIGONIER_BIG = _polygon(-79.245, 40.242, -79.235, 40.252) +LIGONIER_SMALL = _polygon(-79.245, 40.242, -79.244, 40.243) class TestFarmFieldGeo(TransactionCase): From a7485fdb5f7d37c2bdcade743900ac6a311e2750 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:35:12 -0400 Subject: [PATCH 16/17] fix(farm_field_geo): use pyproj+shapely.ops.transform for reprojection base_geoengine returns the geometry field as a plain shapely Polygon on read, which has no .transform() method (that's GEOS/Django-style API, not shapely's). Reproject via pyproj.Transformer + shapely.ops.transform to get area in EPSG:5070 meters before dividing by acre conversion. --- farm_field_geo/models/farm_field.py | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/farm_field_geo/models/farm_field.py b/farm_field_geo/models/farm_field.py index 6bac78c..1f920aa 100644 --- a/farm_field_geo/models/farm_field.py +++ b/farm_field_geo/models/farm_field.py @@ -1,14 +1,24 @@ +from functools import lru_cache + +from pyproj import Transformer +from shapely.ops import transform as shapely_transform + 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. +# Square meters in one US survey acre. 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 + +@lru_cache(maxsize=1) +def _wgs84_to_albers_conus(): + """Build the EPSG:4326 → EPSG:5070 transformer once and reuse it. + + EPSG:5070 (Conus Albers Equal Area) is what NRCS and NASS use for + lower-48 acreage. Reprojecting the WGS84 polygon there before measuring + area gives a number that matches soil-survey + yield tooling for the + same parcel. + """ + return Transformer.from_crs("EPSG:4326", "EPSG:5070", always_xy=True).transform class FarmField(models.Model): @@ -31,7 +41,12 @@ class FarmField(models.Model): @api.depends("geom") def _compute_acres_from_geom(self): + # base_geoengine returns the field as a shapely geometry on read. + # shapely has no `.transform()` method — reproject explicitly via + # pyproj + shapely.ops.transform before measuring area. + transformer = _wgs84_to_albers_conus() for rec in self: if not rec.geom: continue - rec.acres = rec.geom.transform(ALBERS_CONUS_SRID).area / M2_PER_ACRE + projected = shapely_transform(transformer, rec.geom) + rec.acres = projected.area / M2_PER_ACRE From 3232f8728f06b021c040a58f51339e549a6aae1a Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 10:41:10 -0400 Subject: [PATCH 17/17] fix(farm_field_geo): declare pyproj + shapely as external deps CI failed with 'No module named pyproj'. Adding to manifest's external_dependencies.python; pre-commit auto-regens requirements.txt to match. --- farm_field_geo/__manifest__.py | 6 ++++++ requirements.txt | 2 ++ 2 files changed, 8 insertions(+) diff --git a/farm_field_geo/__manifest__.py b/farm_field_geo/__manifest__.py index 0ce349f..ed144e2 100644 --- a/farm_field_geo/__manifest__.py +++ b/farm_field_geo/__manifest__.py @@ -14,6 +14,12 @@ "farm_field", "base_geoengine", ], + "external_dependencies": { + # pyproj: reproject WGS84 polygon → EPSG:5070 Albers for acreage. + # shapely: base_geoengine already requires it; listed here to make + # the dep graph explicit (CI's oca_install_addons uses this list). + "python": ["pyproj", "shapely"], + }, "data": [ "views/farm_field_views.xml", ], diff --git a/requirements.txt b/requirements.txt index 56190be..b863f07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ # generated from manifests external_dependencies intuit-oauth +pyproj python-quickbooks +shapely