diff --git a/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py new file mode 100644 index 0000000..e943f77 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/554e6168c339_add_landassetparent_model.py @@ -0,0 +1,125 @@ +"""add LandAssetParent model + +Revision ID: 554e6168c339 +Revises: 8cc1565d38e7 +Create Date: 2026-02-14 20:41:24.859064 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "554e6168c339" +down_revision: Union[str, None] = "8cc1565d38e7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # land_asset_parent + op.create_table( + "land_asset_parent", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("land_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("parent_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["land_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_land_asset_uuid_land_asset"), + ), + sa.ForeignKeyConstraint( + ["parent_asset_uuid"], + ["land_asset.uuid"], + name=op.f("fk_land_asset_parent_parent_asset_uuid_land_asset"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset_parent")), + ) + op.create_table( + "land_asset_parent_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "land_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "parent_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "transaction_id", sa.BigInteger(), autoincrement=False, nullable=False + ), + sa.Column("end_transaction_id", sa.BigInteger(), nullable=True), + sa.Column("operation_type", sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint( + "uuid", "transaction_id", name=op.f("pk_land_asset_parent_version") + ), + ) + op.create_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + "land_asset_parent_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_operation_type"), + "land_asset_parent_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_land_asset_parent_version_pk_transaction_id", + "land_asset_parent_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_land_asset_parent_version_pk_validity", + "land_asset_parent_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_land_asset_parent_version_transaction_id"), + "land_asset_parent_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # land_asset_parent + op.drop_index( + op.f("ix_land_asset_parent_version_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_index( + "ix_land_asset_parent_version_pk_validity", + table_name="land_asset_parent_version", + ) + op.drop_index( + "ix_land_asset_parent_version_pk_transaction_id", + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_operation_type"), + table_name="land_asset_parent_version", + ) + op.drop_index( + op.f("ix_land_asset_parent_version_end_transaction_id"), + table_name="land_asset_parent_version", + ) + op.drop_table("land_asset_parent_version") + op.drop_table("land_asset_parent") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 27d0070..e8bce3f 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -31,7 +31,7 @@ from .users import WuttaFarmUser # wuttafarm proper models from .assets import AssetType -from .land import LandType, LandAsset +from .land import LandType, LandAsset, LandAssetParent from .structures import StructureType, Structure from .animals import AnimalType, Animal from .groups import Group diff --git a/src/wuttafarm/db/model/land.py b/src/wuttafarm/db/model/land.py index 17e22c1..da94cf1 100644 --- a/src/wuttafarm/db/model/land.py +++ b/src/wuttafarm/db/model/land.py @@ -153,5 +153,39 @@ class LandAsset(model.Base): """, ) + _parents = orm.relationship( + "LandAssetParent", + foreign_keys="LandAssetParent.land_asset_uuid", + back_populates="land_asset", + cascade="all, delete-orphan", + cascade_backrefs=False, + ) + def __str__(self): return self.name or "" + + +class LandAssetParent(model.Base): + """ + Represents a "land asset's parent relationship" from farmOS. + """ + + __tablename__ = "land_asset_parent" + __versioned__ = {} + + uuid = model.uuid_column() + + land_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) + + land_asset = orm.relationship( + LandAsset, + foreign_keys=land_asset_uuid, + back_populates="_parents", + ) + + parent_asset_uuid = model.uuid_fk_column("land_asset.uuid", nullable=False) + + parent_asset = orm.relationship( + LandAsset, + foreign_keys=parent_asset_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index d8c67b4..adc63d0 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -393,9 +393,11 @@ class LandAssetImporter(FromFarmOS, ToWutta): "is_fixed", "notes", "archived", + "parents", ] def setup(self): + """ """ super().setup() model = self.app.model @@ -408,6 +410,17 @@ class LandAssetImporter(FromFarmOS, ToWutta): land_assets = self.farmos_client.asset.get("land") return land_assets["data"] + def normalize_source_data(self, **kwargs): + """ """ + data = super().normalize_source_data(**kwargs) + + if "parents" in self.fields: + # nb. make sure parent-less (root) assets come first, so they + # exist when child assets need to reference them + data.sort(key=lambda l: len(l["parents"])) + + return data + def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -426,7 +439,7 @@ class LandAssetImporter(FromFarmOS, ToWutta): else: archived = land["attributes"]["status"] == "archived" - return { + data = { "farmos_uuid": UUID(land["id"]), "drupal_id": land["attributes"]["drupal_internal__id"], "name": land["attributes"]["name"], @@ -437,6 +450,57 @@ class LandAssetImporter(FromFarmOS, ToWutta): "notes": notes, } + if "parents" in self.fields: + data["parents"] = [] + for parent in land["relationships"]["parent"]["data"]: + assert parent["type"] == "asset--land" + data["parents"].append(UUID(parent["id"])) + + return data + + def normalize_target_object(self, land): + data = super().normalize_target_object(land) + + if "parents" in self.fields: + data["parents"] = [p.parent_asset.farmos_uuid for p in land._parents] + + return data + + def update_target_object(self, land, source_data, target_data=None): + model = self.app.model + land = super().update_target_object(land, source_data, target_data) + + if "parents" in self.fields: + if not target_data or target_data["parents"] != source_data["parents"]: + + for farmos_uuid in source_data["parents"]: + if not target_data or farmos_uuid not in target_data["parents"]: + self.target_session.flush() + parent = ( + self.target_session.query(model.LandAsset) + .filter(model.LandAsset.farmos_uuid == farmos_uuid) + .one() + ) + land._parents.append(model.LandAssetParent(parent_asset=parent)) + + if target_data: + for farmos_uuid in target_data["parents"]: + if farmos_uuid not in source_data["parents"]: + parent = ( + self.target_session.query(model.LandAsset) + .filter(model.LandAsset.farmos_uuid == farmos_uuid) + .one() + ) + parent = ( + self.target_session.query(model.LandAssetParent) + .filter(model.LandAssetParent.land_asset == land) + .filter(model.LandAssetParent.parent_asset == parent) + .one() + ) + self.target_session.delete(parent) + + return land + class LandTypeImporter(FromFarmOS, ToWutta): """ diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index f646a96..7c048ee 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -27,7 +27,7 @@ import json import colander -from wuttaweb.forms.schema import ObjectRef +from wuttaweb.forms.schema import ObjectRef, WuttaSet class AnimalTypeRef(ObjectRef): @@ -160,3 +160,20 @@ class UsersType(colander.SchemaType): from wuttafarm.web.forms.widgets import UsersWidget return UsersWidget(self.request, **kwargs) + + +class LandParentRefs(WuttaSet): + """ + Schema type for Parents field which references land assets. + """ + + def serialize(self, node, appstruct): + if not appstruct: + appstruct = [] + uuids = [u.hex for u in appstruct] + return json.dumps(uuids) + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import LandParentRefsWidget + + return LandParentRefsWidget(self.request, **kwargs) diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index f6a99fc..45519b9 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -29,6 +29,9 @@ import colander from deform.widget import Widget from webhelpers2.html import HTML, tags +from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget +from wuttaweb.db import Session + class ImageWidget(Widget): """ @@ -132,3 +135,34 @@ class UsersWidget(Widget): return HTML.tag("ul", c=items) return super().serialize(field, cstruct, **kw) + + +class LandParentRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for Parents field which references land assets. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + readonly = kw.get("readonly", self.readonly) + if readonly: + parents = [] + for uuid in json.loads(cstruct): + parent = session.get(model.LandAsset, uuid) + parents.append( + HTML.tag( + "li", + c=tags.link_to( + str(parent), + self.request.route_url( + "land_assets.view", uuid=parent.uuid + ), + ), + ) + ) + return HTML.tag("ul", c=parents) + + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttafarm/web/views/land_assets.py b/src/wuttafarm/web/views/land_assets.py index 45659f3..7105465 100644 --- a/src/wuttafarm/web/views/land_assets.py +++ b/src/wuttafarm/web/views/land_assets.py @@ -23,9 +23,11 @@ Master view for Land Assets """ +from webhelpers2.html import HTML, tags + from wuttafarm.db.model.land import LandAsset from wuttafarm.web.views import WuttaFarmMasterView -from wuttafarm.web.forms.schema import LandTypeRef +from wuttafarm.web.forms.schema import LandTypeRef, LandParentRefs class LandAssetView(WuttaFarmMasterView): @@ -47,6 +49,7 @@ class LandAssetView(WuttaFarmMasterView): "drupal_id", "name", "land_type", + "parents", "archived", ] @@ -59,6 +62,7 @@ class LandAssetView(WuttaFarmMasterView): form_fields = [ "name", + "parents", "notes", "asset_type", "land_type", @@ -85,6 +89,13 @@ class LandAssetView(WuttaFarmMasterView): g.set_sorter("land_type", model.LandType.name) g.set_filter("land_type", model.LandType.name, label="Land Type Name") + # parents + g.set_renderer("parents", self.render_parents_for_grid) + + def render_parents_for_grid(self, land, field, value): + parents = [str(p.parent_asset) for p in land._parents] + return ", ".join(parents) + def grid_row_class(self, land, data, i): """ """ if land.archived: @@ -94,6 +105,11 @@ class LandAssetView(WuttaFarmMasterView): def configure_form(self, form): f = form super().configure_form(f) + land = f.model_instance + + # parents + f.set_node("parents", LandParentRefs(self.request)) + f.set_default("parents", [p.parent_asset_uuid for p in land._parents]) # notes f.set_widget("notes", "notes")