From eb16990b0b850d1bbcee7b181b116bf412f2f40c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 2 Mar 2026 19:44:52 -0600 Subject: [PATCH] feat: add schema, import support for `Asset.owners` --- .../versions/12de43facb95_add_asset_owners.py | 114 +++++++++++++++ src/wuttafarm/db/model/asset.py | 39 +++++ src/wuttafarm/importing/farmos.py | 136 +++++++++--------- src/wuttafarm/normal.py | 34 +++++ src/wuttafarm/web/views/animals.py | 3 + src/wuttafarm/web/views/assets.py | 18 +++ src/wuttafarm/web/views/structures.py | 1 + 7 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py diff --git a/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py new file mode 100644 index 0000000..67a4c25 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/12de43facb95_add_asset_owners.py @@ -0,0 +1,114 @@ +"""add Asset.owners + +Revision ID: 12de43facb95 +Revises: 85d4851e8292 +Create Date: 2026-03-02 19:03:35.511398 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "12de43facb95" +down_revision: Union[str, None] = "85d4851e8292" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # asset_owner + op.create_table( + "asset_owner", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["asset_uuid"], ["asset.uuid"], name=op.f("fk_asset_owner_asset_uuid_asset") + ), + sa.ForeignKeyConstraint( + ["user_uuid"], ["user.uuid"], name=op.f("fk_asset_owner_user_uuid_user") + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_owner")), + ) + op.create_table( + "asset_owner_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "user_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_asset_owner_version") + ), + ) + op.create_index( + op.f("ix_asset_owner_version_end_transaction_id"), + "asset_owner_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_operation_type"), + "asset_owner_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_transaction_id", + "asset_owner_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_owner_version_pk_validity", + "asset_owner_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_owner_version_transaction_id"), + "asset_owner_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_owner + op.drop_index( + op.f("ix_asset_owner_version_transaction_id"), table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_validity", table_name="asset_owner_version" + ) + op.drop_index( + "ix_asset_owner_version_pk_transaction_id", table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_operation_type"), table_name="asset_owner_version" + ) + op.drop_index( + op.f("ix_asset_owner_version_end_transaction_id"), + table_name="asset_owner_version", + ) + op.drop_table("asset_owner_version") + op.drop_table("asset_owner") diff --git a/src/wuttafarm/db/model/asset.py b/src/wuttafarm/db/model/asset.py index 8c975c9..0face47 100644 --- a/src/wuttafarm/db/model/asset.py +++ b/src/wuttafarm/db/model/asset.py @@ -193,6 +193,19 @@ class Asset(model.Base): creator=lambda parent: AssetParent(parent=parent), ) + _owners = orm.relationship( + "AssetOwner", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="asset", + ) + + owners = association_proxy( + "_owners", + "user", + creator=lambda user: AssetOwner(user=user), + ) + def __str__(self): return self.asset_name or "" @@ -225,6 +238,8 @@ def add_asset_proxies(subclass): Asset.make_proxy(subclass, "asset", "thumbnail_url") Asset.make_proxy(subclass, "asset", "image_url") Asset.make_proxy(subclass, "asset", "archived") + Asset.make_proxy(subclass, "asset", "parents") + Asset.make_proxy(subclass, "asset", "owners") class EggMixin: @@ -262,3 +277,27 @@ class AssetParent(model.Base): Asset, foreign_keys=parent_uuid, ) + + +class AssetOwner(model.Base): + """ + Represents a "asset's owner relationship" from farmOS. + """ + + __tablename__ = "asset_owner" + __versioned__ = {} + + uuid = model.uuid_column() + + asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False) + asset = orm.relationship( + Asset, + foreign_keys=asset_uuid, + back_populates="_owners", + ) + + user_uuid = model.uuid_fk_column("user.uuid", nullable=False) + user = orm.relationship( + model.User, + foreign_keys=user_uuid, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index da69813..1cd3523 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -187,6 +187,7 @@ class AssetImporterBase(FromFarmOS, ToWutta): fields.extend( [ "parents", + "owners", ] ) return fields @@ -194,8 +195,9 @@ class AssetImporterBase(FromFarmOS, ToWutta): def get_source_objects(self): """ """ asset_type = self.get_farmos_asset_type() - result = self.farmos_client.asset.get(asset_type) - return result["data"] + return list( + self.farmos_client.asset.iterate(asset_type, params={"include": "image"}) + ) def normalize_source_data(self, **kwargs): """ """ @@ -208,49 +210,40 @@ class AssetImporterBase(FromFarmOS, ToWutta): return data - def normalize_asset(self, asset): + def normalize_source_object(self, asset): """ """ - image_url = None - thumbnail_url = None - if relationships := asset.get("relationships"): + data = self.normal.normalize_farmos_asset(asset) - if image := relationships.get("image"): - if image["data"]: - image = self.farmos_client.resource.get_id( - "file", "file", image["data"][0]["id"] - ) - if image_style := image["data"]["attributes"].get( - "image_style_uri" - ): - image_url = image_style["large"] - thumbnail_url = image_style["thumbnail"] + data["farmos_uuid"] = UUID(data.pop("uuid")) + data["asset_type"] = self.get_asset_type(asset) - if notes := asset["attributes"]["notes"]: - notes = notes["value"] + if "image_url" in self.fields or "thumbnail_url" in self.fields: + data["image_url"] = None + data["thumbnail_url"] = None + if relationships := asset.get("relationships"): - if self.farmos_4x: - archived = asset["attributes"]["archived"] - else: - archived = asset["attributes"]["status"] == "archived" + if image := relationships.get("image"): + if image["data"]: + image = self.farmos_client.resource.get_id( + "file", "file", image["data"][0]["id"] + ) + if image_style := image["data"]["attributes"].get( + "image_style_uri" + ): + data["image_url"] = image_style["large"] + data["thumbnail_url"] = image_style["thumbnail"] - parents = None if "parents" in self.fields: - parents = [] + data["parents"] = [] for parent in asset["relationships"]["parent"]["data"]: - parents.append((self.get_asset_type(parent), UUID(parent["id"]))) + data["parents"].append( + (self.get_asset_type(parent), UUID(parent["id"])) + ) - return { - "farmos_uuid": UUID(asset["id"]), - "drupal_id": asset["attributes"]["drupal_internal__id"], - "asset_name": asset["attributes"]["name"], - "is_location": asset["attributes"]["is_location"], - "is_fixed": asset["attributes"]["is_fixed"], - "archived": archived, - "notes": notes, - "image_url": image_url, - "thumbnail_url": thumbnail_url, - "parents": parents, - } + if "owners" in self.fields: + data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]] + + return data def get_asset_type(self, asset): return asset["type"].split("--")[1] @@ -259,10 +252,10 @@ class AssetImporterBase(FromFarmOS, ToWutta): data = super().normalize_target_object(asset) if "parents" in self.fields: - data["parents"] = [ - (p.parent.asset_type, p.parent.farmos_uuid) - for p in asset.asset._parents - ] + data["parents"] = [(p.asset_type, p.farmos_uuid) for p in asset.parents] + + if "owners" in self.fields: + data["owners"] = [user.farmos_uuid for user in asset.owners] return data @@ -303,6 +296,30 @@ class AssetImporterBase(FromFarmOS, ToWutta): ) self.target_session.delete(parent) + if "owners" in self.fields: + if not target_data or target_data["owners"] != source_data["owners"]: + + for farmos_uuid in source_data["owners"]: + if not target_data or farmos_uuid not in target_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.append(user) + + if target_data: + for farmos_uuid in target_data["owners"]: + if farmos_uuid not in source_data["owners"]: + user = ( + self.target_session.query(model.User) + .join(model.WuttaFarmUser) + .filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid) + .one() + ) + asset.owners.remove(user) + return asset @@ -338,11 +355,6 @@ class AnimalAssetImporter(AssetImporterBase): if animal_type.farmos_uuid: self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type - def get_source_objects(self): - """ """ - animals = self.farmos_client.asset.get("animal") - return animals["data"] - def normalize_source_object(self, animal): """ """ animal_type_uuid = None @@ -374,10 +386,9 @@ class AnimalAssetImporter(AssetImporterBase): else: sterile = animal["attributes"]["is_castrated"] - data = self.normalize_asset(animal) + data = super().normalize_source_object(animal) data.update( { - "asset_type": "animal", "animal_type_uuid": animal_type_uuid, "sex": animal["attributes"]["sex"], "is_sterile": sterile, @@ -468,17 +479,11 @@ class GroupAssetImporter(AssetImporterBase): "parents", ] - def get_source_objects(self): - """ """ - groups = self.farmos_client.asset.get("group") - return groups["data"] - def normalize_source_object(self, group): """ """ - data = self.normalize_asset(group) + data = super().normalize_source_object(group) data.update( { - "asset_type": "group", "produces_eggs": group["attributes"]["produces_eggs"], } ) @@ -514,11 +519,6 @@ class LandAssetImporter(AssetImporterBase): for land_type in self.target_session.query(model.LandType): self.land_types_by_id[land_type.drupal_id] = land_type - def get_source_objects(self): - """ """ - land_assets = self.farmos_client.asset.get("land") - return land_assets["data"] - def normalize_source_object(self, land): """ """ land_type_id = land["attributes"]["land_type"] @@ -529,10 +529,9 @@ class LandAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(land) + data = super().normalize_source_object(land) data.update( { - "asset_type": "land", "land_type_uuid": land_type.uuid, } ) @@ -638,10 +637,9 @@ class PlantAssetImporter(AssetImporterBase): else: log.warning("plant type not found: %s", plant_type["id"]) - data = self.normalize_asset(plant) + data = super().normalize_source_object(plant) data.update( { - "asset_type": "plant", "plant_types": set(plant_types), } ) @@ -718,11 +716,6 @@ class StructureAssetImporter(AssetImporterBase): for structure_type in self.target_session.query(model.StructureType): self.structure_types_by_id[structure_type.drupal_id] = structure_type - def get_source_objects(self): - """ """ - structures = self.farmos_client.asset.get("structure") - return structures["data"] - def normalize_source_object(self, structure): """ """ structure_type_id = structure["attributes"]["structure_type"] @@ -735,10 +728,9 @@ class StructureAssetImporter(AssetImporterBase): ) return None - data = self.normalize_asset(structure) + data = super().normalize_source_object(structure) data.update( { - "asset_type": "structure", "structure_type_uuid": structure_type.uuid, } ) @@ -1167,7 +1159,7 @@ class LogImporterBase(FromFarmOS, ToWutta): if not target_data or target_data["owners"] != source_data["owners"]: for farmos_uuid in source_data["owners"]: - if not target_data or farmos_uuid not in target_data["assets"]: + if not target_data or farmos_uuid not in target_data["owners"]: user = ( self.target_session.query(model.User) .join(model.WuttaFarmUser) diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 3efd443..fa9b9da 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -84,6 +84,40 @@ class Normalizer(GenericHandler): self._farmos_units = units return self._farmos_units + def normalize_farmos_asset(self, asset, included={}): + """ """ + + if notes := asset["attributes"]["notes"]: + notes = notes["value"] + + owner_objects = [] + owner_uuids = [] + if relationships := asset.get("relationships"): + + if owners := relationships.get("owner"): + for user in owners["data"]: + user_uuid = user["id"] + owner_uuids.append(user_uuid) + if user := included.get(user_uuid): + owner_objects.append( + { + "uuid": user["id"], + "name": user["attributes"]["name"], + } + ) + + return { + "uuid": asset["id"], + "drupal_id": asset["attributes"]["drupal_internal__id"], + "asset_name": asset["attributes"]["name"], + "is_location": asset["attributes"]["is_location"], + "is_fixed": asset["attributes"]["is_fixed"], + "archived": asset["attributes"]["archived"], + "notes": notes, + "owners": owner_objects, + "owner_uuids": owner_uuids, + } + def normalize_farmos_log(self, log, included={}): if timestamp := log["attributes"]["timestamp"]: diff --git a/src/wuttafarm/web/views/animals.py b/src/wuttafarm/web/views/animals.py index b52a353..31bbfe8 100644 --- a/src/wuttafarm/web/views/animals.py +++ b/src/wuttafarm/web/views/animals.py @@ -228,6 +228,9 @@ class AnimalAssetView(AssetMasterView): "birthdate", "is_sterile", "sex", + "group_membership", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/assets.py b/src/wuttafarm/web/views/assets.py index b463953..38746bd 100644 --- a/src/wuttafarm/web/views/assets.py +++ b/src/wuttafarm/web/views/assets.py @@ -136,6 +136,10 @@ class AssetMasterView(WuttaFarmMasterView): # parents g.set_renderer("parents", self.render_parents_for_grid) + # owners + g.set_label("owners", "Owner") + g.set_renderer("owners", self.render_owners_for_grid) + # archived g.set_renderer("archived", "boolean") g.set_sorter("archived", model.Asset.archived) @@ -155,6 +159,17 @@ class AssetMasterView(WuttaFarmMasterView): parents = [str(p.parent) for p in asset.parents] return ", ".join(parents) + def render_owners_for_grid(self, asset, field, value): + + if self.farmos_style_grid_links: + links = [] + for user in asset.owners: + url = self.request.route_url("users.view", uuid=user.uuid) + links.append(tags.link_to(user.username, url)) + return ", ".join(links) + + return ", ".join([user.username for user in asset.owners]) + def grid_row_class(self, asset, data, i): """ """ if asset.archived: @@ -314,8 +329,11 @@ class AllAssetView(AssetMasterView): "thumbnail", "drupal_id", "asset_name", + "group_membership", "asset_type", "parents", + "owners", + "locations", "archived", ] diff --git a/src/wuttafarm/web/views/structures.py b/src/wuttafarm/web/views/structures.py index 4d36d41..9d5d227 100644 --- a/src/wuttafarm/web/views/structures.py +++ b/src/wuttafarm/web/views/structures.py @@ -160,6 +160,7 @@ class StructureAssetView(AssetMasterView): "asset_name", "structure_type", "parents", + "owners", "archived", ]