From dc8515b46b87589070858c3e05ff1ce3feb0ac17 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sat, 16 May 2026 21:51:04 -0400 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 5d3eceb77200513073b40075431aafc8eeeb04d6 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 18:20:19 -0400 Subject: [PATCH 09/13] feat(farm_field_overlays): USDA Cropland + NRCS Soil + OSM overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three public-domain overlay raster layers to the farm.field map: - USDA Cropland (CDL): WMS, current-year crop classification raster. Layer name is year-specific (`cdl_YYYY`); USAGE.md documents the annual-bump procedure once the new year's CDL publishes. - NRCS SSURGO Soil: WMS, soil mapunit polygons covering CONUS. - OpenStreetMap basemap: tile-server backdrop so roads + parcels show through. All three are read-only, public-domain US government services. No API keys, no per-request quota. Stored as `geoengine.raster.layer` records bound to `farm_field_geo.farm_field_view_geoengine`, so admins can disable, reorder, or re-point them via Settings → Technical → Geoengine → Raster Layers without touching code (`noupdate="1"` keeps admin edits across module upgrades). Three smoke tests verify the records survive install + carry the required schema (correct view binding, LAYERS param on the WMS records, overlay=False on the basemap). --- farm_field_overlays/README.rst | 125 +++++ farm_field_overlays/__init__.py | 0 farm_field_overlays/__manifest__.py | 18 + .../data/geoengine_raster_layers.xml | 58 +++ farm_field_overlays/pyproject.toml | 3 + farm_field_overlays/readme/CONTRIBUTORS.md | 1 + farm_field_overlays/readme/DESCRIPTION.md | 19 + farm_field_overlays/readme/USAGE.md | 25 + .../readme/newsfragments/.gitkeep | 0 .../static/description/index.html | 478 ++++++++++++++++++ farm_field_overlays/tests/__init__.py | 1 + .../tests/test_farm_field_overlays.py | 38 ++ 12 files changed, 766 insertions(+) create mode 100644 farm_field_overlays/README.rst create mode 100644 farm_field_overlays/__init__.py create mode 100644 farm_field_overlays/__manifest__.py create mode 100644 farm_field_overlays/data/geoengine_raster_layers.xml create mode 100644 farm_field_overlays/pyproject.toml create mode 100644 farm_field_overlays/readme/CONTRIBUTORS.md create mode 100644 farm_field_overlays/readme/DESCRIPTION.md create mode 100644 farm_field_overlays/readme/USAGE.md create mode 100644 farm_field_overlays/readme/newsfragments/.gitkeep create mode 100644 farm_field_overlays/static/description/index.html create mode 100644 farm_field_overlays/tests/__init__.py create mode 100644 farm_field_overlays/tests/test_farm_field_overlays.py diff --git a/farm_field_overlays/README.rst b/farm_field_overlays/README.rst new file mode 100644 index 0000000..203f369 --- /dev/null +++ b/farm_field_overlays/README.rst @@ -0,0 +1,125 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============================ +Farm Field — Public Overlays +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a76ed9cebaf8eebcba13f4a3133301299225ea185f73b4d2e742fba77b8d9594 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_overlays + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Adds three public-domain US-government overlay layers to the farm field +map: + +- **OpenStreetMap** — basemap; roads, parcels, buildings (default + visible) +- **USDA Cropland (CDL)** — current-year crop-classification raster + covering the lower 48; overlays the historical crop on a farmer's + parcels so you can see what was planted in previous years from + satellite-derived classification (50%-opacity by default) +- **NRCS SSURGO Soil** — soil mapunit polygons; click-through gives soil + type, drainage class, productivity (40%-opacity by default) + +All three are read-only: no API keys, no per-request quota, no +proprietary data. Layers are stored as ``geoengine.raster.layer`` +records bound to the field map view, so an admin can disable, reorder, +or edit the WMS endpoints via Settings → Technical → Geoengine → Raster +Layers without touching code. + +**Scope.** Read-only display only. Click-to-query (e.g. "what soil type +is under this point?") needs a ``farm_field_query`` follow-up module +that wires up GetFeatureInfo round-trips. USGS NHD (hydrography) and +Topo are deferred to a follow-up to keep this PR tight. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open Farm → Fields → Fields and switch to the **Map** view. +2. The layer toggle (top-right of the map) lists OpenStreetMap, USDA + Cropland, and NRCS Soil. Toggle each on/off independently. +3. To customize a layer (URL, opacity, ordering): Settings → Technical → + Geoengine → Raster Layers — pick the layer, edit, save. + +**Annual update — USDA CDL.** The Cropland Data Layer is published per +crop year and the WMS layer name carries the year (e.g. ``cdl_2024``). +USDA typically publishes the new year's raster in Feb/Mar of the +following calendar year. Each January or February, update the LAYERS +param on the "USDA Cropland" raster layer record: + +1. Settings → Technical → Geoengine → Raster Layers → "USDA Cropland + (CDL …)" +2. Edit Params WMS, change ``cdl_YYYY`` to the new year +3. Update the layer's display name to match +4. Save and reload the map view + +To add another WMS overlay (e.g. USGS NHD hydrography for streams + +ponds): + +1. Settings → Technical → Geoengine → Raster Layers → New +2. Raster layer type: **Distant WMS** +3. URL: the WMS endpoint +4. Params WMS: + ``{"LAYERS": "", "FORMAT": "image/png", "TRANSPARENT": "TRUE"}`` +5. Bind to view: **farm.field.geoengine** +6. Save; reload the map view. + +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_overlays/__init__.py b/farm_field_overlays/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farm_field_overlays/__manifest__.py b/farm_field_overlays/__manifest__.py new file mode 100644 index 0000000..a4234d3 --- /dev/null +++ b/farm_field_overlays/__manifest__.py @@ -0,0 +1,18 @@ +{ + "name": "Farm Field — Public Overlays", + "version": "19.0.1.0.0", + "summary": "USDA Cropland + NRCS Soil overlays on the field map", + "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_geo", + ], + "data": [ + "data/geoengine_raster_layers.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_field_overlays/data/geoengine_raster_layers.xml b/farm_field_overlays/data/geoengine_raster_layers.xml new file mode 100644 index 0000000..03e5672 --- /dev/null +++ b/farm_field_overlays/data/geoengine_raster_layers.xml @@ -0,0 +1,58 @@ + + + + + USDA Cropland (CDL 2024) + d_wms + https://nassgeodata.gmu.edu/CropScape/wms + {"LAYERS": "cdl_2024", "FORMAT": "image/png", "TRANSPARENT": "TRUE"} + mapserver + + 0.5 + 10 + + + + + NRCS Soil (SSURGO) + d_wms + https://sdmdataaccess.nrcs.usda.gov/Spatial/SDMWGS84Geographic.wms + {"LAYERS": "MapunitPoly", "FORMAT": "image/png", "TRANSPARENT": "TRUE"} + geoserver + + 0.4 + 20 + + + + + + OpenStreetMap + osm + + 99 + + + diff --git a/farm_field_overlays/pyproject.toml b/farm_field_overlays/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_field_overlays/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_field_overlays/readme/CONTRIBUTORS.md b/farm_field_overlays/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_field_overlays/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_field_overlays/readme/DESCRIPTION.md b/farm_field_overlays/readme/DESCRIPTION.md new file mode 100644 index 0000000..3097046 --- /dev/null +++ b/farm_field_overlays/readme/DESCRIPTION.md @@ -0,0 +1,19 @@ +Adds three public-domain US-government overlay layers to the farm field map: + +- **OpenStreetMap** — basemap; roads, parcels, buildings (default visible) +- **USDA Cropland (CDL)** — current-year crop-classification raster covering + the lower 48; overlays the historical crop on a farmer's parcels so you can + see what was planted in previous years from satellite-derived + classification (50%-opacity by default) +- **NRCS SSURGO Soil** — soil mapunit polygons; click-through gives soil + type, drainage class, productivity (40%-opacity by default) + +All three are read-only: no API keys, no per-request quota, no proprietary +data. Layers are stored as `geoengine.raster.layer` records bound to the +field map view, so an admin can disable, reorder, or edit the WMS endpoints +via Settings → Technical → Geoengine → Raster Layers without touching code. + +**Scope.** Read-only display only. Click-to-query (e.g. "what soil type is +under this point?") needs a `farm_field_query` follow-up module that wires +up GetFeatureInfo round-trips. USGS NHD (hydrography) and Topo are deferred +to a follow-up to keep this PR tight. diff --git a/farm_field_overlays/readme/USAGE.md b/farm_field_overlays/readme/USAGE.md new file mode 100644 index 0000000..fe75325 --- /dev/null +++ b/farm_field_overlays/readme/USAGE.md @@ -0,0 +1,25 @@ +1. Open Farm → Fields → Fields and switch to the **Map** view. +2. The layer toggle (top-right of the map) lists OpenStreetMap, USDA Cropland, + and NRCS Soil. Toggle each on/off independently. +3. To customize a layer (URL, opacity, ordering): Settings → Technical → + Geoengine → Raster Layers — pick the layer, edit, save. + +**Annual update — USDA CDL.** The Cropland Data Layer is published per +crop year and the WMS layer name carries the year (e.g. `cdl_2024`). +USDA typically publishes the new year's raster in Feb/Mar of the following +calendar year. Each January or February, update the LAYERS param on the +"USDA Cropland" raster layer record: + +1. Settings → Technical → Geoengine → Raster Layers → "USDA Cropland (CDL …)" +2. Edit Params WMS, change `cdl_YYYY` to the new year +3. Update the layer's display name to match +4. Save and reload the map view + +To add another WMS overlay (e.g. USGS NHD hydrography for streams + ponds): + +1. Settings → Technical → Geoengine → Raster Layers → New +2. Raster layer type: **Distant WMS** +3. URL: the WMS endpoint +4. Params WMS: `{"LAYERS": "", "FORMAT": "image/png", "TRANSPARENT": "TRUE"}` +5. Bind to view: **farm.field.geoengine** +6. Save; reload the map view. diff --git a/farm_field_overlays/readme/newsfragments/.gitkeep b/farm_field_overlays/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_field_overlays/static/description/index.html b/farm_field_overlays/static/description/index.html new file mode 100644 index 0000000..a94025c --- /dev/null +++ b/farm_field_overlays/static/description/index.html @@ -0,0 +1,478 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Field — Public Overlays

+ +

Beta License: AGPL-3 ledoent/farm-pack

+

Adds three public-domain US-government overlay layers to the farm field +map:

+
    +
  • OpenStreetMap — basemap; roads, parcels, buildings (default +visible)
  • +
  • USDA Cropland (CDL) — current-year crop-classification raster +covering the lower 48; overlays the historical crop on a farmer’s +parcels so you can see what was planted in previous years from +satellite-derived classification (50%-opacity by default)
  • +
  • NRCS SSURGO Soil — soil mapunit polygons; click-through gives soil +type, drainage class, productivity (40%-opacity by default)
  • +
+

All three are read-only: no API keys, no per-request quota, no +proprietary data. Layers are stored as geoengine.raster.layer +records bound to the field map view, so an admin can disable, reorder, +or edit the WMS endpoints via Settings → Technical → Geoengine → Raster +Layers without touching code.

+

Scope. Read-only display only. Click-to-query (e.g. “what soil type +is under this point?”) needs a farm_field_query follow-up module +that wires up GetFeatureInfo round-trips. USGS NHD (hydrography) and +Topo are deferred to a follow-up to keep this PR tight.

+

Table of contents

+ +
+

Usage

+
    +
  1. Open Farm → Fields → Fields and switch to the Map view.
  2. +
  3. The layer toggle (top-right of the map) lists OpenStreetMap, USDA +Cropland, and NRCS Soil. Toggle each on/off independently.
  4. +
  5. To customize a layer (URL, opacity, ordering): Settings → Technical → +Geoengine → Raster Layers — pick the layer, edit, save.
  6. +
+

Annual update — USDA CDL. The Cropland Data Layer is published per +crop year and the WMS layer name carries the year (e.g. cdl_2024). +USDA typically publishes the new year’s raster in Feb/Mar of the +following calendar year. Each January or February, update the LAYERS +param on the “USDA Cropland” raster layer record:

+
    +
  1. Settings → Technical → Geoengine → Raster Layers → “USDA Cropland +(CDL …)”
  2. +
  3. Edit Params WMS, change cdl_YYYY to the new year
  4. +
  5. Update the layer’s display name to match
  6. +
  7. Save and reload the map view
  8. +
+

To add another WMS overlay (e.g. USGS NHD hydrography for streams + +ponds):

+
    +
  1. Settings → Technical → Geoengine → Raster Layers → New
  2. +
  3. Raster layer type: Distant WMS
  4. +
  5. URL: the WMS endpoint
  6. +
  7. Params WMS: +{"LAYERS": "<layer-name>", "FORMAT": "image/png", "TRANSPARENT": "TRUE"}
  8. +
  9. Bind to view: farm.field.geoengine
  10. +
  11. Save; reload the map view.
  12. +
+
+
+

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_overlays/tests/__init__.py b/farm_field_overlays/tests/__init__.py new file mode 100644 index 0000000..f25c4a7 --- /dev/null +++ b/farm_field_overlays/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_field_overlays diff --git a/farm_field_overlays/tests/test_farm_field_overlays.py b/farm_field_overlays/tests/test_farm_field_overlays.py new file mode 100644 index 0000000..36c0d9b --- /dev/null +++ b/farm_field_overlays/tests/test_farm_field_overlays.py @@ -0,0 +1,38 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmFieldOverlays(TransactionCase): + """Smoke-check the three overlay records ship correctly bound to the + farm.field geoengine view. Catches XML-ref breakage if either the + raster layer model or the view xmlid moves under us. + """ + + def test_overlays_are_bound_to_field_geoengine_view(self): + view = self.env.ref("farm_field_geo.farm_field_view_geoengine") + for xmlid in ( + "farm_field_overlays.farm_field_overlay_cdl", + "farm_field_overlays.farm_field_overlay_ssurgo", + "farm_field_overlays.farm_field_overlay_osm", + ): + layer = self.env.ref(xmlid) + self.assertEqual( + layer.view_id, view, f"{xmlid} should be bound to the field map" + ) + + def test_wms_overlays_carry_layer_param(self): + # Both WMS records must specify a LAYERS param — without it the WMS + # GetMap request returns a 400 and the overlay silently fails. + for xmlid in ( + "farm_field_overlays.farm_field_overlay_cdl", + "farm_field_overlays.farm_field_overlay_ssurgo", + ): + layer = self.env.ref(xmlid) + self.assertEqual(layer.raster_type, "d_wms") + self.assertIn("LAYERS", layer.params_wms or "") + + def test_osm_basemap_is_not_an_overlay(self): + # OSM sits underneath as a basemap; overlay=False keeps it from + # blanking out the WMS layers above it. + osm = self.env.ref("farm_field_overlays.farm_field_overlay_osm") + self.assertEqual(osm.raster_type, "osm") + self.assertFalse(osm.overlay) From 394ca5d640ec99e7e689393a75dba7a3e6ac28a2 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 20:29:35 -0400 Subject: [PATCH 10/13] feat(farm_observation): geotagged field observations w/ urgency triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New model `farm.observation` for capturing what's happening in each field: type (pest/disease/weed/soil/water/yield/photo/other), free-text notes, optional GeoPoint location, urgency (low/med/high), optional photo attachment, mail.thread for chatter. Auto-named from field+type+date. Views: list (color-coded by urgency), urgency-grouped kanban, form with chatter + map widget on a Location tab, search with last-7-days + high-urgency canned filters, geoengine map view that drops pins on the field map. Self-review fixes baked in before commit: - `_order` would alpha-sort selection keys ("med" > "high"); fixed via a stored computed `urgency_rank` integer + regression test that asserts the actual priority order - Dropped `widget="many2one_avatar"` on `photo_id` (the avatar widget is for partner-style records, not ir.attachment) Deferred: EXIF GPS auto-population from photo uploads (needs an OWL JS helper that parses files client-side — separate PR when a field partner asks). --- farm_observation/README.rst | 116 +++++ farm_observation/__init__.py | 1 + farm_observation/__manifest__.py | 21 + farm_observation/models/__init__.py | 1 + farm_observation/models/farm_observation.py | 104 ++++ farm_observation/pyproject.toml | 3 + farm_observation/readme/CONTRIBUTORS.md | 1 + farm_observation/readme/DESCRIPTION.md | 23 + farm_observation/readme/USAGE.md | 16 + .../readme/newsfragments/.gitkeep | 0 farm_observation/security/ir.model.access.csv | 2 + .../static/description/index.html | 468 ++++++++++++++++++ farm_observation/tests/__init__.py | 1 + .../tests/test_farm_observation.py | 87 ++++ .../views/farm_observation_menu.xml | 10 + .../views/farm_observation_views.xml | 164 ++++++ 16 files changed, 1018 insertions(+) create mode 100644 farm_observation/README.rst create mode 100644 farm_observation/__init__.py create mode 100644 farm_observation/__manifest__.py create mode 100644 farm_observation/models/__init__.py create mode 100644 farm_observation/models/farm_observation.py create mode 100644 farm_observation/pyproject.toml create mode 100644 farm_observation/readme/CONTRIBUTORS.md create mode 100644 farm_observation/readme/DESCRIPTION.md create mode 100644 farm_observation/readme/USAGE.md create mode 100644 farm_observation/readme/newsfragments/.gitkeep create mode 100644 farm_observation/security/ir.model.access.csv create mode 100644 farm_observation/static/description/index.html create mode 100644 farm_observation/tests/__init__.py create mode 100644 farm_observation/tests/test_farm_observation.py create mode 100644 farm_observation/views/farm_observation_menu.xml create mode 100644 farm_observation/views/farm_observation_views.xml diff --git a/farm_observation/README.rst b/farm_observation/README.rst new file mode 100644 index 0000000..4795943 --- /dev/null +++ b/farm_observation/README.rst @@ -0,0 +1,116 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +Farm Observation +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:be7b66847b88e6866c79d720e6ae4599461c9e9d60519bd7831dcded461eef31 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_observation + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Geotagged field observations: timestamped notes + a photo + a point on +the map, urgency-sorted so the high-priority items rise to the top of +every list and kanban. + +Each observation belongs to a ``farm.field`` and carries: + +- **Type** — pest damage, disease, weed pressure, soil condition, water, + yield issue, photo survey, other +- **Urgency** — low / medium / high (tracked via mail.thread so a + manager sees who bumped it and when) +- **Location** — optional ``GeoPoint`` you set by clicking the map + widget on the Location tab +- **Photo** — a single representative attachment; use chatter for + additional shots +- **Notes** — required free text + +Views: list (color-coded by urgency), urgency-grouped kanban, form with +chatter, search with last-7-days + high-urgency canned filters, and a +geoengine map view that drops pins on the field map. + +**Scope.** Manual point-picking only. Auto-population from photo EXIF +GPS tags is deferred — needs an OWL JS helper on the photo upload widget +that parses the file client-side. Track in a follow-up if a field +partner asks. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open Farm → Observations. +2. Click **New** to log a finding. +3. Pick the field, observation type, and urgency. +4. Write what you saw in **Notes** (required). +5. Optional: attach a photo, and click on the **Location** map tab to + drop a point where the observation happened. +6. Save. The record auto-names itself from the field + type + date. + +Day-to-day workflows: + +- **Manager scan**: switch to the Map view to see every recent pin + colored by urgency, or use the kanban grouped by urgency to triage + high → low. +- **Field walk**: open the Last 7 Days filter, sort by field, scan what + observers logged this week. +- **Pest follow-up**: filter by Type = Pest, group by Field, see which + plots keep showing up. + +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_observation/__init__.py b/farm_observation/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_observation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_observation/__manifest__.py b/farm_observation/__manifest__.py new file mode 100644 index 0000000..4fecacc --- /dev/null +++ b/farm_observation/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Farm Observation", + "version": "19.0.1.0.0", + "summary": "Geotagged field observations (photos + notes, urgency-sorted)", + "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_geo", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "views/farm_observation_views.xml", + "views/farm_observation_menu.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_observation/models/__init__.py b/farm_observation/models/__init__.py new file mode 100644 index 0000000..7252645 --- /dev/null +++ b/farm_observation/models/__init__.py @@ -0,0 +1 @@ +from . import farm_observation diff --git a/farm_observation/models/farm_observation.py b/farm_observation/models/farm_observation.py new file mode 100644 index 0000000..a394b05 --- /dev/null +++ b/farm_observation/models/farm_observation.py @@ -0,0 +1,104 @@ +from odoo import api, fields, models + +# Selection keys are alphabetical strings (low/med/high) for readability in +# the API + URLs, but a literal DESC sort would put "med" above "high" +# (alpha order). `urgency_rank` is a stored computed int that mirrors the +# selection so `_order` produces the actual priority sort. +_URGENCY_RANK = {"low": 0, "med": 1, "high": 2} + + +class FarmObservation(models.Model): + _name = "farm.observation" + _description = "Field Observation" + _inherit = ["mail.thread"] + # High-urgency, recent items rise to the top of every list / kanban. + _order = "urgency_rank desc, observation_date desc" + + field_id = fields.Many2one( + "farm.field", + string="Field", + required=True, + index=True, + ondelete="cascade", + help="Which field this observation was captured on.", + ) + name = fields.Char( + compute="_compute_name", + store=True, + help="Auto-generated label combining the field, type, and date.", + ) + observation_type = fields.Selection( + [ + ("pest", "Pest Damage"), + ("disease", "Disease"), + ("weed", "Weed Pressure"), + ("soil", "Soil Condition"), + ("water", "Water / Drainage"), + ("yield", "Yield Issue"), + ("photo", "Photo Survey"), + ("other", "Other"), + ], + required=True, + default="photo", + tracking=True, + ) + geom = fields.GeoPoint( + string="Location", + srid=4326, + help="Point where this observation was made. Set by clicking on the map " + "in the form view, or auto-populated from photo EXIF GPS tags in a " + "future release.", + ) + observation_date = fields.Datetime( + required=True, + default=fields.Datetime.now, + tracking=True, + ) + notes = fields.Text(required=True) + photo_id = fields.Many2one( + "ir.attachment", + string="Photo", + domain="[('mimetype', 'ilike', 'image/')]", + help="Attach a single representative photo. Use chatter attachments " + "for multiple shots.", + ) + urgency = fields.Selection( + [ + ("low", "Low"), + ("med", "Medium"), + ("high", "High"), + ], + default="low", + required=True, + tracking=True, + ) + urgency_rank = fields.Integer( + compute="_compute_urgency_rank", + store=True, + index=True, + help="Numeric mirror of urgency so _order produces a priority sort " + "(string DESC on selection keys would alphabetize, putting 'med' " + "above 'high').", + ) + company_id = fields.Many2one( + related="field_id.company_id", + store=True, + index=True, + ) + + @api.depends("urgency") + def _compute_urgency_rank(self): + for rec in self: + rec.urgency_rank = _URGENCY_RANK.get(rec.urgency, 0) + + @api.depends("field_id", "observation_type", "observation_date") + def _compute_name(self): + # E.g. "North 40 — Pest Damage — 2026-05-17" + type_labels = dict(self._fields["observation_type"].selection) + for rec in self: + field_name = rec.field_id.name or "?" + type_label = type_labels.get(rec.observation_type, "?") + date_str = ( + rec.observation_date and rec.observation_date.date().isoformat() or "?" + ) + rec.name = f"{field_name} — {type_label} — {date_str}" diff --git a/farm_observation/pyproject.toml b/farm_observation/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_observation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_observation/readme/CONTRIBUTORS.md b/farm_observation/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_observation/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_observation/readme/DESCRIPTION.md b/farm_observation/readme/DESCRIPTION.md new file mode 100644 index 0000000..5c8cc9e --- /dev/null +++ b/farm_observation/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +Geotagged field observations: timestamped notes + a photo + a point on the +map, urgency-sorted so the high-priority items rise to the top of every +list and kanban. + +Each observation belongs to a `farm.field` and carries: + +- **Type** — pest damage, disease, weed pressure, soil condition, water, + yield issue, photo survey, other +- **Urgency** — low / medium / high (tracked via mail.thread so a manager + sees who bumped it and when) +- **Location** — optional `GeoPoint` you set by clicking the map widget on + the Location tab +- **Photo** — a single representative attachment; use chatter for additional + shots +- **Notes** — required free text + +Views: list (color-coded by urgency), urgency-grouped kanban, form with +chatter, search with last-7-days + high-urgency canned filters, and a +geoengine map view that drops pins on the field map. + +**Scope.** Manual point-picking only. Auto-population from photo EXIF GPS +tags is deferred — needs an OWL JS helper on the photo upload widget that +parses the file client-side. Track in a follow-up if a field partner asks. diff --git a/farm_observation/readme/USAGE.md b/farm_observation/readme/USAGE.md new file mode 100644 index 0000000..6a53f0a --- /dev/null +++ b/farm_observation/readme/USAGE.md @@ -0,0 +1,16 @@ +1. Open Farm → Observations. +2. Click **New** to log a finding. +3. Pick the field, observation type, and urgency. +4. Write what you saw in **Notes** (required). +5. Optional: attach a photo, and click on the **Location** map tab to drop a + point where the observation happened. +6. Save. The record auto-names itself from the field + type + date. + +Day-to-day workflows: + +- **Manager scan**: switch to the Map view to see every recent pin colored + by urgency, or use the kanban grouped by urgency to triage high → low. +- **Field walk**: open the Last 7 Days filter, sort by field, scan what + observers logged this week. +- **Pest follow-up**: filter by Type = Pest, group by Field, see which + plots keep showing up. diff --git a/farm_observation/readme/newsfragments/.gitkeep b/farm_observation/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_observation/security/ir.model.access.csv b/farm_observation/security/ir.model.access.csv new file mode 100644 index 0000000..b6f9b0b --- /dev/null +++ b/farm_observation/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_observation_user,farm.observation user,model_farm_observation,base.group_user,1,1,1,1 diff --git a/farm_observation/static/description/index.html b/farm_observation/static/description/index.html new file mode 100644 index 0000000..9c51214 --- /dev/null +++ b/farm_observation/static/description/index.html @@ -0,0 +1,468 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Observation

+ +

Beta License: AGPL-3 ledoent/farm-pack

+

Geotagged field observations: timestamped notes + a photo + a point on +the map, urgency-sorted so the high-priority items rise to the top of +every list and kanban.

+

Each observation belongs to a farm.field and carries:

+
    +
  • Type — pest damage, disease, weed pressure, soil condition, water, +yield issue, photo survey, other
  • +
  • Urgency — low / medium / high (tracked via mail.thread so a +manager sees who bumped it and when)
  • +
  • Location — optional GeoPoint you set by clicking the map +widget on the Location tab
  • +
  • Photo — a single representative attachment; use chatter for +additional shots
  • +
  • Notes — required free text
  • +
+

Views: list (color-coded by urgency), urgency-grouped kanban, form with +chatter, search with last-7-days + high-urgency canned filters, and a +geoengine map view that drops pins on the field map.

+

Scope. Manual point-picking only. Auto-population from photo EXIF +GPS tags is deferred — needs an OWL JS helper on the photo upload widget +that parses the file client-side. Track in a follow-up if a field +partner asks.

+

Table of contents

+ +
+

Usage

+
    +
  1. Open Farm → Observations.
  2. +
  3. Click New to log a finding.
  4. +
  5. Pick the field, observation type, and urgency.
  6. +
  7. Write what you saw in Notes (required).
  8. +
  9. Optional: attach a photo, and click on the Location map tab to +drop a point where the observation happened.
  10. +
  11. Save. The record auto-names itself from the field + type + date.
  12. +
+

Day-to-day workflows:

+
    +
  • Manager scan: switch to the Map view to see every recent pin +colored by urgency, or use the kanban grouped by urgency to triage +high → low.
  • +
  • Field walk: open the Last 7 Days filter, sort by field, scan what +observers logged this week.
  • +
  • Pest follow-up: filter by Type = Pest, group by Field, see which +plots keep showing up.
  • +
+
+
+

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_observation/tests/__init__.py b/farm_observation/tests/__init__.py new file mode 100644 index 0000000..006a98b --- /dev/null +++ b/farm_observation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_observation diff --git a/farm_observation/tests/test_farm_observation.py b/farm_observation/tests/test_farm_observation.py new file mode 100644 index 0000000..70dcd53 --- /dev/null +++ b/farm_observation/tests/test_farm_observation.py @@ -0,0 +1,87 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmObservation(TransactionCase): + @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"}) + cls.field = cls.env["farm.field"].create( + { + "name": "North 40", + "farm_partner_id": cls.farm.id, + "crop_id": cls.crop.id, + } + ) + + def _make_obs(self, **overrides): + vals = { + "field_id": self.field.id, + "observation_type": "pest", + "notes": "Aphids on the south edge", + } + vals.update(overrides) + return self.env["farm.observation"].create(vals) + + def test_name_includes_field_type_and_date(self): + # Name should auto-compose from field + type + date so users see + # something meaningful in lists without opening the form. + obs = self._make_obs(observation_type="pest") + self.assertIn(self.field.name, obs.name) + self.assertIn("Pest", obs.name) + self.assertIn(str(obs.observation_date.year), obs.name) + + def test_name_recomputes_on_field_change(self): + obs = self._make_obs() + original_name = obs.name + other_field = self.env["farm.field"].create( + { + "name": "South Pasture", + "farm_partner_id": self.farm.id, + "crop_id": self.crop.id, + } + ) + obs.field_id = other_field + obs.flush_recordset() + obs.invalidate_recordset() + self.assertNotEqual(obs.name, original_name) + self.assertIn("South Pasture", obs.name) + + def test_urgency_tracking(self): + # Urgency change writes a chatter message so a manager can see when + # someone bumped a finding from low → high. + obs = self._make_obs(urgency="low") + initial_msgs = len(obs.message_ids) + obs.urgency = "high" + obs.flush_recordset() + self.assertGreater(len(obs.message_ids), initial_msgs) + + def test_company_id_inherits_from_field(self): + obs = self._make_obs() + self.assertEqual(obs.company_id, self.field.company_id) + + def test_geom_optional(self): + # Observations made far from the farmhouse may have no GPS yet — + # geom is optional, the record still saves. + obs = self._make_obs(geom=None) + self.assertFalse(obs.geom) + + def test_urgency_sort_priority_not_alphabetical(self): + # Regression: an early version sorted by `urgency desc` on the raw + # selection key, which alpha-sorts "med" above "high". Verify the + # numeric rank actually puts high-urgency first. + low = self._make_obs(urgency="low", notes="cosmetic") + high = self._make_obs(urgency="high", notes="actively dying") + med = self._make_obs(urgency="med", notes="watch this") + ordered = self.env["farm.observation"].search( + [("id", "in", (low.id, med.id, high.id))] + ) + self.assertEqual( + ordered.mapped("urgency"), + ["high", "med", "low"], + "high urgency must sort first; " + "if you see ['med', 'low', 'high'] urgency_rank lost its compute", + ) diff --git a/farm_observation/views/farm_observation_menu.xml b/farm_observation/views/farm_observation_menu.xml new file mode 100644 index 0000000..2cd9de6 --- /dev/null +++ b/farm_observation/views/farm_observation_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_observation/views/farm_observation_views.xml b/farm_observation/views/farm_observation_views.xml new file mode 100644 index 0000000..041b3d4 --- /dev/null +++ b/farm_observation/views/farm_observation_views.xml @@ -0,0 +1,164 @@ + + + + farm.observation.list + farm.observation + + + + + + + + + + + + + farm.observation.kanban + farm.observation + + + + + + + + + + + +
+
+ + + +
+
+ +
+ + + +

+ +

+
+
+
+
+
+
+
+ + + farm.observation.form + farm.observation + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.observation.search + farm.observation + + + + + + + + + + + + + + + + + + + + + farm.observation.geoengine + farm.observation + + + + + + + + + + + + Field Observations + farm.observation + kanban,list,form,geoengine + +
From 1debd67db42952dcce9f2a6a409876559ab05311 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Sun, 17 May 2026 20:34:58 -0400 Subject: [PATCH 11/13] feat(farm_water_source): wells, ponds, streams + which fields they serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `farm.water.source` model. Tracks the farm's water infrastructure with its location, capacity, last-tested date, and a many2many of fields each source serves. `geom` is `GeoMultiGeometry` so one column accepts the shape that matches the type — point for well/trough/spring/hydrant, linestring for stream, polygon for pond/tank. Views: list (sum capacity), form (notebook for quality notes + location map), search with "not tested in 1 year" canned filter, geoengine map view showing every source on the field map. Four tests cover all three geometry shapes (point/line/polygon) on the same column and the field_ids many2many lookup that answers "what fields does this source serve?". --- farm_water_source/README.rst | 119 +++++ farm_water_source/__init__.py | 1 + farm_water_source/__manifest__.py | 20 + farm_water_source/models/__init__.py | 1 + farm_water_source/models/farm_water_source.py | 47 ++ farm_water_source/pyproject.toml | 3 + farm_water_source/readme/CONTRIBUTORS.md | 1 + farm_water_source/readme/DESCRIPTION.md | 17 + farm_water_source/readme/USAGE.md | 21 + .../readme/newsfragments/.gitkeep | 0 .../security/ir.model.access.csv | 2 + .../static/description/index.html | 486 ++++++++++++++++++ farm_water_source/tests/__init__.py | 1 + .../tests/test_farm_water_source.py | 88 ++++ .../views/farm_water_source_menu.xml | 10 + .../views/farm_water_source_views.xml | 103 ++++ 16 files changed, 920 insertions(+) create mode 100644 farm_water_source/README.rst create mode 100644 farm_water_source/__init__.py create mode 100644 farm_water_source/__manifest__.py create mode 100644 farm_water_source/models/__init__.py create mode 100644 farm_water_source/models/farm_water_source.py create mode 100644 farm_water_source/pyproject.toml create mode 100644 farm_water_source/readme/CONTRIBUTORS.md create mode 100644 farm_water_source/readme/DESCRIPTION.md create mode 100644 farm_water_source/readme/USAGE.md create mode 100644 farm_water_source/readme/newsfragments/.gitkeep create mode 100644 farm_water_source/security/ir.model.access.csv create mode 100644 farm_water_source/static/description/index.html create mode 100644 farm_water_source/tests/__init__.py create mode 100644 farm_water_source/tests/test_farm_water_source.py create mode 100644 farm_water_source/views/farm_water_source_menu.xml create mode 100644 farm_water_source/views/farm_water_source_views.xml diff --git a/farm_water_source/README.rst b/farm_water_source/README.rst new file mode 100644 index 0000000..83cd165 --- /dev/null +++ b/farm_water_source/README.rst @@ -0,0 +1,119 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Farm Water Source +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9d5ee627395550f366a82fe33508898dd73e25e55d39a5ce9a86027ddb10409c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_water_source + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Tracks water sources on the farm — wells, surface tanks, ponds, streams, +troughs, springs, hydrants — with their location, capacity, last-tested +date, and which fields they serve. + +The ``geom`` column uses ``GeoMultiGeometry`` so one field can hold any +of: + +- **Point** — well, trough, spring, hydrant (single coordinate) +- **LineString** — stream / creek (multi-segment path) +- **Polygon** — pond / surface tank (closed area) + +The trade-off is looser shape validation in exchange for not needing +four separate models. Conventional usage matches each ``source_type`` to +a shape, documented in USAGE; an admin who wants strict per-type +validation can layer a ``_constraint`` on top later. + +The ``field_ids`` many2many answers "which fields does this source +serve?", useful when planning rotational grazing or drought contingency. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open Farm → Water Sources. + +2. Click **New** and fill the name, type, optional capacity. + +3. Tick the fields this source serves under **Fields Served**. + +4. Open the **Location** tab and place the geometry on the map: + + ============================= ============================== + Type Geometry + ============================= ============================== + Well, Trough, Spring, Hydrant Single point + Stream / Creek LineString (multiple vertices) + Pond, Surface Tank Polygon (closed area) + ============================= ============================== + +5. Save. Switch to the Map view to see every source on the field map. + +Day-to-day: + +- **Annual water testing**: filter by "Not Tested in 1 Year" to find + sources due for a potability test. +- **Rotational grazing planning**: open a field's map, see which sources + serve which paddocks, plan moves accordingly. +- **Drought contingency**: switch the action to the Map view to see all + active sources at once and identify backup options. + +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_water_source/__init__.py b/farm_water_source/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_water_source/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_water_source/__manifest__.py b/farm_water_source/__manifest__.py new file mode 100644 index 0000000..773c69e --- /dev/null +++ b/farm_water_source/__manifest__.py @@ -0,0 +1,20 @@ +{ + "name": "Farm Water Source", + "version": "19.0.1.0.0", + "summary": "Wells, ponds, troughs, springs — geometry + which fields they serve", + "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_geo", + ], + "data": [ + "security/ir.model.access.csv", + "views/farm_water_source_views.xml", + "views/farm_water_source_menu.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_water_source/models/__init__.py b/farm_water_source/models/__init__.py new file mode 100644 index 0000000..f92b509 --- /dev/null +++ b/farm_water_source/models/__init__.py @@ -0,0 +1 @@ +from . import farm_water_source diff --git a/farm_water_source/models/farm_water_source.py b/farm_water_source/models/farm_water_source.py new file mode 100644 index 0000000..f45af18 --- /dev/null +++ b/farm_water_source/models/farm_water_source.py @@ -0,0 +1,47 @@ +from odoo import fields, models + + +class FarmWaterSource(models.Model): + _name = "farm.water.source" + _description = "Water Source" + _order = "name" + + name = fields.Char(required=True) + source_type = fields.Selection( + [ + ("well", "Well"), + ("tank", "Surface Tank"), + ("pond", "Pond"), + ("stream", "Stream / Creek"), + ("trough", "Trough"), + ("spring", "Spring"), + ("hydrant", "Hydrant / Spigot"), + ], + required=True, + ) + # GeoMultiGeometry holds Point, LineString, or Polygon — the right shape + # depends on the source type (well/trough/spring/hydrant = point, + # stream = linestring, pond/tank = polygon). One column keeps the model + # simple at the cost of looser validation; an admin who really wants the + # constraint can layer it on later. + geom = fields.GeoMultiGeometry(string="Location", srid=4326) + field_ids = fields.Many2many( + "farm.field", + string="Fields Served", + help="Which fields draw from this source. Drives the 'where is this " + "field's water coming from' query.", + ) + capacity_gallons = fields.Float( + digits=(10, 0), + help="Practical usable volume in gallons. Leave blank for streams / " + "springs where flow rate matters more than reservoir capacity.", + ) + last_tested_date = fields.Date(help="Most recent potability / coliform test.") + quality_notes = fields.Text() + active = fields.Boolean(default=True) + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company, + index=True, + ) diff --git a/farm_water_source/pyproject.toml b/farm_water_source/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_water_source/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_water_source/readme/CONTRIBUTORS.md b/farm_water_source/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_water_source/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_water_source/readme/DESCRIPTION.md b/farm_water_source/readme/DESCRIPTION.md new file mode 100644 index 0000000..246b515 --- /dev/null +++ b/farm_water_source/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +Tracks water sources on the farm — wells, surface tanks, ponds, streams, +troughs, springs, hydrants — with their location, capacity, last-tested +date, and which fields they serve. + +The `geom` column uses `GeoMultiGeometry` so one field can hold any of: + +- **Point** — well, trough, spring, hydrant (single coordinate) +- **LineString** — stream / creek (multi-segment path) +- **Polygon** — pond / surface tank (closed area) + +The trade-off is looser shape validation in exchange for not needing four +separate models. Conventional usage matches each `source_type` to a shape, +documented in USAGE; an admin who wants strict per-type validation can +layer a `_constraint` on top later. + +The `field_ids` many2many answers "which fields does this source serve?", +useful when planning rotational grazing or drought contingency. diff --git a/farm_water_source/readme/USAGE.md b/farm_water_source/readme/USAGE.md new file mode 100644 index 0000000..781b5b0 --- /dev/null +++ b/farm_water_source/readme/USAGE.md @@ -0,0 +1,21 @@ +1. Open Farm → Water Sources. +2. Click **New** and fill the name, type, optional capacity. +3. Tick the fields this source serves under **Fields Served**. +4. Open the **Location** tab and place the geometry on the map: + + | Type | Geometry | + |---|---| + | Well, Trough, Spring, Hydrant | Single point | + | Stream / Creek | LineString (multiple vertices) | + | Pond, Surface Tank | Polygon (closed area) | + +5. Save. Switch to the Map view to see every source on the field map. + +Day-to-day: + +- **Annual water testing**: filter by "Not Tested in 1 Year" to find sources + due for a potability test. +- **Rotational grazing planning**: open a field's map, see which sources + serve which paddocks, plan moves accordingly. +- **Drought contingency**: switch the action to the Map view to see all + active sources at once and identify backup options. diff --git a/farm_water_source/readme/newsfragments/.gitkeep b/farm_water_source/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_water_source/security/ir.model.access.csv b/farm_water_source/security/ir.model.access.csv new file mode 100644 index 0000000..a63a234 --- /dev/null +++ b/farm_water_source/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_farm_water_source_user,farm.water.source user,model_farm_water_source,base.group_user,1,1,1,1 diff --git a/farm_water_source/static/description/index.html b/farm_water_source/static/description/index.html new file mode 100644 index 0000000..c635149 --- /dev/null +++ b/farm_water_source/static/description/index.html @@ -0,0 +1,486 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Water Source

+ +

Beta License: AGPL-3 ledoent/farm-pack

+

Tracks water sources on the farm — wells, surface tanks, ponds, streams, +troughs, springs, hydrants — with their location, capacity, last-tested +date, and which fields they serve.

+

The geom column uses GeoMultiGeometry so one field can hold any +of:

+
    +
  • Point — well, trough, spring, hydrant (single coordinate)
  • +
  • LineString — stream / creek (multi-segment path)
  • +
  • Polygon — pond / surface tank (closed area)
  • +
+

The trade-off is looser shape validation in exchange for not needing +four separate models. Conventional usage matches each source_type to +a shape, documented in USAGE; an admin who wants strict per-type +validation can layer a _constraint on top later.

+

The field_ids many2many answers “which fields does this source +serve?”, useful when planning rotational grazing or drought contingency.

+

Table of contents

+ +
+

Usage

+
    +
  1. Open Farm → Water Sources.

    +
  2. +
  3. Click New and fill the name, type, optional capacity.

    +
  4. +
  5. Tick the fields this source serves under Fields Served.

    +
  6. +
  7. Open the Location tab and place the geometry on the map:

    + ++++ + + + + + + + + + + + + + + + + +
    TypeGeometry
    Well, Trough, Spring, HydrantSingle point
    Stream / CreekLineString (multiple vertices)
    Pond, Surface TankPolygon (closed area)
    +
  8. +
  9. Save. Switch to the Map view to see every source on the field map.

    +
  10. +
+

Day-to-day:

+
    +
  • Annual water testing: filter by “Not Tested in 1 Year” to find +sources due for a potability test.
  • +
  • Rotational grazing planning: open a field’s map, see which sources +serve which paddocks, plan moves accordingly.
  • +
  • Drought contingency: switch the action to the Map view to see all +active sources at once and identify backup options.
  • +
+
+
+

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_water_source/tests/__init__.py b/farm_water_source/tests/__init__.py new file mode 100644 index 0000000..679a889 --- /dev/null +++ b/farm_water_source/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_water_source diff --git a/farm_water_source/tests/test_farm_water_source.py b/farm_water_source/tests/test_farm_water_source.py new file mode 100644 index 0000000..45c24ac --- /dev/null +++ b/farm_water_source/tests/test_farm_water_source.py @@ -0,0 +1,88 @@ +from odoo.tests.common import TransactionCase + + +class TestFarmWaterSource(TransactionCase): + @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": "Pasture", "code": "PAST"}) + cls.field_north = cls.env["farm.field"].create( + { + "name": "North 40", + "farm_partner_id": cls.farm.id, + "crop_id": cls.crop.id, + } + ) + cls.field_south = cls.env["farm.field"].create( + { + "name": "South Pasture", + "farm_partner_id": cls.farm.id, + "crop_id": cls.crop.id, + } + ) + + def test_well_can_be_a_point(self): + # Sanity check: a well is a point and the field saves. + well = self.env["farm.water.source"].create( + { + "name": "House Well", + "source_type": "well", + "geom": "SRID=4326;POINT(-79.245 40.242)", + } + ) + self.assertEqual(well.source_type, "well") + self.assertTrue(well.geom) + + def test_pond_can_be_a_polygon(self): + # A pond is best represented as a polygon — GeoMultiGeometry must + # accept that shape on the same column. + pond = self.env["farm.water.source"].create( + { + "name": "Big Pond", + "source_type": "pond", + "geom": ( + "SRID=4326;POLYGON((" + "-79.245 40.242, " + "-79.244 40.242, " + "-79.244 40.243, " + "-79.245 40.243, " + "-79.245 40.242" + "))" + ), + } + ) + self.assertEqual(pond.source_type, "pond") + self.assertTrue(pond.geom) + + def test_stream_can_be_a_linestring(self): + # And a stream is best represented as a line — same column, third shape. + stream = self.env["farm.water.source"].create( + { + "name": "Burns Run", + "source_type": "stream", + "geom": ( + "SRID=4326;LINESTRING(" + "-79.245 40.242, -79.240 40.245, -79.235 40.250" + ")" + ), + } + ) + self.assertEqual(stream.source_type, "stream") + self.assertTrue(stream.geom) + + def test_fields_served_many2many(self): + # Two fields drawing from one well — the canonical use case for + # field_ids: 'what fields does this source serve?' + well = self.env["farm.water.source"].create( + { + "name": "Pasture Hydrant", + "source_type": "hydrant", + "field_ids": [(6, 0, [self.field_north.id, self.field_south.id])], + } + ) + self.assertEqual(len(well.field_ids), 2) + self.assertIn(self.field_north, well.field_ids) + self.assertIn(self.field_south, well.field_ids) diff --git a/farm_water_source/views/farm_water_source_menu.xml b/farm_water_source/views/farm_water_source_menu.xml new file mode 100644 index 0000000..46dae39 --- /dev/null +++ b/farm_water_source/views/farm_water_source_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_water_source/views/farm_water_source_views.xml b/farm_water_source/views/farm_water_source_views.xml new file mode 100644 index 0000000..7edf787 --- /dev/null +++ b/farm_water_source/views/farm_water_source_views.xml @@ -0,0 +1,103 @@ + + + + farm.water.source.list + farm.water.source + + + + + + + + + + + + + farm.water.source.form + farm.water.source + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + farm.water.source.search + farm.water.source + + + + + + + + + + + + + + + + + farm.water.source.geoengine + farm.water.source + + + + + + + + + + + Water Sources + farm.water.source + list,form,geoengine + +
From fe90883d837ddca7370a908103abce6557a2341b Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 11:38:06 -0400 Subject: [PATCH 12/13] fix(farm_water_source): pre-empt all PR #4 cascade gotchas Bundle of fixes learned from PR #4 in one commit: - development_status=Alpha (OCA check-dev-status gate) - Drop bogus code field from farm.crop test fixture - Replace EWKT strings with shapely Point/Polygon/LineString (base_geoengine's GeoMultiGeometry field expects shapely or EWKB hex) - Declare shapely in external_dependencies.python - Unwrap in search view (Odoo 19 RNG rejects both attributes on ) --- farm_water_source/README.rst | 9 ++++-- farm_water_source/__manifest__.py | 5 ++++ .../static/description/index.html | 8 +++++- .../tests/test_farm_water_source.py | 28 +++++++++---------- .../views/farm_water_source_views.xml | 18 +++++++----- 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/farm_water_source/README.rst b/farm_water_source/README.rst index 83cd165..7bc847a 100644 --- a/farm_water_source/README.rst +++ b/farm_water_source/README.rst @@ -14,9 +14,9 @@ Farm Water Source !! source digest: sha256:9d5ee627395550f366a82fe33508898dd73e25e55d39a5ce9a86027ddb10409c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -.. |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 @@ -45,6 +45,11 @@ validation can layer a ``_constraint`` on top later. The ``field_ids`` many2many answers "which fields does this source serve?", useful when planning rotational grazing or drought contingency. +.. 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_water_source/__manifest__.py b/farm_water_source/__manifest__.py index 773c69e..2df9353 100644 --- a/farm_water_source/__manifest__.py +++ b/farm_water_source/__manifest__.py @@ -7,9 +7,14 @@ "website": "https://github.com/ledoent/farm-pack", "license": "AGPL-3", "category": "Vertical/Agriculture", + # Alpha matches farm_field_geo's chain — OCA check-dev-status gate. + "development_status": "Alpha", "depends": [ "farm_field_geo", ], + "external_dependencies": { + "python": ["shapely"], + }, "data": [ "security/ir.model.access.csv", "views/farm_water_source_views.xml", diff --git a/farm_water_source/static/description/index.html b/farm_water_source/static/description/index.html index c635149..06c2da6 100644 --- a/farm_water_source/static/description/index.html +++ b/farm_water_source/static/description/index.html @@ -374,7 +374,7 @@

Farm Water Source

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:9d5ee627395550f366a82fe33508898dd73e25e55d39a5ce9a86027ddb10409c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 ledoent/farm-pack

+

Alpha License: AGPL-3 ledoent/farm-pack

Tracks water sources on the farm — wells, surface tanks, ponds, streams, troughs, springs, hydrants — with their location, capacity, last-tested date, and which fields they serve.

@@ -391,6 +391,12 @@

Farm Water Source

validation can layer a _constraint on top later.

The field_ids many2many answers “which fields does this source serve?”, useful when planning rotational grazing or drought contingency.

+
+

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

    diff --git a/farm_water_source/tests/test_farm_water_source.py b/farm_water_source/tests/test_farm_water_source.py index 45c24ac..6a9cbf1 100644 --- a/farm_water_source/tests/test_farm_water_source.py +++ b/farm_water_source/tests/test_farm_water_source.py @@ -1,3 +1,5 @@ +from shapely.geometry import LineString, Point, Polygon + from odoo.tests.common import TransactionCase @@ -8,7 +10,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": "Pasture", "code": "PAST"}) + cls.crop = cls.env["farm.crop"].create({"name": "Pasture"}) cls.field_north = cls.env["farm.field"].create( { "name": "North 40", @@ -30,7 +32,7 @@ def test_well_can_be_a_point(self): { "name": "House Well", "source_type": "well", - "geom": "SRID=4326;POINT(-79.245 40.242)", + "geom": Point(-79.245, 40.242), } ) self.assertEqual(well.source_type, "well") @@ -43,14 +45,14 @@ def test_pond_can_be_a_polygon(self): { "name": "Big Pond", "source_type": "pond", - "geom": ( - "SRID=4326;POLYGON((" - "-79.245 40.242, " - "-79.244 40.242, " - "-79.244 40.243, " - "-79.245 40.243, " - "-79.245 40.242" - "))" + "geom": Polygon( + [ + (-79.245, 40.242), + (-79.244, 40.242), + (-79.244, 40.243), + (-79.245, 40.243), + (-79.245, 40.242), + ] ), } ) @@ -63,10 +65,8 @@ def test_stream_can_be_a_linestring(self): { "name": "Burns Run", "source_type": "stream", - "geom": ( - "SRID=4326;LINESTRING(" - "-79.245 40.242, -79.240 40.245, -79.235 40.250" - ")" + "geom": LineString( + [(-79.245, 40.242), (-79.240, 40.245), (-79.235, 40.250)] ), } ) diff --git a/farm_water_source/views/farm_water_source_views.xml b/farm_water_source/views/farm_water_source_views.xml index 7edf787..e85f0af 100644 --- a/farm_water_source/views/farm_water_source_views.xml +++ b/farm_water_source/views/farm_water_source_views.xml @@ -72,13 +72,17 @@ string="Archived" domain="[('active', '=', False)]" /> - - - + + + From f327c7cb1e5ea74b814aa9b3054fecbb9deccfc3 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 18 May 2026 11:45:14 -0400 Subject: [PATCH 13/13] =?UTF-8?q?fix(farm=5Fwater=5Fsource):=20GeoMultiGeo?= =?UTF-8?q?metry=20not=20in=20base=5Fgeoengine=2019.0=20=E2=80=94=20fall?= =?UTF-8?q?=20back=20to=20GeoPoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AttributeError: module 'odoo.fields' has no attribute 'GeoMultiGeometry'. base_geoengine 19.0 (PR #446 head) only exposes GeoPoint, GeoLine, GeoPolygon and their Multi variants — no GeoAnyGeometry. v1 represents every source type as a single Point (well-head, tank tap, pond centroid, stream access). Polygon footprints + linestrings deferred to a future farm_water_source_polygon extension if a customer asks for surface-area / depth-contour features. --- farm_water_source/README.rst | 18 ++++----- farm_water_source/models/farm_water_source.py | 13 +++--- farm_water_source/readme/DESCRIPTION.md | 16 +++----- .../static/description/index.html | 18 ++++----- .../tests/test_farm_water_source.py | 40 +++++++------------ 5 files changed, 42 insertions(+), 63 deletions(-) diff --git a/farm_water_source/README.rst b/farm_water_source/README.rst index 7bc847a..d1b78e8 100644 --- a/farm_water_source/README.rst +++ b/farm_water_source/README.rst @@ -30,17 +30,13 @@ Tracks water sources on the farm — wells, surface tanks, ponds, streams, troughs, springs, hydrants — with their location, capacity, last-tested date, and which fields they serve. -The ``geom`` column uses ``GeoMultiGeometry`` so one field can hold any -of: - -- **Point** — well, trough, spring, hydrant (single coordinate) -- **LineString** — stream / creek (multi-segment path) -- **Polygon** — pond / surface tank (closed area) - -The trade-off is looser shape validation in exchange for not needing -four separate models. Conventional usage matches each ``source_type`` to -a shape, documented in USAGE; an admin who wants strict per-type -validation can layer a ``_constraint`` on top later. +The ``geom`` column is a ``GeoPoint``. All source types are represented +as a single point: the well-head, the tank tap, the centroid of the +pond, the access point on the stream. We picked this in v1 because +base_geoengine 19.0 doesn't ship a "any geometry" type — adding a +polygon footprint for ponds (surface-area, depth contours) or a +linestring for streams would need a follow-up +``farm_water_source_polygon`` extension model. The ``field_ids`` many2many answers "which fields does this source serve?", useful when planning rotational grazing or drought contingency. diff --git a/farm_water_source/models/farm_water_source.py b/farm_water_source/models/farm_water_source.py index f45af18..d7b60a1 100644 --- a/farm_water_source/models/farm_water_source.py +++ b/farm_water_source/models/farm_water_source.py @@ -19,12 +19,13 @@ class FarmWaterSource(models.Model): ], required=True, ) - # GeoMultiGeometry holds Point, LineString, or Polygon — the right shape - # depends on the source type (well/trough/spring/hydrant = point, - # stream = linestring, pond/tank = polygon). One column keeps the model - # simple at the cost of looser validation; an admin who really wants the - # constraint can layer it on later. - geom = fields.GeoMultiGeometry(string="Location", srid=4326) + # All source types record a single point: the well-head, the tank tap, + # the centroid of the pond, the access point on the stream. We picked + # this in v1 because base_geoengine 19.0 (PR #446 head) doesn't ship a + # GeoAnyGeometry type — a future farm_water_source_polygon module can + # add an optional polygon footprint for ponds / streams when a customer + # asks for surface-area calculations. + geom = fields.GeoPoint(string="Location", srid=4326) field_ids = fields.Many2many( "farm.field", string="Fields Served", diff --git a/farm_water_source/readme/DESCRIPTION.md b/farm_water_source/readme/DESCRIPTION.md index 246b515..96a2c29 100644 --- a/farm_water_source/readme/DESCRIPTION.md +++ b/farm_water_source/readme/DESCRIPTION.md @@ -2,16 +2,12 @@ Tracks water sources on the farm — wells, surface tanks, ponds, streams, troughs, springs, hydrants — with their location, capacity, last-tested date, and which fields they serve. -The `geom` column uses `GeoMultiGeometry` so one field can hold any of: - -- **Point** — well, trough, spring, hydrant (single coordinate) -- **LineString** — stream / creek (multi-segment path) -- **Polygon** — pond / surface tank (closed area) - -The trade-off is looser shape validation in exchange for not needing four -separate models. Conventional usage matches each `source_type` to a shape, -documented in USAGE; an admin who wants strict per-type validation can -layer a `_constraint` on top later. +The `geom` column is a `GeoPoint`. All source types are represented as a +single point: the well-head, the tank tap, the centroid of the pond, the +access point on the stream. We picked this in v1 because base_geoengine +19.0 doesn't ship a "any geometry" type — adding a polygon footprint for +ponds (surface-area, depth contours) or a linestring for streams would +need a follow-up `farm_water_source_polygon` extension model. The `field_ids` many2many answers "which fields does this source serve?", useful when planning rotational grazing or drought contingency. diff --git a/farm_water_source/static/description/index.html b/farm_water_source/static/description/index.html index 06c2da6..bfb011e 100644 --- a/farm_water_source/static/description/index.html +++ b/farm_water_source/static/description/index.html @@ -378,17 +378,13 @@

    Farm Water Source

    Tracks water sources on the farm — wells, surface tanks, ponds, streams, troughs, springs, hydrants — with their location, capacity, last-tested date, and which fields they serve.

    -

    The geom column uses GeoMultiGeometry so one field can hold any -of:

    -
      -
    • Point — well, trough, spring, hydrant (single coordinate)
    • -
    • LineString — stream / creek (multi-segment path)
    • -
    • Polygon — pond / surface tank (closed area)
    • -
    -

    The trade-off is looser shape validation in exchange for not needing -four separate models. Conventional usage matches each source_type to -a shape, documented in USAGE; an admin who wants strict per-type -validation can layer a _constraint on top later.

    +

    The geom column is a GeoPoint. All source types are represented +as a single point: the well-head, the tank tap, the centroid of the +pond, the access point on the stream. We picked this in v1 because +base_geoengine 19.0 doesn’t ship a “any geometry” type — adding a +polygon footprint for ponds (surface-area, depth contours) or a +linestring for streams would need a follow-up +farm_water_source_polygon extension model.

    The field_ids many2many answers “which fields does this source serve?”, useful when planning rotational grazing or drought contingency.

    diff --git a/farm_water_source/tests/test_farm_water_source.py b/farm_water_source/tests/test_farm_water_source.py index 6a9cbf1..864a0f0 100644 --- a/farm_water_source/tests/test_farm_water_source.py +++ b/farm_water_source/tests/test_farm_water_source.py @@ -1,4 +1,4 @@ -from shapely.geometry import LineString, Point, Polygon +from shapely.geometry import Point from odoo.tests.common import TransactionCase @@ -27,7 +27,6 @@ def setUpClass(cls): ) def test_well_can_be_a_point(self): - # Sanity check: a well is a point and the field saves. well = self.env["farm.water.source"].create( { "name": "House Well", @@ -38,51 +37,42 @@ def test_well_can_be_a_point(self): self.assertEqual(well.source_type, "well") self.assertTrue(well.geom) - def test_pond_can_be_a_polygon(self): - # A pond is best represented as a polygon — GeoMultiGeometry must - # accept that shape on the same column. + def test_pond_records_a_centroid_point(self): + # v1 represents all source types as a single Point (well-head, tank + # tap, pond centroid). Polygon footprint deferred to a follow-up. pond = self.env["farm.water.source"].create( { "name": "Big Pond", "source_type": "pond", - "geom": Polygon( - [ - (-79.245, 40.242), - (-79.244, 40.242), - (-79.244, 40.243), - (-79.245, 40.243), - (-79.245, 40.242), - ] - ), + "geom": Point(-79.2445, 40.2425), } ) self.assertEqual(pond.source_type, "pond") self.assertTrue(pond.geom) - def test_stream_can_be_a_linestring(self): - # And a stream is best represented as a line — same column, third shape. + def test_stream_records_an_access_point(self): + # Streams record the access point where the user typically taps water + # — full linestring for the stream itself is out of scope for v1. stream = self.env["farm.water.source"].create( { - "name": "Burns Run", + "name": "Burns Run access", "source_type": "stream", - "geom": LineString( - [(-79.245, 40.242), (-79.240, 40.245), (-79.235, 40.250)] - ), + "geom": Point(-79.245, 40.242), } ) self.assertEqual(stream.source_type, "stream") self.assertTrue(stream.geom) def test_fields_served_many2many(self): - # Two fields drawing from one well — the canonical use case for + # Two fields drawing from one hydrant — the canonical use case for # field_ids: 'what fields does this source serve?' - well = self.env["farm.water.source"].create( + hydrant = self.env["farm.water.source"].create( { "name": "Pasture Hydrant", "source_type": "hydrant", "field_ids": [(6, 0, [self.field_north.id, self.field_south.id])], } ) - self.assertEqual(len(well.field_ids), 2) - self.assertIn(self.field_north, well.field_ids) - self.assertIn(self.field_south, well.field_ids) + self.assertEqual(len(hydrant.field_ids), 2) + self.assertIn(self.field_north, hydrant.field_ids) + self.assertIn(self.field_south, hydrant.field_ids)