diff --git a/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py new file mode 100644 index 0000000..406ad64 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/c5183b781d34_add_plant_seasons.py @@ -0,0 +1,205 @@ +"""add plant seasons + +Revision ID: c5183b781d34 +Revises: 5f474125a80e +Create Date: 2026-03-06 20:18:40.160531 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = "c5183b781d34" +down_revision: Union[str, None] = "5f474125a80e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # season + op.create_table( + "season", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True), + sa.Column("drupal_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_season")), + sa.UniqueConstraint("drupal_id", name=op.f("uq_season_drupal_id")), + sa.UniqueConstraint("farmos_uuid", name=op.f("uq_season_farmos_uuid")), + sa.UniqueConstraint("name", name=op.f("uq_season_name")), + ) + op.create_table( + "season_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column("name", sa.String(length=100), autoincrement=False, nullable=True), + sa.Column( + "description", sa.String(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "farmos_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column("drupal_id", sa.Integer(), 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_season_version") + ), + ) + op.create_index( + op.f("ix_season_version_end_transaction_id"), + "season_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_season_version_operation_type"), + "season_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_season_version_pk_transaction_id", + "season_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_season_version_pk_validity", + "season_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_season_version_transaction_id"), + "season_version", + ["transaction_id"], + unique=False, + ) + + # asset_plant_season + op.create_table( + "asset_plant_season", + sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("plant_asset_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.Column("season_uuid", wuttjamaican.db.util.UUID(), nullable=False), + sa.ForeignKeyConstraint( + ["plant_asset_uuid"], + ["asset_plant.uuid"], + name=op.f("fk_asset_plant_season_plant_asset_uuid_asset_plant"), + ), + sa.ForeignKeyConstraint( + ["season_uuid"], + ["season.uuid"], + name=op.f("fk_asset_plant_season_season_uuid_season"), + ), + sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_plant_season")), + ) + op.create_table( + "asset_plant_season_version", + sa.Column( + "uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False + ), + sa.Column( + "plant_asset_uuid", + wuttjamaican.db.util.UUID(), + autoincrement=False, + nullable=True, + ), + sa.Column( + "season_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_plant_season_version") + ), + ) + op.create_index( + op.f("ix_asset_plant_season_version_end_transaction_id"), + "asset_plant_season_version", + ["end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_season_version_operation_type"), + "asset_plant_season_version", + ["operation_type"], + unique=False, + ) + op.create_index( + "ix_asset_plant_season_version_pk_transaction_id", + "asset_plant_season_version", + ["uuid", sa.literal_column("transaction_id DESC")], + unique=False, + ) + op.create_index( + "ix_asset_plant_season_version_pk_validity", + "asset_plant_season_version", + ["uuid", "transaction_id", "end_transaction_id"], + unique=False, + ) + op.create_index( + op.f("ix_asset_plant_season_version_transaction_id"), + "asset_plant_season_version", + ["transaction_id"], + unique=False, + ) + + +def downgrade() -> None: + + # asset_plant_season + op.drop_index( + op.f("ix_asset_plant_season_version_transaction_id"), + table_name="asset_plant_season_version", + ) + op.drop_index( + "ix_asset_plant_season_version_pk_validity", + table_name="asset_plant_season_version", + ) + op.drop_index( + "ix_asset_plant_season_version_pk_transaction_id", + table_name="asset_plant_season_version", + ) + op.drop_index( + op.f("ix_asset_plant_season_version_operation_type"), + table_name="asset_plant_season_version", + ) + op.drop_index( + op.f("ix_asset_plant_season_version_end_transaction_id"), + table_name="asset_plant_season_version", + ) + op.drop_table("asset_plant_season_version") + op.drop_table("asset_plant_season") + + # season + op.drop_index(op.f("ix_season_version_transaction_id"), table_name="season_version") + op.drop_index("ix_season_version_pk_validity", table_name="season_version") + op.drop_index("ix_season_version_pk_transaction_id", table_name="season_version") + op.drop_index(op.f("ix_season_version_operation_type"), table_name="season_version") + op.drop_index( + op.f("ix_season_version_end_transaction_id"), table_name="season_version" + ) + op.drop_table("season_version") + op.drop_table("season") diff --git a/src/wuttafarm/db/model/__init__.py b/src/wuttafarm/db/model/__init__.py index 15514fb..929e64b 100644 --- a/src/wuttafarm/db/model/__init__.py +++ b/src/wuttafarm/db/model/__init__.py @@ -37,7 +37,13 @@ from .asset_land import LandType, LandAsset from .asset_structure import StructureType, StructureAsset from .asset_animal import AnimalType, AnimalAsset from .asset_group import GroupAsset -from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType +from .asset_plant import ( + PlantType, + Season, + PlantAsset, + PlantAssetPlantType, + PlantAssetSeason, +) from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner from .log_activity import ActivityLog from .log_harvest import HarvestLog diff --git a/src/wuttafarm/db/model/asset_plant.py b/src/wuttafarm/db/model/asset_plant.py index 62f7e9b..fa1be03 100644 --- a/src/wuttafarm/db/model/asset_plant.py +++ b/src/wuttafarm/db/model/asset_plant.py @@ -91,6 +91,65 @@ class PlantType(model.Base): return self.name or "" +class Season(model.Base): + """ + Represents a "season" (taxonomy term) from farmOS + """ + + __tablename__ = "season" + __versioned__ = {} + __wutta_hint__ = { + "model_title": "Season", + "model_title_plural": "Seasons", + } + + uuid = model.uuid_column() + + name = sa.Column( + sa.String(length=100), + nullable=False, + unique=True, + doc=""" + Name of the season. + """, + ) + + description = sa.Column( + sa.String(length=255), + nullable=True, + doc=""" + Optional description for the season. + """, + ) + + farmos_uuid = sa.Column( + model.UUID(), + nullable=True, + unique=True, + doc=""" + UUID for the season within farmOS. + """, + ) + + drupal_id = sa.Column( + sa.Integer(), + nullable=True, + unique=True, + doc=""" + Drupal internal ID for the season. + """, + ) + + _plant_assets = orm.relationship( + "PlantAssetSeason", + cascade_backrefs=False, + back_populates="season", + ) + + def __str__(self): + return self.name or "" + + class PlantAsset(AssetMixin, model.Base): """ Represents a plant asset from farmOS @@ -117,6 +176,19 @@ class PlantAsset(AssetMixin, model.Base): creator=lambda pt: PlantAssetPlantType(plant_type=pt), ) + _seasons = orm.relationship( + "PlantAssetSeason", + cascade="all, delete-orphan", + cascade_backrefs=False, + back_populates="plant_asset", + ) + + seasons = association_proxy( + "_seasons", + "season", + creator=lambda s: PlantAssetSeason(season=s), + ) + add_asset_proxies(PlantAsset) @@ -146,3 +218,30 @@ class PlantAssetPlantType(model.Base): """, back_populates="_plant_assets", ) + + +class PlantAssetSeason(model.Base): + """ + Associates one or more seasons with a plant asset. + """ + + __tablename__ = "asset_plant_season" + __versioned__ = {} + + uuid = model.uuid_column() + + plant_asset_uuid = model.uuid_fk_column("asset_plant.uuid", nullable=False) + plant_asset = orm.relationship( + PlantAsset, + foreign_keys=plant_asset_uuid, + back_populates="_seasons", + ) + + season_uuid = model.uuid_fk_column("season.uuid", nullable=False) + season = orm.relationship( + Season, + doc=""" + Reference to the season. + """, + back_populates="_plant_assets", + ) diff --git a/src/wuttafarm/farmos/importing/model.py b/src/wuttafarm/farmos/importing/model.py index ac4dc86..ff1022d 100644 --- a/src/wuttafarm/farmos/importing/model.py +++ b/src/wuttafarm/farmos/importing/model.py @@ -368,6 +368,12 @@ class PlantTypeImporter(ToFarmOSTaxonomy): farmos_taxonomy_type = "plant_type" +class SeasonImporter(ToFarmOSTaxonomy): + + model_title = "Season" + farmos_taxonomy_type = "season" + + class PlantAssetImporter(ToFarmOSAsset): model_title = "PlantAsset" @@ -377,6 +383,7 @@ class PlantAssetImporter(ToFarmOSAsset): "uuid", "asset_name", "plant_type_uuids", + "season_uuids", "notes", "archived", ] @@ -388,6 +395,9 @@ class PlantAssetImporter(ToFarmOSAsset): "plant_type_uuids": [ UUID(p["id"]) for p in plant["relationships"]["plant_type"]["data"] ], + "season_uuids": [ + UUID(p["id"]) for p in plant["relationships"]["season"]["data"] + ], } ) return data @@ -413,6 +423,15 @@ class PlantAssetImporter(ToFarmOSAsset): "type": "taxonomy_term--plant_type", } ) + if "season_uuids" in self.fields: + rels["season"] = {"data": []} + for uuid in source_data["season_uuids"]: + rels["season"]["data"].append( + { + "id": str(uuid), + "type": "taxonomy_term--season", + } + ) payload["attributes"].update(attrs) if rels: diff --git a/src/wuttafarm/farmos/importing/wuttafarm.py b/src/wuttafarm/farmos/importing/wuttafarm.py index f4f4948..b492fac 100644 --- a/src/wuttafarm/farmos/importing/wuttafarm.py +++ b/src/wuttafarm/farmos/importing/wuttafarm.py @@ -102,6 +102,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler): importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter + importers["Season"] = SeasonImporter importers["PlantAsset"] = PlantAssetImporter importers["Unit"] = UnitImporter importers["StandardQuantity"] = StandardQuantityImporter @@ -316,6 +317,28 @@ class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter) } +class SeasonImporter(FromWuttaFarm, farmos_importing.model.SeasonImporter): + """ + WuttaFarm → farmOS API exporter for Seasons + """ + + source_model_class = model.Season + + supported_fields = [ + "uuid", + "name", + ] + + drupal_internal_id_field = "drupal_internal__tid" + + def normalize_source_object(self, season): + return { + "uuid": season.farmos_uuid or self.app.make_true_uuid(), + "name": season.name, + "_src_object": season, + } + + class PlantAssetImporter(FromWuttaFarmAsset, farmos_importing.model.PlantAssetImporter): """ WuttaFarm → farmOS API exporter for Plant Assets @@ -328,6 +351,7 @@ class PlantAssetImporter(FromWuttaFarmAsset, farmos_importing.model.PlantAssetIm fields.extend( [ "plant_type_uuids", + "season_uuids", ] ) return fields @@ -336,9 +360,8 @@ class PlantAssetImporter(FromWuttaFarmAsset, farmos_importing.model.PlantAssetIm data = super().normalize_source_object(plant) data.update( { - "plant_type_uuids": [ - t.plant_type.farmos_uuid for t in plant._plant_types - ], + "plant_type_uuids": [pt.farmos_uuid for pt in plant.plant_types], + "season_uuids": [s.farmos_uuid for s in plant.seasons], } ) return data diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index ea1fd4b..c0671a7 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -110,6 +110,7 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler): importers["AnimalAsset"] = AnimalAssetImporter importers["GroupAsset"] = GroupAssetImporter importers["PlantType"] = PlantTypeImporter + importers["Season"] = SeasonImporter importers["PlantAsset"] = PlantAssetImporter importers["Measure"] = MeasureImporter importers["Unit"] = UnitImporter @@ -577,6 +578,34 @@ class PlantTypeImporter(FromFarmOS, ToWutta): } +class SeasonImporter(FromFarmOS, ToWutta): + """ + farmOS API → WuttaFarm importer for Seasons + """ + + model_class = model.Season + + supported_fields = [ + "farmos_uuid", + "drupal_id", + "name", + "description", + ] + + def get_source_objects(self): + """ """ + return list(self.farmos_client.resource.iterate("taxonomy_term", "season")) + + def normalize_source_object(self, season): + """ """ + return { + "farmos_uuid": UUID(season["id"]), + "drupal_id": season["attributes"]["drupal_internal__tid"], + "name": season["attributes"]["name"], + "description": season["attributes"]["description"], + } + + class PlantAssetImporter(AssetImporterBase): """ farmOS API → WuttaFarm importer for Plant Assets @@ -589,6 +618,7 @@ class PlantAssetImporter(AssetImporterBase): fields.extend( [ "plant_types", + "seasons", ] ) return fields @@ -602,11 +632,17 @@ class PlantAssetImporter(AssetImporterBase): if plant_type.farmos_uuid: self.plant_types_by_farmos_uuid[plant_type.farmos_uuid] = plant_type + self.seasons_by_farmos_uuid = {} + for season in self.target_session.query(model.Season): + if season.farmos_uuid: + self.seasons_by_farmos_uuid[season.farmos_uuid] = season + def normalize_source_object(self, plant): """ """ data = super().normalize_source_object(plant) plant_types = [] + seasons = [] if relationships := plant.get("relationships"): if plant_type := relationships.get("plant_type"): @@ -619,9 +655,18 @@ class PlantAssetImporter(AssetImporterBase): else: log.warning("plant type not found: %s", plant_type["id"]) + if season := relationships.get("season"): + seasons = [] + for season in season["data"]: + if wf_season := self.seasons_by_farmos_uuid.get(UUID(season["id"])): + seasons.append(wf_season.uuid) + else: + log.warning("season not found: %s", season["id"]) + data.update( { "plant_types": set(plant_types), + "seasons": set(seasons), } ) return data @@ -632,6 +677,9 @@ class PlantAssetImporter(AssetImporterBase): if "plant_types" in self.fields: data["plant_types"] = set([pt.uuid for pt in plant.plant_types]) + if "seasons" in self.fields: + data["seasons"] = set([s.uuid for s in plant.seasons]) + return data def update_target_object(self, plant, source_data, target_data=None): @@ -664,6 +712,25 @@ class PlantAssetImporter(AssetImporterBase): ) self.target_session.delete(plant_type) + if "seasons" in self.fields: + if not target_data or target_data["seasons"] != source_data["seasons"]: + + for uuid in source_data["seasons"]: + if not target_data or uuid not in target_data["seasons"]: + self.target_session.flush() + plant._seasons.append(model.PlantAssetSeason(season_uuid=uuid)) + + if target_data: + for uuid in target_data["seasons"]: + if uuid not in source_data["seasons"]: + season = ( + self.target_session.query(model.PlantAssetSeason) + .filter(model.PlantAssetSeason.plant_asset == plant) + .filter(model.PlantAssetSeason.season_uuid == uuid) + .one() + ) + self.target_session.delete(season) + return plant diff --git a/src/wuttafarm/normal.py b/src/wuttafarm/normal.py index 2e38f49..38edbab 100644 --- a/src/wuttafarm/normal.py +++ b/src/wuttafarm/normal.py @@ -125,6 +125,11 @@ class Normalizer(GenericHandler): } ) + # if self.farmos_4x: + # archived = asset["attributes"]["archived"] + # else: + # archived = asset["attributes"]["status"] == "archived" + return { "uuid": asset["id"], "drupal_id": asset["attributes"]["drupal_internal__id"], diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index bad5670..2bcb4c8 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -288,6 +288,31 @@ class PlantTypeRefs(WuttaSet): return PlantTypeRefsWidget(self.request, **kwargs) +class SeasonRefs(WuttaSet): + """ + Schema type for Plant Types field (on a Plant Asset). + """ + + def serialize(self, node, appstruct): + if not appstruct: + return [] + + return [season.uuid.hex for season in appstruct] + + def widget_maker(self, **kwargs): + from wuttafarm.web.forms.widgets import SeasonRefsWidget + + model = self.app.model + session = Session() + + if "values" not in kwargs: + seasons = session.query(model.Season).order_by(model.Season.name).all() + values = [(s.uuid.hex, str(s)) for s in seasons] + kwargs["values"] = values + + return SeasonRefsWidget(self.request, **kwargs) + + class StructureType(colander.SchemaType): def __init__(self, request, *args, **kwargs): diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index d74a436..6cf1cfc 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -332,6 +332,78 @@ class PlantTypeRefsWidget(Widget): return set(pstruct.split(",")) +class SeasonRefsWidget(Widget): + """ + Widget for Seasons field (on a Plant Asset). + """ + + template = "seasonrefs" + values = () + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + + def serialize(self, field, cstruct, **kw): + """ """ + model = self.app.model + session = Session() + + if cstruct in (colander.null, None): + cstruct = () + + if readonly := kw.get("readonly", self.readonly): + items = [] + + seasons = ( + session.query(model.Season) + .filter(model.Season.uuid.in_(cstruct)) + .order_by(model.Season.name) + .all() + ) + + for season in seasons: + items.append( + HTML.tag( + "li", + c=tags.link_to( + str(season), + self.request.route_url("seasons.view", uuid=season.uuid), + ), + ) + ) + + return HTML.tag("ul", c=items) + + values = kw.get("values", self.values) + if not isinstance(values, sequence_types): + raise TypeError("Values must be a sequence type (list, tuple, or range).") + + kw["values"] = _normalize_choices(values) + tmpl_values = self.get_template_values(field, cstruct, kw) + return field.renderer(self.template, **tmpl_values) + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + values["js_values"] = json.dumps(values["values"]) + + if self.request.has_perm("seasons.create"): + values["can_create"] = True + + return values + + def deserialize(self, field, pstruct): + """ """ + if not pstruct: + return set() + + return set(pstruct.split(",")) + + class StructureWidget(Widget): """ Widget to display a "structure" field. diff --git a/src/wuttafarm/web/menus.py b/src/wuttafarm/web/menus.py index fe7719e..3b75b4e 100644 --- a/src/wuttafarm/web/menus.py +++ b/src/wuttafarm/web/menus.py @@ -128,6 +128,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "plant_types", "perm": "plant_types.list", }, + { + "title": "Seasons", + "route": "seasons", + "perm": "seasons.list", + }, { "title": "Structure Types", "route": "structure_types", @@ -281,6 +286,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_plant_types", "perm": "farmos_plant_types.list", }, + { + "title": "Seasons", + "route": "farmos_seasons", + "perm": "farmos_seasons.list", + }, { "title": "Structure Types", "route": "farmos_structure_types", @@ -369,6 +379,11 @@ class WuttaFarmMenuHandler(base.MenuHandler): "route": "farmos_plant_types", "perm": "farmos_plant_types.list", }, + { + "title": "Seasons", + "route": "farmos_seasons", + "perm": "farmos_seasons.list", + }, { "title": "Structure Types", "route": "farmos_structure_types", diff --git a/src/wuttafarm/web/templates/deform/seasonrefs.pt b/src/wuttafarm/web/templates/deform/seasonrefs.pt new file mode 100644 index 0000000..955241a --- /dev/null +++ b/src/wuttafarm/web/templates/deform/seasonrefs.pt @@ -0,0 +1,13 @@ +
+ + + +
diff --git a/src/wuttafarm/web/templates/wuttafarm-components.mako b/src/wuttafarm/web/templates/wuttafarm-components.mako index 1a07690..c537489 100644 --- a/src/wuttafarm/web/templates/wuttafarm-components.mako +++ b/src/wuttafarm/web/templates/wuttafarm-components.mako @@ -3,6 +3,7 @@ ${self.make_assets_picker_component()} ${self.make_animal_type_picker_component()} ${self.make_plant_types_picker_component()} + ${self.make_seasons_picker_component()} <%def name="make_assets_picker_component()"> @@ -433,3 +434,199 @@ <% request.register_component('plant-types-picker', 'PlantTypesPicker') %> + +<%def name="make_seasons_picker_component()"> + + + diff --git a/src/wuttafarm/web/views/common.py b/src/wuttafarm/web/views/common.py index 674d76e..445f810 100644 --- a/src/wuttafarm/web/views/common.py +++ b/src/wuttafarm/web/views/common.py @@ -95,6 +95,8 @@ class CommonView(base.CommonView): "farmos_quantities_standard.view", "farmos_quantity_types.list", "farmos_quantity_types.view", + "farmos_seasons.list", + "farmos_seasons.view", "farmos_structure_assets.list", "farmos_structure_assets.view", "farmos_structure_types.list", @@ -132,6 +134,12 @@ class CommonView(base.CommonView): "logs_observation.view", "logs_observation.versions", "quick.eggs", + "plant_types.list", + "plant_types.view", + "plant_types.versions", + "seasons.list", + "seasons.view", + "seasons.versions", "structure_types.list", "structure_types.view", "structure_types.versions", diff --git a/src/wuttafarm/web/views/farmos/plants.py b/src/wuttafarm/web/views/farmos/plants.py index 57bf2d4..40768c4 100644 --- a/src/wuttafarm/web/views/farmos/plants.py +++ b/src/wuttafarm/web/views/farmos/plants.py @@ -32,7 +32,12 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget from wuttafarm.web.views.farmos.master import TaxonomyMasterView from wuttafarm.web.views.farmos import FarmOSMasterView -from wuttafarm.web.forms.schema import UsersType, StructureType, FarmOSPlantTypes +from wuttafarm.web.forms.schema import ( + UsersType, + StructureType, + FarmOSPlantTypes, + FarmOSRefs, +) from wuttafarm.web.forms.widgets import ImageWidget @@ -75,6 +80,43 @@ class PlantTypeView(TaxonomyMasterView): return buttons +class SeasonView(TaxonomyMasterView): + """ + Master view for Seasons in farmOS. + """ + + model_name = "farmos_season" + model_title = "farmOS Season" + model_title_plural = "farmOS Seasons" + + route_prefix = "farmos_seasons" + url_prefix = "/farmOS/seasons" + + farmos_taxonomy_type = "season" + farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview" + + def get_xref_buttons(self, season): + buttons = super().get_xref_buttons(season) + model = self.app.model + session = self.Session() + + if wf_season := ( + session.query(model.Season) + .filter(model.Season.farmos_uuid == season["uuid"]) + .first() + ): + buttons.append( + self.make_button( + f"View {self.app.get_title()} record", + primary=True, + url=self.request.route_url("seasons.view", uuid=wf_season.uuid), + icon_left="eye", + ) + ) + + return buttons + + class PlantAssetView(FarmOSMasterView): """ Master view for farmOS Plant Assets @@ -89,6 +131,10 @@ class PlantAssetView(FarmOSMasterView): farmos_refurl_path = "/assets/plant" + labels = { + "seasons": "Season", + } + grid_columns = [ "name", "archived", @@ -99,6 +145,7 @@ class PlantAssetView(FarmOSMasterView): form_fields = [ "name", "plant_types", + "seasons", "archived", "owners", "location", @@ -151,6 +198,21 @@ class PlantAssetView(FarmOSMasterView): } ) + # add seasons + if seasons := relationships.get("season"): + if seasons["data"]: + data["seasons"] = [] + for season in seasons["data"]: + season = self.farmos_client.resource.get_id( + "taxonomy_term", "season", season["id"] + ) + data["seasons"].append( + { + "uuid": season["data"]["id"], + "name": season["data"]["attributes"]["name"], + } + ) + # add location if location := relationships.get("location"): if location["data"]: @@ -199,22 +261,14 @@ class PlantAssetView(FarmOSMasterView): return plant["name"] def normalize_plant(self, plant): - - if notes := plant["attributes"]["notes"]: - notes = notes["value"] - - if self.farmos_4x: - archived = plant["attributes"]["archived"] - else: - archived = plant["attributes"]["status"] == "archived" - + normal = self.normal.normalize_farmos_asset(plant) return { - "uuid": plant["id"], - "drupal_id": plant["attributes"]["drupal_internal__id"], - "name": plant["attributes"]["name"], + "uuid": normal["uuid"], + "drupal_id": normal["drupal_id"], + "name": normal["asset_name"], "location": colander.null, # TODO - "archived": archived, - "notes": notes or colander.null, + "archived": normal["archived"], + "notes": normal["notes"] or colander.null, } def configure_form(self, form): @@ -225,6 +279,9 @@ class PlantAssetView(FarmOSMasterView): # plant_types f.set_node("plant_types", FarmOSPlantTypes(self.request)) + # seasons + f.set_node("seasons", FarmOSRefs(self.request, "farmos_seasons")) + # location f.set_node("location", StructureType(self.request)) @@ -279,6 +336,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config) diff --git a/src/wuttafarm/web/views/plants.py b/src/wuttafarm/web/views/plants.py index 2d4bdce..16bd3c0 100644 --- a/src/wuttafarm/web/views/plants.py +++ b/src/wuttafarm/web/views/plants.py @@ -28,9 +28,9 @@ from webhelpers2.html import tags from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.util import get_form_data -from wuttafarm.db.model import PlantType, PlantAsset +from wuttafarm.db.model import PlantType, Season, PlantAsset from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView -from wuttafarm.web.forms.schema import PlantTypeRefs +from wuttafarm.web.forms.schema import PlantTypeRefs, SeasonRefs from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.util import get_farmos_client_for_user @@ -195,6 +195,166 @@ class PlantTypeView(AssetTypeMasterView): ) +class SeasonView(AssetTypeMasterView): + """ + Master view for Seasons + """ + + model_class = Season + route_prefix = "seasons" + url_prefix = "/seasons" + + farmos_entity_type = "taxonomy_term" + farmos_bundle = "season" + farmos_refurl_path = "/admin/structure/taxonomy/manage/season/overview" + + grid_columns = [ + "name", + "description", + ] + + sort_defaults = "name" + + filter_defaults = { + "name": {"active": True, "verb": "contains"}, + } + + form_fields = [ + "name", + "description", + "drupal_id", + "farmos_uuid", + ] + + has_rows = True + row_model_class = PlantAsset + rows_viewable = True + + row_grid_columns = [ + "asset_name", + "archived", + ] + + rows_sort_defaults = "asset_name" + + def configure_grid(self, grid): + g = grid + super().configure_grid(g) + + # name + g.set_link("name") + + def get_farmos_url(self, season): + return self.app.get_farmos_url(f"/taxonomy/term/{season.drupal_id}") + + def get_xref_buttons(self, season): + buttons = super().get_xref_buttons(season) + + if season.farmos_uuid: + buttons.append( + self.make_button( + "View farmOS record", + primary=True, + url=self.request.route_url( + "farmos_seasons.view", uuid=season.farmos_uuid + ), + icon_left="eye", + ) + ) + + return buttons + + def delete(self): + season = self.get_instance() + + if season._plant_assets: + self.request.session.flash( + "Cannot delete season which is still referenced by plant assets.", + "warning", + ) + url = self.get_action_url("view", season) + return self.redirect(self.request.get_referrer(default=url)) + + return super().delete() + + def get_row_grid_data(self, season): + model = self.app.model + session = self.Session() + return ( + session.query(model.PlantAsset) + .join(model.Asset) + .outerjoin(model.PlantAssetSeason) + .filter(model.PlantAssetSeason.season == season) + ) + + def configure_row_grid(self, grid): + g = grid + super().configure_row_grid(g) + model = self.app.model + + # asset_name + g.set_link("asset_name") + g.set_sorter("asset_name", model.Asset.asset_name) + g.set_filter("asset_name", model.Asset.asset_name) + + # archived + g.set_renderer("archived", "boolean") + g.set_sorter("archived", model.Asset.archived) + g.set_filter("archived", model.Asset.archived) + + def get_row_action_url_view(self, plant, i): + return self.request.route_url("plant_assets.view", uuid=plant.uuid) + + def ajax_create(self): + """ + AJAX view to create a new season. + """ + model = self.app.model + session = self.Session() + data = get_form_data(self.request) + + name = data.get("name") + if not name: + return {"error": "Name is required"} + + season = model.Season(name=name) + session.add(season) + session.flush() + + if self.app.is_farmos_mirror(): + client = get_farmos_client_for_user(self.request) + self.app.auto_sync_to_farmos(season, client=client) + + return { + "uuid": season.uuid.hex, + "name": season.name, + "farmos_uuid": season.farmos_uuid.hex, + "drupal_id": season.drupal_id, + } + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._season_defaults(config) + + @classmethod + def _season_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + url_prefix = cls.get_url_prefix() + + # ajax_create + config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new") + config.add_view( + cls, + attr="ajax_create", + route_name=f"{route_prefix}.ajax_create", + permission=f"{permission_prefix}.create", + renderer="json", + ) + + class PlantAssetView(AssetMasterView): """ Master view for Plant Assets @@ -209,6 +369,7 @@ class PlantAssetView(AssetMasterView): labels = { "plant_types": "Crop/Variety", + "seasons": "Season", } grid_columns = [ @@ -254,17 +415,26 @@ class PlantAssetView(AssetMasterView): f.set_default("plant_types", [pt.uuid for pt in plant.plant_types]) # season - if not (self.creating or self.editing): # TODO - f.fields.insert_after("plant_types", "season") + f.fields.insert_after("plant_types", "seasons") + f.set_node("seasons", SeasonRefs(self.request)) + f.set_required("seasons", False) + if not self.creating: + # nb. must explcitly declare value for non-standard field + f.set_default("seasons", plant.seasons) def objectify(self, form): - model = self.app.model - session = self.Session() plant = super().objectify(form) data = form.validated + self.set_plant_types(plant, data["plant_types"]) + self.set_seasons(plant, data["seasons"]) + + return plant + + def set_plant_types(self, plant, desired): + model = self.app.model + session = self.Session() current = [pt.uuid for pt in plant.plant_types] - desired = data["plant_types"] for uuid in desired: if uuid not in current: @@ -278,7 +448,22 @@ class PlantAssetView(AssetMasterView): assert plant_type plant.plant_types.remove(plant_type) - return plant + def set_seasons(self, plant, desired): + model = self.app.model + session = self.Session() + current = [s.uuid for s in plant.seasons] + + for uuid in desired: + if uuid not in current: + season = session.get(model.Season, uuid) + assert season + plant.seasons.append(season) + + for uuid in current: + if uuid not in desired: + season = session.get(model.Season, uuid) + assert season + plant.seasons.remove(season) def defaults(config, **kwargs): @@ -287,6 +472,9 @@ def defaults(config, **kwargs): PlantTypeView = kwargs.get("PlantTypeView", base["PlantTypeView"]) PlantTypeView.defaults(config) + SeasonView = kwargs.get("SeasonView", base["SeasonView"]) + SeasonView.defaults(config) + PlantAssetView = kwargs.get("PlantAssetView", base["PlantAssetView"]) PlantAssetView.defaults(config)