diff --git a/farm_fence/README.rst b/farm_fence/README.rst new file mode 100644 index 0000000..d20c4c8 --- /dev/null +++ b/farm_fence/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 Fence +========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:171f636929817374863dbdf0c0d452afdc46cc06088475d08a8ef94f19f96cc9 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_fence + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Tracks fence lines on the farm with type (barbed wire, high-tensile, +electric, post-and-rail, woven wire, polywire, temporary), height, +condition (good / fair / needs repair), last-checked date, and an +auto-computed length in feet from the polyline geometry. + +``length_feet`` mirrors the acreage compute in ``farm_field_geo``: the +WGS84 polyline is reprojected to **EPSG:5070 Conus Albers Equal Area** +and the geodesic length in meters is converted to US survey feet +(0.3048006096012192 m/ft). The number matches what NRCS would report for +the same fence line in a rangeland improvement filing. + +Repairs-needed bubble to the top of every list and grouped kanban +because ``_order`` uses a stored computed ``condition_rank`` integer +mirroring the selection — a naive string DESC would put "good" above +"fair" (alpha order). + +Field link is optional (``ondelete="set null"``) so a perimeter fence +spanning multiple fields keeps its history even when one field is +archived or deleted. + +.. 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 +===== + +1. Open Farm → Fences. +2. Click **New** and pick a fence type + height. +3. Optional: link a primary field. Leave blank for perimeter fences. +4. Open the **Fence Line** tab and trace the fence on the map. +5. Save. **Length (feet)** auto-computes from the polyline. + +Day-to-day: + +- **Walk-the-fence rotation**: filter by "Not Checked in 6 Months" to + plan the next inspection sweep. +- **Repair triage**: kanban grouped by Condition puts Needs Repair at + the top so you can plan the materials run before the weekend. +- **Materials estimate**: open a fence record, read Length (feet), + multiply by the wire / post spec to size the order. +- **Insurance / NRCS reporting**: switch to the Map view to print a + screenshot of the entire fencing system colored by type, with each + fence's length in the legend. + +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_fence/__init__.py b/farm_fence/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_fence/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_fence/__manifest__.py b/farm_fence/__manifest__.py new file mode 100644 index 0000000..f9c3e42 --- /dev/null +++ b/farm_fence/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Farm Fence", + "version": "19.0.1.0.0", + "summary": "Fence lines + condition tracking + auto-computed length", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "maintainers": ["dnplkndll"], + "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", + "mail", + ], + "external_dependencies": { + "python": ["pyproj", "shapely"], + }, + "data": [ + "security/ir.model.access.csv", + "views/farm_fence_views.xml", + "views/farm_fence_menu.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_fence/models/__init__.py b/farm_fence/models/__init__.py new file mode 100644 index 0000000..79537f8 --- /dev/null +++ b/farm_fence/models/__init__.py @@ -0,0 +1 @@ +from . import farm_fence diff --git a/farm_fence/models/farm_fence.py b/farm_fence/models/farm_fence.py new file mode 100644 index 0000000..b4192a2 --- /dev/null +++ b/farm_fence/models/farm_fence.py @@ -0,0 +1,127 @@ +from functools import lru_cache + +from pyproj import Transformer +from shapely.ops import transform as shapely_transform + +from odoo import api, fields, models + +# US survey foot in meters. Used to convert EPSG:5070 length (m) to feet +# for display — US farm fencing is universally specified in feet. +M_PER_US_SURVEY_FOOT = 0.3048006096012192 + +# Selection keys are descriptive strings (good/fair/repair) for the UI, but +# alpha-DESC would order "repair > good > fair" — fair-condition fences +# would hide below good-condition ones. `condition_rank` mirrors the +# selection so `_order` produces the actual urgency sort. +_CONDITION_RANK = {"good": 0, "fair": 1, "repair": 2} + + +@lru_cache(maxsize=1) +def _wgs84_to_albers_conus(): + """Build the EPSG:4326 → EPSG:5070 transformer once and reuse it. + + Same approach farm_field_geo uses for acreage — Albers reprojection + gives a length number that matches what NRCS reports for the same + fence in a rangeland-improvement filing. + """ + return Transformer.from_crs("EPSG:4326", "EPSG:5070", always_xy=True).transform + + +class FarmFence(models.Model): + _name = "farm.fence" + _description = "Fence" + _inherit = ["mail.thread"] + # Repairs-needed bubble to the top. + _order = "condition_rank desc, name" + # Enforces field_id.company_id == fence.company_id at write time (only + # checked when field_id is set; perimeter fences with a null field_id + # pass through). + _check_company_auto = True + + name = fields.Char(required=True, tracking=True) + active = fields.Boolean( + default=True, + help="Untick to archive fences that have been removed or replaced. " + "Their geometry and history stay on the field map's archived view.", + ) + field_id = fields.Many2one( + "farm.field", + string="Primary Field", + help="Field this fence belongs to. Leave blank for perimeter fences " + "that span multiple fields.", + ondelete="set null", + check_company=True, + ) + geom = fields.GeoLine( + string="Fence Line", + srid=4326, + help="Polyline tracing the fence. Length and the map view come from " + "this geometry. base_geoengine 19.0 names the type GeoLine (no " + "'String' suffix).", + ) + length_feet = fields.Float( + compute="_compute_length_feet", + store=True, + digits=(10, 1), + help="Auto-computed from the polyline length, reprojected to " + "EPSG:5070 (Albers Equal Area) and converted from meters to US " + "survey feet.", + ) + fence_type = fields.Selection( + [ + ("barbed_wire", "Barbed Wire"), + ("high_tensile", "High-Tensile"), + ("electric", "Electric"), + ("post_rail", "Post & Rail"), + ("woven_wire", "Woven Wire"), + ("polywire", "Polywire (rotational)"), + ("temporary", "Temporary"), + ], + required=True, + tracking=True, + ) + height_inches = fields.Float(digits=(5, 1)) + condition = fields.Selection( + [ + ("good", "Good"), + ("fair", "Fair"), + ("repair", "Needs Repair"), + ], + default="good", + required=True, + tracking=True, + ) + condition_rank = fields.Integer( + compute="_compute_condition_rank", + store=True, + index=True, + help="Numeric mirror of condition so _order produces an urgency sort " + "(string DESC on selection keys would put 'good' above 'fair').", + ) + last_checked_date = fields.Date(tracking=True) + notes = fields.Text() + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company, + index=True, + ) + + @api.depends("condition") + def _compute_condition_rank(self): + for rec in self: + rec.condition_rank = _CONDITION_RANK.get(rec.condition, 0) + + @api.depends("geom") + def _compute_length_feet(self): + # base_geoengine returns the field as a plain shapely geometry on + # read; shapely has no .transform() method. Reproject via pyproj + + # shapely.ops.transform before measuring length. Mirrors the + # acreage compute in farm_field_geo. + transformer = _wgs84_to_albers_conus() + for rec in self: + if not rec.geom: + rec.length_feet = 0.0 + continue + projected = shapely_transform(transformer, rec.geom) + rec.length_feet = projected.length / M_PER_US_SURVEY_FOOT diff --git a/farm_fence/pyproject.toml b/farm_fence/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_fence/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_fence/readme/CONTRIBUTORS.md b/farm_fence/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_fence/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_fence/readme/DESCRIPTION.md b/farm_fence/readme/DESCRIPTION.md new file mode 100644 index 0000000..f9ce4bf --- /dev/null +++ b/farm_fence/readme/DESCRIPTION.md @@ -0,0 +1,19 @@ +Tracks fence lines on the farm with type (barbed wire, high-tensile, +electric, post-and-rail, woven wire, polywire, temporary), height, +condition (good / fair / needs repair), last-checked date, and an +auto-computed length in feet from the polyline geometry. + +`length_feet` mirrors the acreage compute in `farm_field_geo`: the +WGS84 polyline is reprojected to **EPSG:5070 Conus Albers Equal Area** +and the geodesic length in meters is converted to US survey feet +(0.3048006096012192 m/ft). The number matches what NRCS would report +for the same fence line in a rangeland improvement filing. + +Repairs-needed bubble to the top of every list and grouped kanban +because `_order` uses a stored computed `condition_rank` integer +mirroring the selection — a naive string DESC would put "good" above +"fair" (alpha order). + +Field link is optional (`ondelete="set null"`) so a perimeter fence +spanning multiple fields keeps its history even when one field is +archived or deleted. diff --git a/farm_fence/readme/USAGE.md b/farm_fence/readme/USAGE.md new file mode 100644 index 0000000..d41f806 --- /dev/null +++ b/farm_fence/readme/USAGE.md @@ -0,0 +1,17 @@ +1. Open Farm → Fences. +2. Click **New** and pick a fence type + height. +3. Optional: link a primary field. Leave blank for perimeter fences. +4. Open the **Fence Line** tab and trace the fence on the map. +5. Save. **Length (feet)** auto-computes from the polyline. + +Day-to-day: + +- **Walk-the-fence rotation**: filter by "Not Checked in 6 Months" to plan + the next inspection sweep. +- **Repair triage**: kanban grouped by Condition puts Needs Repair at the + top so you can plan the materials run before the weekend. +- **Materials estimate**: open a fence record, read Length (feet), multiply + by the wire / post spec to size the order. +- **Insurance / NRCS reporting**: switch to the Map view to print a screenshot + of the entire fencing system colored by type, with each fence's length + in the legend. diff --git a/farm_fence/readme/newsfragments/.gitkeep b/farm_fence/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_fence/security/ir.model.access.csv b/farm_fence/security/ir.model.access.csv new file mode 100644 index 0000000..5379344 --- /dev/null +++ b/farm_fence/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_fence_user,farm.fence user,model_farm_fence,base.group_user,1,1,1,1 diff --git a/farm_fence/static/description/index.html b/farm_fence/static/description/index.html new file mode 100644 index 0000000..1fb236a --- /dev/null +++ b/farm_fence/static/description/index.html @@ -0,0 +1,468 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Fence

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Tracks fence lines on the farm with type (barbed wire, high-tensile, +electric, post-and-rail, woven wire, polywire, temporary), height, +condition (good / fair / needs repair), last-checked date, and an +auto-computed length in feet from the polyline geometry.

+

length_feet mirrors the acreage compute in farm_field_geo: the +WGS84 polyline is reprojected to EPSG:5070 Conus Albers Equal Area +and the geodesic length in meters is converted to US survey feet +(0.3048006096012192 m/ft). The number matches what NRCS would report for +the same fence line in a rangeland improvement filing.

+

Repairs-needed bubble to the top of every list and grouped kanban +because _order uses a stored computed condition_rank integer +mirroring the selection — a naive string DESC would put “good” above +“fair” (alpha order).

+

Field link is optional (ondelete="set null") so a perimeter fence +spanning multiple fields keeps its history even when one field is +archived or deleted.

+
+

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

+
    +
  1. Open Farm → Fences.
  2. +
  3. Click New and pick a fence type + height.
  4. +
  5. Optional: link a primary field. Leave blank for perimeter fences.
  6. +
  7. Open the Fence Line tab and trace the fence on the map.
  8. +
  9. Save. Length (feet) auto-computes from the polyline.
  10. +
+

Day-to-day:

+
    +
  • Walk-the-fence rotation: filter by “Not Checked in 6 Months” to +plan the next inspection sweep.
  • +
  • Repair triage: kanban grouped by Condition puts Needs Repair at +the top so you can plan the materials run before the weekend.
  • +
  • Materials estimate: open a fence record, read Length (feet), +multiply by the wire / post spec to size the order.
  • +
  • Insurance / NRCS reporting: switch to the Map view to print a +screenshot of the entire fencing system colored by type, with each +fence’s length in the legend.
  • +
+
+
+

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_fence/tests/__init__.py b/farm_fence/tests/__init__.py new file mode 100644 index 0000000..2912e88 --- /dev/null +++ b/farm_fence/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_fence diff --git a/farm_fence/tests/test_farm_fence.py b/farm_fence/tests/test_farm_fence.py new file mode 100644 index 0000000..56f62c3 --- /dev/null +++ b/farm_fence/tests/test_farm_fence.py @@ -0,0 +1,107 @@ +from shapely.geometry import LineString + +from odoo.tests.common import TransactionCase + +# Same reference rectangle farm_field_geo uses, but treated as a fence +# linestring tracing the perimeter. At 40N a 0.01° square is ~850 m × +# 1110 m, so the full closed loop is ~3920 m ≈ 12,860 feet. The compute +# uses Albers reprojection, so we allow generous slack. +LIGONIER_PERIMETER = LineString( + [ + (-79.245, 40.242), + (-79.235, 40.242), + (-79.235, 40.252), + (-79.245, 40.252), + (-79.245, 40.242), + ] +) +LIGONIER_SHORT = LineString([(-79.245, 40.242), (-79.244, 40.242)]) + + +class TestFarmFence(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"}) + cls.field = cls.env["farm.field"].create( + { + "name": "North 40", + "farm_partner_id": cls.farm.id, + "crop_id": cls.crop.id, + } + ) + + def _make_fence(self, **overrides): + vals = { + "name": "Test Fence", + "fence_type": "barbed_wire", + "field_id": self.field.id, + } + vals.update(overrides) + return self.env["farm.fence"].create(vals) + + def test_length_feet_zero_without_geom(self): + # No line drawn yet → 0 feet, compute doesn't crash. + fence = self._make_fence() + self.assertEqual(fence.length_feet, 0.0) + + def test_length_feet_from_perimeter(self): + # ~3.9 km perimeter → ~12,800 feet. Allow ±15% slack for projection. + fence = self._make_fence(geom=LIGONIER_PERIMETER) + self.assertGreater(fence.length_feet, 10000.0) + self.assertLess(fence.length_feet, 15000.0) + + def test_length_recomputes_on_geom_change(self): + fence = self._make_fence(geom=LIGONIER_SHORT) + short = fence.length_feet + self.assertGreater(short, 0.0) + fence.geom = LIGONIER_PERIMETER + fence.flush_recordset() + fence.invalidate_recordset() + self.assertGreater(fence.length_feet, short * 30) + + def test_condition_sort_priority_not_alphabetical(self): + # Regression: an early version sorted by `condition desc` on the raw + # selection key, which alpha-sorts "repair > good > fair" — fair + # would hide below good. Verify the numeric rank sorts repairs first. + good = self._make_fence(name="A — fence", condition="good") + repair = self._make_fence(name="B — fence", condition="repair") + fair = self._make_fence(name="C — fence", condition="fair") + ordered = self.env["farm.fence"].search( + [("id", "in", (good.id, fair.id, repair.id))] + ) + self.assertEqual( + ordered.mapped("condition"), + ["repair", "fair", "good"], + "Needs-repair must sort first; if you see good > fair > repair " + "condition_rank lost its compute", + ) + + def test_condition_tracking_declared_on_field(self): + # tracking=True wiring is Odoo's mail.thread plumbing; probing the + # field declaration is more reliable in TransactionCase than chasing + # message_ids deltas (PR #4 found that flaky). + field = self.env["farm.fence"]._fields["condition"] + self.assertTrue(field.tracking, "condition field must declare tracking=True") + + def test_field_unlink_sets_field_id_null_not_cascade(self): + # Perimeter fences span multiple fields — when one referenced field + # is deleted, the fence should survive with field_id=None, not + # cascade-delete. Verifies ondelete='set null'. + temp_field = self.env["farm.field"].create( + { + "name": "Doomed", + "farm_partner_id": self.farm.id, + "crop_id": self.crop.id, + } + ) + fence = self._make_fence(field_id=temp_field.id) + fence_id = fence.id + temp_field.unlink() + survivor = self.env["farm.fence"].browse(fence_id) + survivor.invalidate_recordset() + self.assertTrue(survivor.exists(), "Fence must survive its field's deletion") + self.assertFalse(survivor.field_id, "field_id must be cleared, not retained") diff --git a/farm_fence/views/farm_fence_menu.xml b/farm_fence/views/farm_fence_menu.xml new file mode 100644 index 0000000..b24ab27 --- /dev/null +++ b/farm_fence/views/farm_fence_menu.xml @@ -0,0 +1,10 @@ + + + + diff --git a/farm_fence/views/farm_fence_views.xml b/farm_fence/views/farm_fence_views.xml new file mode 100644 index 0000000..d75918f --- /dev/null +++ b/farm_fence/views/farm_fence_views.xml @@ -0,0 +1,130 @@ + + + + farm.fence.list + farm.fence + + + + + + + + + + + + + + farm.fence.form + farm.fence + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + farm.fence.search + farm.fence + + + + + + + + + + + + + + + + + + + + farm.fence.geoengine + farm.fence + + + + + + + + + + + + Fences + farm.fence + list,form,geoengine + +