Compare commits

..

No commits in common. "36eca088953c26060c5fef655c754ec98c030fba" and "98be276bd12f1787a6af4d65379ec13a32621cde" have entirely different histories.

44 changed files with 949 additions and 3654 deletions

View file

@ -5,35 +5,6 @@ All notable changes to WuttaFarm will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.4.0 (2026-02-17)
### Feat
- add basic support for WuttaFarm → farmOS export
- convert group assets to use common base/mixin
- convert structure assets to use common base/mixin
- convert land assets to use common base/mixin
- add "generic" assets, new animal assets based on that
### Fix
- misc. field tweaks for asset forms
- show warning when viewing an archived asset
- fix some perms for all assets view
- fix initial admin perms per route renaming
- add parent relationships support for land assets
- cleanup Land views to better match farmOS
- cleanup Structure views to better match farmOS
- cleanup Group views to better match farmOS
- add / display thumbnail image for animals
- improve handling of 'archived' records for grid/form views
- use Male/Female dict enum for animal sex field
- prevent direct edit of `farmos_uuid` and `drupal_id` fields
- use same datetime display format as farmOS
- convert `active` flag to `archived`
- suppress output when user farmos/drupal keys are empty
- customize page footer to mention farmOS
## v0.3.1 (2026-02-14)
### Fix

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
version = "0.4.0"
version = "0.3.1"
description = "Web app to integrate with and extend farmOS"
readme = "README.md"
authors = [
@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog",
"uvicorn[standard]",
"WuttaSync",
"WuttaWeb[continuum]>=0.28.1",
"WuttaWeb[continuum]>=0.27.4",
]
@ -58,7 +58,6 @@ wuttafarm = "wuttafarm.app:WuttaFarmAppProvider"
"wuttafarm" = "wuttafarm.web.menus:WuttaFarmMenuHandler"
[project.entry-points."wuttasync.importing"]
"export.to_farmos.from_wuttafarm" = "wuttafarm.farmos.importing.wuttafarm:FromWuttaFarmToFarmOS"
"import.to_wuttafarm.from_farmos" = "wuttafarm.importing.farmos:FromFarmOSToWuttaFarm"

View file

@ -31,8 +31,6 @@ class WuttaFarmAppHandler(base.AppHandler):
Custom :term:`app handler` for WuttaFarm.
"""
display_format_datetime = "%a, %m/%d/%Y - %H:%M"
default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler"
@ -85,38 +83,6 @@ class WuttaFarmAppHandler(base.AppHandler):
handler = self.get_farmos_handler()
return handler.is_farmos_4x(*args, **kwargs)
def export_to_farmos(self, obj, require=True):
"""
Export the given object to farmOS, using configured handler.
This should ensure the given object is also *updated* with the
farmOS UUID and Drupal ID, when new record is created in
farmOS.
:param obj: Any data object in WuttaFarm, e.g. AnimalAsset
instance.
:param require: If true, this will *require* the export
handler to support objects of the given type. If false,
then nothing will happen / export is silently skipped when
there is no such exporter.
"""
handler = self.app.get_import_handler("export.to_farmos.from_wuttafarm")
model_name = type(obj).__name__
if model_name not in handler.importers:
if require:
raise ValueError(f"no exporter found for {model_name}")
return
# nb. begin txn to establish the API client
# TODO: should probably use current user oauth2 token instead
# of always making a new one here, which is what happens IIUC
handler.begin_target_transaction()
importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal])
class WuttaFarmAppProvider(base.AppProvider):
"""

View file

@ -26,6 +26,5 @@ WuttaFarm CLI
from .base import wuttafarm_typer
# nb. must bring in all modules for discovery to work
from . import export_farmos
from . import import_farmos
from . import install

View file

@ -1,41 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
See also: :ref:`wuttafarm-export-farmos`
"""
import typer
from wuttasync.cli import import_command, ImportCommandHandler
from wuttafarm.cli import wuttafarm_typer
@wuttafarm_typer.command()
@import_command
def export_farmos(ctx: typer.Context, **kwargs):
"""
Export data from WuttaFarm to farmOS API
"""
config = ctx.parent.wutta_config
handler = ImportCommandHandler(config, key="export.to_farmos.from_wuttafarm")
handler.run(ctx)

View file

@ -39,9 +39,8 @@ class WuttaFarmConfig(WuttaConfigExtension):
config.setdefault(f"{config.appname}.app_title", "WuttaFarm")
config.setdefault(f"{config.appname}.app_dist", "WuttaFarm")
# app model/enum
# app model
config.setdefault(f"{config.appname}.model_spec", "wuttafarm.db.model")
config.setdefault(f"{config.appname}.enum_spec", "wuttafarm.enum")
# app handler
config.setdefault(

View file

@ -1,41 +0,0 @@
"""add animal thumbnail url
Revision ID: 2a49127e974b
Revises: 8898184c5c75
Create Date: 2026-02-14 19:41:22.039343
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "2a49127e974b"
down_revision: Union[str, None] = "8898184c5c75"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.add_column(
"animal", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
)
op.add_column(
"animal_version",
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# animal
op.drop_column("animal_version", "thumbnail_url")
op.drop_column("animal", "thumbnail_url")

View file

@ -1,236 +0,0 @@
"""use shared base for Structure Assets
Revision ID: 34ec51d80f52
Revises: d882682c82f9
Create Date: 2026-02-15 13:19:18.814523
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "34ec51d80f52"
down_revision: Union[str, None] = "d882682c82f9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_structure
op.create_table(
"asset_structure",
sa.Column("structure_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["structure_type_uuid"],
["structure_type.uuid"],
name=op.f("fk_asset_structure_structure_type_uuid_structure_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_structure_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_structure")),
)
op.create_table(
"asset_structure_version",
sa.Column(
"structure_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
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_structure_version")
),
)
op.create_index(
op.f("ix_asset_structure_version_end_transaction_id"),
"asset_structure_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_structure_version_operation_type"),
"asset_structure_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_structure_version_pk_transaction_id",
"asset_structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_structure_version_pk_validity",
"asset_structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_structure_version_transaction_id"),
"asset_structure_version",
["transaction_id"],
unique=False,
)
# structure
op.drop_index(
op.f("ix_structure_version_end_transaction_id"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_operation_type"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_pk_transaction_id"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_pk_validity"), table_name="structure_version"
)
op.drop_index(
op.f("ix_structure_version_transaction_id"), table_name="structure_version"
)
op.drop_table("structure_version")
op.drop_table("structure")
def downgrade() -> None:
# structure
op.create_table(
"structure",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column(
"structure_type_uuid", sa.UUID(), autoincrement=False, nullable=False
),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.ForeignKeyConstraint(
["structure_type_uuid"],
["structure_type.uuid"],
name=op.f("fk_structure_structure_type_uuid_structure_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_structure")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_structure_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_structure_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_structure_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"structure_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("structure_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_structure_version")
),
)
op.create_index(
op.f("ix_structure_version_transaction_id"),
"structure_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_pk_validity"),
"structure_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_pk_transaction_id"),
"structure_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_structure_version_operation_type"),
"structure_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_structure_version_end_transaction_id"),
"structure_version",
["end_transaction_id"],
unique=False,
)
# asset_structure
op.drop_index(
op.f("ix_asset_structure_version_transaction_id"),
table_name="asset_structure_version",
)
op.drop_index(
"ix_asset_structure_version_pk_validity", table_name="asset_structure_version"
)
op.drop_index(
"ix_asset_structure_version_pk_transaction_id",
table_name="asset_structure_version",
)
op.drop_index(
op.f("ix_asset_structure_version_operation_type"),
table_name="asset_structure_version",
)
op.drop_index(
op.f("ix_asset_structure_version_end_transaction_id"),
table_name="asset_structure_version",
)
op.drop_table("asset_structure_version")
op.drop_table("asset_structure")

View file

@ -1,125 +0,0 @@
"""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")

View file

@ -1,250 +0,0 @@
"""convert active to archived
Revision ID: 8898184c5c75
Revises: 3e2ef02bf264
Create Date: 2026-02-14 18:41:23.042951
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "8898184c5c75"
down_revision: Union[str, None] = "3e2ef02bf264"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.alter_column("animal", "active", new_column_name="archived")
animal = sa.sql.table(
"animal",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(animal.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal.update()
.where(animal.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("animal_version", "active", new_column_name="archived")
animal_version = sa.sql.table(
"animal_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(animal_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal_version.update()
.where(animal_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# group
op.alter_column("group", "active", new_column_name="archived")
group = sa.sql.table(
"group",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(group.select())
for row in cursor.fetchall():
op.get_bind().execute(
group.update()
.where(group.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("group_version", "active", new_column_name="archived")
group_version = sa.sql.table(
"group_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(group_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
group_version.update()
.where(group_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# land_asset
op.alter_column("land_asset", "active", new_column_name="archived")
land_asset = sa.sql.table(
"land_asset",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(land_asset.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset.update()
.where(land_asset.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("land_asset_version", "active", new_column_name="archived")
land_asset_version = sa.sql.table(
"land_asset_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(land_asset_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset_version.update()
.where(land_asset_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
# structure
op.alter_column("structure", "active", new_column_name="archived")
structure = sa.sql.table(
"structure",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(structure.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure.update()
.where(structure.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
op.alter_column("structure_version", "active", new_column_name="archived")
structure_version = sa.sql.table(
"structure_version",
sa.sql.column("uuid"),
sa.sql.column("archived"),
)
cursor = op.get_bind().execute(structure_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure_version.update()
.where(structure_version.c.uuid == row.uuid)
.values({"archived": not row.archived})
)
def downgrade() -> None:
# structure
op.alter_column("structure", "archived", new_column_name="active")
structure = sa.sql.table(
"structure",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(structure.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure.update()
.where(structure.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("structure_version", "archived", new_column_name="active")
structure_version = sa.sql.table(
"structure_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(structure_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
structure_version.update()
.where(structure_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# land_asset
op.alter_column("land_asset", "archived", new_column_name="active")
land_asset = sa.sql.table(
"land_asset",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(land_asset.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset.update()
.where(land_asset.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("land_asset_version", "archived", new_column_name="active")
land_asset_version = sa.sql.table(
"land_asset_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(land_asset_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
land_asset_version.update()
.where(land_asset_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# group
op.alter_column("group", "archived", new_column_name="active")
group = sa.sql.table(
"group",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(group.select())
for row in cursor.fetchall():
op.get_bind().execute(
group.update()
.where(group.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("group_version", "archived", new_column_name="active")
group_version = sa.sql.table(
"group_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(group_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
group_version.update()
.where(group_version.c.uuid == row.uuid)
.values({"active": not row.active})
)
# animal
op.alter_column("animal", "archived", new_column_name="active")
animal = sa.sql.table(
"animal",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(animal.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal.update()
.where(animal.c.uuid == row.uuid)
.values({"active": not row.active})
)
op.alter_column("animal_version", "archived", new_column_name="active")
animal_version = sa.sql.table(
"animal_version",
sa.sql.column("uuid"),
sa.sql.column("active"),
)
cursor = op.get_bind().execute(animal_version.select())
for row in cursor.fetchall():
op.get_bind().execute(
animal_version.update()
.where(animal_version.c.uuid == row.uuid)
.values({"active": not row.active})
)

View file

@ -1,41 +0,0 @@
"""add structure thumbnail url
Revision ID: 8cc1565d38e7
Revises: 2a49127e974b
Create Date: 2026-02-14 20:07:33.913573
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "8cc1565d38e7"
down_revision: Union[str, None] = "2a49127e974b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# structure
op.add_column(
"structure", sa.Column("thumbnail_url", sa.String(length=255), nullable=True)
)
op.add_column(
"structure_version",
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# structure
op.drop_column("structure_version", "thumbnail_url")
op.drop_column("structure", "thumbnail_url")

View file

@ -1,194 +0,0 @@
"""use shared base for Group Assets
Revision ID: aecfd9175624
Revises: 34ec51d80f52
Create Date: 2026-02-15 13:57:01.055304
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "aecfd9175624"
down_revision: Union[str, None] = "34ec51d80f52"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_group
op.create_table(
"asset_group",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_group_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_group")),
)
op.create_table(
"asset_group_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
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_group_version")
),
)
op.create_index(
op.f("ix_asset_group_version_end_transaction_id"),
"asset_group_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_group_version_operation_type"),
"asset_group_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_group_version_pk_transaction_id",
"asset_group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_group_version_pk_validity",
"asset_group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_group_version_transaction_id"),
"asset_group_version",
["transaction_id"],
unique=False,
)
# group
op.drop_index(
op.f("ix_group_version_end_transaction_id"), table_name="group_version"
)
op.drop_index(op.f("ix_group_version_operation_type"), table_name="group_version")
op.drop_index(
op.f("ix_group_version_pk_transaction_id"), table_name="group_version"
)
op.drop_index(op.f("ix_group_version_pk_validity"), table_name="group_version")
op.drop_index(op.f("ix_group_version_transaction_id"), table_name="group_version")
op.drop_table("group_version")
op.drop_table("group")
def downgrade() -> None:
# group
op.create_table(
"group",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_group")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_group_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_group_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_group_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"group_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_group_version")
),
)
op.create_index(
op.f("ix_group_version_transaction_id"),
"group_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_pk_validity"),
"group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_group_version_pk_transaction_id"),
"group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_group_version_operation_type"),
"group_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_group_version_end_transaction_id"),
"group_version",
["end_transaction_id"],
unique=False,
)
# asset_group
op.drop_index(
op.f("ix_asset_group_version_transaction_id"), table_name="asset_group_version"
)
op.drop_index(
"ix_asset_group_version_pk_validity", table_name="asset_group_version"
)
op.drop_index(
"ix_asset_group_version_pk_transaction_id", table_name="asset_group_version"
)
op.drop_index(
op.f("ix_asset_group_version_operation_type"), table_name="asset_group_version"
)
op.drop_index(
op.f("ix_asset_group_version_end_transaction_id"),
table_name="asset_group_version",
)
op.drop_table("asset_group_version")
op.drop_table("asset_group")

View file

@ -1,333 +0,0 @@
"""add generic, animal assets
Revision ID: d6e8d16d6854
Revises: 554e6168c339
Create Date: 2026-02-15 09:11:04.886362
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d6e8d16d6854"
down_revision: Union[str, None] = "554e6168c339"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal
op.drop_table("animal")
op.drop_index(
op.f("ix_animal_version_end_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_operation_type"), table_name="animal_version")
op.drop_index(
op.f("ix_animal_version_pk_transaction_id"), table_name="animal_version"
)
op.drop_index(op.f("ix_animal_version_pk_validity"), table_name="animal_version")
op.drop_index(op.f("ix_animal_version_transaction_id"), table_name="animal_version")
op.drop_table("animal_version")
# asset
op.create_table(
"asset",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("farmos_uuid", wuttjamaican.db.util.UUID(), nullable=True),
sa.Column("drupal_id", sa.Integer(), nullable=True),
sa.Column("asset_type", sa.String(length=100), nullable=False),
sa.Column("asset_name", sa.String(length=100), nullable=False),
sa.Column("is_location", sa.Boolean(), nullable=False),
sa.Column("is_fixed", sa.Boolean(), nullable=False),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("thumbnail_url", sa.String(length=255), nullable=True),
sa.Column("image_url", sa.String(length=255), nullable=True),
sa.Column("archived", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["asset_type"],
["asset_type.drupal_id"],
name=op.f("fk_asset_asset_type_asset_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset")),
sa.UniqueConstraint("drupal_id", name=op.f("uq_asset_drupal_id")),
sa.UniqueConstraint("farmos_uuid", name=op.f("uq_asset_farmos_uuid")),
)
op.create_table(
"asset_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"farmos_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("drupal_id", sa.Integer(), autoincrement=False, nullable=True),
sa.Column(
"asset_type", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column(
"asset_name", sa.String(length=100), autoincrement=False, nullable=True
),
sa.Column("is_location", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column("notes", sa.Text(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column(
"image_url", sa.String(length=255), autoincrement=False, nullable=True
),
sa.Column("archived", sa.Boolean(), 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_version")
),
)
op.create_index(
op.f("ix_asset_version_end_transaction_id"),
"asset_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_operation_type"),
"asset_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_version_pk_transaction_id",
"asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_version_pk_validity",
"asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_version_transaction_id"),
"asset_version",
["transaction_id"],
unique=False,
)
# asset_animal
op.create_table(
"asset_animal",
sa.Column("animal_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("birthdate", sa.DateTime(), nullable=True),
sa.Column("sex", sa.String(length=1), nullable=True),
sa.Column("is_sterile", sa.Boolean(), nullable=True),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_asset_animal_animal_type_uuid_animal_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_animal_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_animal")),
)
op.create_table(
"asset_animal_version",
sa.Column(
"animal_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.String(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.Boolean(), autoincrement=False, nullable=True),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
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_animal_version")
),
)
op.create_index(
op.f("ix_asset_animal_version_end_transaction_id"),
"asset_animal_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_operation_type"),
"asset_animal_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_transaction_id",
"asset_animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_animal_version_pk_validity",
"asset_animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_animal_version_transaction_id"),
"asset_animal_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_animal
op.drop_index(
op.f("ix_asset_animal_version_transaction_id"),
table_name="asset_animal_version",
)
op.drop_index(
"ix_asset_animal_version_pk_validity", table_name="asset_animal_version"
)
op.drop_index(
"ix_asset_animal_version_pk_transaction_id", table_name="asset_animal_version"
)
op.drop_index(
op.f("ix_asset_animal_version_operation_type"),
table_name="asset_animal_version",
)
op.drop_index(
op.f("ix_asset_animal_version_end_transaction_id"),
table_name="asset_animal_version",
)
op.drop_table("asset_animal_version")
op.drop_table("asset_animal")
# asset
op.drop_index(op.f("ix_asset_version_transaction_id"), table_name="asset_version")
op.drop_index("ix_asset_version_pk_validity", table_name="asset_version")
op.drop_index("ix_asset_version_pk_transaction_id", table_name="asset_version")
op.drop_index(op.f("ix_asset_version_operation_type"), table_name="asset_version")
op.drop_index(
op.f("ix_asset_version_end_transaction_id"), table_name="asset_version"
)
op.drop_table("asset_version")
op.drop_table("asset")
# animal
op.create_table(
"animal",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("birthdate", sa.DateTime(), autoincrement=False, nullable=True),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.ForeignKeyConstraint(
["animal_type_uuid"],
["animal_type.uuid"],
name=op.f("fk_animal_animal_type_uuid_animal_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_animal")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_animal_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_animal_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"animal_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("animal_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column(
"birthdate", postgresql.TIMESTAMP(), autoincrement=False, nullable=True
),
sa.Column("sex", sa.VARCHAR(length=1), autoincrement=False, nullable=True),
sa.Column("is_sterile", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column(
"image_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.Column(
"thumbnail_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_animal_version")
),
)
op.create_index(
op.f("ix_animal_version_transaction_id"),
"animal_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_validity"),
"animal_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_pk_transaction_id"),
"animal_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_animal_version_operation_type"),
"animal_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_animal_version_end_transaction_id"),
"animal_version",
["end_transaction_id"],
unique=False,
)

View file

@ -1,411 +0,0 @@
"""use shared base for Land Assets
Revision ID: d882682c82f9
Revises: d6e8d16d6854
Create Date: 2026-02-15 12:00:27.036011
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d882682c82f9"
down_revision: Union[str, None] = "d6e8d16d6854"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_parent
op.create_table(
"asset_parent",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("parent_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"],
["asset.uuid"],
name=op.f("fk_asset_parent_asset_uuid_asset"),
),
sa.ForeignKeyConstraint(
["parent_uuid"],
["asset.uuid"],
name=op.f("fk_asset_parent_parent_uuid_asset"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_parent")),
)
op.create_table(
"asset_parent_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(
"parent_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_parent_version")
),
)
op.create_index(
op.f("ix_asset_parent_version_end_transaction_id"),
"asset_parent_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_parent_version_operation_type"),
"asset_parent_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_parent_version_pk_transaction_id",
"asset_parent_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_parent_version_pk_validity",
"asset_parent_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_parent_version_transaction_id"),
"asset_parent_version",
["transaction_id"],
unique=False,
)
# asset_land
op.create_table(
"asset_land",
sa.Column("land_type_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["land_type_uuid"],
["land_type.uuid"],
name=op.f("fk_asset_land_land_type_uuid_land_type"),
),
sa.ForeignKeyConstraint(
["uuid"], ["asset.uuid"], name=op.f("fk_asset_land_uuid_asset")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_land")),
sa.UniqueConstraint(
"land_type_uuid", name=op.f("uq_asset_land_land_type_uuid")
),
)
op.create_table(
"asset_land_version",
sa.Column(
"land_type_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
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_land_version")
),
)
op.create_index(
op.f("ix_asset_land_version_end_transaction_id"),
"asset_land_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_land_version_operation_type"),
"asset_land_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_land_version_pk_transaction_id",
"asset_land_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_land_version_pk_validity",
"asset_land_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_land_version_transaction_id"),
"asset_land_version",
["transaction_id"],
unique=False,
)
# land_asset_parent
op.drop_index(
op.f("ix_land_asset_parent_version_end_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_pk_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_pk_validity"),
table_name="land_asset_parent_version",
)
op.drop_index(
op.f("ix_land_asset_parent_version_transaction_id"),
table_name="land_asset_parent_version",
)
op.drop_table("land_asset_parent_version")
op.drop_table("land_asset_parent")
# land_asset
op.drop_index(
op.f("ix_land_asset_version_end_transaction_id"),
table_name="land_asset_version",
)
op.drop_index(
op.f("ix_land_asset_version_operation_type"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_pk_transaction_id"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_pk_validity"), table_name="land_asset_version"
)
op.drop_index(
op.f("ix_land_asset_version_transaction_id"), table_name="land_asset_version"
)
op.drop_table("land_asset_version")
op.drop_table("land_asset")
def downgrade() -> None:
# land_asset
op.create_table(
"land_asset",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["land_type_uuid"],
["land_type.uuid"],
name=op.f("fk_land_asset_land_type_uuid_land_type"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_land_asset")),
sa.UniqueConstraint(
"drupal_id",
name=op.f("uq_land_asset_drupal_id"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"farmos_uuid",
name=op.f("uq_land_asset_farmos_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"land_type_uuid",
name=op.f("uq_land_asset_land_type_uuid"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
sa.UniqueConstraint(
"name",
name=op.f("uq_land_asset_name"),
postgresql_include=[],
postgresql_nulls_not_distinct=False,
),
)
op.create_table(
"land_asset_version",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("name", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column("land_type_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("is_location", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("is_fixed", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("notes", sa.TEXT(), autoincrement=False, nullable=True),
sa.Column("archived", sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column("farmos_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("drupal_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_land_asset_version")
),
)
op.create_index(
op.f("ix_land_asset_version_transaction_id"),
"land_asset_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_pk_validity"),
"land_asset_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_pk_transaction_id"),
"land_asset_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_operation_type"),
"land_asset_version",
["operation_type"],
unique=False,
)
op.create_index(
op.f("ix_land_asset_version_end_transaction_id"),
"land_asset_version",
["end_transaction_id"],
unique=False,
)
# land_asset_parent
op.create_table(
"land_asset_parent",
sa.Column("uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, 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", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("land_asset_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("parent_asset_uuid", sa.UUID(), autoincrement=False, nullable=True),
sa.Column("transaction_id", sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column(
"end_transaction_id", sa.BIGINT(), autoincrement=False, nullable=True
),
sa.Column("operation_type", sa.SMALLINT(), autoincrement=False, 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_transaction_id"),
"land_asset_parent_version",
["transaction_id"],
unique=False,
)
op.create_index(
op.f("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_pk_transaction_id"),
"land_asset_parent_version",
["uuid", sa.literal_column("transaction_id DESC")],
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(
op.f("ix_land_asset_parent_version_end_transaction_id"),
"land_asset_parent_version",
["end_transaction_id"],
unique=False,
)
# asset_land
op.drop_table("asset_land")
op.drop_index(
op.f("ix_asset_land_version_transaction_id"), table_name="asset_land_version"
)
op.drop_index("ix_asset_land_version_pk_validity", table_name="asset_land_version")
op.drop_index(
"ix_asset_land_version_pk_transaction_id", table_name="asset_land_version"
)
op.drop_index(
op.f("ix_asset_land_version_operation_type"), table_name="asset_land_version"
)
op.drop_index(
op.f("ix_asset_land_version_end_transaction_id"),
table_name="asset_land_version",
)
op.drop_table("asset_land_version")
# asset_parent
op.drop_index(
op.f("ix_asset_parent_version_transaction_id"),
table_name="asset_parent_version",
)
op.drop_index(
"ix_asset_parent_version_pk_validity", table_name="asset_parent_version"
)
op.drop_index(
"ix_asset_parent_version_pk_transaction_id", table_name="asset_parent_version"
)
op.drop_index(
op.f("ix_asset_parent_version_operation_type"),
table_name="asset_parent_version",
)
op.drop_index(
op.f("ix_asset_parent_version_end_transaction_id"),
table_name="asset_parent_version",
)
op.drop_table("asset_parent_version")
op.drop_table("asset_parent")

View file

@ -30,9 +30,9 @@ from wuttjamaican.db.model import *
from .users import WuttaFarmUser
# wuttafarm proper models
from .assets import AssetType, Asset, AssetParent
from .assets import AssetType
from .land import LandType, LandAsset
from .structures import StructureType, StructureAsset
from .animals import AnimalType, AnimalAsset
from .groups import GroupAsset
from .structures import StructureType, Structure
from .animals import AnimalType, Animal
from .groups import Group
from .logs import LogType, ActivityLog

View file

@ -28,8 +28,6 @@ from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
class AnimalType(model.Base):
"""
@ -96,19 +94,28 @@ class AnimalType(model.Base):
return self.name or ""
class AnimalAsset(AssetMixin, model.Base):
class Animal(model.Base):
"""
Represents an animal asset from farmOS
Represents an animal from farmOS
"""
__tablename__ = "asset_animal"
__tablename__ = "animal"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Animal Asset",
"model_title_plural": "Animal Assets",
"farmos_asset_type": "animal",
"model_title": "Animal",
"model_title_plural": "Animals",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name for the animal.
""",
)
animal_type_uuid = model.uuid_fk_column("animal_type.uuid", nullable=False)
animal_type = orm.relationship(
"AnimalType",
@ -141,5 +148,47 @@ class AnimalAsset(AssetMixin, model.Base):
""",
)
active = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the animal is currently active.
""",
)
add_asset_proxies(AnimalAsset)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Arbitrary notes for the animal.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the animal.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the animal within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the animal.
""",
)
def __str__(self):
return self.name or ""

View file

@ -25,7 +25,6 @@ Model definition for Asset Types
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from wuttjamaican.db import model
@ -81,160 +80,3 @@ class AssetType(model.Base):
def __str__(self):
return self.name or ""
class Asset(model.Base):
"""
Represents an asset (of any kind) from farmOS.
"""
__tablename__ = "asset"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Asset",
"model_title_plural": "All Assets",
}
uuid = model.uuid_column()
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the asset within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the asset.
""",
)
asset_type = sa.Column(
sa.String(length=100), sa.ForeignKey("asset_type.drupal_id"), nullable=False
)
asset_name = sa.Column(
sa.String(length=100),
nullable=False,
doc="""
Name of the asset.
""",
)
is_location = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset should be considered a location.
""",
)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset's location is fixed.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Notes for the asset.
""",
)
thumbnail_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional thumbnail URL for the asset.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the asset.
""",
)
archived = sa.Column(
sa.Boolean(),
nullable=False,
default=False,
doc="""
Whether the asset is archived.
""",
)
_parents = orm.relationship(
"AssetParent",
foreign_keys="AssetParent.asset_uuid",
back_populates="asset",
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self):
return self.asset_name or ""
class AssetMixin:
uuid = model.uuid_fk_column("asset.uuid", nullable=False, primary_key=True)
@declared_attr
def asset(cls):
return orm.relationship(Asset)
def __str__(self):
return self.asset_name or ""
def add_asset_proxies(subclass):
Asset.make_proxy(subclass, "asset", "farmos_uuid")
Asset.make_proxy(subclass, "asset", "drupal_id")
Asset.make_proxy(subclass, "asset", "asset_type")
Asset.make_proxy(subclass, "asset", "asset_name")
Asset.make_proxy(subclass, "asset", "is_location")
Asset.make_proxy(subclass, "asset", "is_fixed")
Asset.make_proxy(subclass, "asset", "notes")
Asset.make_proxy(subclass, "asset", "thumbnail_url")
Asset.make_proxy(subclass, "asset", "image_url")
Asset.make_proxy(subclass, "asset", "archived")
class AssetParent(model.Base):
"""
Represents an "asset's parent relationship" from farmOS.
"""
__tablename__ = "asset_parent"
__versioned__ = {}
uuid = model.uuid_column()
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
Asset,
foreign_keys=asset_uuid,
)
parent_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
parent = orm.relationship(
Asset,
foreign_keys=parent_uuid,
)

View file

@ -23,23 +23,84 @@
Model definition for Groups
"""
import sqlalchemy as sa
from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
class GroupAsset(AssetMixin, model.Base):
class Group(model.Base):
"""
Represents a group asset from farmOS
Represents a "group" from farmOS
"""
__tablename__ = "asset_group"
__tablename__ = "group"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Group Asset",
"model_title_plural": "Group Assets",
"farmos_asset_type": "group",
"model_title": "Group",
"model_title_plural": "Groups",
}
uuid = model.uuid_column()
add_asset_proxies(GroupAsset)
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name for the group.
""",
)
is_location = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the group is considered to be a location.
""",
)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the group location is fixed.
""",
)
active = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the group is active.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Arbitrary notes for the group.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the group within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the group.
""",
)
def __str__(self):
return self.name or ""

View file

@ -28,8 +28,6 @@ from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
class LandType(model.Base):
"""
@ -78,21 +76,81 @@ class LandType(model.Base):
return self.name or ""
class LandAsset(AssetMixin, model.Base):
class LandAsset(model.Base):
"""
Represents a "land asset" from farmOS
"""
__tablename__ = "asset_land"
__tablename__ = "land_asset"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Land Asset",
"model_title_plural": "Land Assets",
"farmos_asset_type": "animal",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the land asset.
""",
)
land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True)
land_type = orm.relationship(LandType, back_populates="land_assets")
is_location = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the land asset should be considered a location.
""",
)
add_asset_proxies(LandAsset)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the land asset's location is fixed.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Notes for the land asset.
""",
)
active = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the land asset is currently active.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the land asset within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the land asset.
""",
)
def __str__(self):
return self.name or ""

View file

@ -28,8 +28,6 @@ from sqlalchemy import orm
from wuttjamaican.db import model
from wuttafarm.db.model.assets import AssetMixin, add_asset_proxies
class StructureType(model.Base):
"""
@ -76,19 +74,37 @@ class StructureType(model.Base):
return self.name or ""
class StructureAsset(AssetMixin, model.Base):
class Structure(model.Base):
"""
Represents a structure from farmOS
"""
__tablename__ = "asset_structure"
__tablename__ = "structure"
__versioned__ = {}
__wutta_hint__ = {
"model_title": "Structure Asset",
"model_title_plural": "Structure Assets",
"farmos_asset_type": "structure",
"model_title": "Structure",
"model_title_plural": "Structures",
}
uuid = model.uuid_column()
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name for the structure.
""",
)
active = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the structure is currently active.
""",
)
structure_type_uuid = model.uuid_fk_column("structure_type.uuid", nullable=False)
structure_type = orm.relationship(
"StructureType",
@ -97,5 +113,55 @@ class StructureAsset(AssetMixin, model.Base):
""",
)
is_location = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the structure is considered a location.
""",
)
add_asset_proxies(StructureAsset)
is_fixed = sa.Column(
sa.Boolean(),
nullable=False,
doc="""
Whether the structure location is fixed.
""",
)
notes = sa.Column(
sa.Text(),
nullable=True,
doc="""
Arbitrary notes for the structure.
""",
)
image_url = sa.Column(
sa.String(length=255),
nullable=True,
doc="""
Optional image URL for the structure.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
unique=True,
doc="""
UUID for the structure within farmOS.
""",
)
drupal_id = sa.Column(
sa.Integer(),
nullable=True,
unique=True,
doc="""
Drupal internal ID for the structure.
""",
)
def __str__(self):
return self.name or ""

View file

@ -26,12 +26,6 @@ Email sending config for WuttaFarm
from wuttasync.emails import ImportExportWarning
class export_to_farmos_from_wuttafarm_warning(ImportExportWarning):
"""
Diff warning for WuttaFarm farmOS export.
"""
class import_to_wuttafarm_from_farmos_warning(ImportExportWarning):
"""
Diff warning for farmOS WuttaFarm import.

View file

@ -1,36 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm enum values
"""
from collections import OrderedDict
from wuttjamaican.enum import *
ANIMAL_SEX = OrderedDict(
[
("M", "Male"),
("F", "Female"),
]
)

View file

@ -1,26 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importing data *into* farmOS
"""
from . import model

View file

@ -1,365 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Importer models targeting farmOS
"""
import datetime
from uuid import UUID
import requests
from wuttasync.importing import Importer
class ToFarmOS(Importer):
"""
Base class for data importer targeting the farmOS API.
"""
key = "uuid"
caches_target = True
def format_datetime(self, dt):
"""
Convert a WuttaFarm datetime object to the format required for
pushing to the farmOS API.
"""
if dt is None:
return None
dt = self.app.localtime(dt)
return dt.timestamp()
def normalize_datetime(self, dt):
"""
Convert a farmOS datetime value to naive UTC used by
WuttaFarm.
:param dt: Date/time string value "as-is" from the farmOS API.
:returns: Equivalent naive UTC ``datetime``
"""
if dt is None:
return None
dt = datetime.datetime.fromisoformat(dt)
return self.app.make_utc(dt)
class ToFarmOSAsset(ToFarmOS):
"""
Base class for asset data importer targeting the farmOS API.
"""
farmos_asset_type = None
def get_target_objects(self, **kwargs):
assets = self.farmos_client.asset.get(self.farmos_asset_type)
return assets["data"]
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
asset = self.farmos_client.asset.get_id(self.farmos_asset_type, str(uuid))
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return asset["data"]
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_asset_payload(source_data)
result = self.farmos_client.asset.send(self.farmos_asset_type, payload)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, asset, source_data, target_data=None):
if self.dry_run:
return asset
payload = self.get_asset_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.asset.send(self.farmos_asset_type, payload)
return self.normalize_target_object(result["data"])
def normalize_target_object(self, asset):
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
return {
"uuid": UUID(asset["id"]),
"asset_name": asset["attributes"]["name"],
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"notes": notes,
"archived": asset["attributes"]["archived"],
}
def get_asset_payload(self, source_data):
attrs = {}
if "asset_name" in self.fields:
attrs["name"] = source_data["asset_name"]
if "is_location" in self.fields:
attrs["is_location"] = source_data["is_location"]
if "is_fixed" in self.fields:
attrs["is_fixed"] = source_data["is_fixed"]
if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]}
if "archived" in self.fields:
attrs["archived"] = source_data["archived"]
payload = {"attributes": attrs}
return payload
class AnimalAssetImporter(ToFarmOSAsset):
model_title = "AnimalAsset"
farmos_asset_type = "animal"
supported_fields = [
"uuid",
"asset_name",
"animal_type_uuid",
"sex",
"is_sterile",
"birthdate",
"notes",
"archived",
]
def normalize_target_object(self, animal):
data = super().normalize_target_object(animal)
data.update(
{
"animal_type_uuid": UUID(
animal["relationships"]["animal_type"]["data"]["id"]
),
"sex": animal["attributes"]["sex"],
"is_sterile": animal["attributes"]["is_sterile"],
"birthdate": self.normalize_datetime(animal["attributes"]["birthdate"]),
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "sex" in self.fields:
attrs["sex"] = source_data["sex"]
if "is_sterile" in self.fields:
attrs["is_sterile"] = source_data["is_sterile"]
if "birthdate" in self.fields:
attrs["birthdate"] = self.format_datetime(source_data["birthdate"])
rels = {}
if "animal_type_uuid" in self.fields:
rels["animal_type"] = {
"data": {
"id": str(source_data["animal_type_uuid"]),
"type": "taxonomy_term--animal_type",
}
}
payload["attributes"].update(attrs)
if rels:
payload.setdefault("relationships", {}).update(rels)
return payload
class AnimalTypeImporter(ToFarmOS):
model_title = "AnimalType"
supported_fields = [
"uuid",
"name",
]
def get_target_objects(self, **kwargs):
result = self.farmos_client.resource.get("taxonomy_term", "animal_type")
return result["data"]
def get_target_object(self, key):
# fetch from cache, if applicable
if self.caches_target:
return super().get_target_object(key)
# okay now must fetch via API
if self.get_keys() != ["uuid"]:
raise ValueError("must use uuid key for this to work")
uuid = key[0]
try:
result = self.farmos_client.resource.get_id(
"taxonomy_term", "animal_type", str(uuid)
)
except requests.HTTPError as exc:
if exc.response.status_code == 404:
return None
raise
return result["data"]
def normalize_target_object(self, obj):
return {
"uuid": UUID(obj["id"]),
"name": obj["attributes"]["name"],
}
def get_type_payload(self, source_data):
return {
"attributes": {
"name": source_data["name"],
}
}
def create_target_object(self, key, source_data):
if source_data.get("__ignoreme__"):
return None
if self.dry_run:
return source_data
payload = self.get_type_payload(source_data)
result = self.farmos_client.resource.send(
"taxonomy_term", "animal_type", payload
)
normal = self.normalize_target_object(result["data"])
normal["_new_object"] = result["data"]
return normal
def update_target_object(self, asset, source_data, target_data=None):
if self.dry_run:
return asset
payload = self.get_type_payload(source_data)
payload["id"] = str(source_data["uuid"])
result = self.farmos_client.resource.send(
"taxonomy_term", "animal_type", payload
)
return self.normalize_target_object(result["data"])
class GroupAssetImporter(ToFarmOSAsset):
model_title = "GroupAsset"
farmos_asset_type = "group"
supported_fields = [
"uuid",
"asset_name",
"notes",
"archived",
]
class LandAssetImporter(ToFarmOSAsset):
model_title = "LandAsset"
farmos_asset_type = "land"
supported_fields = [
"uuid",
"asset_name",
"land_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_target_object(self, land):
data = super().normalize_target_object(land)
data.update(
{
"land_type_id": land["attributes"]["land_type"],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "land_type_id" in self.fields:
attrs["land_type"] = source_data["land_type_id"]
if attrs:
payload["attributes"].update(attrs)
return payload
class StructureAssetImporter(ToFarmOSAsset):
model_title = "StructureAsset"
farmos_asset_type = "structure"
supported_fields = [
"uuid",
"asset_name",
"structure_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_target_object(self, structure):
data = super().normalize_target_object(structure)
data.update(
{
"structure_type_id": structure["attributes"]["structure_type"],
}
)
return data
def get_asset_payload(self, source_data):
payload = super().get_asset_payload(source_data)
attrs = {}
if "structure_type_id" in self.fields:
attrs["structure_type"] = source_data["structure_type_id"]
if attrs:
payload["attributes"].update(attrs)
return payload

View file

@ -1,263 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttaFarm farmOS data export
"""
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
from wuttasync.importing import ImportHandler, FromWuttaHandler, FromWutta, Orientation
from wuttafarm.db import model
from wuttafarm.farmos import importing as farmos_importing
class FromWuttaFarmHandler(FromWuttaHandler):
"""
Base class for import handler targeting WuttaFarm
"""
source_key = "wuttafarm"
class ToFarmOSHandler(ImportHandler):
"""
Base class for export handlers using CSV file(s) as data target.
"""
target_key = "farmos"
generic_target_title = "farmOS"
# TODO: a lot of duplication to cleanup here; see FromFarmOSHandler
def begin_target_transaction(self):
"""
Establish the farmOS API client.
"""
token = self.get_farmos_oauth2_token()
self.farmos_client = self.app.get_farmos_client(token=token)
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
def get_farmos_oauth2_token(self):
client_id = self.config.get(
"farmos.oauth2.importing.client_id", default="wuttafarm"
)
client_secret = self.config.require("farmos.oauth2.importing.client_secret")
scope = self.config.get("farmos.oauth2.importing.scope", default="farm_manager")
client = BackendApplicationClient(client_id=client_id)
oauth = OAuth2Session(client=client)
return oauth.fetch_token(
token_url=self.app.get_farmos_url("/oauth/token"),
include_client_id=True,
client_secret=client_secret,
scope=scope,
)
def get_importer_kwargs(self, key, **kwargs):
kwargs = super().get_importer_kwargs(key, **kwargs)
kwargs["farmos_client"] = self.farmos_client
kwargs["farmos_4x"] = self.farmos_4x
return kwargs
class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
"""
Handler for WuttaFarm farmOS API export.
"""
orientation = Orientation.EXPORT
def define_importers(self):
""" """
importers = super().define_importers()
importers["LandAsset"] = LandAssetImporter
importers["StructureAsset"] = StructureAssetImporter
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
return importers
class FromWuttaFarm(FromWutta):
drupal_internal_id_field = "drupal_internal__id"
def create_target_object(self, key, source_data):
obj = super().create_target_object(key, source_data)
if obj is None:
return None
if not self.dry_run:
# set farmOS, Drupal key fields in WuttaFarm
api_object = obj["_new_object"]
wf_object = source_data["_src_object"]
wf_object.farmos_uuid = obj["uuid"]
wf_object.drupal_id = api_object["attributes"][
self.drupal_internal_id_field
]
return obj
class AnimalAssetImporter(FromWuttaFarm, farmos_importing.model.AnimalAssetImporter):
"""
WuttaFarm farmOS API exporter for Animal Assets
"""
source_model_class = model.AnimalAsset
supported_fields = [
"uuid",
"asset_name",
"animal_type_uuid",
"sex",
"is_sterile",
"birthdate",
"notes",
"archived",
]
def normalize_source_object(self, animal):
return {
"uuid": animal.farmos_uuid or self.app.make_true_uuid(),
"asset_name": animal.asset_name,
"animal_type_uuid": animal.animal_type.farmos_uuid,
"sex": animal.sex,
"is_sterile": animal.is_sterile,
"birthdate": animal.birthdate,
"notes": animal.notes,
"archived": animal.archived,
"_src_object": animal,
}
class AnimalTypeImporter(FromWuttaFarm, farmos_importing.model.AnimalTypeImporter):
"""
WuttaFarm farmOS API exporter for Animal Types
"""
source_model_class = model.AnimalType
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, animal_type):
return {
"uuid": animal_type.farmos_uuid or self.app.make_true_uuid(),
"name": animal_type.name,
"_src_object": animal_type,
}
class GroupAssetImporter(FromWuttaFarm, farmos_importing.model.GroupAssetImporter):
"""
WuttaFarm farmOS API exporter for Group Assets
"""
source_model_class = model.GroupAsset
supported_fields = [
"uuid",
"asset_name",
"notes",
"archived",
]
def normalize_source_object(self, group):
return {
"uuid": group.farmos_uuid or self.app.make_true_uuid(),
"asset_name": group.asset_name,
"notes": group.notes,
"archived": group.archived,
"_src_object": group,
}
class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter):
"""
WuttaFarm farmOS API exporter for Land Assets
"""
source_model_class = model.LandAsset
supported_fields = [
"uuid",
"asset_name",
"land_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_source_object(self, land):
return {
"uuid": land.farmos_uuid or self.app.make_true_uuid(),
"asset_name": land.asset_name,
"land_type_id": land.land_type.drupal_id,
"is_location": land.is_location,
"is_fixed": land.is_fixed,
"notes": land.notes,
"archived": land.archived,
"_src_object": land,
}
class StructureAssetImporter(
FromWuttaFarm, farmos_importing.model.StructureAssetImporter
):
"""
WuttaFarm farmOS API exporter for Structure Assets
"""
source_model_class = model.StructureAsset
supported_fields = [
"uuid",
"asset_name",
"structure_type_id",
"is_location",
"is_fixed",
"notes",
"archived",
]
def normalize_source_object(self, structure):
return {
"uuid": structure.farmos_uuid or self.app.make_true_uuid(),
"asset_name": structure.asset_name,
"structure_type_id": structure.structure_type.drupal_id,
"is_location": structure.is_location,
"is_fixed": structure.is_fixed,
"notes": structure.notes,
"archived": structure.archived,
"_src_object": structure,
}

View file

@ -100,10 +100,10 @@ class FromFarmOSToWuttaFarm(FromFarmOSHandler, ToWuttaFarmHandler):
importers["LandType"] = LandTypeImporter
importers["LandAsset"] = LandAssetImporter
importers["StructureType"] = StructureTypeImporter
importers["StructureAsset"] = StructureAssetImporter
importers["Structure"] = StructureImporter
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
importers["Animal"] = AnimalImporter
importers["Group"] = GroupImporter
importers["LogType"] = LogTypeImporter
importers["ActivityLog"] = ActivityLogImporter
return importers
@ -176,170 +176,24 @@ class ActivityLogImporter(FromFarmOS, ToWutta):
}
class AssetImporterBase(FromFarmOS, ToWutta):
"""
Base class for farmOS API WuttaFarm asset importers
"""
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
# nb. must explicitly declare proxy fields
fields.extend(
[
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"is_location",
"is_fixed",
"notes",
"archived",
"image_url",
"thumbnail_url",
]
)
return fields
def get_supported_fields(self):
""" """
fields = list(super().get_supported_fields())
fields.extend(
[
"parents",
]
)
return fields
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_asset(self, asset):
""" """
image_url = None
thumbnail_url = None
if relationships := asset.get("relationships"):
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"]
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
archived = asset["attributes"]["archived"]
else:
archived = asset["attributes"]["status"] == "archived"
parents = None
if "parents" in self.fields:
parents = []
for parent in asset["relationships"]["parent"]["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,
}
def get_asset_type(self, asset):
return asset["type"].split("--")[1]
def normalize_target_object(self, asset):
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
]
return data
def update_target_object(self, asset, source_data, target_data=None):
model = self.app.model
asset = super().update_target_object(asset, source_data, target_data)
if "parents" in self.fields:
if not target_data or target_data["parents"] != source_data["parents"]:
for key in source_data["parents"]:
asset_type, farmos_uuid = key
if not target_data or key not in target_data["parents"]:
self.target_session.flush()
parent = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
asset.asset._parents.append(model.AssetParent(parent=parent))
if target_data:
for key in target_data["parents"]:
asset_type, farmos_uuid = key
if key not in source_data["parents"]:
parent = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
parent = (
self.target_session.query(model.AssetParent)
.filter(model.AssetParent.asset == asset)
.filter(model.AssetParent.parent == parent)
.one()
)
self.target_session.delete(parent)
return asset
class AnimalAssetImporter(AssetImporterBase):
class AnimalImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Animals
"""
model_class = model.AnimalAsset
model_class = model.Animal
supported_fields = [
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"name",
"animal_type_uuid",
"sex",
"is_sterile",
"birthdate",
"notes",
"archived",
"active",
"image_url",
"thumbnail_url",
]
def setup(self):
@ -359,18 +213,25 @@ class AnimalAssetImporter(AssetImporterBase):
def normalize_source_object(self, animal):
""" """
animal_type_uuid = None
image_url = None
if relationships := animal.get("relationships"):
if animal_type := relationships.get("animal_type"):
if animal_type["data"]:
if wf_animal_type := self.animal_types_by_farmos_uuid.get(
if animal_type := self.animal_types_by_farmos_uuid.get(
UUID(animal_type["data"]["id"])
):
animal_type_uuid = wf_animal_type.uuid
else:
log.warning(
"animal type not found: %s", animal_type["data"]["id"]
)
animal_type_uuid = animal_type.uuid
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"]
if not animal_type_uuid:
log.warning("missing/invalid animal_type for farmOS Animal: %s", animal)
@ -387,17 +248,26 @@ class AnimalAssetImporter(AssetImporterBase):
else:
sterile = animal["attributes"]["is_castrated"]
data = self.normalize_asset(animal)
data.update(
{
"asset_type": "animal",
"animal_type_uuid": animal_type_uuid,
"sex": animal["attributes"]["sex"],
"is_sterile": sterile,
"birthdate": birthdate,
}
)
return data
if notes := animal["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
active = not animal["attributes"]["archived"]
else:
active = animal["attributes"]["status"] == "active"
return {
"farmos_uuid": UUID(animal["id"]),
"drupal_id": animal["attributes"]["drupal_internal__id"],
"name": animal["attributes"]["name"],
"animal_type_uuid": animal_type.uuid,
"sex": animal["attributes"]["sex"],
"is_sterile": sterile,
"birthdate": birthdate,
"active": active,
"notes": notes,
"image_url": image_url,
}
class AnimalTypeImporter(FromFarmOS, ToWutta):
@ -460,25 +330,21 @@ class AssetTypeImporter(FromFarmOS, ToWutta):
}
class GroupAssetImporter(AssetImporterBase):
class GroupImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Group Assets
farmOS API WuttaFarm importer for Groups
"""
model_class = model.GroupAsset
model_class = model.Group
supported_fields = [
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"name",
"is_location",
"is_fixed",
"notes",
"archived",
"image_url",
"thumbnail_url",
"parents",
"active",
]
def get_source_objects(self):
@ -488,16 +354,26 @@ class GroupAssetImporter(AssetImporterBase):
def normalize_source_object(self, group):
""" """
data = self.normalize_asset(group)
data.update(
{
"asset_type": "group",
}
)
return data
if notes := group["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
active = not group["attributes"]["archived"]
else:
active = group["attributes"]["status"] == "active"
return {
"farmos_uuid": UUID(group["id"]),
"drupal_id": group["attributes"]["drupal_internal__id"],
"name": group["attributes"]["name"],
"is_location": group["attributes"]["is_location"],
"is_fixed": group["attributes"]["is_fixed"],
"active": active,
"notes": notes,
}
class LandAssetImporter(AssetImporterBase):
class LandAssetImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Land Assets
"""
@ -507,18 +383,15 @@ class LandAssetImporter(AssetImporterBase):
supported_fields = [
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"name",
"land_type_uuid",
"is_location",
"is_fixed",
"notes",
"archived",
"parents",
"active",
]
def setup(self):
""" """
super().setup()
model = self.app.model
@ -541,14 +414,24 @@ class LandAssetImporter(AssetImporterBase):
)
return None
data = self.normalize_asset(land)
data.update(
{
"asset_type": "land",
"land_type_uuid": land_type.uuid,
}
)
return data
if notes := land["attributes"]["notes"]:
notes = notes["value"]
if self.farmos_4x:
active = not land["attributes"]["archived"]
else:
active = land["attributes"]["status"] == "active"
return {
"farmos_uuid": UUID(land["id"]),
"drupal_id": land["attributes"]["drupal_internal__id"],
"name": land["attributes"]["name"],
"land_type_uuid": land_type.uuid,
"is_location": land["attributes"]["is_location"],
"is_fixed": land["attributes"]["is_fixed"],
"active": active,
"notes": notes,
}
class LandTypeImporter(FromFarmOS, ToWutta):
@ -607,26 +490,23 @@ class LogTypeImporter(FromFarmOS, ToWutta):
}
class StructureAssetImporter(AssetImporterBase):
class StructureImporter(FromFarmOS, ToWutta):
"""
farmOS API WuttaFarm importer for Structure Assets
farmOS API WuttaFarm importer for Structures
"""
model_class = model.StructureAsset
model_class = model.Structure
supported_fields = [
"farmos_uuid",
"drupal_id",
"asset_type",
"asset_name",
"name",
"structure_type_uuid",
"is_location",
"is_fixed",
"notes",
"archived",
"active",
"image_url",
"thumbnail_url",
"parents",
]
def setup(self):
@ -648,20 +528,43 @@ class StructureAssetImporter(AssetImporterBase):
structure_type = self.structure_types_by_id.get(structure_type_id)
if not structure_type:
log.warning(
"invalid structure_type '%s' for farmOS Structure Asset: %s",
"invalid structure_type '%s' for farmOS Structure: %s",
structure_type_id,
structure,
)
return None
data = self.normalize_asset(structure)
data.update(
{
"asset_type": "structure",
"structure_type_uuid": structure_type.uuid,
}
)
return data
if notes := structure["attributes"]["notes"]:
notes = notes["value"]
image_url = None
if relationships := structure.get("relationships"):
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"]
if self.farmos_4x:
active = not structure["attributes"]["archived"]
else:
active = structure["attributes"]["status"] == "active"
return {
"farmos_uuid": UUID(structure["id"]),
"drupal_id": structure["attributes"]["drupal_internal__id"],
"name": structure["attributes"]["name"],
"structure_type_uuid": structure_type.uuid,
"is_location": structure["attributes"]["is_location"],
"is_fixed": structure["attributes"]["is_fixed"],
"active": active,
"notes": notes,
"image_url": image_url,
}
class StructureTypeImporter(FromFarmOS, ToWutta):

View file

@ -27,7 +27,7 @@ import json
import colander
from wuttaweb.forms.schema import ObjectRef, WuttaSet
from wuttaweb.forms.schema import ObjectRef
class AnimalTypeRef(ObjectRef):
@ -160,20 +160,3 @@ class UsersType(colander.SchemaType):
from wuttafarm.web.forms.widgets import UsersWidget
return UsersWidget(self.request, **kwargs)
class AssetParentRefs(WuttaSet):
"""
Schema type for Parents field which references 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 AssetParentRefsWidget
return AssetParentRefsWidget(self.request, **kwargs)

View file

@ -29,9 +29,6 @@ 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):
"""
@ -135,34 +132,3 @@ class UsersWidget(Widget):
return HTML.tag("ul", c=items)
return super().serialize(field, cstruct, **kw)
class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Parents field which references 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.Asset, uuid)
parents.append(
HTML.tag(
"li",
c=tags.link_to(
str(parent),
self.request.route_url(
f"{parent.asset_type}_assets.view", uuid=parent.uuid
),
),
)
)
return HTML.tag("ul", c=parents)
return super().serialize(field, cstruct, **kw)

View file

@ -45,46 +45,41 @@ class WuttaFarmMenuHandler(base.MenuHandler):
"type": "menu",
"items": [
{
"title": "All Assets",
"route": "assets",
"perm": "assets.list",
"title": "Animals",
"route": "animals",
"perm": "animals.list",
},
{
"title": "Animal",
"route": "animal_assets",
"perm": "animal_assets.list",
"title": "Groups",
"route": "groups",
"perm": "groups.list",
},
{
"title": "Group",
"route": "group_assets",
"perm": "group_assets.list",
"title": "Structures",
"route": "structures",
"perm": "structures.list",
},
{
"title": "Land",
"route": "land_assets",
"perm": "land_assets.list",
},
{
"title": "Structure",
"route": "structure_assets",
"perm": "structure_assets.list",
},
{"type": "sep"},
{
"title": "Animal Types",
"route": "animal_types",
"perm": "animal_types.list",
},
{
"title": "Land Types",
"route": "land_types",
"perm": "land_types.list",
},
{
"title": "Structure Types",
"route": "structure_types",
"perm": "structure_types.list",
},
{
"title": "Land Types",
"route": "land_types",
"perm": "land_types.list",
},
{
"title": "Asset Types",
"route": "asset_types",

View file

@ -1,14 +0,0 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="page_content()">
% if instance.archived:
<b-notification type="is-warning">
This asset is archived.
Archived assets should only be edited if they need corrections.
</b-notification>
% endif
${parent.page_content()}
</%def>

View file

@ -42,8 +42,10 @@ def includeme(config):
# native table views
config.include("wuttafarm.web.views.asset_types")
config.include("wuttafarm.web.views.assets")
config.include("wuttafarm.web.views.land")
config.include("wuttafarm.web.views.land_types")
config.include("wuttafarm.web.views.structure_types")
config.include("wuttafarm.web.views.animal_types")
config.include("wuttafarm.web.views.land_assets")
config.include("wuttafarm.web.views.structures")
config.include("wuttafarm.web.views.animals")
config.include("wuttafarm.web.views.groups")

View file

@ -0,0 +1,128 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Animal Types
"""
from wuttafarm.db.model.animals import AnimalType, Animal
from wuttafarm.web.views import WuttaFarmMasterView
class AnimalTypeView(WuttaFarmMasterView):
"""
Master view for Animal Types
"""
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
"name",
"description",
"changed",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"description",
"changed",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = Animal
rows_viewable = True
row_grid_columns = [
"name",
"sex",
"is_sterile",
"birthdate",
"active",
]
rows_sort_defaults = "name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_farmos_url(self, animal_type):
return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
def get_xref_buttons(self, animal_type):
buttons = super().get_xref_buttons(animal_type)
if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
return session.query(model.Animal).filter(
model.Animal.animal_type == animal_type
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
# name
g.set_link("name")
def get_row_action_url_view(self, animal, i):
return self.request.route_url("animals.view", uuid=animal.uuid)
def defaults(config, **kwargs):
base = globals()
AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"])
AnimalTypeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -23,29 +23,30 @@
Master view for Animals
"""
from wuttaweb.forms.schema import WuttaDictEnum
from wuttafarm.db.model import AnimalType, AnimalAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.db.model.animals import Animal
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
class AnimalTypeView(AssetTypeMasterView):
class AnimalView(WuttaFarmMasterView):
"""
Master view for Animal Types
Master view for Animals
"""
model_class = AnimalType
route_prefix = "animal_types"
url_prefix = "/animal-types"
model_class = Animal
route_prefix = "animals"
url_prefix = "/animals"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
farmos_refurl_path = "/assets/animal"
grid_columns = [
"name",
"description",
"changed",
"animal_type",
"sex",
"is_sterile",
"birthdate",
"active",
]
sort_defaults = "name"
@ -56,126 +57,15 @@ class AnimalTypeView(AssetTypeMasterView):
form_fields = [
"name",
"description",
"changed",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = AnimalAsset
rows_viewable = True
row_grid_columns = [
"asset_name",
"sex",
"is_sterile",
"birthdate",
"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, animal_type):
return self.app.get_farmos_url(f"/taxonomy/term/{animal_type.drupal_id}")
def get_xref_buttons(self, animal_type):
buttons = super().get_xref_buttons(animal_type)
if animal_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animal_types.view", uuid=animal_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
return (
session.query(model.AnimalAsset)
.join(model.Asset)
.filter(model.AnimalAsset.animal_type == animal_type)
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
model = self.app.model
enum = self.app.enum
# 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)
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
# 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, animal, i):
return self.request.route_url("animal_assets.view", uuid=animal.uuid)
class AnimalAssetView(AssetMasterView):
"""
Master view for Animal Assets
"""
model_class = AnimalAsset
route_prefix = "animal_assets"
url_prefix = "/assets/animal"
farmos_refurl_path = "/assets/animal"
labels = {
"animal_type": "Species/Breed",
"is_sterile": "Sterile",
}
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"animal_type",
"birthdate",
"is_sterile",
"sex",
"archived",
]
form_fields = [
"asset_name",
"animal_type",
"birthdate",
"sex",
"is_sterile",
"active",
"notes",
"asset_type",
"archived",
"farmos_uuid",
"drupal_id",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
@ -183,44 +73,57 @@ class AnimalAssetView(AssetMasterView):
g = grid
super().configure_grid(g)
model = self.app.model
enum = self.app.enum
# name
g.set_link("name")
# animal_type
g.set_joiner("animal_type", lambda q: q.join(model.AnimalType))
g.set_sorter("animal_type", model.AnimalType.name)
g.set_filter("animal_type", model.AnimalType.name)
# birthdate
g.set_renderer("birthdate", "date")
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.set_filter("animal_type", model.AnimalType.name, label="Animal Type Name")
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
animal = f.model_instance
animal = form.model_instance
# animal_type
f.set_node("animal_type", AnimalTypeRef(self.request))
# sex
if self.viewing and animal.sex is None:
pass # TODO: dict enum widget does not handle null values well
else:
f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))
f.set_required("sex", False)
# notes
f.set_widget("notes", "notes")
# image
if animal.image_url:
f.set_widget("image", ImageWidget("animal image"))
f.set_default("image", animal.image_url)
def get_farmos_url(self, animal):
return self.app.get_farmos_url(f"/asset/{animal.drupal_id}")
def get_xref_buttons(self, animal):
buttons = super().get_xref_buttons(animal)
if animal.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_animals.view", uuid=animal.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
AnimalTypeView = kwargs.get("AnimalTypeView", base["AnimalTypeView"])
AnimalTypeView.defaults(config)
AnimalAssetView = kwargs.get("AnimalAssetView", base["AnimalAssetView"])
AnimalAssetView.defaults(config)
AnimalView = kwargs.get("AnimalView", base["AnimalView"])
AnimalView.defaults(config)
def includeme(config):

View file

@ -1,292 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Assets
"""
from collections import OrderedDict
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset
from wuttafarm.web.forms.schema import AssetParentRefs
from wuttafarm.web.forms.widgets import ImageWidget
def get_asset_type_enum(config):
app = config.get_app()
model = app.model
session = Session()
asset_types = OrderedDict()
query = session.query(model.AssetType).order_by(model.AssetType.name)
for asset_type in query:
asset_types[asset_type.drupal_id] = asset_type.name
return asset_types
class AssetView(WuttaFarmMasterView):
"""
Master view for Assets
"""
model_class = Asset
route_prefix = "assets"
url_prefix = "/assets"
farmos_refurl_path = "/assets"
viewable = False
creatable = False
editable = False
deletable = False
model_is_versioned = False
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"asset_type",
"parents",
"archived",
]
sort_defaults = "asset_name"
filter_defaults = {
"asset_name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# thumbnail
g.set_renderer("thumbnail", self.render_grid_thumbnail)
g.set_label("thumbnail", "", column_only=True)
g.set_centered("thumbnail")
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
# asset_name
g.set_link("asset_name")
# asset_type
g.set_enum("asset_type", get_asset_type_enum(self.config))
# parents
g.set_renderer("parents", self.render_parents_for_grid)
# view action links to final asset record
def asset_url(asset, i):
return self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
)
g.add_action("view", icon="eye", url=asset_url)
def render_parents_for_grid(self, asset, field, value):
parents = [str(p.parent) for p in asset._parents]
return ", ".join(parents)
def grid_row_class(self, asset, data, i):
""" """
if asset.archived:
return "has-background-warning"
return None
class AssetTypeMasterView(WuttaFarmMasterView):
"""
Base class for "Asset Type" master views.
A bit of a misnmer perhaps, this is *not* for the actual AssetType
model, but rather the "secondary" types, e.g. AnimalType,
LandType etc.
"""
class AssetMasterView(WuttaFarmMasterView):
"""
Base class for Asset master views
"""
sort_defaults = "asset_name"
filter_defaults = {
"asset_name": {"active": True, "verb": "contains"},
"archived": {"active": True, "verb": "is_false"},
}
def get_fallback_templates(self, template):
templates = super().get_fallback_templates(template)
if self.viewing:
templates.insert(0, "/assets/master/view.mako")
return templates
def get_query(self, session=None):
""" """
model = self.app.model
model_class = self.get_model_class()
session = session or self.Session()
return session.query(model_class).join(model.Asset)
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
# thumbnail
g.set_renderer("thumbnail", self.render_grid_thumbnail)
g.set_label("thumbnail", "", column_only=True)
g.set_centered("thumbnail")
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
g.set_sorter("drupal_id", model.Asset.drupal_id)
g.set_filter("drupal_id", model.Asset.drupal_id)
# 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)
# parents
g.set_renderer("parents", self.render_parents_for_grid)
# archived
g.set_renderer("archived", "boolean")
g.set_sorter("archived", model.Asset.archived)
g.set_filter("archived", model.Asset.archived)
def render_parents_for_grid(self, asset, field, value):
parents = [str(p.parent) for p in asset.asset._parents]
return ", ".join(parents)
def grid_row_class(self, asset, data, i):
""" """
if asset.archived:
return "has-background-warning"
return None
def configure_form(self, form):
f = form
super().configure_form(f)
asset = form.model_instance
# asset_type
if self.creating:
f.remove("asset_type")
else:
f.set_node(
"asset_type",
WuttaDictEnum(self.request, get_asset_type_enum(self.config)),
)
f.set_readonly("asset_type")
# parents
if self.creating or self.editing:
f.remove("parents") # TODO: add support for this
else:
f.set_node("parents", AssetParentRefs(self.request))
f.set_default("parents", [p.parent_uuid for p in asset.asset._parents])
# notes
f.set_widget("notes", "notes")
# thumbnail_url
if self.creating or self.editing:
f.remove("thumbnail_url")
# image_url
if self.creating or self.editing:
f.remove("image_url")
# thumbnail
if self.creating or self.editing:
f.remove("thumbnail")
elif asset.thumbnail_url:
f.set_widget("thumbnail", ImageWidget("animal thumbnail"))
f.set_default("thumbnail", asset.thumbnail_url)
# image
if self.creating or self.editing:
f.remove("image")
elif asset.image_url:
f.set_widget("image", ImageWidget("animal image"))
f.set_default("image", asset.image_url)
def objectify(self, form):
asset = super().objectify(form)
if self.creating:
model_class = self.get_model_class()
asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"]
return asset
def get_farmos_url(self, asset):
return self.app.get_farmos_url(f"/asset/{asset.drupal_id}")
def get_xref_buttons(self, asset):
buttons = super().get_xref_buttons(asset)
if asset.farmos_uuid:
# TODO
route = None
if asset.asset_type == "animal":
route = "farmos_animals.view"
elif asset.asset_type == "group":
route = "farmos_groups.view"
elif asset.asset_type == "land":
route = "farmos_land_assets.view"
elif asset.asset_type == "structure":
route = "farmos_structures.view"
if route:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(route, uuid=asset.farmos_uuid),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
AssetView = kwargs.get("AssetView", base["AssetView"])
AssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -54,17 +54,12 @@ class CommonView(base.CommonView):
"activity_logs.list",
"activity_logs.view",
"activity_logs.versions",
"animal_types.create",
"animal_types.edit",
"animal_types.list",
"animal_types.view",
"animal_types.versions",
"animal_assets.create",
"animal_assets.edit",
"animal_assets.list",
"animal_assets.view",
"animal_assets.versions",
"assets.list",
"animals.list",
"animals.view",
"animals.versions",
"asset_types.list",
"asset_types.view",
"asset_types.versions",
@ -90,13 +85,9 @@ class CommonView(base.CommonView):
"farmos_structures.view",
"farmos_users.list",
"farmos_users.view",
"group_asests.create",
"group_asests.edit",
"group_asests.list",
"group_asests.view",
"group_asests.versions",
"land_assets.create",
"land_assets.edit",
"groups.list",
"groups.view",
"groups.versions",
"land_assets.list",
"land_assets.view",
"land_assets.versions",
@ -109,11 +100,9 @@ class CommonView(base.CommonView):
"structure_types.list",
"structure_types.view",
"structure_types.versions",
"structure_assets.create",
"structure_assets.edit",
"structure_assets.list",
"structure_assets.view",
"structure_assets.versions",
"structures.list",
"structures.view",
"structures.versions",
]
for perm in site_admin_perms:
auth.grant_permission(site_admin, perm)

View file

@ -251,17 +251,15 @@ class AnimalView(FarmOSMasterView):
]
if wf_animal := (
session.query(model.Asset)
.filter(model.Asset.farmos_uuid == animal["uuid"])
session.query(model.Animal)
.filter(model.Animal.farmos_uuid == animal["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"animal_assets.view", uuid=wf_animal.uuid
),
url=self.request.route_url("animals.view", uuid=wf_animal.uuid),
icon_left="eye",
)
)

View file

@ -166,15 +166,15 @@ class GroupView(FarmOSMasterView):
]
if wf_group := (
session.query(model.GroupAsset)
.filter(model.GroupAsset.farmos_uuid == group["uuid"])
session.query(model.Group)
.filter(model.Group.farmos_uuid == group["uuid"])
.first()
):
buttons.append(
self.make_button(
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url("group_assets.view", uuid=wf_group.uuid),
url=self.request.route_url("groups.view", uuid=wf_group.uuid),
icon_left="eye",
)
)

View file

@ -211,8 +211,8 @@ class StructureView(FarmOSMasterView):
]
if wf_structure := (
session.query(model.StructureAsset)
.filter(model.StructureAsset.farmos_uuid == structure["uuid"])
session.query(model.Structure)
.filter(model.Structure.farmos_uuid == structure["uuid"])
.first()
):
buttons.append(
@ -220,7 +220,7 @@ class StructureView(FarmOSMasterView):
f"View {self.app.get_title()} record",
primary=True,
url=self.request.route_url(
"structure_assets.view", uuid=wf_structure.uuid
"structures.view", uuid=wf_structure.uuid
),
icon_left="eye",
)

View file

@ -23,37 +23,78 @@
Master view for Groups
"""
from wuttafarm.web.views.assets import AssetMasterView
from wuttafarm.db.model.groups import GroupAsset
from wuttafarm.db.model.groups import Group
from wuttafarm.web.views import WuttaFarmMasterView
class GroupView(AssetMasterView):
class GroupView(WuttaFarmMasterView):
"""
Master view for Groups
"""
model_class = GroupAsset
route_prefix = "group_assets"
url_prefix = "/assets/group"
model_class = Group
route_prefix = "groups"
url_prefix = "/groups"
farmos_refurl_path = "/assets/group"
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"archived",
"name",
"is_location",
"is_fixed",
"active",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"asset_name",
"name",
"is_location",
"is_fixed",
"active",
"notes",
"asset_type",
"archived",
"farmos_uuid",
"drupal_id",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def configure_form(self, form):
f = form
super().configure_form(f)
# notes
f.set_widget("notes", "notes")
def get_farmos_url(self, group):
return self.app.get_farmos_url(f"/asset/{group.drupal_id}")
def get_xref_buttons(self, group):
buttons = super().get_xref_buttons(group)
if group.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_groups.view", uuid=group.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()

View file

@ -0,0 +1,117 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Land Assets
"""
from wuttafarm.db.model.land import LandAsset
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.forms.schema import LandTypeRef
class LandAssetView(WuttaFarmMasterView):
"""
Master view for Land Assets
"""
model_class = LandAsset
route_prefix = "land_assets"
url_prefix = "/land-assets"
farmos_refurl_path = "/assets/land"
grid_columns = [
"name",
"land_type",
"is_location",
"is_fixed",
"notes",
"active",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"land_type",
"is_location",
"is_fixed",
"notes",
"active",
"farmos_uuid",
"drupal_id",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
# name
g.set_link("name")
# land_type
g.set_joiner("land_type", lambda q: q.join(model.LandType))
g.set_sorter("land_type", model.LandType.name)
g.set_filter("land_type", model.LandType.name, label="Land Type Name")
def configure_form(self, form):
f = form
super().configure_form(f)
# land_type
f.set_node("land_type", LandTypeRef(self.request))
def get_farmos_url(self, land):
return self.app.get_farmos_url(f"/asset/{land.drupal_id}")
def get_xref_buttons(self, land_asset):
buttons = super().get_xref_buttons(land_asset)
if land_asset.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_land_assets.view", uuid=land_asset.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"])
LandAssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -23,14 +23,11 @@
Master view for Land Types
"""
from webhelpers2.html import HTML, tags
from wuttafarm.db.model.land import LandType, LandAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import LandTypeRef
from wuttafarm.web.views import WuttaFarmMasterView
class LandTypeView(AssetTypeMasterView):
class LandTypeView(WuttaFarmMasterView):
"""
Master view for Land Types
"""
@ -60,13 +57,13 @@ class LandTypeView(AssetTypeMasterView):
rows_viewable = True
row_grid_columns = [
"asset_name",
"name",
"is_location",
"is_fixed",
"archived",
"active",
]
rows_sort_defaults = "asset_name"
rows_sort_defaults = "name"
def configure_grid(self, grid):
g = grid
@ -95,102 +92,27 @@ class LandTypeView(AssetTypeMasterView):
def get_row_grid_data(self, land_type):
model = self.app.model
session = self.Session()
return (
session.query(model.LandAsset)
.join(model.Asset)
.filter(model.LandAsset.land_type == land_type)
return session.query(model.LandAsset).filter(
model.LandAsset.land_type == land_type
)
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)
# is_location
g.set_renderer("is_location", "boolean")
g.set_sorter("is_location", model.Asset.is_location)
g.set_filter("is_location", model.Asset.is_location)
# is_fixed
g.set_renderer("is_fixed", "boolean")
g.set_sorter("is_fixed", model.Asset.is_fixed)
g.set_filter("is_fixed", model.Asset.is_fixed)
# archived
g.set_renderer("archived", "boolean")
g.set_sorter("archived", model.Asset.archived)
g.set_filter("archived", model.Asset.archived)
# name
g.set_link("name")
def get_row_action_url_view(self, land_asset, i):
return self.request.route_url("land_assets.view", uuid=land_asset.uuid)
class LandAssetView(AssetMasterView):
"""
Master view for Land Assets
"""
model_class = LandAsset
route_prefix = "land_assets"
url_prefix = "/assets/land"
farmos_refurl_path = "/assets/land"
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"land_type",
"parents",
"archived",
]
form_fields = [
"asset_name",
"parents",
"notes",
"asset_type",
"land_type",
"is_location",
"is_fixed",
"archived",
"farmos_uuid",
"drupal_id",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
# land_type
g.set_joiner("land_type", lambda q: q.join(model.LandType))
g.set_sorter("land_type", model.LandType.name)
g.set_filter("land_type", model.LandType.name, label="Land Type Name")
def configure_form(self, form):
f = form
super().configure_form(f)
land = f.model_instance
# land_type
f.set_node("land_type", LandTypeRef(self.request))
def defaults(config, **kwargs):
base = globals()
LandTypeView = kwargs.get("LandTypeView", base["LandTypeView"])
LandTypeView.defaults(config)
LandAssetView = kwargs.get("LandAssetView", base["LandAssetView"])
LandAssetView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -23,8 +23,6 @@
Base class for WuttaFarm master views
"""
from webhelpers2.html import tags
from wuttaweb.views import MasterView
@ -39,14 +37,12 @@ class WuttaFarmMasterView(MasterView):
"farmos_uuid": "farmOS UUID",
"drupal_id": "Drupal ID",
"image_url": "Image URL",
"thumbnail_url": "Thumbnail URL",
}
row_labels = {
"farmos_uuid": "farmOS UUID",
"drupal_id": "Drupal ID",
"image_url": "Image URL",
"thumbnail_url": "Thumbnail URL",
}
def get_farmos_url(self, obj):
@ -59,13 +55,6 @@ class WuttaFarmMasterView(MasterView):
return context
def render_grid_thumbnail(self, obj, field, value):
if obj.thumbnail_url:
return tags.image(
obj.thumbnail_url, f"thumbnail for {self.get_model_title()}"
)
return None
def get_xref_buttons(self, obj):
url = self.get_farmos_url(obj)
if url:
@ -79,24 +68,3 @@ class WuttaFarmMasterView(MasterView):
)
]
return []
def configure_form(self, form):
""" """
f = form
super().configure_form(f)
# farmos_uuid
if self.creating:
f.remove("farmos_uuid")
else:
f.set_readonly("farmos_uuid")
# drupal_id
if self.creating:
f.remove("drupal_id")
else:
f.set_readonly("drupal_id")
def persist(self, obj, session=None):
super().persist(obj, session)
self.app.export_to_farmos(obj, require=False)

View file

@ -0,0 +1,118 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttaFarm --Web app to integrate with and extend farmOS
# Copyright © 2026 Lance Edgar
#
# This file is part of WuttaFarm.
#
# WuttaFarm is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# WuttaFarm is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# WuttaFarm. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Master view for Structure Types
"""
from wuttafarm.db.model.structures import StructureType, Structure
from wuttafarm.web.views import WuttaFarmMasterView
class StructureTypeView(WuttaFarmMasterView):
"""
Master view for Structure Types
"""
model_class = StructureType
route_prefix = "structure_types"
url_prefix = "/structure-types"
grid_columns = [
"name",
]
sort_defaults = "name"
filter_defaults = {
"name": {"active": True, "verb": "contains"},
}
form_fields = [
"name",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = Structure
rows_viewable = True
row_grid_columns = [
"name",
"is_location",
"is_fixed",
"active",
]
rows_sort_defaults = "name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_xref_buttons(self, structure_type):
buttons = super().get_xref_buttons(structure_type)
if structure_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_structure_types.view", uuid=structure_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, structure_type):
model = self.app.model
session = self.Session()
return session.query(model.Structure).filter(
model.Structure.structure_type == structure_type
)
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
# name
g.set_link("name")
def get_row_action_url_view(self, structure, i):
return self.request.route_url("structures.view", uuid=structure.uuid)
def defaults(config, **kwargs):
base = globals()
StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"])
StructureTypeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -23,23 +23,29 @@
Master view for Structures
"""
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.db.model import StructureType, StructureAsset
from wuttafarm.db.model.structures import Structure
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.web.forms.schema import StructureTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
class StructureTypeView(AssetTypeMasterView):
class StructureView(WuttaFarmMasterView):
"""
Master view for Structure Types
Master view for Structures
"""
model_class = StructureType
route_prefix = "structure_types"
url_prefix = "/structure-types"
model_class = Structure
route_prefix = "structures"
url_prefix = "/structures"
farmos_refurl_path = "/assets/structure"
grid_columns = [
"name",
"structure_type",
"is_location",
"is_fixed",
"active",
]
sort_defaults = "name"
@ -50,119 +56,14 @@ class StructureTypeView(AssetTypeMasterView):
form_fields = [
"name",
"farmos_uuid",
"drupal_id",
]
has_rows = True
row_model_class = StructureAsset
rows_viewable = True
row_grid_columns = [
"asset_name",
"structure_type",
"is_location",
"is_fixed",
"archived",
]
rows_sort_defaults = "asset_name"
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# name
g.set_link("name")
def get_xref_buttons(self, structure_type):
buttons = super().get_xref_buttons(structure_type)
if structure_type.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_structure_types.view", uuid=structure_type.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def get_row_grid_data(self, structure_type):
model = self.app.model
session = self.Session()
return (
session.query(model.StructureAsset)
.join(model.Asset)
.filter(model.StructureAsset.structure_type == structure_type)
)
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)
# is_location
g.set_renderer("is_location", "boolean")
g.set_sorter("is_location", model.Asset.is_location)
g.set_filter("is_location", model.Asset.is_location)
# is_fixed
g.set_renderer("is_fixed", "boolean")
g.set_sorter("is_fixed", model.Asset.is_fixed)
g.set_filter("is_fixed", model.Asset.is_fixed)
# 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, structure, i):
return self.request.route_url("structure_assets.view", uuid=structure.uuid)
class StructureAssetView(AssetMasterView):
"""
Master view for Structures
"""
model_class = StructureAsset
route_prefix = "structure_assets"
url_prefix = "/asset/structures"
farmos_refurl_path = "/assets/structure"
grid_columns = [
"thumbnail",
"drupal_id",
"asset_name",
"structure_type",
"parents",
"archived",
]
form_fields = [
"asset_name",
"parents",
"notes",
"asset_type",
"structure_type",
"is_location",
"is_fixed",
"archived",
"active",
"farmos_uuid",
"drupal_id",
"thumbnail_url",
"image_url",
"thumbnail",
"image",
]
@ -171,6 +72,9 @@ class StructureAssetView(AssetMasterView):
super().configure_grid(g)
model = self.app.model
# name
g.set_link("name")
# structure_type
g.set_joiner("structure_type", lambda q: q.join(model.StructureType))
g.set_sorter("structure_type", model.StructureType.name)
@ -186,15 +90,37 @@ class StructureAssetView(AssetMasterView):
# structure_type
f.set_node("structure_type", StructureTypeRef(self.request))
# image
if structure.image_url:
f.set_widget("image", ImageWidget("structure image"))
f.set_default("image", structure.image_url)
def get_farmos_url(self, structure):
return self.app.get_farmos_url(f"/asset/{structure.drupal_id}")
def get_xref_buttons(self, structure):
buttons = super().get_xref_buttons(structure)
if structure.farmos_uuid:
buttons.append(
self.make_button(
"View farmOS record",
primary=True,
url=self.request.route_url(
"farmos_structures.view", uuid=structure.farmos_uuid
),
icon_left="eye",
)
)
return buttons
def defaults(config, **kwargs):
base = globals()
StructureTypeView = kwargs.get("StructureTypeView", base["StructureTypeView"])
StructureTypeView.defaults(config)
StructureAssetView = kwargs.get("StructureAssetView", base["StructureAssetView"])
StructureAssetView.defaults(config)
StructureView = kwargs.get("StructureView", base["StructureView"])
StructureView.defaults(config)
def includeme(config):