Compare commits

..

50 commits

Author SHA1 Message Date
b2b49d93ae docs: fix doc warning 2026-03-04 14:20:09 -06:00
7bffa6cba6 fix: bump version requirement for wuttaweb 2026-03-04 14:15:23 -06:00
0a1aee591a bump: version 0.6.0 → 0.7.0 2026-03-04 14:14:52 -06:00
a0f73e6a32 fix: show drupal ID column for asset types 2026-03-04 12:59:55 -06:00
e8a8ce2528 feat: expose "group membership" for assets 2026-03-04 12:59:55 -06:00
b2c3d3a301 fix: remove unique constraint for LandAsset.land_type_uuid
not sure why that was in there..assuming a mistake
2026-03-04 12:59:55 -06:00
759eb906b9 feat: expose "current location" for assets
based on most recent movement log, as in farmOS
2026-03-04 12:59:55 -06:00
41870ee2e2 fix: move farmOS UUID field below the Drupal ID 2026-03-04 12:59:55 -06:00
0ac2485bff feat: add schema, sync support for Log.is_movement 2026-03-04 12:59:55 -06:00
eb16990b0b feat: add schema, import support for Asset.owners 2026-03-04 12:59:55 -06:00
ce103137a5 fix: add links for Parents column in All Assets grid 2026-03-04 12:59:55 -06:00
547cc6e4ae feat: add schema, import support for Log.quick 2026-03-04 12:59:55 -06:00
32d23a7073 feat: show quantities when viewing log 2026-03-04 12:59:55 -06:00
7890b18568 fix: set timestamp for new log in quick eggs form 2026-03-04 12:59:55 -06:00
90ff7eb793 fix: set default grid pagesize to 50
to better match farmOS
2026-03-04 12:59:55 -06:00
d07f3ed716 feat: add sync support for MedicalLog.vet 2026-03-04 12:59:54 -06:00
7d2ae48067 feat: add schema, import support for Log.quantities 2026-03-04 12:59:54 -06:00
1d877545ae feat: add schema, import support for Log.groups 2026-03-04 12:59:54 -06:00
87f3764ebf feat: add schema, import support for Log.locations
still need to add support for edit, export
2026-03-04 12:59:54 -06:00
3ae4d639ec feat: add sync support for Log.is_group_assignment 2026-03-04 12:59:54 -06:00
a5550091d3 feat: add support for exporting log status, timestamp to farmOS 2026-03-04 12:59:54 -06:00
61402c183e fix: add placeholder for log 'quick' field 2026-03-04 12:59:54 -06:00
64e4392a92 feat: add support for log 'owners' 2026-03-04 12:59:54 -06:00
ae73d2f87f fix: define log grid columns to match farmOS
some of these still do not have values yet..
2026-03-04 12:59:54 -06:00
86e36bc64a fix: make AllLogView inherit from LogMasterView
and improve asset rendering for those grids
2026-03-04 12:59:54 -06:00
d1817a3611 fix: rename views for "all records" (all assets, all logs etc.)
just for clarity's sake, i think it's better
2026-03-04 12:59:54 -06:00
d465934818 fix: ensure token refresh works regardless where API client is used 2026-03-04 12:59:54 -06:00
c353d5bcef feat: add support for edit, import/export of plant type data
esp. plant types for a plant asset
2026-03-04 12:59:54 -06:00
bdda586ccd fix: render links for Plant Type column in Plant Assets grid 2026-03-04 12:59:54 -06:00
0d989dcb2c fix: fix land asset type 2026-03-04 12:59:54 -06:00
2f84f76d89 fix: prevent edit for asset types, land types when app is mirror 2026-03-04 12:59:51 -06:00
3343524325 fix: add farmOS-style links for Parents column in Land Assets grid 2026-02-28 22:08:57 -06:00
28ecb4d786 fix: remove unique constraint for AnimalType.name
since it is not guaranteed unique in farmOS; can't do it here either
or else import may fail
2026-02-28 22:08:57 -06:00
338da0208c fix: prevent delete if animal type is still being referenced 2026-02-28 22:08:57 -06:00
ec67340e66 feat: add way to create animal type when editing animal 2026-02-28 22:08:55 -06:00
1c0286eda0 fix: add reminder to restart if changing integration mode 2026-02-28 22:08:55 -06:00
7d5ff47e8e feat: add related version tables for asset/log revision history 2026-02-28 22:08:53 -06:00
5046171b76 fix: prevent edit for user farmos_uuid, drupal_id 2026-02-28 22:08:53 -06:00
f374ae426c fix: remove 'contains' verb for sex filter 2026-02-28 22:08:53 -06:00
2a375b0a6f fix: add enum, row hilite for log status 2026-02-28 22:08:53 -06:00
a5d7f89fcb feat: improve mirror/deletion for assets, logs, animal types 2026-02-28 22:08:51 -06:00
96ccf30e46 feat: auto-delete asset from farmOS if deleting via mirror app 2026-02-28 22:08:48 -06:00
38dad49bbd fix: fix Sex field when empty and deleting an animal 2026-02-26 17:35:05 -06:00
f2be7d0a53 fix: add get_farmos_client_for_user() convenience function 2026-02-26 17:25:49 -06:00
9b4afb845b fix: use current user token for auto-sync within web app
to ensure data writes to farmOS have correct authorship
2026-02-26 17:04:55 -06:00
f4b5f3960c fix: set log type, status enums for log grids 2026-02-25 15:22:25 -06:00
127ea49d74 fix: add more default perms for first site admin user 2026-02-25 14:59:54 -06:00
30e1fd23d6 fix: only show quick form menu if perms allow 2026-02-25 14:59:54 -06:00
df517cfbfa fix: expose config for farmOS OAuth2 client_id and scope
refs: #3
2026-02-25 14:59:46 -06:00
ec6ac443fb fix: add separate permission for each quick form view 2026-02-25 11:22:49 -06:00
68 changed files with 3175 additions and 488 deletions

View file

@ -5,6 +5,61 @@ 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.7.0 (2026-03-04)
### Feat
- expose "group membership" for assets
- expose "current location" for assets
- add schema, sync support for `Log.is_movement`
- add schema, import support for `Asset.owners`
- add schema, import support for `Log.quick`
- show quantities when viewing log
- add sync support for `MedicalLog.vet`
- add schema, import support for `Log.quantities`
- add schema, import support for `Log.groups`
- add schema, import support for `Log.locations`
- add sync support for `Log.is_group_assignment`
- add support for exporting log status, timestamp to farmOS
- add support for log 'owners'
- add support for edit, import/export of plant type data
- add way to create animal type when editing animal
- add related version tables for asset/log revision history
- improve mirror/deletion for assets, logs, animal types
- auto-delete asset from farmOS if deleting via mirror app
### Fix
- show drupal ID column for asset types
- remove unique constraint for `LandAsset.land_type_uuid`
- move farmOS UUID field below the Drupal ID
- add links for Parents column in All Assets grid
- set timestamp for new log in quick eggs form
- set default grid pagesize to 50
- add placeholder for log 'quick' field
- define log grid columns to match farmOS
- make AllLogView inherit from LogMasterView
- rename views for "all records" (all assets, all logs etc.)
- ensure token refresh works regardless where API client is used
- render links for Plant Type column in Plant Assets grid
- fix land asset type
- prevent edit for asset types, land types when app is mirror
- add farmOS-style links for Parents column in Land Assets grid
- remove unique constraint for `AnimalType.name`
- prevent delete if animal type is still being referenced
- add reminder to restart if changing integration mode
- prevent edit for user farmos_uuid, drupal_id
- remove 'contains' verb for sex filter
- add enum, row hilite for log status
- fix Sex field when empty and deleting an animal
- add `get_farmos_client_for_user()` convenience function
- use current user token for auto-sync within web app
- set log type, status enums for log grids
- add more default perms for first site admin user
- only show quick form menu if perms allow
- expose config for farmOS OAuth2 client_id and scope
- add separate permission for each quick form view
## v0.6.0 (2026-02-25)
### Feat

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaFarm"
version = "0.6.0"
version = "0.7.0"
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.29.0",
]

View file

@ -36,6 +36,21 @@ class WuttaFarmAppHandler(base.AppHandler):
default_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler"
def get_asset_handler(self):
"""
Get the configured asset handler.
:rtype: :class:`~wuttafarm.assets.AssetHandler`
"""
if "asset" not in self.handlers:
spec = self.config.get(
f"{self.appname}.asset_handler",
default="wuttafarm.assets:AssetHandler",
)
factory = self.load_object(spec)
self.handlers["asset"] = factory(self.config)
return self.handlers["asset"]
def get_farmos_handler(self):
"""
Get the configured farmOS integration handler.
@ -136,7 +151,7 @@ class WuttaFarmAppHandler(base.AppHandler):
factory = self.load_object(spec)
return factory(self.config, farmos_client)
def auto_sync_to_farmos(self, obj, model_name=None, require=True):
def auto_sync_to_farmos(self, obj, model_name=None, client=None, require=True):
"""
Export the given object to farmOS, using configured handler.
@ -147,6 +162,9 @@ class WuttaFarmAppHandler(base.AppHandler):
:param obj: Any data object in WuttaFarm, e.g. AnimalAsset
instance.
:param client: Existing farmOS API client to use. If not
specified, a new one will be instantiated.
: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
@ -162,14 +180,12 @@ class WuttaFarmAppHandler(base.AppHandler):
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()
handler.begin_target_transaction(client)
importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal])
def auto_sync_from_farmos(self, obj, model_name, require=True):
def auto_sync_from_farmos(self, obj, model_name, client=None, require=True):
"""
Import the given object from farmOS, using configured handler.
@ -178,6 +194,9 @@ class WuttaFarmAppHandler(base.AppHandler):
:param model_name': Model name for the importer to use,
e.g. ``"AnimalAsset"``.
:param client: Existing farmOS API client to use. If not
specified, a new one will be instantiated.
:param require: If true, this will *require* the import
handler to support objects of the given type. If false,
then nothing will happen / import is silently skipped when
@ -191,9 +210,7 @@ class WuttaFarmAppHandler(base.AppHandler):
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_source_transaction()
handler.begin_source_transaction(client)
with self.short_session(commit=True) as session:
handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False)

65
src/wuttafarm/assets.py Normal file
View file

@ -0,0 +1,65 @@
# -*- 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/>.
#
################################################################################
"""
Asset handler
"""
from wuttjamaican.app import GenericHandler
class AssetHandler(GenericHandler):
"""
Base class and default implementation for the asset
:term:`handler`.
"""
def get_groups(self, asset):
model = self.app.model
session = self.app.get_session(asset)
grplog = (
session.query(model.Log)
.join(model.LogAsset)
.filter(model.LogAsset.asset == asset)
.filter(model.Log.is_group_assignment == True)
.order_by(model.Log.timestamp.desc())
.first()
)
if grplog:
return grplog.groups
return []
def get_locations(self, asset):
model = self.app.model
session = self.app.get_session(asset)
loclog = (
session.query(model.Log)
.join(model.LogAsset)
.filter(model.LogAsset.asset == asset)
.filter(model.Log.is_movement == True)
.order_by(model.Log.timestamp.desc())
.first()
)
if loclog:
return loclog.locations
return []

View file

@ -50,11 +50,12 @@ class WuttaFarmConfig(WuttaConfigExtension):
f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler"
)
# web app menu
# web app stuff
config.setdefault(
f"{config.appname}.web.menus.handler.default_spec",
"wuttafarm.web.menus:WuttaFarmMenuHandler",
)
config.setdefault("wuttaweb.grids.default_pagesize", "50")
# web app libcache
# config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static')

View file

@ -0,0 +1,37 @@
"""add Log.is_movement
Revision ID: 0771322957bd
Revises: 12de43facb95
Create Date: 2026-03-02 20:21:03.889847
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "0771322957bd"
down_revision: Union[str, None] = "12de43facb95"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("is_movement", sa.Boolean(), nullable=True))
op.add_column(
"log_version",
sa.Column("is_movement", sa.Boolean(), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "is_movement")
op.drop_column("log", "is_movement")

View file

@ -0,0 +1,114 @@
"""add Asset.owners
Revision ID: 12de43facb95
Revises: 85d4851e8292
Create Date: 2026-03-02 19:03:35.511398
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "12de43facb95"
down_revision: Union[str, None] = "85d4851e8292"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_owner
op.create_table(
"asset_owner",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"], ["asset.uuid"], name=op.f("fk_asset_owner_asset_uuid_asset")
),
sa.ForeignKeyConstraint(
["user_uuid"], ["user.uuid"], name=op.f("fk_asset_owner_user_uuid_user")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_asset_owner")),
)
op.create_table(
"asset_owner_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"asset_uuid",
wuttjamaican.db.util.UUID(),
autoincrement=False,
nullable=True,
),
sa.Column(
"user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_asset_owner_version")
),
)
op.create_index(
op.f("ix_asset_owner_version_end_transaction_id"),
"asset_owner_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_owner_version_operation_type"),
"asset_owner_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_asset_owner_version_pk_transaction_id",
"asset_owner_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_asset_owner_version_pk_validity",
"asset_owner_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_asset_owner_version_transaction_id"),
"asset_owner_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# asset_owner
op.drop_index(
op.f("ix_asset_owner_version_transaction_id"), table_name="asset_owner_version"
)
op.drop_index(
"ix_asset_owner_version_pk_validity", table_name="asset_owner_version"
)
op.drop_index(
"ix_asset_owner_version_pk_transaction_id", table_name="asset_owner_version"
)
op.drop_index(
op.f("ix_asset_owner_version_operation_type"), table_name="asset_owner_version"
)
op.drop_index(
op.f("ix_asset_owner_version_end_transaction_id"),
table_name="asset_owner_version",
)
op.drop_table("asset_owner_version")
op.drop_table("asset_owner")

View file

@ -0,0 +1,118 @@
"""add LogLocation
Revision ID: 3bef7d380a38
Revises: f3c7e273bfa3
Create Date: 2026-02-28 20:41:56.051847
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "3bef7d380a38"
down_revision: Union[str, None] = "f3c7e273bfa3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_location
op.create_table(
"log_location",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"],
["asset.uuid"],
name=op.f("fk_log_location_asset_uuid_asset"),
),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_location_log_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_location")),
)
op.create_table(
"log_location_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"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_log_location_version")
),
)
op.create_index(
op.f("ix_log_location_version_end_transaction_id"),
"log_location_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_location_version_operation_type"),
"log_location_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_location_version_pk_transaction_id",
"log_location_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_location_version_pk_validity",
"log_location_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_location_version_transaction_id"),
"log_location_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_location
op.drop_index(
op.f("ix_log_location_version_transaction_id"),
table_name="log_location_version",
)
op.drop_index(
"ix_log_location_version_pk_validity", table_name="log_location_version"
)
op.drop_index(
"ix_log_location_version_pk_transaction_id", table_name="log_location_version"
)
op.drop_index(
op.f("ix_log_location_version_operation_type"),
table_name="log_location_version",
)
op.drop_index(
op.f("ix_log_location_version_end_transaction_id"),
table_name="log_location_version",
)
op.drop_table("log_location_version")
op.drop_table("log_location")

View file

@ -0,0 +1,37 @@
"""remove unique for animal_type.name
Revision ID: 45c7718d2ed2
Revises: 5b6c87d8cddf
Create Date: 2026-02-27 16:53:59.310342
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "45c7718d2ed2"
down_revision: Union[str, None] = "5b6c87d8cddf"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# animal_type
op.drop_constraint(op.f("uq_animal_type_name"), "animal_type", type_="unique")
def downgrade() -> None:
# animal_type
op.create_unique_constraint(
op.f("uq_animal_type_name"),
"animal_type",
["name"],
postgresql_nulls_not_distinct=False,
)

View file

@ -0,0 +1,108 @@
"""add LogOwner
Revision ID: 47d0ebd84554
Revises: 45c7718d2ed2
Create Date: 2026-02-28 19:18:49.122090
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "47d0ebd84554"
down_revision: Union[str, None] = "45c7718d2ed2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_owner
op.create_table(
"log_owner",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("user_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_owner_log_uuid_log")
),
sa.ForeignKeyConstraint(
["user_uuid"], ["user.uuid"], name=op.f("fk_log_owner_user_uuid_user")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_owner")),
)
op.create_table(
"log_owner_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"user_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"transaction_id", sa.BigInteger(), autoincrement=False, nullable=False
),
sa.Column("end_transaction_id", sa.BigInteger(), nullable=True),
sa.Column("operation_type", sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint(
"uuid", "transaction_id", name=op.f("pk_log_owner_version")
),
)
op.create_index(
op.f("ix_log_owner_version_end_transaction_id"),
"log_owner_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_owner_version_operation_type"),
"log_owner_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_owner_version_pk_transaction_id",
"log_owner_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_owner_version_pk_validity",
"log_owner_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_owner_version_transaction_id"),
"log_owner_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_owner
op.drop_index(
op.f("ix_log_owner_version_transaction_id"), table_name="log_owner_version"
)
op.drop_index("ix_log_owner_version_pk_validity", table_name="log_owner_version")
op.drop_index(
"ix_log_owner_version_pk_transaction_id", table_name="log_owner_version"
)
op.drop_index(
op.f("ix_log_owner_version_operation_type"), table_name="log_owner_version"
)
op.drop_index(
op.f("ix_log_owner_version_end_transaction_id"), table_name="log_owner_version"
)
op.drop_table("log_owner_version")
op.drop_table("log_owner")

View file

@ -0,0 +1,39 @@
"""remove unwanted unique constraint
Revision ID: 5f474125a80e
Revises: 0771322957bd
Create Date: 2026-03-04 12:03:16.034291
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "5f474125a80e"
down_revision: Union[str, None] = "0771322957bd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# asset_land
op.drop_constraint(
op.f("uq_asset_land_land_type_uuid"), "asset_land", type_="unique"
)
def downgrade() -> None:
# asset_land
op.create_unique_constraint(
op.f("uq_asset_land_land_type_uuid"),
"asset_land",
["land_type_uuid"],
postgresql_nulls_not_distinct=False,
)

View file

@ -0,0 +1,111 @@
"""add LogGroup
Revision ID: 74d32b4ec210
Revises: 3bef7d380a38
Create Date: 2026-02-28 21:35:24.125784
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "74d32b4ec210"
down_revision: Union[str, None] = "3bef7d380a38"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_group
op.create_table(
"log_group",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("asset_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["asset_uuid"], ["asset.uuid"], name=op.f("fk_log_group_asset_uuid_asset")
),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_group_log_uuid_log")
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_group")),
)
op.create_table(
"log_group_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"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_log_group_version")
),
)
op.create_index(
op.f("ix_log_group_version_end_transaction_id"),
"log_group_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_group_version_operation_type"),
"log_group_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_group_version_pk_transaction_id",
"log_group_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_group_version_pk_validity",
"log_group_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_group_version_transaction_id"),
"log_group_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_group
op.drop_index(
op.f("ix_log_group_version_transaction_id"), table_name="log_group_version"
)
op.drop_index("ix_log_group_version_pk_validity", table_name="log_group_version")
op.drop_index(
"ix_log_group_version_pk_transaction_id", table_name="log_group_version"
)
op.drop_index(
op.f("ix_log_group_version_operation_type"), table_name="log_group_version"
)
op.drop_index(
op.f("ix_log_group_version_end_transaction_id"), table_name="log_group_version"
)
op.drop_table("log_group_version")
op.drop_table("log_group")

View file

@ -0,0 +1,37 @@
"""add Log.quick
Revision ID: 85d4851e8292
Revises: d459db991404
Create Date: 2026-03-02 18:42:56.070281
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "85d4851e8292"
down_revision: Union[str, None] = "d459db991404"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("quick", sa.String(length=20), nullable=True))
op.add_column(
"log_version",
sa.Column("quick", sa.String(length=20), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "quick")
op.drop_column("log", "quick")

View file

@ -0,0 +1,118 @@
"""add LogQuantity
Revision ID: 9e875e5cbdc1
Revises: 74d32b4ec210
Create Date: 2026-02-28 21:55:31.876087
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "9e875e5cbdc1"
down_revision: Union[str, None] = "74d32b4ec210"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_quantity
op.create_table(
"log_quantity",
sa.Column("uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("log_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.Column("quantity_uuid", wuttjamaican.db.util.UUID(), nullable=False),
sa.ForeignKeyConstraint(
["log_uuid"], ["log.uuid"], name=op.f("fk_log_quantity_log_uuid_log")
),
sa.ForeignKeyConstraint(
["quantity_uuid"],
["quantity.uuid"],
name=op.f("fk_log_quantity_quantity_uuid_quantity"),
),
sa.PrimaryKeyConstraint("uuid", name=op.f("pk_log_quantity")),
)
op.create_table(
"log_quantity_version",
sa.Column(
"uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=False
),
sa.Column(
"log_uuid", wuttjamaican.db.util.UUID(), autoincrement=False, nullable=True
),
sa.Column(
"quantity_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_log_quantity_version")
),
)
op.create_index(
op.f("ix_log_quantity_version_end_transaction_id"),
"log_quantity_version",
["end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_quantity_version_operation_type"),
"log_quantity_version",
["operation_type"],
unique=False,
)
op.create_index(
"ix_log_quantity_version_pk_transaction_id",
"log_quantity_version",
["uuid", sa.literal_column("transaction_id DESC")],
unique=False,
)
op.create_index(
"ix_log_quantity_version_pk_validity",
"log_quantity_version",
["uuid", "transaction_id", "end_transaction_id"],
unique=False,
)
op.create_index(
op.f("ix_log_quantity_version_transaction_id"),
"log_quantity_version",
["transaction_id"],
unique=False,
)
def downgrade() -> None:
# log_quantity
op.drop_index(
op.f("ix_log_quantity_version_transaction_id"),
table_name="log_quantity_version",
)
op.drop_index(
"ix_log_quantity_version_pk_validity", table_name="log_quantity_version"
)
op.drop_index(
"ix_log_quantity_version_pk_transaction_id", table_name="log_quantity_version"
)
op.drop_index(
op.f("ix_log_quantity_version_operation_type"),
table_name="log_quantity_version",
)
op.drop_index(
op.f("ix_log_quantity_version_end_transaction_id"),
table_name="log_quantity_version",
)
op.drop_table("log_quantity_version")
op.drop_table("log_quantity")

View file

@ -0,0 +1,37 @@
"""add MedicalLog.vet
Revision ID: d459db991404
Revises: 9e875e5cbdc1
Create Date: 2026-02-28 22:17:57.001134
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "d459db991404"
down_revision: Union[str, None] = "9e875e5cbdc1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log_medical
op.add_column("log_medical", sa.Column("vet", sa.String(length=100), nullable=True))
op.add_column(
"log_medical_version",
sa.Column("vet", sa.String(length=100), autoincrement=False, nullable=True),
)
def downgrade() -> None:
# log_medical
op.drop_column("log_medical_version", "vet")
op.drop_column("log_medical", "vet")

View file

@ -0,0 +1,39 @@
"""add Log.is_group_assignment
Revision ID: f3c7e273bfa3
Revises: 47d0ebd84554
Create Date: 2026-02-28 20:04:40.700474
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "f3c7e273bfa3"
down_revision: Union[str, None] = "47d0ebd84554"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# log
op.add_column("log", sa.Column("is_group_assignment", sa.Boolean(), nullable=True))
op.add_column(
"log_version",
sa.Column(
"is_group_assignment", sa.Boolean(), autoincrement=False, nullable=True
),
)
def downgrade() -> None:
# log
op.drop_column("log_version", "is_group_assignment")
op.drop_column("log", "is_group_assignment")

View file

@ -38,7 +38,7 @@ from .asset_structure import StructureType, StructureAsset
from .asset_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset
from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType
from .log import LogType, Log, LogAsset
from .log import LogType, Log, LogAsset, LogGroup, LogLocation, LogQuantity, LogOwner
from .log_activity import ActivityLog
from .log_harvest import HarvestLog
from .log_medical import MedicalLog

View file

@ -26,6 +26,7 @@ Model definition for Asset Types
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
@ -186,6 +187,25 @@ class Asset(model.Base):
cascade_backrefs=False,
)
parents = association_proxy(
"_parents",
"parent",
creator=lambda parent: AssetParent(parent=parent),
)
_owners = orm.relationship(
"AssetOwner",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="asset",
)
owners = association_proxy(
"_owners",
"user",
creator=lambda user: AssetOwner(user=user),
)
def __str__(self):
return self.asset_name or ""
@ -196,7 +216,12 @@ class AssetMixin:
@declared_attr
def asset(cls):
return orm.relationship(Asset)
return orm.relationship(
Asset,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self):
return self.asset_name or ""
@ -213,6 +238,8 @@ def add_asset_proxies(subclass):
Asset.make_proxy(subclass, "asset", "thumbnail_url")
Asset.make_proxy(subclass, "asset", "image_url")
Asset.make_proxy(subclass, "asset", "archived")
Asset.make_proxy(subclass, "asset", "parents")
Asset.make_proxy(subclass, "asset", "owners")
class EggMixin:
@ -250,3 +277,27 @@ class AssetParent(model.Base):
Asset,
foreign_keys=parent_uuid,
)
class AssetOwner(model.Base):
"""
Represents a "asset's owner relationship" from farmOS.
"""
__tablename__ = "asset_owner"
__versioned__ = {}
uuid = model.uuid_column()
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
Asset,
foreign_keys=asset_uuid,
back_populates="_owners",
)
user_uuid = model.uuid_fk_column("user.uuid", nullable=False)
user = orm.relationship(
model.User,
foreign_keys=user_uuid,
)

View file

@ -48,7 +48,6 @@ class AnimalType(model.Base):
name = sa.Column(
sa.String(length=100),
nullable=False,
unique=True,
doc="""
Name of the animal type.
""",
@ -80,6 +79,14 @@ class AnimalType(model.Base):
""",
)
animal_assets = orm.relationship(
"AnimalAsset",
doc="""
List of animal assets of this type.
""",
back_populates="animal_type",
)
def __str__(self):
return self.name or ""
@ -103,6 +110,7 @@ class AnimalAsset(AssetMixin, EggMixin, model.Base):
doc="""
Reference to the animal type.
""",
back_populates="animal_assets",
)
birthdate = sa.Column(

View file

@ -88,10 +88,10 @@ class LandAsset(AssetMixin, model.Base):
__wutta_hint__ = {
"model_title": "Land Asset",
"model_title_plural": "Land Assets",
"farmos_asset_type": "animal",
"farmos_asset_type": "land",
}
land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False, unique=True)
land_type_uuid = model.uuid_fk_column("land_type.uuid", nullable=False)
land_type = orm.relationship(LandType, back_populates="land_assets")

View file

@ -25,6 +25,7 @@ Model definition for Plant Assets
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
@ -80,6 +81,12 @@ class PlantType(model.Base):
""",
)
_plant_assets = orm.relationship(
"PlantAssetPlantType",
cascade_backrefs=False,
back_populates="plant_type",
)
def __str__(self):
return self.name or ""
@ -99,9 +106,17 @@ class PlantAsset(AssetMixin, model.Base):
_plant_types = orm.relationship(
"PlantAssetPlantType",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="plant_asset",
)
plant_types = association_proxy(
"_plant_types",
"plant_type",
creator=lambda pt: PlantAssetPlantType(plant_type=pt),
)
add_asset_proxies(PlantAsset)
@ -129,4 +144,5 @@ class PlantAssetPlantType(model.Base):
doc="""
Reference to the plant type.
""",
back_populates="_plant_assets",
)

View file

@ -26,6 +26,7 @@ Model definition for Logs
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model
@ -119,6 +120,22 @@ class Log(model.Base):
""",
)
is_movement = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the log represents a movement to new location.
""",
)
is_group_assignment = sa.Column(
sa.Boolean(),
nullable=True,
doc="""
Whether the log represents a group assignment.
""",
)
status = sa.Column(
sa.String(length=20),
nullable=False,
@ -135,6 +152,15 @@ class Log(model.Base):
""",
)
quick = sa.Column(
sa.String(length=20),
nullable=True,
doc="""
Identifier of quick form used to create the log, if
applicable.
""",
)
farmos_uuid = sa.Column(
model.UUID(),
nullable=True,
@ -153,7 +179,70 @@ class Log(model.Base):
""",
)
_assets = orm.relationship("LogAsset", back_populates="log")
_assets = orm.relationship(
"LogAsset",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
assets = association_proxy(
"_assets",
"asset",
creator=lambda asset: LogAsset(asset=asset),
)
_groups = orm.relationship(
"LogGroup",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
groups = association_proxy(
"_groups",
"asset",
creator=lambda asset: LogGroup(asset=asset),
)
_locations = orm.relationship(
"LogLocation",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
locations = association_proxy(
"_locations",
"asset",
creator=lambda asset: LogLocation(asset=asset),
)
_quantities = orm.relationship(
"LogQuantity",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
quantities = association_proxy(
"_quantities",
"quantity",
creator=lambda quantity: LogQuantity(quantity=quantity),
)
_owners = orm.relationship(
"LogOwner",
cascade="all, delete-orphan",
cascade_backrefs=False,
back_populates="log",
)
owners = association_proxy(
"_owners",
"user",
creator=lambda user: LogOwner(user=user),
)
def __str__(self):
return self.message or ""
@ -165,7 +254,12 @@ class LogMixin:
@declared_attr
def log(cls):
return orm.relationship(Log)
return orm.relationship(
Log,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self):
return self.message or ""
@ -177,8 +271,16 @@ def add_log_proxies(subclass):
Log.make_proxy(subclass, "log", "log_type")
Log.make_proxy(subclass, "log", "message")
Log.make_proxy(subclass, "log", "timestamp")
Log.make_proxy(subclass, "log", "is_movement")
Log.make_proxy(subclass, "log", "is_group_assignment")
Log.make_proxy(subclass, "log", "status")
Log.make_proxy(subclass, "log", "notes")
Log.make_proxy(subclass, "log", "quick")
Log.make_proxy(subclass, "log", "assets")
Log.make_proxy(subclass, "log", "groups")
Log.make_proxy(subclass, "log", "locations")
Log.make_proxy(subclass, "log", "quantities")
Log.make_proxy(subclass, "log", "owners")
class LogAsset(model.Base):
@ -203,3 +305,99 @@ class LogAsset(model.Base):
"Asset",
foreign_keys=asset_uuid,
)
class LogGroup(model.Base):
"""
Represents a "log's group relationship" from farmOS.
"""
__tablename__ = "log_group"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_groups",
)
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
"Asset",
foreign_keys=asset_uuid,
)
class LogLocation(model.Base):
"""
Represents a "log's location relationship" from farmOS.
"""
__tablename__ = "log_location"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_locations",
)
asset_uuid = model.uuid_fk_column("asset.uuid", nullable=False)
asset = orm.relationship(
"Asset",
foreign_keys=asset_uuid,
)
class LogQuantity(model.Base):
"""
Represents a "log's quantity relationship" from farmOS.
"""
__tablename__ = "log_quantity"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_quantities",
)
quantity_uuid = model.uuid_fk_column("quantity.uuid", nullable=False)
quantity = orm.relationship(
"Quantity",
foreign_keys=quantity_uuid,
)
class LogOwner(model.Base):
"""
Represents a "log's owner relationship" from farmOS.
"""
__tablename__ = "log_owner"
__versioned__ = {}
uuid = model.uuid_column()
log_uuid = model.uuid_fk_column("log.uuid", nullable=False)
log = orm.relationship(
Log,
foreign_keys=log_uuid,
back_populates="_owners",
)
user_uuid = model.uuid_fk_column("user.uuid", nullable=False)
user = orm.relationship(
model.User,
foreign_keys=user_uuid,
)

View file

@ -23,6 +23,8 @@
Model definition for Medical Logs
"""
import sqlalchemy as sa
from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies
@ -41,5 +43,13 @@ class MedicalLog(LogMixin, model.Base):
"farmos_log_type": "medical",
}
vet = sa.Column(
sa.String(length=100),
nullable=True,
doc="""
Name of the veterinarian, if applicable.
""",
)
add_log_proxies(MedicalLog)

View file

@ -94,3 +94,9 @@ class FarmOSHandler(GenericHandler):
return f"{base}/{path}"
return base
def get_oauth2_client_id(self):
return self.config.get("farmos.oauth2.client_id", default="farm")
def get_oauth2_scope(self):
return self.config.get("farmos.oauth2.scope", default="farm_manager")

View file

@ -347,6 +347,12 @@ class LandAssetImporter(ToFarmOSAsset):
return payload
class PlantTypeImporter(ToFarmOSTaxonomy):
model_title = "PlantType"
farmos_taxonomy_type = "plant_type"
class PlantAssetImporter(ToFarmOSAsset):
model_title = "PlantAsset"
@ -452,7 +458,12 @@ class ToFarmOSLog(ToFarmOS):
supported_fields = [
"uuid",
"name",
"timestamp",
"is_movement",
"is_group_assignment",
"status",
"notes",
"quick",
]
def get_target_objects(self, **kwargs):
@ -507,7 +518,12 @@ class ToFarmOSLog(ToFarmOS):
return {
"uuid": UUID(log["id"]),
"name": log["attributes"]["name"],
"timestamp": self.normalize_datetime(log["attributes"]["timestamp"]),
"is_movement": log["attributes"]["is_movement"],
"is_group_assignment": log["attributes"]["is_group_assignment"],
"status": log["attributes"]["status"],
"notes": notes,
"quick": log["attributes"]["quick"],
}
def get_log_payload(self, source_data):
@ -515,8 +531,18 @@ class ToFarmOSLog(ToFarmOS):
attrs = {}
if "name" in self.fields:
attrs["name"] = source_data["name"]
if "timestamp" in self.fields:
attrs["timestamp"] = self.format_datetime(source_data["timestamp"])
if "is_movement" in self.fields:
attrs["is_movement"] = source_data["is_movement"]
if "is_group_assignment" in self.fields:
attrs["is_group_assignment"] = source_data["is_group_assignment"]
if "status" in self.fields:
attrs["status"] = source_data["status"]
if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]}
if "quick" in self.fields:
attrs["quick"] = {"value": source_data["quick"]}
payload = {"attributes": attrs}
@ -540,6 +566,32 @@ class MedicalLogImporter(ToFarmOSLog):
model_title = "MedicalLog"
farmos_log_type = "medical"
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"vet",
]
)
return fields
def normalize_target_object(self, log):
data = super().normalize_target_object(log)
data.update(
{
"vet": log["attributes"]["vet"],
}
)
return data
def get_log_payload(self, source_data):
payload = super().get_log_payload(source_data)
if "vet" in self.fields:
payload["attributes"]["vet"] = source_data["vet"]
return payload
class ObservationLogImporter(ToFarmOSLog):

View file

@ -50,12 +50,15 @@ class ToFarmOSHandler(ImportHandler):
# TODO: a lot of duplication to cleanup here; see FromFarmOSHandler
def begin_target_transaction(self):
def begin_target_transaction(self, client=None):
"""
Establish the farmOS API client.
"""
token = self.get_farmos_oauth2_token()
self.farmos_client = self.app.get_farmos_client(token=token)
if client:
self.farmos_client = client
else:
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):
@ -98,6 +101,7 @@ class FromWuttaFarmToFarmOS(FromWuttaFarmHandler, ToFarmOSHandler):
importers["AnimalType"] = AnimalTypeImporter
importers["AnimalAsset"] = AnimalAssetImporter
importers["GroupAsset"] = GroupAssetImporter
importers["PlantType"] = PlantTypeImporter
importers["PlantAsset"] = PlantAssetImporter
importers["Unit"] = UnitImporter
importers["ActivityLog"] = ActivityLogImporter
@ -263,6 +267,28 @@ class LandAssetImporter(FromWuttaFarm, farmos_importing.model.LandAssetImporter)
}
class PlantTypeImporter(FromWuttaFarm, farmos_importing.model.PlantTypeImporter):
"""
WuttaFarm farmOS API exporter for Plant Types
"""
source_model_class = model.PlantType
supported_fields = [
"uuid",
"name",
]
drupal_internal_id_field = "drupal_internal__tid"
def normalize_source_object(self, plant_type):
return {
"uuid": plant_type.farmos_uuid or self.app.make_true_uuid(),
"name": plant_type.name,
"_src_object": plant_type,
}
class PlantAssetImporter(FromWuttaFarm, farmos_importing.model.PlantAssetImporter):
"""
WuttaFarm farmOS API exporter for Plant Assets
@ -334,6 +360,10 @@ class FromWuttaFarmLog(FromWuttaFarm):
supported_fields = [
"uuid",
"name",
"timestamp",
"is_movement",
"is_group_assignment",
"status",
"notes",
]
@ -341,6 +371,10 @@ class FromWuttaFarmLog(FromWuttaFarm):
return {
"uuid": log.farmos_uuid or self.app.make_true_uuid(),
"name": log.message,
"timestamp": log.timestamp,
"is_movement": log.is_movement,
"is_group_assignment": log.is_group_assignment,
"status": log.status,
"notes": log.notes,
"_src_object": log,
}
@ -369,6 +403,24 @@ class MedicalLogImporter(FromWuttaFarmLog, farmos_importing.model.MedicalLogImpo
source_model_class = model.MedicalLog
def get_supported_fields(self):
fields = list(super().get_supported_fields())
fields.extend(
[
"vet",
]
)
return fields
def normalize_source_object(self, log):
data = super().normalize_source_object(log)
data.update(
{
"vet": log.vet,
}
)
return data
class ObservationLogImporter(
FromWuttaFarmLog, farmos_importing.model.ObservationLogImporter

View file

@ -46,12 +46,15 @@ class FromFarmOSHandler(ImportHandler):
source_key = "farmos"
generic_source_title = "farmOS"
def begin_source_transaction(self):
def begin_source_transaction(self, client=None):
"""
Establish the farmOS API client.
"""
token = self.get_farmos_oauth2_token()
self.farmos_client = self.app.get_farmos_client(token=token)
if client:
self.farmos_client = client
else:
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)
self.normal = self.app.get_normalizer(self.farmos_client)
@ -184,6 +187,7 @@ class AssetImporterBase(FromFarmOS, ToWutta):
fields.extend(
[
"parents",
"owners",
]
)
return fields
@ -191,8 +195,9 @@ class AssetImporterBase(FromFarmOS, ToWutta):
def get_source_objects(self):
""" """
asset_type = self.get_farmos_asset_type()
result = self.farmos_client.asset.get(asset_type)
return result["data"]
return list(
self.farmos_client.asset.iterate(asset_type, params={"include": "image"})
)
def normalize_source_data(self, **kwargs):
""" """
@ -205,49 +210,40 @@ class AssetImporterBase(FromFarmOS, ToWutta):
return data
def normalize_asset(self, asset):
def normalize_source_object(self, asset):
""" """
image_url = None
thumbnail_url = None
if relationships := asset.get("relationships"):
data = self.normal.normalize_farmos_asset(asset)
if image := relationships.get("image"):
if image["data"]:
image = self.farmos_client.resource.get_id(
"file", "file", image["data"][0]["id"]
)
if image_style := image["data"]["attributes"].get(
"image_style_uri"
):
image_url = image_style["large"]
thumbnail_url = image_style["thumbnail"]
data["farmos_uuid"] = UUID(data.pop("uuid"))
data["asset_type"] = self.get_asset_type(asset)
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
if "image_url" in self.fields or "thumbnail_url" in self.fields:
data["image_url"] = None
data["thumbnail_url"] = None
if relationships := asset.get("relationships"):
if self.farmos_4x:
archived = asset["attributes"]["archived"]
else:
archived = asset["attributes"]["status"] == "archived"
if image := relationships.get("image"):
if image["data"]:
image = self.farmos_client.resource.get_id(
"file", "file", image["data"][0]["id"]
)
if image_style := image["data"]["attributes"].get(
"image_style_uri"
):
data["image_url"] = image_style["large"]
data["thumbnail_url"] = image_style["thumbnail"]
parents = None
if "parents" in self.fields:
parents = []
data["parents"] = []
for parent in asset["relationships"]["parent"]["data"]:
parents.append((self.get_asset_type(parent), UUID(parent["id"])))
data["parents"].append(
(self.get_asset_type(parent), UUID(parent["id"]))
)
return {
"farmos_uuid": UUID(asset["id"]),
"drupal_id": asset["attributes"]["drupal_internal__id"],
"asset_name": asset["attributes"]["name"],
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"archived": archived,
"notes": notes,
"image_url": image_url,
"thumbnail_url": thumbnail_url,
"parents": parents,
}
if "owners" in self.fields:
data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]]
return data
def get_asset_type(self, asset):
return asset["type"].split("--")[1]
@ -256,10 +252,10 @@ class AssetImporterBase(FromFarmOS, ToWutta):
data = super().normalize_target_object(asset)
if "parents" in self.fields:
data["parents"] = [
(p.parent.asset_type, p.parent.farmos_uuid)
for p in asset.asset._parents
]
data["parents"] = [(p.asset_type, p.farmos_uuid) for p in asset.parents]
if "owners" in self.fields:
data["owners"] = [user.farmos_uuid for user in asset.owners]
return data
@ -300,6 +296,30 @@ class AssetImporterBase(FromFarmOS, ToWutta):
)
self.target_session.delete(parent)
if "owners" in self.fields:
if not target_data or target_data["owners"] != source_data["owners"]:
for farmos_uuid in source_data["owners"]:
if not target_data or farmos_uuid not in target_data["owners"]:
user = (
self.target_session.query(model.User)
.join(model.WuttaFarmUser)
.filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid)
.one()
)
asset.owners.append(user)
if target_data:
for farmos_uuid in target_data["owners"]:
if farmos_uuid not in source_data["owners"]:
user = (
self.target_session.query(model.User)
.join(model.WuttaFarmUser)
.filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid)
.one()
)
asset.owners.remove(user)
return asset
@ -335,11 +355,6 @@ class AnimalAssetImporter(AssetImporterBase):
if animal_type.farmos_uuid:
self.animal_types_by_farmos_uuid[animal_type.farmos_uuid] = animal_type
def get_source_objects(self):
""" """
animals = self.farmos_client.asset.get("animal")
return animals["data"]
def normalize_source_object(self, animal):
""" """
animal_type_uuid = None
@ -371,10 +386,9 @@ class AnimalAssetImporter(AssetImporterBase):
else:
sterile = animal["attributes"]["is_castrated"]
data = self.normalize_asset(animal)
data = super().normalize_source_object(animal)
data.update(
{
"asset_type": "animal",
"animal_type_uuid": animal_type_uuid,
"sex": animal["attributes"]["sex"],
"is_sterile": sterile,
@ -465,17 +479,11 @@ class GroupAssetImporter(AssetImporterBase):
"parents",
]
def get_source_objects(self):
""" """
groups = self.farmos_client.asset.get("group")
return groups["data"]
def normalize_source_object(self, group):
""" """
data = self.normalize_asset(group)
data = super().normalize_source_object(group)
data.update(
{
"asset_type": "group",
"produces_eggs": group["attributes"]["produces_eggs"],
}
)
@ -511,11 +519,6 @@ class LandAssetImporter(AssetImporterBase):
for land_type in self.target_session.query(model.LandType):
self.land_types_by_id[land_type.drupal_id] = land_type
def get_source_objects(self):
""" """
land_assets = self.farmos_client.asset.get("land")
return land_assets["data"]
def normalize_source_object(self, land):
""" """
land_type_id = land["attributes"]["land_type"]
@ -526,10 +529,9 @@ class LandAssetImporter(AssetImporterBase):
)
return None
data = self.normalize_asset(land)
data = super().normalize_source_object(land)
data.update(
{
"asset_type": "land",
"land_type_uuid": land_type.uuid,
}
)
@ -622,7 +624,7 @@ class PlantAssetImporter(AssetImporterBase):
def normalize_source_object(self, plant):
""" """
plant_types = None
plant_types = []
if relationships := plant.get("relationships"):
if plant_type := relationships.get("plant_type"):
@ -635,11 +637,10 @@ class PlantAssetImporter(AssetImporterBase):
else:
log.warning("plant type not found: %s", plant_type["id"])
data = self.normalize_asset(plant)
data = super().normalize_source_object(plant)
data.update(
{
"asset_type": "plant",
"plant_types": plant_types,
"plant_types": set(plant_types),
}
)
return data
@ -648,7 +649,7 @@ class PlantAssetImporter(AssetImporterBase):
data = super().normalize_target_object(plant)
if "plant_types" in self.fields:
data["plant_types"] = [t.plant_type_uuid for t in plant._plant_types]
data["plant_types"] = set([pt.uuid for pt in plant.plant_types])
return data
@ -715,11 +716,6 @@ class StructureAssetImporter(AssetImporterBase):
for structure_type in self.target_session.query(model.StructureType):
self.structure_types_by_id[structure_type.drupal_id] = structure_type
def get_source_objects(self):
""" """
structures = self.farmos_client.asset.get("structure")
return structures["data"]
def normalize_source_object(self, structure):
""" """
structure_type_id = structure["attributes"]["structure_type"]
@ -732,10 +728,9 @@ class StructureAssetImporter(AssetImporterBase):
)
return None
data = self.normalize_asset(structure)
data = super().normalize_source_object(structure)
data.update(
{
"asset_type": "structure",
"structure_type_uuid": structure_type.uuid,
}
)
@ -964,8 +959,11 @@ class LogImporterBase(FromFarmOS, ToWutta):
"log_type",
"message",
"timestamp",
"is_movement",
"is_group_assignment",
"notes",
"status",
"quick",
]
)
return fields
@ -976,6 +974,10 @@ class LogImporterBase(FromFarmOS, ToWutta):
fields.extend(
[
"assets",
"groups",
"locations",
"quantities",
"owners",
]
)
return fields
@ -992,6 +994,7 @@ class LogImporterBase(FromFarmOS, ToWutta):
data["farmos_uuid"] = UUID(data.pop("uuid"))
data["message"] = data.pop("name")
data["timestamp"] = self.app.make_utc(data["timestamp"])
data["quick"] = ", ".join(data["quick"]) if data["quick"] else None
# TODO
data["log_type"] = self.get_farmos_log_type()
@ -1001,6 +1004,23 @@ class LogImporterBase(FromFarmOS, ToWutta):
(a["asset_type"], UUID(a["uuid"])) for a in data["assets"]
]
if "groups" in self.fields:
data["groups"] = [
(asset["asset_type"], UUID(asset["uuid"])) for asset in data["groups"]
]
if "locations" in self.fields:
data["locations"] = [
(asset["asset_type"], UUID(asset["uuid"]))
for asset in data["locations"]
]
if "quantities" in self.fields:
data["quantities"] = [UUID(uuid) for uuid in data["quantity_uuids"]]
if "owners" in self.fields:
data["owners"] = [UUID(uuid) for uuid in data["owner_uuids"]]
return data
def normalize_target_object(self, log):
@ -1008,9 +1028,25 @@ class LogImporterBase(FromFarmOS, ToWutta):
if "assets" in self.fields:
data["assets"] = [
(a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets
(asset.asset_type, asset.farmos_uuid) for asset in log.assets
]
if "groups" in self.fields:
data["groups"] = [
(asset.asset_type, asset.farmos_uuid) for asset in log.groups
]
if "locations" in self.fields:
data["locations"] = [
(asset.asset_type, asset.farmos_uuid) for asset in log.locations
]
if "quantities" in self.fields:
data["quantities"] = [qty.farmos_uuid for qty in log.quantities]
if "owners" in self.fields:
data["owners"] = [user.farmos_uuid for user in log.owners]
return data
def update_target_object(self, log, source_data, target_data=None):
@ -1023,14 +1059,13 @@ class LogImporterBase(FromFarmOS, ToWutta):
for key in source_data["assets"]:
asset_type, farmos_uuid = key
if not target_data or key not in target_data["assets"]:
self.target_session.flush()
asset = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.log._assets.append(model.LogAsset(asset=asset))
log.assets.append(asset)
if target_data:
for key in target_data["assets"]:
@ -1042,13 +1077,108 @@ class LogImporterBase(FromFarmOS, ToWutta):
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.assets.remove(asset)
if "groups" in self.fields:
if not target_data or target_data["groups"] != source_data["groups"]:
for key in source_data["groups"]:
asset_type, farmos_uuid = key
if not target_data or key not in target_data["groups"]:
asset = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.groups.append(asset)
if target_data:
for key in target_data["groups"]:
asset_type, farmos_uuid = key
if key not in source_data["groups"]:
asset = (
self.target_session.query(model.LogAsset)
.filter(model.LogAsset.log == log)
.filter(model.LogAsset.asset == asset)
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
self.target_session.delete(asset)
log.groups.remove(asset)
if "locations" in self.fields:
if not target_data or target_data["locations"] != source_data["locations"]:
for key in source_data["locations"]:
asset_type, farmos_uuid = key
if not target_data or key not in target_data["locations"]:
asset = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.locations.append(asset)
if target_data:
for key in target_data["locations"]:
asset_type, farmos_uuid = key
if key not in source_data["locations"]:
asset = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.locations.remove(asset)
if "quantities" in self.fields:
if (
not target_data
or target_data["quantities"] != source_data["quantities"]
):
for farmos_uuid in source_data["quantities"]:
if not target_data or farmos_uuid not in target_data["quantities"]:
qty = (
self.target_session.query(model.Quantity)
.filter(model.Quantity.farmos_uuid == farmos_uuid)
.one()
)
log.quantities.append(qty)
if target_data:
for farmos_uuid in target_data["quantities"]:
if farmos_uuid not in source_data["quantities"]:
qty = (
self.target_session.query(model.Quantity)
.filter(model.Quantity.farmos_uuid == farmos_uuid)
.one()
)
log.quantities.remove(qty)
if "owners" in self.fields:
if not target_data or target_data["owners"] != source_data["owners"]:
for farmos_uuid in source_data["owners"]:
if not target_data or farmos_uuid not in target_data["owners"]:
user = (
self.target_session.query(model.User)
.join(model.WuttaFarmUser)
.filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid)
.one()
)
log.owners.append(user)
if target_data:
for farmos_uuid in target_data["owners"]:
if farmos_uuid not in source_data["owners"]:
user = (
self.target_session.query(model.User)
.join(model.WuttaFarmUser)
.filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid)
.one()
)
log.owners.remove(user)
return log
@ -1060,17 +1190,6 @@ class ActivityLogImporter(LogImporterBase):
model_class = model.ActivityLog
supported_fields = [
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
"assets",
]
class HarvestLogImporter(LogImporterBase):
"""
@ -1079,17 +1198,6 @@ class HarvestLogImporter(LogImporterBase):
model_class = model.HarvestLog
supported_fields = [
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
"assets",
]
class MedicalLogImporter(LogImporterBase):
"""
@ -1098,16 +1206,16 @@ class MedicalLogImporter(LogImporterBase):
model_class = model.MedicalLog
supported_fields = [
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
"assets",
]
def get_simple_fields(self):
""" """
fields = list(super().get_simple_fields())
# nb. must explicitly declare proxy fields
fields.extend(
[
"vet",
]
)
return fields
class ObservationLogImporter(LogImporterBase):
@ -1117,17 +1225,6 @@ class ObservationLogImporter(LogImporterBase):
model_class = model.ObservationLog
supported_fields = [
"farmos_uuid",
"drupal_id",
"log_type",
"message",
"timestamp",
"notes",
"status",
"assets",
]
class QuantityImporterBase(FromFarmOS, ToWutta):
"""

View file

@ -84,6 +84,40 @@ class Normalizer(GenericHandler):
self._farmos_units = units
return self._farmos_units
def normalize_farmos_asset(self, asset, included={}):
""" """
if notes := asset["attributes"]["notes"]:
notes = notes["value"]
owner_objects = []
owner_uuids = []
if relationships := asset.get("relationships"):
if owners := relationships.get("owner"):
for user in owners["data"]:
user_uuid = user["id"]
owner_uuids.append(user_uuid)
if user := included.get(user_uuid):
owner_objects.append(
{
"uuid": user["id"],
"name": user["attributes"]["name"],
}
)
return {
"uuid": asset["id"],
"drupal_id": asset["attributes"]["drupal_internal__id"],
"asset_name": asset["attributes"]["name"],
"is_location": asset["attributes"]["is_location"],
"is_fixed": asset["attributes"]["is_fixed"],
"archived": asset["attributes"]["archived"],
"notes": notes,
"owners": owner_objects,
"owner_uuids": owner_uuids,
}
def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]:
@ -96,8 +130,12 @@ class Normalizer(GenericHandler):
log_type_object = {}
log_type_uuid = None
asset_objects = []
group_objects = []
group_uuids = []
quantity_objects = []
quantity_uuids = []
location_objects = []
location_uuids = []
owner_objects = []
owner_uuids = []
if relationships := log.get("relationships"):
@ -132,6 +170,54 @@ class Normalizer(GenericHandler):
)
asset_objects.append(asset_object)
if groups := relationships.get("group"):
for group in groups["data"]:
group_uuid = group["id"]
group_uuids.append(group_uuid)
group_object = {
"uuid": group["id"],
"type": group["type"],
"asset_type": group["type"].split("--")[1],
}
if group := included.get(group_uuid):
attrs = group["attributes"]
rels = group["relationships"]
group_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
group_objects.append(group_object)
if locations := relationships.get("location"):
for location in locations["data"]:
location_uuid = location["id"]
location_uuids.append(location_uuid)
location_object = {
"uuid": location["id"],
"type": location["type"],
"asset_type": location["type"].split("--")[1],
}
if location := included.get(location_uuid):
attrs = location["attributes"]
rels = location["relationships"]
location_object.update(
{
"drupal_id": attrs["drupal_internal__id"],
"name": attrs["name"],
"is_location": attrs["is_location"],
"is_fixed": attrs["is_fixed"],
"archived": attrs["archived"],
"notes": attrs["notes"],
}
)
location_objects.append(location_object)
if quantities := relationships.get("quantity"):
for quantity in quantities["data"]:
quantity_uuid = quantity["id"]
@ -188,12 +274,19 @@ class Normalizer(GenericHandler):
"name": log["attributes"]["name"],
"timestamp": timestamp,
"assets": asset_objects,
"groups": group_objects,
"group_uuids": group_uuids,
"quantities": quantity_objects,
"quantity_uuids": quantity_uuids,
"is_group_assignment": log["attributes"]["is_group_assignment"],
"is_movement": log["attributes"]["is_movement"],
"quick": log["attributes"]["quick"],
"status": log["attributes"]["status"],
"notes": notes,
"locations": location_objects,
"location_uuids": location_uuids,
"owners": owner_objects,
"owner_uuids": owner_uuids,
# TODO: should we do this here or make caller do it?
"vet": log["attributes"].get("vet"),
}

37
src/wuttafarm/util.py Normal file
View file

@ -0,0 +1,37 @@
# -*- 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/>.
#
################################################################################
"""
misc. utilities
"""
from collections import OrderedDict
def get_log_type_enum(config, session=None):
app = config.get_app()
model = app.model
log_types = OrderedDict()
with app.short_session(session=session) as sess:
query = sess.query(model.LogType).order_by(model.LogType.name)
for log_type in query:
log_types[log_type.drupal_id] = log_type.name
return log_types

View file

@ -40,6 +40,15 @@ def main(global_config, **settings):
"wuttaweb:templates",
],
)
settings.setdefault(
"pyramid_deform.template_search_path",
" ".join(
[
"wuttafarm.web:templates/deform",
"wuttaweb:templates/deform",
]
),
)
# make config objects
wutta_config = base.make_wutta_config(settings)

View file

@ -27,6 +27,7 @@ import json
import colander
from wuttaweb.db import Session
from wuttaweb.forms.schema import ObjectRef, WuttaSet
from wuttaweb.forms.widgets import NotesWidget
@ -55,6 +56,12 @@ class AnimalTypeRef(ObjectRef):
animal_type = obj
return self.request.route_url("animal_types.view", uuid=animal_type.uuid)
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import AnimalTypeRefWidget
kwargs["factory"] = AnimalTypeRefWidget
return super().widget_maker(**kwargs)
class LogQuick(WuttaSet):
@ -185,25 +192,6 @@ class FarmOSQuantityRefs(WuttaSet):
return FarmOSQuantityRefsWidget(**kwargs)
class AnimalTypeType(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
return json.dumps(appstruct)
def widget_maker(self, **kwargs): # pylint: disable=empty-docstring
""" """
from wuttafarm.web.forms.widgets import AnimalTypeWidget
return AnimalTypeWidget(self.request, **kwargs)
class FarmOSPlantTypes(colander.SchemaType):
def __init__(self, request, *args, **kwargs):
@ -255,13 +243,23 @@ class PlantTypeRefs(WuttaSet):
def serialize(self, node, appstruct):
if not appstruct:
appstruct = []
uuids = [u.hex for u in appstruct]
return json.dumps(uuids)
return colander.null
return [uuid.hex for uuid in appstruct]
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import PlantTypeRefsWidget
model = self.app.model
session = Session()
if "values" not in kwargs:
plant_types = (
session.query(model.PlantType).order_by(model.PlantType.name).all()
)
values = [(pt.uuid.hex, str(pt)) for pt in plant_types]
kwargs["values"] = values
return PlantTypeRefsWidget(self.request, **kwargs)
@ -366,21 +364,55 @@ class AssetParentRefs(WuttaSet):
return AssetParentRefsWidget(self.request, **kwargs)
class LogAssetRefs(WuttaSet):
class AssetRefs(WuttaSet):
"""
Schema type for Assets field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
appstruct = []
uuids = [u.hex for u in appstruct]
return json.dumps(uuids)
return colander.null
return {asset.uuid for asset in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogAssetRefsWidget
from wuttafarm.web.forms.widgets import AssetRefsWidget
return LogAssetRefsWidget(self.request, **kwargs)
return AssetRefsWidget(self.request, **kwargs)
class LogQuantityRefs(WuttaSet):
"""
Schema type for Quantities field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return {qty.uuid for qty in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogQuantityRefsWidget
return LogQuantityRefsWidget(self.request, **kwargs)
class OwnerRefs(WuttaSet):
"""
Schema type for Owners field (on a Log record)
"""
def serialize(self, node, appstruct):
if not appstruct:
return colander.null
return {user.uuid for user in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import OwnerRefsWidget
return OwnerRefsWidget(self.request, **kwargs)
class Notes(colander.String):

View file

@ -26,10 +26,10 @@ Custom form widgets for WuttaFarm
import json
import colander
from deform.widget import Widget, SelectWidget
from deform.widget import Widget, SelectWidget, sequence_types, _normalize_choices
from webhelpers2.html import HTML, tags
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget
from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects
@ -228,33 +228,6 @@ class FarmOSUnitRefWidget(Widget):
return super().serialize(field, cstruct, **kw)
class AnimalTypeWidget(Widget):
"""
Widget to display an "animal type" field.
"""
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
""" """
readonly = kw.get("readonly", self.readonly)
if readonly:
if cstruct in (colander.null, None):
return HTML.tag("span")
animal_type = json.loads(cstruct)
return tags.link_to(
animal_type["name"],
self.request.route_url(
"farmos_animal_types.view", uuid=animal_type["uuid"]
),
)
return super().serialize(field, cstruct, **kw)
class FarmOSPlantTypesWidget(Widget):
"""
Widget to display a farmOS "plant types" field.
@ -285,22 +258,40 @@ class FarmOSPlantTypesWidget(Widget):
return super().serialize(field, cstruct, **kw)
class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget):
class PlantTypeRefsWidget(Widget):
"""
Widget for Plant Types field (on a Plant Asset).
"""
template = "planttyperefs"
values = ()
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
plant_types = []
for uuid in json.loads(cstruct):
plant_type = session.get(model.PlantType, uuid)
plant_types.append(
if cstruct in (colander.null, None):
cstruct = ()
if readonly := kw.get("readonly", self.readonly):
items = []
plant_types = (
session.query(model.PlantType)
.filter(model.PlantType.uuid.in_(cstruct))
.order_by(model.PlantType.name)
.all()
)
for plant_type in plant_types:
items.append(
HTML.tag(
"li",
c=tags.link_to(
@ -311,9 +302,34 @@ class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget):
),
)
)
return HTML.tag("ul", c=plant_types)
return super().serialize(field, cstruct, **kw)
return HTML.tag("ul", c=items)
values = kw.get("values", self.values)
if not isinstance(values, sequence_types):
raise TypeError("Values must be a sequence type (list, tuple, or range).")
kw["values"] = _normalize_choices(values)
tmpl_values = self.get_template_values(field, cstruct, kw)
return field.renderer(self.template, **tmpl_values)
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
values["js_values"] = json.dumps(values["values"])
if self.request.has_perm("plant_types.create"):
values["can_create"] = True
return values
def deserialize(self, field, pstruct):
""" """
if not pstruct:
return colander.null
return set(pstruct.split(","))
class StructureWidget(Widget):
@ -372,6 +388,11 @@ class UsersWidget(Widget):
return super().serialize(field, cstruct, **kw)
##############################
# native data widgets
##############################
class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Parents field which references assets.
@ -403,9 +424,9 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
return super().serialize(field, cstruct, **kw)
class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
class AssetRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Assets field (on a Log record)
Widget for Assets field (of various kinds).
"""
def serialize(self, field, cstruct, **kw):
@ -416,7 +437,7 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly)
if readonly:
assets = []
for uuid in json.loads(cstruct):
for uuid in cstruct or []:
asset = session.get(model.Asset, uuid)
assets.append(
HTML.tag(
@ -432,3 +453,85 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw)
class LogQuantityRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Quantities field (on a Log record)
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
quantities = []
for uuid in cstruct or []:
qty = session.get(model.Quantity, uuid)
quantities.append(
HTML.tag(
"li",
c=tags.link_to(
qty.render_as_text(self.config),
# TODO
self.request.route_url(
"quantities_standard.view", uuid=qty.uuid
),
),
)
)
return HTML.tag("ul", c=quantities)
return super().serialize(field, cstruct, **kw)
class OwnerRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Owners field (on an Asset or Log record)
"""
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
session = Session()
readonly = kw.get("readonly", self.readonly)
if readonly:
owners = [session.get(model.User, uuid) for uuid in cstruct or []]
owners = [user for user in owners if user]
owners.sort(key=lambda user: user.username)
links = []
for user in owners:
links.append(
HTML.tag(
"li",
c=tags.link_to(
user.username,
self.request.route_url("users.view", uuid=user.uuid),
),
)
)
return HTML.tag("ul", c=links)
return super().serialize(field, cstruct, **kw)
class AnimalTypeRefWidget(ObjectRefWidget):
"""
Custom widget which uses the ``<animal-type-picker>`` component.
"""
template = "animaltyperef"
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
values["js_values"] = json.dumps(values["values"])
if self.request.has_perm("animal_types.create"):
values["can_create"] = True
return values

View file

@ -72,7 +72,7 @@ class WuttaFarmMenuHandler(base.MenuHandler):
{
"title": "Eggs",
"route": "quick.eggs",
# "perm": "assets.list",
"perm": "quick.eggs",
},
],
}

View file

@ -14,14 +14,47 @@
</b-input>
</b-field>
<b-field grouped>
<b-field label="OAuth2 Client ID">
<b-input name="farmos.oauth2.client_id"
v-model="simpleSettings['farmos.oauth2.client_id']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
<b-field label="OAuth2 Scope">
<b-input name="farmos.oauth2.scope"
v-model="simpleSettings['farmos.oauth2.scope']"
@input="settingsNeedSaved = true">
</b-input>
</b-field>
</b-field>
<b-field label="OAuth2 Redirect URI">
<wutta-copyable-text text="${url('farmos_oauth_callback')}" />
</b-field>
<b-field label="farmOS Integration Mode">
<b-select name="${app.appname}.farmos_integration_mode"
v-model="simpleSettings['${app.appname}.farmos_integration_mode']"
@input="settingsNeedSaved = true">
% for value, label in enum.FARMOS_INTEGRATION_MODE.items():
<option value="${value}">${label}</option>
% endfor
</b-select>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b-select name="${app.appname}.farmos_integration_mode"
v-model="simpleSettings['${app.appname}.farmos_integration_mode']"
@input="settingsNeedSaved = true">
% for value, label in enum.FARMOS_INTEGRATION_MODE.items():
<option value="${value}">${label}</option>
% endfor
</b-select>
<${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}">
<b-icon pack="fas" icon="info-circle" type="is-warning" />
<template #content>
<p class="block">
<span class="has-text-weight-bold">RESTART IS REQUIRED</span>
if you change the integration mode.
</p>
</template>
</${b}-tooltip>
</div>
</b-field>
<b-checkbox name="${app.appname}.farmos_style_grid_links"

View file

@ -1,4 +1,5 @@
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" />
<%def name="index_title_controls()">
${parent.index_title_controls()}
@ -14,3 +15,8 @@
% endif
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${make_wuttafarm_components()}
</%def>

View file

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

View file

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

View file

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

View file

@ -23,9 +23,29 @@
Misc. utilities for web app
"""
from pyramid import httpexceptions
from webhelpers2.html import HTML
def get_farmos_client_for_user(request):
token = request.session.get("farmos.oauth2.token")
if not token:
raise httpexceptions.HTTPForbidden()
# nb. must give a *copy* of the token to farmOS client, since it
# will mutate it in-place and we don't want that to happen for our
# original copy in the user session. (otherwise the auto-refresh
# will not work correctly for subsequent calls.)
token = dict(token)
def token_updater(token):
save_farmos_oauth2_token(request, token)
config = request.wutta_config
app = config.get_app()
return app.get_farmos_client(token=token, token_updater=token_updater)
def save_farmos_oauth2_token(request, token):
"""
Common logic for saving the given OAuth2 token within the user

View file

@ -26,11 +26,13 @@ Master view for Animals
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
from wuttafarm.db.model import AnimalType, AnimalAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
class AnimalTypeView(AssetTypeMasterView):
@ -42,6 +44,8 @@ class AnimalTypeView(AssetTypeMasterView):
route_prefix = "animal_types"
url_prefix = "/animal-types"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "animal_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [
@ -58,8 +62,8 @@ class AnimalTypeView(AssetTypeMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
has_rows = True
@ -103,6 +107,19 @@ class AnimalTypeView(AssetTypeMasterView):
return buttons
def delete(self):
animal_type = self.get_instance()
if animal_type.animal_assets:
self.request.session.flash(
"Cannot delete animal type which is still referenced by animal assets.",
"warning",
)
url = self.get_action_url("view", animal_type)
return self.redirect(self.request.get_referrer(default=url))
return super().delete()
def get_row_grid_data(self, animal_type):
model = self.app.model
session = self.Session()
@ -125,6 +142,7 @@ class AnimalTypeView(AssetTypeMasterView):
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.filters["sex"].verbs = ["equal", "not_equal"]
# archived
g.set_renderer("archived", "boolean")
@ -134,6 +152,55 @@ class AnimalTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, animal, i):
return self.request.route_url("animal_assets.view", uuid=animal.uuid)
def ajax_create(self):
"""
AJAX view to create a new animal type.
"""
model = self.app.model
session = self.Session()
data = get_form_data(self.request)
name = data.get("name")
if not name:
return {"error": "Name is required"}
animal_type = model.AnimalType(name=name)
session.add(animal_type)
session.flush()
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(animal_type, client=client)
return {
"uuid": animal_type.uuid.hex,
"name": animal_type.name,
"farmos_uuid": animal_type.farmos_uuid.hex,
"drupal_id": animal_type.drupal_id,
}
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._animal_type_defaults(config)
@classmethod
def _animal_type_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
# ajax_create
config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
config.add_view(
cls,
attr="ajax_create",
route_name=f"{route_prefix}.ajax_create",
permission=f"{permission_prefix}.create",
renderer="json",
)
class AnimalAssetView(AssetMasterView):
"""
@ -145,9 +212,10 @@ class AnimalAssetView(AssetMasterView):
url_prefix = "/assets/animal"
farmos_refurl_path = "/assets/animal"
farmos_bundle = "animal"
labels = {
"animal_type": "Species/Breed",
"animal_type": "Species / Breed",
"is_sterile": "Sterile",
}
@ -160,6 +228,9 @@ class AnimalAssetView(AssetMasterView):
"birthdate",
"is_sterile",
"sex",
"groups",
"owners",
"locations",
"archived",
]
@ -172,9 +243,12 @@ class AnimalAssetView(AssetMasterView):
"is_sterile",
"notes",
"asset_type",
"owners",
"locations",
"groups",
"archived",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
@ -201,6 +275,7 @@ class AnimalAssetView(AssetMasterView):
# sex
g.set_enum("sex", enum.ANIMAL_SEX)
g.filters["sex"].verbs = ["equal", "not_equal"]
def render_animal_type_for_grid(self, animal, field, value):
url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid)
@ -216,7 +291,7 @@ class AnimalAssetView(AssetMasterView):
f.set_node("animal_type", AnimalTypeRef(self.request))
# sex
if self.viewing and animal.sex is None:
if not (self.creating or self.editing) 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))

View file

@ -38,6 +38,7 @@ class AssetTypeView(WuttaFarmMasterView):
grid_columns = [
"name",
"drupal_id",
"description",
]
@ -50,8 +51,8 @@ class AssetTypeView(WuttaFarmMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):
@ -78,6 +79,19 @@ class AssetTypeView(WuttaFarmMasterView):
return buttons
@classmethod
def defaults(cls, config):
""" """
wutta_config = config.registry.settings.get("wutta_config")
app = wutta_config.get_app()
if app.is_farmos_mirror():
cls.creatable = False
cls.editable = False
cls.deletable = False
cls._defaults(config)
def defaults(config, **kwargs):
base = globals()

View file

@ -25,13 +25,17 @@ Master view for Assets
from collections import OrderedDict
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset, Log
from wuttafarm.web.forms.schema import AssetParentRefs
from wuttafarm.web.forms.schema import AssetParentRefs, OwnerRefs, AssetRefs
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.util import get_log_type_enum
from wuttafarm.web.util import get_farmos_client_for_user
def get_asset_type_enum(config):
@ -45,79 +49,6 @@ def get_asset_type_enum(config):
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.
@ -133,6 +64,12 @@ class AssetMasterView(WuttaFarmMasterView):
Base class for Asset master views
"""
farmos_entity_type = "asset"
labels = {
"groups": "Group Membership",
}
sort_defaults = "asset_name"
filter_defaults = {
@ -175,7 +112,10 @@ class AssetMasterView(WuttaFarmMasterView):
model = self.app.model
model_class = self.get_model_class()
session = session or self.Session()
return session.query(model_class).join(model.Asset)
query = session.query(model_class)
if model_class is not model.Asset:
query = query.join(model.Asset)
return query
def configure_grid(self, grid):
g = grid
@ -200,15 +140,77 @@ class AssetMasterView(WuttaFarmMasterView):
# parents
g.set_renderer("parents", self.render_parents_for_grid)
# groups
g.set_renderer("groups", self.render_groups_for_grid)
# owners
g.set_label("owners", "Owner")
g.set_renderer("owners", self.render_owners_for_grid)
# locations
g.set_label("locations", "Location")
g.set_renderer("locations", self.render_locations_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]
if self.farmos_style_grid_links:
links = []
for parent in asset.parents:
url = self.request.route_url(
f"{parent.asset_type}_assets.view", uuid=parent.uuid
)
links.append(tags.link_to(str(parent), url))
return ", ".join(links)
parents = [str(p.parent) for p in asset.parents]
return ", ".join(parents)
def render_owners_for_grid(self, asset, field, value):
if self.farmos_style_grid_links:
links = []
for user in asset.owners:
url = self.request.route_url("users.view", uuid=user.uuid)
links.append(tags.link_to(user.username, url))
return ", ".join(links)
return ", ".join([user.username for user in asset.owners])
def render_groups_for_grid(self, asset, field, value):
asset_handler = self.app.get_asset_handler()
groups = asset_handler.get_groups(asset)
if self.farmos_style_grid_links:
links = []
for group in groups:
url = self.request.route_url(
f"{group.asset_type}_assets.view", uuid=group.uuid
)
links.append(tags.link_to(str(group), url))
return ", ".join(links)
return ", ".join([str(group) for group in groups])
def render_locations_for_grid(self, asset, field, value):
asset_handler = self.app.get_asset_handler()
locations = asset_handler.get_locations(asset)
if self.farmos_style_grid_links:
links = []
for loc in locations:
url = self.request.route_url(
f"{loc.asset_type}_assets.view", uuid=loc.uuid
)
links.append(tags.link_to(str(loc), url))
return ", ".join(links)
return ", ".join([str(loc) for loc in locations])
def grid_row_class(self, asset, data, i):
""" """
if asset.archived:
@ -218,6 +220,7 @@ class AssetMasterView(WuttaFarmMasterView):
def configure_form(self, form):
f = form
super().configure_form(f)
asset_handler = self.app.get_asset_handler()
asset = form.model_instance
# asset_type
@ -230,12 +233,39 @@ class AssetMasterView(WuttaFarmMasterView):
)
f.set_readonly("asset_type")
# owners
if self.creating or self.editing:
f.remove("owners") # TODO: need to support this
else:
f.set_node("owners", OwnerRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("owners", asset.owners)
# locations
if self.creating or self.editing:
# nb. this is a calculated field
f.remove("locations")
else:
f.set_label("locations", "Current Location")
f.set_node("locations", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("locations", asset_handler.get_locations(asset))
# groups
if self.creating or self.editing:
# nb. this is a calculated field
f.remove("groups")
else:
f.set_node("groups", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("groups", asset_handler.get_groups(asset))
# 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])
f.set_default("parents", [p.uuid for p in asset.parents])
# notes
f.set_widget("notes", "notes")
@ -266,11 +296,14 @@ class AssetMasterView(WuttaFarmMasterView):
asset = super().objectify(form)
if self.creating:
model_class = self.get_model_class()
asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"]
asset.asset_type = self.get_asset_type()
return asset
def get_asset_type(self):
model_class = self.get_model_class()
return model_class.__wutta_hint__["farmos_asset_type"]
def get_farmos_url(self, asset):
return self.app.get_farmos_url(f"/asset/{asset.drupal_id}")
@ -278,7 +311,7 @@ class AssetMasterView(WuttaFarmMasterView):
buttons = super().get_xref_buttons(asset)
if asset.farmos_uuid:
asset_type = self.get_model_class().__wutta_hint__["farmos_asset_type"]
asset_type = self.get_asset_type()
route = f"farmos_{asset_type}_assets.view"
url = self.request.route_url(route, uuid=asset.farmos_uuid)
buttons.append(
@ -289,6 +322,21 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons
def get_version_joins(self):
"""
We override this to declare the relationship between the
view's data model (which is some type of asset table) and the
canonical ``Asset`` model, so the revision history views
include transactions which reference either version table.
See also parent method,
:meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()`
"""
model = self.app.model
return super().get_version_joins() + [
model.Asset,
]
def get_row_grid_data(self, asset):
model = self.app.model
session = self.Session()
@ -301,7 +349,12 @@ class AssetMasterView(WuttaFarmMasterView):
def configure_row_grid(self, grid):
g = grid
super().configure_row_grid(g)
enum = self.app.enum
model = self.app.model
session = self.Session()
# status
g.set_enum("status", enum.LOG_STATUS)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
@ -318,16 +371,62 @@ class AssetMasterView(WuttaFarmMasterView):
# log_type
g.set_sorter("log_type", model.Log.log_type)
g.set_filter("log_type", model.Log.log_type)
g.set_enum("log_type", get_log_type_enum(self.config, session=session))
def get_row_action_url_view(self, log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
class AllAssetView(AssetMasterView):
"""
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",
"groups",
"asset_type",
"parents",
"owners",
"locations",
"archived",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# asset_type
g.set_enum("asset_type", get_asset_type_enum(self.config))
# 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 defaults(config, **kwargs):
base = globals()
AssetView = kwargs.get("AssetView", base["AssetView"])
AssetView.defaults(config)
AllAssetView = kwargs.get("AllAssetView", base["AllAssetView"])
AllAssetView.defaults(config)
def includeme(config):

View file

@ -55,9 +55,10 @@ class AuthView(base.AuthView):
return None
def get_farmos_oauth2_session(self):
farmos = self.app.get_farmos_handler()
return OAuth2Session(
client_id="farm",
scope="farm_manager",
client_id=farmos.get_oauth2_client_id(),
scope=farmos.get_oauth2_scope(),
redirect_uri=self.request.route_url("farmos_oauth_callback"),
)

View file

@ -87,10 +87,20 @@ class CommonView(base.CommonView):
"farmos_logs_medical.view",
"farmos_logs_observation.list",
"farmos_logs_observation.view",
"farmos_plant_assets.list",
"farmos_plant_assets.view",
"farmos_plant_types.list",
"farmos_plant_types.view",
"farmos_quantities_standard.list",
"farmos_quantities_standard.view",
"farmos_quantity_types.list",
"farmos_quantity_types.view",
"farmos_structure_assets.list",
"farmos_structure_assets.view",
"farmos_structure_types.list",
"farmos_structure_types.view",
"farmos_units.list",
"farmos_units.view",
"farmos_users.list",
"farmos_users.view",
"group_assets.create",
@ -121,6 +131,7 @@ class CommonView(base.CommonView):
"logs_observation.list",
"logs_observation.view",
"logs_observation.versions",
"quick.eggs",
"structure_types.list",
"structure_types.view",
"structure_types.versions",

View file

@ -39,7 +39,7 @@ from wuttafarm.web.grids import (
NullableBooleanFilter,
DateTimeFilter,
)
from wuttafarm.web.forms.schema import FarmOSRef
from wuttafarm.web.forms.schema import FarmOSRef, FarmOSAssetRefs
class AnimalView(AssetMasterView):
@ -87,9 +87,9 @@ class AnimalView(AssetMasterView):
"is_sterile",
"notes",
"asset_type_name",
"groups",
"owners",
"locations",
"groups",
"archived",
"thumbnail_url",
"image_url",
@ -147,7 +147,7 @@ class AnimalView(AssetMasterView):
def render_groups_for_grid(self, animal, field, value):
groups = []
for group in animal["group_objects"]:
for group in animal["groups"]:
if self.farmos_style_grid_links:
url = self.request.route_url(
"farmos_group_assets.view", uuid=group["uuid"]
@ -209,6 +209,7 @@ class AnimalView(AssetMasterView):
group = {
"uuid": group["id"],
"name": group["attributes"]["name"],
"asset_type": "group",
}
group_objects.append(group)
group_names.append(group["name"])
@ -218,7 +219,7 @@ class AnimalView(AssetMasterView):
"animal_type": animal_type_object,
"animal_type_uuid": animal_type_object["uuid"],
"animal_type_name": animal_type_object["name"],
"group_objects": group_objects,
"groups": group_objects,
"group_names": group_names,
"birthdate": birthdate,
"sex": animal["attributes"]["sex"] or colander.null,
@ -273,6 +274,8 @@ class AnimalView(AssetMasterView):
# groups
if self.creating or self.editing:
f.remove("groups") # TODO
else:
f.set_node("groups", FarmOSAssetRefs(self.request))
def get_api_payload(self, animal):
payload = super().get_api_payload(animal)

View file

@ -53,8 +53,8 @@ class AssetMasterView(FarmOSMasterView):
labels = {
"name": "Asset Name",
"asset_type_name": "Asset Type",
"owners": "Owner",
"locations": "Location",
"groups": "Group Membership",
"thumbnail_url": "Thumbnail URL",
"image_url": "Image URL",
}
@ -104,6 +104,7 @@ class AssetMasterView(FarmOSMasterView):
g.set_filter("name", StringFilter)
# owners
g.set_label("owners", "Owner")
g.set_renderer("owners", self.render_owners_for_grid)
# locations
@ -239,6 +240,7 @@ class AssetMasterView(FarmOSMasterView):
if self.creating or self.editing:
f.remove("locations")
else:
f.set_label("locations", "Current Location")
f.set_node("locations", FarmOSLocationRefs(self.request))
# owners

View file

@ -23,6 +23,7 @@
View for farmOS Harvest Logs
"""
import colander
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDateTime, WuttaDictEnum
@ -59,6 +60,7 @@ class LogMasterView(FarmOSMasterView):
labels = {
"name": "Log Name",
"log_type_name": "Log Type",
"locations": "Location",
"quantities": "Quantity",
}
@ -68,6 +70,7 @@ class LogMasterView(FarmOSMasterView):
"timestamp",
"name",
"assets",
"locations",
"quantities",
"is_group_assignment",
"owners",
@ -84,17 +87,21 @@ class LogMasterView(FarmOSMasterView):
"name",
"timestamp",
"assets",
"groups",
"locations",
"quantities",
"notes",
"status",
"log_type_name",
"owners",
"is_movement",
"is_group_assignment",
"quick",
"drupal_id",
]
def get_farmos_api_includes(self):
return {"log_type", "quantity", "asset", "owner"}
return {"log_type", "quantity", "asset", "group", "location", "owner"}
def get_grid_data(self, **kwargs):
return ResourceData(
@ -139,6 +146,12 @@ class LogMasterView(FarmOSMasterView):
# assets
g.set_renderer("assets", self.render_assets_for_grid)
# groups
g.set_renderer("groups", self.render_assets_for_grid)
# locations
g.set_renderer("locations", self.render_assets_for_grid)
# quantities
g.set_renderer("quantities", self.render_quantities_for_grid)
@ -152,6 +165,9 @@ class LogMasterView(FarmOSMasterView):
g.set_renderer("owners", self.render_owners_for_grid)
def render_assets_for_grid(self, log, field, value):
if not value:
return ""
assets = []
for asset in value:
if self.farmos_style_grid_links:
@ -210,9 +226,21 @@ class LogMasterView(FarmOSMasterView):
# assets
f.set_node("assets", FarmOSAssetRefs(self.request))
# groups
f.set_node("groups", FarmOSAssetRefs(self.request))
# locations
f.set_node("locations", FarmOSAssetRefs(self.request))
# quantities
f.set_node("quantities", FarmOSQuantityRefs(self.request))
# is_movement
f.set_node("is_movement", colander.Boolean())
# is_group_assignment
f.set_node("is_group_assignment", colander.Boolean())
# notes
f.set_node("notes", Notes())

View file

@ -48,7 +48,6 @@ class HarvestLogView(LogMasterView):
"name",
"assets",
"quantities",
"is_group_assignment",
"owners",
]

View file

@ -24,6 +24,7 @@ View for farmOS Medical Logs
"""
from wuttafarm.web.views.farmos.logs import LogMasterView
from wuttafarm.web.grids import SimpleSorter, StringFilter
class MedicalLogView(LogMasterView):
@ -41,6 +42,35 @@ class MedicalLogView(LogMasterView):
farmos_log_type = "medical"
farmos_refurl_path = "/logs/medical"
labels = {
"vet": "Veterinarian",
}
grid_columns = [
"status",
"drupal_id",
"timestamp",
"name",
"assets",
"vet",
"owners",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# vet
g.set_sorter("vet", SimpleSorter("vet"))
g.set_filter("vet", StringFilter)
def configure_form(self, form):
f = form
super().configure_form(f)
# vet
f.fields.insert_after("timestamp", "vet")
def defaults(config, **kwargs):
base = globals()

View file

@ -41,6 +41,18 @@ class ObservationLogView(LogMasterView):
farmos_log_type = "observation"
farmos_refurl_path = "/logs/observation"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"name",
"assets",
"locations",
"groups",
"is_group_assignment",
"owners",
]
def defaults(config, **kwargs):
base = globals()

View file

@ -34,7 +34,7 @@ from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.util import save_farmos_oauth2_token, use_farmos_style_grid_links
from wuttafarm.web.util import get_farmos_client_for_user, use_farmos_style_grid_links
from wuttafarm.web.grids import (
ResourceData,
StringFilter,
@ -70,28 +70,12 @@ class FarmOSMasterView(MasterView):
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client()
self.farmos_client = get_farmos_client_for_user(self.request)
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client)
self.raw_json = None
self.farmos_style_grid_links = use_farmos_style_grid_links(self.config)
def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token")
if not token:
raise self.forbidden()
# nb. must give a *copy* of the token to farmOS client, since
# it will mutate it in-place and we don't want that to happen
# for our original copy in the user session. (otherwise the
# auto-refresh will not work correctly for subsequent calls.)
token = dict(token)
def token_updater(token):
save_farmos_oauth2_token(self.request, token)
return self.app.get_farmos_client(token=token, token_updater=token_updater)
def get_fallback_templates(self, template):
""" """
templates = super().get_fallback_templates(template)

View file

@ -37,6 +37,7 @@ class GroupView(AssetMasterView):
url_prefix = "/assets/group"
farmos_refurl_path = "/assets/group"
farmos_bundle = "group"
grid_columns = [
"thumbnail",
@ -52,8 +53,8 @@ class GroupView(AssetMasterView):
"asset_type",
"produces_eggs",
"archived",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]

View file

@ -51,8 +51,8 @@ class LandTypeView(AssetTypeMasterView):
form_fields = [
"name",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
has_rows = True
@ -129,6 +129,19 @@ class LandTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, land_asset, i):
return self.request.route_url("land_assets.view", uuid=land_asset.uuid)
@classmethod
def defaults(cls, config):
""" """
wutta_config = config.registry.settings.get("wutta_config")
app = wutta_config.get_app()
if app.is_farmos_mirror():
cls.creatable = False
cls.editable = False
cls.deletable = False
cls._defaults(config)
class LandAssetView(AssetMasterView):
"""
@ -139,6 +152,7 @@ class LandAssetView(AssetMasterView):
route_prefix = "land_assets"
url_prefix = "/assets/land"
farmos_bundle = "land"
farmos_refurl_path = "/assets/land"
grid_columns = [
@ -159,8 +173,8 @@ class LandAssetView(AssetMasterView):
"is_location",
"is_fixed",
"archived",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):

View file

@ -26,6 +26,7 @@ Base views for Logs
from collections import OrderedDict
import colander
from webhelpers2.html import tags, HTML
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session
@ -33,18 +34,8 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType, Log
from wuttafarm.web.forms.schema import LogAssetRefs
def get_log_type_enum(config):
app = config.get_app()
model = app.model
session = Session()
log_types = OrderedDict()
query = session.query(model.LogType).order_by(model.LogType.name)
for log_type in query:
log_types[log_type.drupal_id] = log_type.name
return log_types
from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum
class LogTypeView(WuttaFarmMasterView):
@ -70,8 +61,8 @@ class LogTypeView(WuttaFarmMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):
@ -99,85 +90,17 @@ class LogTypeView(WuttaFarmMasterView):
return buttons
class LogView(WuttaFarmMasterView):
"""
Master view for All Logs
"""
model_class = Log
route_prefix = "log"
url_prefix = "/logs"
farmos_refurl_path = "/logs"
viewable = False
creatable = False
editable = False
deletable = False
model_is_versioned = False
labels = {
"message": "Log Name",
}
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"log_type",
"assets",
"location",
"quantity",
"groups",
"is_group_assignment",
]
sort_defaults = ("timestamp", "desc")
filter_defaults = {
"message": {"active": True, "verb": "contains"},
}
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
# timestamp
g.set_renderer("timestamp", "date")
g.set_link("timestamp")
# message
g.set_link("message")
# log_type
g.set_enum("log_type", get_log_type_enum(self.config))
# assets
g.set_renderer("assets", self.render_assets_for_grid)
# view action links to final log record
def log_url(log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
g.add_action("view", icon="eye", url=log_url)
def render_assets_for_grid(self, log, field, value):
assets = [str(a.asset) for a in log._assets]
return ", ".join(assets)
class LogMasterView(WuttaFarmMasterView):
"""
Base class for Asset master views
"""
farmos_entity_type = "log"
labels = {
"message": "Log Name",
"owners": "Owner",
"locations": "Location",
"quantities": "Quantity",
}
grid_columns = [
@ -186,8 +109,8 @@ class LogMasterView(WuttaFarmMasterView):
"timestamp",
"message",
"assets",
# "location",
"quantity",
"locations",
"quantities",
"is_group_assignment",
"owners",
]
@ -196,21 +119,25 @@ class LogMasterView(WuttaFarmMasterView):
filter_defaults = {
"message": {"active": True, "verb": "contains"},
"status": {"active": True, "verb": "not_equal", "value": "abandoned"},
}
form_fields = [
"message",
"timestamp",
"assets",
"location",
"quantity",
"groups",
"locations",
"quantities",
"notes",
"status",
"log_type",
"owners",
"is_movement",
"is_group_assignment",
"farmos_uuid",
"quick",
"drupal_id",
"farmos_uuid",
]
def get_query(self, session=None):
@ -218,16 +145,26 @@ class LogMasterView(WuttaFarmMasterView):
model = self.app.model
model_class = self.get_model_class()
session = session or self.Session()
return session.query(model_class).join(model.Log)
query = session.query(model_class)
if model_class is not model.Log:
query = query.join(model.Log)
return query
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
model = self.app.model
enum = self.app.enum
# status
g.set_enum("status", enum.LOG_STATUS)
g.set_sorter("status", model.Log.status)
g.set_filter("status", model.Log.status)
g.set_filter(
"status",
model.Log.status,
verbs=["equal", "not_equal"],
choices=enum.LOG_STATUS,
)
# drupal_id
g.set_label("drupal_id", "ID", column_only=True)
@ -248,13 +185,68 @@ class LogMasterView(WuttaFarmMasterView):
# assets
g.set_renderer("assets", self.render_assets_for_grid)
# groups
g.set_renderer("groups", self.render_assets_for_grid)
# locations
g.set_renderer("locations", self.render_assets_for_grid)
# quantities
g.set_renderer("quantities", self.render_quantities_for_grid)
# is_group_assignment
g.set_renderer("is_group_assignment", "boolean")
g.set_sorter("is_group_assignment", model.Log.is_group_assignment)
g.set_filter("is_group_assignment", model.Log.is_group_assignment)
# owners
g.set_label("owners", "Owner")
g.set_renderer("owners", self.render_owners_for_grid)
def render_assets_for_grid(self, log, field, value):
return ", ".join([a.asset.asset_name for a in log.log._assets])
assets = getattr(log, field)
if self.farmos_style_grid_links:
links = []
for asset in assets:
url = self.request.route_url(
f"{asset.asset_type}_assets.view", uuid=asset.uuid
)
links.append(tags.link_to(str(asset), url))
return ", ".join(links)
return ", ".join([str(a) for a in assets])
def render_quantities_for_grid(self, log, field, value):
quantities = getattr(log, field) or []
items = []
for qty in quantities:
items.append(HTML.tag("li", c=qty.render_as_text(self.config)))
return HTML.tag("ul", c=items)
def render_owners_for_grid(self, log, field, value):
if self.farmos_style_grid_links:
links = []
for user in log.owners:
url = self.request.route_url("users.view", uuid=user.uuid)
links.append(tags.link_to(user.username, url))
return ", ".join(links)
return ", ".join([user.username for user in log.owners])
def grid_row_class(self, log, data, i):
if log.status == "pending":
return "has-background-warning"
if log.status == "abandoned":
return "has-background-danger"
return None
def configure_form(self, form):
f = form
super().configure_form(f)
enum = self.app.enum
session = self.Session()
log = f.model_instance
# timestamp
@ -267,12 +259,25 @@ class LogMasterView(WuttaFarmMasterView):
if self.creating or self.editing:
f.remove("assets") # TODO: need to support this
else:
f.set_node("assets", LogAssetRefs(self.request))
f.set_default("assets", [a.asset_uuid for a in log.log._assets])
f.set_node("assets", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("assets", log.assets)
# location
# groups
if self.creating or self.editing:
f.remove("location") # TODO: need to support this
f.remove("groups") # TODO: need to support this
else:
f.set_node("groups", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("groups", log.groups)
# locations
if self.creating or self.editing:
f.remove("locations") # TODO: need to support this
else:
f.set_node("locations", AssetRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("locations", log.locations)
# log_type
if self.creating:
@ -280,13 +285,19 @@ class LogMasterView(WuttaFarmMasterView):
else:
f.set_node(
"log_type",
WuttaDictEnum(self.request, get_log_type_enum(self.config)),
WuttaDictEnum(
self.request, get_log_type_enum(self.config, session=session)
),
)
f.set_readonly("log_type")
# quantity
# quantities
if self.creating or self.editing:
f.remove("quantity") # TODO: need to support this
f.remove("quantities") # TODO: need to support this
else:
f.set_node("quantities", LogQuantityRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("quantities", log.quantities)
# notes
f.set_widget("notes", "notes")
@ -294,13 +305,23 @@ class LogMasterView(WuttaFarmMasterView):
# owners
if self.creating or self.editing:
f.remove("owners") # TODO: need to support this
else:
f.set_node("owners", OwnerRefs(self.request))
# nb. must explicity declare value for non-standard field
f.set_default("owners", log.owners)
# status
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
# is_movement
f.set_node("is_movement", colander.Boolean())
# is_group_assignment
f.set_node("is_group_assignment", colander.Boolean())
# quick
f.set_readonly("quick") # TODO
def objectify(self, form):
log = super().objectify(form)
@ -333,6 +354,68 @@ class LogMasterView(WuttaFarmMasterView):
return buttons
def get_version_joins(self):
"""
We override this to declare the relationship between the
view's data model (which is some type of log table) and the
canonical ``Log`` model, so the revision history views include
transactions which reference either version table.
See also parent method,
:meth:`~wuttaweb:wuttaweb.views.master.MasterView.get_version_joins()`
"""
model = self.app.model
return super().get_version_joins() + [
model.Log,
(model.LogAsset, "log_uuid", "uuid"),
]
class AllLogView(LogMasterView):
"""
Master view for All Logs
"""
model_class = Log
route_prefix = "log"
url_prefix = "/logs"
farmos_refurl_path = "/logs"
viewable = False
creatable = False
editable = False
deletable = False
model_is_versioned = False
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"log_type",
"assets",
"locations",
"quantities",
"groups",
"is_group_assignment",
"owners",
]
def configure_grid(self, grid):
g = grid
super().configure_grid(g)
session = self.Session()
# log_type
g.set_enum("log_type", get_log_type_enum(self.config, session=session))
# view action links to final log record
def log_url(log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid)
g.add_action("view", icon="eye", url=log_url)
def defaults(config, **kwargs):
base = globals()
@ -340,8 +423,8 @@ def defaults(config, **kwargs):
LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
LogTypeView.defaults(config)
LogView = kwargs.get("LogView", base["LogView"])
LogView.defaults(config)
AllLogView = kwargs.get("AllLogView", base["AllLogView"])
AllLogView.defaults(config)
def includeme(config):

View file

@ -36,6 +36,7 @@ class ActivityLogView(LogMasterView):
route_prefix = "logs_activity"
url_prefix = "/logs/activity"
farmos_bundle = "activity"
farmos_refurl_path = "/logs/activity"

View file

@ -36,8 +36,19 @@ class HarvestLogView(LogMasterView):
route_prefix = "logs_harvest"
url_prefix = "/logs/harvest"
farmos_bundle = "harvest"
farmos_refurl_path = "/logs/harvest"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"assets",
"quantities",
"owners",
]
def defaults(config, **kwargs):
base = globals()

View file

@ -36,8 +36,29 @@ class MedicalLogView(LogMasterView):
route_prefix = "logs_medical"
url_prefix = "/logs/medical"
farmos_bundle = "medical"
farmos_refurl_path = "/logs/medical"
labels = {
"vet": "Veterinarian",
}
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"assets",
"vet",
"owners",
]
def configure_form(self, f):
super().configure_form(f)
# vet
f.fields.insert_after("timestamp", "vet")
def defaults(config, **kwargs):
base = globals()

View file

@ -36,8 +36,21 @@ class ObservationLogView(LogMasterView):
route_prefix = "logs_observation"
url_prefix = "/logs/observation"
farmos_bundle = "observation"
farmos_refurl_path = "/logs/observation"
grid_columns = [
"status",
"drupal_id",
"timestamp",
"message",
"assets",
"locations",
"groups",
"is_group_assignment",
"owners",
]
def defaults(config, **kwargs):
base = globals()

View file

@ -27,7 +27,7 @@ from webhelpers2.html import tags
from wuttaweb.views import MasterView
from wuttafarm.web.util import use_farmos_style_grid_links
from wuttafarm.web.util import use_farmos_style_grid_links, get_farmos_client_for_user
class WuttaFarmMasterView(MasterView):
@ -36,6 +36,8 @@ class WuttaFarmMasterView(MasterView):
"""
farmos_refurl_path = None
farmos_entity_type = None
farmos_bundle = None
labels = {
"farmos_uuid": "farmOS UUID",
@ -104,5 +106,42 @@ class WuttaFarmMasterView(MasterView):
f.set_readonly("drupal_id")
def persist(self, obj, session=None):
# save per usual
super().persist(obj, session)
self.app.auto_sync_to_farmos(obj, require=False)
# maybe also sync change to farmOS
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(obj, client=client, require=False)
def get_farmos_entity_type(self):
if self.farmos_entity_type:
return self.farmos_entity_type
raise NotImplementedError(
f"must define {self.__class__.__name__}.farmos_entity_type"
)
def get_farmos_bundle(self):
if self.farmos_bundle:
return self.farmos_bundle
raise NotImplementedError(
f"must define {self.__class__.__name__}.farmos_bundle"
)
def delete_instance(self, obj):
# save farmOS UUID if we need it
farmos_uuid = None
if hasattr(obj, "farmos_uuid") and self.app.is_farmos_mirror():
farmos_uuid = obj.farmos_uuid
# delete per usual
super().delete_instance(obj)
# maybe delete from farmOS also
if farmos_uuid:
entity_type = self.get_farmos_entity_type()
bundle = self.get_farmos_bundle()
client = get_farmos_client_for_user(self.request)
client.resource.delete(entity_type, bundle, farmos_uuid)

View file

@ -23,12 +23,16 @@
Master view for Plants
"""
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
from wuttafarm.db.model import PlantType, PlantAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import PlantTypeRefs
from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
class PlantTypeView(AssetTypeMasterView):
@ -40,6 +44,8 @@ class PlantTypeView(AssetTypeMasterView):
route_prefix = "plant_types"
url_prefix = "/plant-types"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "plant_type"
farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview"
grid_columns = [
@ -56,8 +62,8 @@ class PlantTypeView(AssetTypeMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
has_rows = True
@ -98,6 +104,19 @@ class PlantTypeView(AssetTypeMasterView):
return buttons
def delete(self):
plant_type = self.get_instance()
if plant_type._plant_assets:
self.request.session.flash(
"Cannot delete plant type which is still referenced by plant assets.",
"warning",
)
url = self.get_action_url("view", plant_type)
return self.redirect(self.request.get_referrer(default=url))
return super().delete()
def get_row_grid_data(self, plant_type):
model = self.app.model
session = self.Session()
@ -126,6 +145,55 @@ class PlantTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, plant, i):
return self.request.route_url("plant_assets.view", uuid=plant.uuid)
def ajax_create(self):
"""
AJAX view to create a new plant type.
"""
model = self.app.model
session = self.Session()
data = get_form_data(self.request)
name = data.get("name")
if not name:
return {"error": "Name is required"}
plant_type = model.PlantType(name=name)
session.add(plant_type)
session.flush()
if self.app.is_farmos_mirror():
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_to_farmos(plant_type, client=client)
return {
"uuid": plant_type.uuid.hex,
"name": plant_type.name,
"farmos_uuid": plant_type.farmos_uuid.hex,
"drupal_id": plant_type.drupal_id,
}
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._plant_type_defaults(config)
@classmethod
def _plant_type_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
url_prefix = cls.get_url_prefix()
# ajax_create
config.add_route(f"{route_prefix}.ajax_create", f"{url_prefix}/ajax/new")
config.add_view(
cls,
attr="ajax_create",
route_name=f"{route_prefix}.ajax_create",
permission=f"{permission_prefix}.create",
renderer="json",
)
class PlantAssetView(AssetMasterView):
"""
@ -136,6 +204,7 @@ class PlantAssetView(AssetMasterView):
route_prefix = "plant_assets"
url_prefix = "/assets/plant"
farmos_bundle = "plant"
farmos_refurl_path = "/assets/plant"
labels = {
@ -158,8 +227,8 @@ class PlantAssetView(AssetMasterView):
"notes",
"asset_type",
"archived",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",
@ -171,10 +240,20 @@ class PlantAssetView(AssetMasterView):
super().configure_grid(g)
# plant_types
g.set_renderer("plant_types", self.render_grid_plant_types)
g.set_renderer("plant_types", self.render_plant_types_for_grid)
def render_grid_plant_types(self, plant, field, value):
return ", ".join([t.plant_type.name for t in plant._plant_types])
def render_plant_types_for_grid(self, plant, field, value):
plant_types = plant._plant_types
if self.farmos_style_grid_links:
links = []
for plant_type in plant_types:
plant_type = plant_type.plant_type
url = self.request.route_url("plant_types.view", uuid=plant_type.uuid)
links.append(tags.link_to(str(plant_type), url))
return ", ".join(links)
return ", ".join([str(pt.plant_type) for pt in plant_types])
def configure_form(self, form):
f = form
@ -183,18 +262,38 @@ class PlantAssetView(AssetMasterView):
plant = f.model_instance
# plant_types
if self.creating or self.editing:
f.remove("plant_types") # TODO: add support for this
else:
f.set_node("plant_types", PlantTypeRefs(self.request))
f.set_default(
"plant_types", [t.plant_type_uuid for t in plant._plant_types]
)
f.set_node("plant_types", PlantTypeRefs(self.request))
if not self.creating:
# nb. must explcitly declare value for non-standard field
f.set_default("plant_types", [pt.uuid for pt in plant.plant_types])
# season
if self.creating or self.editing:
f.remove("season") # TODO: add support for this
def objectify(self, form):
model = self.app.model
session = self.Session()
plant = super().objectify(form)
data = form.validated
current = [pt.uuid for pt in plant.plant_types]
desired = data["plant_types"]
for uuid in desired:
if uuid not in current:
plant_type = session.get(model.PlantType, uuid)
assert plant_type
plant.plant_types.append(plant_type)
for uuid in current:
if uuid not in desired:
plant_type = session.get(model.PlantType, uuid)
assert plant_type
plant.plant_types.remove(plant_type)
return plant
def defaults(config, **kwargs):
base = globals()

View file

@ -66,8 +66,8 @@ class QuantityTypeView(WuttaFarmMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):
@ -119,8 +119,8 @@ class QuantityMasterView(WuttaFarmMasterView):
"value",
"units",
"label",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def get_query(self, session=None):
@ -248,7 +248,7 @@ class QuantityMasterView(WuttaFarmMasterView):
return buttons
class QuantityView(QuantityMasterView):
class AllQuantityView(QuantityMasterView):
"""
Master view for All Quantities
"""
@ -280,8 +280,8 @@ def defaults(config, **kwargs):
QuantityTypeView = kwargs.get("QuantityTypeView", base["QuantityTypeView"])
QuantityTypeView.defaults(config)
QuantityView = kwargs.get("QuantityView", base["QuantityView"])
QuantityView.defaults(config)
AllQuantityView = kwargs.get("AllQuantityView", base["AllQuantityView"])
AllQuantityView.defaults(config)
StandardQuantityView = kwargs.get(
"StandardQuantityView", base["StandardQuantityView"]

View file

@ -27,4 +27,9 @@ from .base import QuickFormView
def includeme(config):
# perm group
config.add_wutta_permission_group("quick", "Quick Forms", overwrite=False)
# quick form views
config.include("wuttafarm.web.views.quick.eggs")

View file

@ -29,7 +29,7 @@ from pyramid.renderers import render_to_response
from wuttaweb.views import View
from wuttafarm.web.util import save_farmos_oauth2_token
from wuttafarm.web.util import get_farmos_client_for_user
log = logging.getLogger(__name__)
@ -42,7 +42,7 @@ class QuickFormView(View):
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.farmos_client = self.get_farmos_client()
self.farmos_client = get_farmos_client_for_user(self.request)
self.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client)
@ -127,22 +127,6 @@ class QuickFormView(View):
def get_template_context(self, context):
return context
def get_farmos_client(self):
token = self.request.session.get("farmos.oauth2.token")
if not token:
raise self.forbidden()
# nb. must give a *copy* of the token to farmOS client, since
# it will mutate it in-place and we don't want that to happen
# for our original copy in the user session. (otherwise the
# auto-refresh will not work correctly for subsequent calls.)
token = dict(token)
def token_updater(token):
save_farmos_oauth2_token(self.request, token)
return self.app.get_farmos_client(token=token, token_updater=token_updater)
@classmethod
def defaults(cls, config):
cls._defaults(config)
@ -151,6 +135,10 @@ class QuickFormView(View):
def _defaults(cls, config):
route_slug = cls.get_route_slug()
url_slug = cls.get_url_slug()
form_title = cls.get_form_title()
config.add_wutta_permission("quick", f"quick.{route_slug}", form_title)
config.add_route(f"quick.{route_slug}", f"/quick/{url_slug}")
config.add_view(cls, route_name=f"quick.{route_slug}")
config.add_view(
cls, route_name=f"quick.{route_slug}", permission=f"quick.{route_slug}"
)

View file

@ -34,6 +34,7 @@ from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.quick import QuickFormView
from wuttafarm.web.util import get_farmos_client_for_user
class EggsQuickForm(QuickFormView):
@ -118,8 +119,11 @@ class EggsQuickForm(QuickFormView):
if self.app.is_farmos_mirror():
quantity = json.loads(response["create-quantity"]["body"])
self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity")
self.app.auto_sync_from_farmos(log["data"], "HarvestLog")
client = get_farmos_client_for_user(self.request)
self.app.auto_sync_from_farmos(
quantity["data"], "StandardQuantity", client=client
)
self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client)
return log
@ -192,6 +196,7 @@ class EggsQuickForm(QuickFormView):
"type": "log--harvest",
"attributes": {
"name": f"Collected {data['count']} {unit_name}",
"timestamp": self.app.localtime(data["timestamp"]).timestamp(),
"notes": notes,
"quick": ["eggs"],
},

View file

@ -57,10 +57,21 @@ class AppInfoView(base.AppInfoView):
return info
def configure_get_simple_settings(self): # pylint: disable=empty-docstring
farmos = self.app.get_farmos_handler()
simple_settings = super().configure_get_simple_settings()
simple_settings.extend(
[
{"name": "farmos.url.base"},
{
"name": "farmos.url.base",
},
{
"name": "farmos.oauth2.client_id",
"default": farmos.get_oauth2_client_id(),
},
{
"name": "farmos.oauth2.scope",
"default": farmos.get_oauth2_scope(),
},
{
"name": f"{self.app.appname}.farmos_integration_mode",
"default": self.app.get_farmos_integration_mode(),

View file

@ -50,8 +50,8 @@ class StructureTypeView(AssetTypeMasterView):
form_fields = [
"name",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
has_rows = True
@ -128,6 +128,19 @@ class StructureTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, structure, i):
return self.request.route_url("structure_assets.view", uuid=structure.uuid)
@classmethod
def defaults(cls, config):
""" """
wutta_config = config.registry.settings.get("wutta_config")
app = wutta_config.get_app()
if app.is_farmos_mirror():
cls.creatable = False
cls.editable = False
cls.deletable = False
cls._defaults(config)
class StructureAssetView(AssetMasterView):
"""
@ -138,6 +151,7 @@ class StructureAssetView(AssetMasterView):
route_prefix = "structure_assets"
url_prefix = "/asset/structures"
farmos_bundle = "structure"
farmos_refurl_path = "/assets/structure"
grid_columns = [
@ -146,6 +160,7 @@ class StructureAssetView(AssetMasterView):
"asset_name",
"structure_type",
"parents",
"owners",
"archived",
]
@ -158,8 +173,8 @@ class StructureAssetView(AssetMasterView):
"is_location",
"is_fixed",
"archived",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
"thumbnail_url",
"image_url",
"thumbnail",

View file

@ -59,6 +59,19 @@ class MeasureView(WuttaFarmMasterView):
# name
g.set_link("name")
@classmethod
def defaults(cls, config):
""" """
wutta_config = config.registry.settings.get("wutta_config")
app = wutta_config.get_app()
if app.is_farmos_mirror():
cls.creatable = False
cls.editable = False
cls.deletable = False
cls._defaults(config)
class UnitView(WuttaFarmMasterView):
"""
@ -69,6 +82,8 @@ class UnitView(WuttaFarmMasterView):
route_prefix = "units"
url_prefix = "/units"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "unit"
farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview"
grid_columns = [
@ -85,8 +100,8 @@ class UnitView(WuttaFarmMasterView):
form_fields = [
"name",
"description",
"farmos_uuid",
"drupal_id",
"farmos_uuid",
]
def configure_grid(self, grid):

View file

@ -55,11 +55,13 @@ class UserView(base.UserView):
# farmos_uuid
if not self.creating:
f.fields.append("farmos_uuid")
f.set_readonly("farmos_uuid")
f.set_default("farmos_uuid", user.farmos_uuid or colander.null)
# drupal_id
if not self.creating:
f.fields.append("drupal_id")
f.set_readonly("drupal_id")
f.set_default("drupal_id", user.drupal_id or colander.null)
def get_xref_buttons(self, user):