feat: add edit/sync support for Plant Seasons

This commit is contained in:
Lance Edgar 2026-03-06 21:38:23 -06:00
parent e61043b9d9
commit 45fd5556f2
15 changed files with 1029 additions and 27 deletions

View file

@ -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")

View file

@ -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

View file

@ -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",
)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"],

View file

@ -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):

View file

@ -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.

View file

@ -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",

View file

@ -0,0 +1,13 @@
<div tal:define="
name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
can_create can_create|False;"
tal:omit-tag="">
<seasons-picker tal:attributes="name name;
v-model vmodel;
:seasons js_values;
:can-create str(can_create).lower();" />
</div>

View file

@ -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>
<%def name="make_assets_picker_component()">
@ -433,3 +434,199 @@
<% request.register_component('plant-types-picker', 'PlantTypesPicker') %>
</script>
</%def>
<%def name="make_seasons_picker_component()">
<script type="text/x-template" id="seasons-picker-template">
<div>
<input type="hidden" :name="name" :value="value" />
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span>Add:</span>
<b-autocomplete v-model="addName"
ref="addName"
:data="addNameData"
field="name"
open-on-focus
keep-first
@select="addNameSelected"
clear-on-select
style="flex-grow: 1;">
<template #empty>No results found</template>
</b-autocomplete>
<b-button type="is-primary"
icon-pack="fas"
icon-left="plus"
@click="createInit()">
New
</b-button>
</div>
<${b}-table :data="seasonData">
<${b}-table-column field="name" v-slot="props">
<span>{{ props.row.name }}</span>
</${b}-table-column>
<${b}-table-column v-slot="props">
<a href="#"
class="has-text-danger"
@click.prevent="removeSeason(props.row)">
<i class="fas fa-trash" /> &nbsp; Remove
</a>
</${b}-table-column>
</${b}-table>
<${b}-modal v-if="canCreate"
has-modal-card
% if request.use_oruga:
v-model:active="createShowDialog"
% else:
:active.sync="createShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">New Season</p>
</header>
<section class="modal-card-body">
<b-field label="Name" horizontal>
<b-input v-model="createName"
ref="createName"
expanded
@keydown.native="createNameKeydown" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
@click="createSave()"
:disabled="createSaving || !createName"
icon-pack="fas"
icon-left="save">
{{ createSaving ? "Working, please wait..." : "Save" }}
</b-button>
<b-button @click="createShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</script>
<script>
const SeasonsPicker = {
template: '#seasons-picker-template',
mixins: [WuttaRequestMixin],
props: {
name: String,
value: Array,
seasons: Array,
canCreate: Boolean,
},
data() {
return {
internalSeasons: this.seasons.map((pt) => {
return {uuid: pt[0], name: pt[1]}
}),
addName: '',
createShowDialog: false,
createName: null,
createSaving: false,
}
},
computed: {
seasonData() {
const data = []
if (this.value) {
for (let ptype of this.internalSeasons) {
if (this.value.includes(ptype.uuid)) {
data.push(ptype)
}
}
}
return data
},
addNameData() {
if (!this.addName) {
return this.internalSeasons
}
return this.internalSeasons.filter((ptype) => {
return ptype.name.toLowerCase().indexOf(this.addName.toLowerCase()) >= 0
})
},
},
methods: {
addNameSelected(option) {
const value = Array.from(this.value || [])
if (!value.includes(option.uuid)) {
value.push(option.uuid)
this.$emit('input', value)
}
this.addName = null
},
createInit() {
this.createName = this.addName
this.createShowDialog = true
this.$nextTick(() => {
this.$refs.createName.focus()
})
},
createNameKeydown(event) {
// nb. must prevent main form submit on ENTER
// (since ultimately this lives within an outer form)
// but also we can submit the modal pseudo-form
if (event.which == 13) {
event.preventDefault()
this.createSave()
}
},
createSave() {
this.createSaving = true
## TODO
% if not app.is_farmos_wrapper():
const url = "${url('seasons.ajax_create')}"
% endif
const params = {name: this.createName}
this.wuttaPOST(url, params, response => {
this.internalSeasons.push(response.data)
const value = Array.from(this.value || [])
value.push(response.data.uuid)
this.$emit('input', value)
this.addName = null
this.createSaving = false
this.createShowDialog = false
}, response => {
this.createSaving = false
})
},
removeSeason(ptype) {
let value = Array.from(this.value)
const i = value.indexOf(ptype.uuid)
value.splice(i, 1)
this.$emit('input', value)
},
},
}
Vue.component('seasons-picker', SeasonsPicker)
<% request.register_component('seasons-picker', 'SeasonsPicker') %>
</script>
</%def>

View file

@ -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",

View file

@ -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)

View file

@ -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)