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/) 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). 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) ## v0.6.0 (2026-02-25)
### Feat ### Feat

View file

@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttaFarm" name = "WuttaFarm"
version = "0.6.0" version = "0.7.0"
description = "Web app to integrate with and extend farmOS" description = "Web app to integrate with and extend farmOS"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@ -34,7 +34,7 @@ dependencies = [
"pyramid_exclog", "pyramid_exclog",
"uvicorn[standard]", "uvicorn[standard]",
"WuttaSync", "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_auth_handler_spec = "wuttafarm.auth:WuttaFarmAuthHandler"
default_install_handler_spec = "wuttafarm.install:WuttaFarmInstallHandler" 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): def get_farmos_handler(self):
""" """
Get the configured farmOS integration handler. Get the configured farmOS integration handler.
@ -136,7 +151,7 @@ class WuttaFarmAppHandler(base.AppHandler):
factory = self.load_object(spec) factory = self.load_object(spec)
return factory(self.config, farmos_client) 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. 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 :param obj: Any data object in WuttaFarm, e.g. AnimalAsset
instance. 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 :param require: If true, this will *require* the export
handler to support objects of the given type. If false, handler to support objects of the given type. If false,
then nothing will happen / export is silently skipped when then nothing will happen / export is silently skipped when
@ -162,14 +180,12 @@ class WuttaFarmAppHandler(base.AppHandler):
return return
# nb. begin txn to establish the API client # nb. begin txn to establish the API client
# TODO: should probably use current user oauth2 token instead handler.begin_target_transaction(client)
# of always making a new one here, which is what happens IIUC
handler.begin_target_transaction()
importer = handler.get_importer(model_name, caches_target=False) importer = handler.get_importer(model_name, caches_target=False)
normal = importer.normalize_source_object(obj) normal = importer.normalize_source_object(obj)
importer.process_data(source_data=[normal]) 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. 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, :param model_name': Model name for the importer to use,
e.g. ``"AnimalAsset"``. 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 :param require: If true, this will *require* the import
handler to support objects of the given type. If false, handler to support objects of the given type. If false,
then nothing will happen / import is silently skipped when then nothing will happen / import is silently skipped when
@ -191,9 +210,7 @@ class WuttaFarmAppHandler(base.AppHandler):
return return
# nb. begin txn to establish the API client # nb. begin txn to establish the API client
# TODO: should probably use current user oauth2 token instead handler.begin_source_transaction(client)
# of always making a new one here, which is what happens IIUC
handler.begin_source_transaction()
with self.short_session(commit=True) as session: with self.short_session(commit=True) as session:
handler.target_session = session handler.target_session = session
importer = handler.get_importer(model_name, caches_target=False) 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" f"{config.appname}.app.handler", "wuttafarm.app:WuttaFarmAppHandler"
) )
# web app menu # web app stuff
config.setdefault( config.setdefault(
f"{config.appname}.web.menus.handler.default_spec", f"{config.appname}.web.menus.handler.default_spec",
"wuttafarm.web.menus:WuttaFarmMenuHandler", "wuttafarm.web.menus:WuttaFarmMenuHandler",
) )
config.setdefault("wuttaweb.grids.default_pagesize", "50")
# web app libcache # web app libcache
# config.setdefault('wuttaweb.static_libcache.module', 'wuttafarm.web.static') # 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_animal import AnimalType, AnimalAsset
from .asset_group import GroupAsset from .asset_group import GroupAsset
from .asset_plant import PlantType, PlantAsset, PlantAssetPlantType 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_activity import ActivityLog
from .log_harvest import HarvestLog from .log_harvest import HarvestLog
from .log_medical import MedicalLog from .log_medical import MedicalLog

View file

@ -26,6 +26,7 @@ Model definition for Asset Types
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model from wuttjamaican.db import model
@ -186,6 +187,25 @@ class Asset(model.Base):
cascade_backrefs=False, 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): def __str__(self):
return self.asset_name or "" return self.asset_name or ""
@ -196,7 +216,12 @@ class AssetMixin:
@declared_attr @declared_attr
def asset(cls): def asset(cls):
return orm.relationship(Asset) return orm.relationship(
Asset,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self): def __str__(self):
return self.asset_name or "" 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", "thumbnail_url")
Asset.make_proxy(subclass, "asset", "image_url") Asset.make_proxy(subclass, "asset", "image_url")
Asset.make_proxy(subclass, "asset", "archived") Asset.make_proxy(subclass, "asset", "archived")
Asset.make_proxy(subclass, "asset", "parents")
Asset.make_proxy(subclass, "asset", "owners")
class EggMixin: class EggMixin:
@ -250,3 +277,27 @@ class AssetParent(model.Base):
Asset, Asset,
foreign_keys=parent_uuid, 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( name = sa.Column(
sa.String(length=100), sa.String(length=100),
nullable=False, nullable=False,
unique=True,
doc=""" doc="""
Name of the animal type. 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): def __str__(self):
return self.name or "" return self.name or ""
@ -103,6 +110,7 @@ class AnimalAsset(AssetMixin, EggMixin, model.Base):
doc=""" doc="""
Reference to the animal type. Reference to the animal type.
""", """,
back_populates="animal_assets",
) )
birthdate = sa.Column( birthdate = sa.Column(

View file

@ -88,10 +88,10 @@ class LandAsset(AssetMixin, model.Base):
__wutta_hint__ = { __wutta_hint__ = {
"model_title": "Land Asset", "model_title": "Land Asset",
"model_title_plural": "Land Assets", "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") land_type = orm.relationship(LandType, back_populates="land_assets")

View file

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

View file

@ -26,6 +26,7 @@ Model definition for Logs
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.associationproxy import association_proxy
from wuttjamaican.db import model 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( status = sa.Column(
sa.String(length=20), sa.String(length=20),
nullable=False, 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( farmos_uuid = sa.Column(
model.UUID(), model.UUID(),
nullable=True, 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): def __str__(self):
return self.message or "" return self.message or ""
@ -165,7 +254,12 @@ class LogMixin:
@declared_attr @declared_attr
def log(cls): def log(cls):
return orm.relationship(Log) return orm.relationship(
Log,
single_parent=True,
cascade="all, delete-orphan",
cascade_backrefs=False,
)
def __str__(self): def __str__(self):
return self.message or "" 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", "log_type")
Log.make_proxy(subclass, "log", "message") Log.make_proxy(subclass, "log", "message")
Log.make_proxy(subclass, "log", "timestamp") 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", "status")
Log.make_proxy(subclass, "log", "notes") 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): class LogAsset(model.Base):
@ -203,3 +305,99 @@ class LogAsset(model.Base):
"Asset", "Asset",
foreign_keys=asset_uuid, 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 Model definition for Medical Logs
""" """
import sqlalchemy as sa
from wuttjamaican.db import model from wuttjamaican.db import model
from wuttafarm.db.model.log import LogMixin, add_log_proxies from wuttafarm.db.model.log import LogMixin, add_log_proxies
@ -41,5 +43,13 @@ class MedicalLog(LogMixin, model.Base):
"farmos_log_type": "medical", "farmos_log_type": "medical",
} }
vet = sa.Column(
sa.String(length=100),
nullable=True,
doc="""
Name of the veterinarian, if applicable.
""",
)
add_log_proxies(MedicalLog) add_log_proxies(MedicalLog)

View file

@ -94,3 +94,9 @@ class FarmOSHandler(GenericHandler):
return f"{base}/{path}" return f"{base}/{path}"
return base 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 return payload
class PlantTypeImporter(ToFarmOSTaxonomy):
model_title = "PlantType"
farmos_taxonomy_type = "plant_type"
class PlantAssetImporter(ToFarmOSAsset): class PlantAssetImporter(ToFarmOSAsset):
model_title = "PlantAsset" model_title = "PlantAsset"
@ -452,7 +458,12 @@ class ToFarmOSLog(ToFarmOS):
supported_fields = [ supported_fields = [
"uuid", "uuid",
"name", "name",
"timestamp",
"is_movement",
"is_group_assignment",
"status",
"notes", "notes",
"quick",
] ]
def get_target_objects(self, **kwargs): def get_target_objects(self, **kwargs):
@ -507,7 +518,12 @@ class ToFarmOSLog(ToFarmOS):
return { return {
"uuid": UUID(log["id"]), "uuid": UUID(log["id"]),
"name": log["attributes"]["name"], "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, "notes": notes,
"quick": log["attributes"]["quick"],
} }
def get_log_payload(self, source_data): def get_log_payload(self, source_data):
@ -515,8 +531,18 @@ class ToFarmOSLog(ToFarmOS):
attrs = {} attrs = {}
if "name" in self.fields: if "name" in self.fields:
attrs["name"] = source_data["name"] 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: if "notes" in self.fields:
attrs["notes"] = {"value": source_data["notes"]} attrs["notes"] = {"value": source_data["notes"]}
if "quick" in self.fields:
attrs["quick"] = {"value": source_data["quick"]}
payload = {"attributes": attrs} payload = {"attributes": attrs}
@ -540,6 +566,32 @@ class MedicalLogImporter(ToFarmOSLog):
model_title = "MedicalLog" model_title = "MedicalLog"
farmos_log_type = "medical" 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): class ObservationLogImporter(ToFarmOSLog):

View file

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

View file

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

View file

@ -84,6 +84,40 @@ class Normalizer(GenericHandler):
self._farmos_units = units self._farmos_units = units
return self._farmos_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={}): def normalize_farmos_log(self, log, included={}):
if timestamp := log["attributes"]["timestamp"]: if timestamp := log["attributes"]["timestamp"]:
@ -96,8 +130,12 @@ class Normalizer(GenericHandler):
log_type_object = {} log_type_object = {}
log_type_uuid = None log_type_uuid = None
asset_objects = [] asset_objects = []
group_objects = []
group_uuids = []
quantity_objects = [] quantity_objects = []
quantity_uuids = [] quantity_uuids = []
location_objects = []
location_uuids = []
owner_objects = [] owner_objects = []
owner_uuids = [] owner_uuids = []
if relationships := log.get("relationships"): if relationships := log.get("relationships"):
@ -132,6 +170,54 @@ class Normalizer(GenericHandler):
) )
asset_objects.append(asset_object) 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"): if quantities := relationships.get("quantity"):
for quantity in quantities["data"]: for quantity in quantities["data"]:
quantity_uuid = quantity["id"] quantity_uuid = quantity["id"]
@ -188,12 +274,19 @@ class Normalizer(GenericHandler):
"name": log["attributes"]["name"], "name": log["attributes"]["name"],
"timestamp": timestamp, "timestamp": timestamp,
"assets": asset_objects, "assets": asset_objects,
"groups": group_objects,
"group_uuids": group_uuids,
"quantities": quantity_objects, "quantities": quantity_objects,
"quantity_uuids": quantity_uuids, "quantity_uuids": quantity_uuids,
"is_group_assignment": log["attributes"]["is_group_assignment"], "is_group_assignment": log["attributes"]["is_group_assignment"],
"is_movement": log["attributes"]["is_movement"],
"quick": log["attributes"]["quick"], "quick": log["attributes"]["quick"],
"status": log["attributes"]["status"], "status": log["attributes"]["status"],
"notes": notes, "notes": notes,
"locations": location_objects,
"location_uuids": location_uuids,
"owners": owner_objects, "owners": owner_objects,
"owner_uuids": owner_uuids, "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", "wuttaweb:templates",
], ],
) )
settings.setdefault(
"pyramid_deform.template_search_path",
" ".join(
[
"wuttafarm.web:templates/deform",
"wuttaweb:templates/deform",
]
),
)
# make config objects # make config objects
wutta_config = base.make_wutta_config(settings) wutta_config = base.make_wutta_config(settings)

View file

@ -27,6 +27,7 @@ import json
import colander import colander
from wuttaweb.db import Session
from wuttaweb.forms.schema import ObjectRef, WuttaSet from wuttaweb.forms.schema import ObjectRef, WuttaSet
from wuttaweb.forms.widgets import NotesWidget from wuttaweb.forms.widgets import NotesWidget
@ -55,6 +56,12 @@ class AnimalTypeRef(ObjectRef):
animal_type = obj animal_type = obj
return self.request.route_url("animal_types.view", uuid=animal_type.uuid) 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): class LogQuick(WuttaSet):
@ -185,25 +192,6 @@ class FarmOSQuantityRefs(WuttaSet):
return FarmOSQuantityRefsWidget(**kwargs) 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): class FarmOSPlantTypes(colander.SchemaType):
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -255,13 +243,23 @@ class PlantTypeRefs(WuttaSet):
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if not appstruct: if not appstruct:
appstruct = [] return colander.null
uuids = [u.hex for u in appstruct]
return json.dumps(uuids) return [uuid.hex for uuid in appstruct]
def widget_maker(self, **kwargs): def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import PlantTypeRefsWidget 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) return PlantTypeRefsWidget(self.request, **kwargs)
@ -366,21 +364,55 @@ class AssetParentRefs(WuttaSet):
return AssetParentRefsWidget(self.request, **kwargs) return AssetParentRefsWidget(self.request, **kwargs)
class LogAssetRefs(WuttaSet): class AssetRefs(WuttaSet):
""" """
Schema type for Assets field (on a Log record) Schema type for Assets field (on a Log record)
""" """
def serialize(self, node, appstruct): def serialize(self, node, appstruct):
if not appstruct: if not appstruct:
appstruct = [] return colander.null
uuids = [u.hex for u in appstruct]
return json.dumps(uuids) return {asset.uuid for asset in appstruct}
def widget_maker(self, **kwargs): 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): class Notes(colander.String):

View file

@ -26,10 +26,10 @@ Custom form widgets for WuttaFarm
import json import json
import colander 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 webhelpers2.html import HTML, tags
from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget from wuttaweb.forms.widgets import WuttaCheckboxChoiceWidget, ObjectRefWidget
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.util import render_quantity_objects from wuttafarm.web.util import render_quantity_objects
@ -228,33 +228,6 @@ class FarmOSUnitRefWidget(Widget):
return super().serialize(field, cstruct, **kw) 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): class FarmOSPlantTypesWidget(Widget):
""" """
Widget to display a farmOS "plant types" field. Widget to display a farmOS "plant types" field.
@ -285,22 +258,40 @@ class FarmOSPlantTypesWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class PlantTypeRefsWidget(WuttaCheckboxChoiceWidget): class PlantTypeRefsWidget(Widget):
""" """
Widget for Plant Types field (on a Plant Asset). 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): def serialize(self, field, cstruct, **kw):
""" """ """ """
model = self.app.model model = self.app.model
session = Session() session = Session()
readonly = kw.get("readonly", self.readonly) if cstruct in (colander.null, None):
if readonly: cstruct = ()
plant_types = []
for uuid in json.loads(cstruct): if readonly := kw.get("readonly", self.readonly):
plant_type = session.get(model.PlantType, uuid) items = []
plant_types.append(
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( HTML.tag(
"li", "li",
c=tags.link_to( 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): class StructureWidget(Widget):
@ -372,6 +388,11 @@ class UsersWidget(Widget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
##############################
# native data widgets
##############################
class AssetParentRefsWidget(WuttaCheckboxChoiceWidget): class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
""" """
Widget for Parents field which references assets. Widget for Parents field which references assets.
@ -403,9 +424,9 @@ class AssetParentRefsWidget(WuttaCheckboxChoiceWidget):
return super().serialize(field, cstruct, **kw) 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): def serialize(self, field, cstruct, **kw):
@ -416,7 +437,7 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly) readonly = kw.get("readonly", self.readonly)
if readonly: if readonly:
assets = [] assets = []
for uuid in json.loads(cstruct): for uuid in cstruct or []:
asset = session.get(model.Asset, uuid) asset = session.get(model.Asset, uuid)
assets.append( assets.append(
HTML.tag( HTML.tag(
@ -432,3 +453,85 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
return HTML.tag("ul", c=assets) return HTML.tag("ul", c=assets)
return super().serialize(field, cstruct, **kw) 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", "title": "Eggs",
"route": "quick.eggs", "route": "quick.eggs",
# "perm": "assets.list", "perm": "quick.eggs",
}, },
], ],
} }

View file

@ -14,14 +14,47 @@
</b-input> </b-input>
</b-field> </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-field label="farmOS Integration Mode">
<b-select name="${app.appname}.farmos_integration_mode" <div style="display: flex; gap: 0.5rem; align-items: center;">
v-model="simpleSettings['${app.appname}.farmos_integration_mode']" <b-select name="${app.appname}.farmos_integration_mode"
@input="settingsNeedSaved = true"> v-model="simpleSettings['${app.appname}.farmos_integration_mode']"
% for value, label in enum.FARMOS_INTEGRATION_MODE.items(): @input="settingsNeedSaved = true">
<option value="${value}">${label}</option> % for value, label in enum.FARMOS_INTEGRATION_MODE.items():
% endfor <option value="${value}">${label}</option>
</b-select> % 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-field>
<b-checkbox name="${app.appname}.farmos_style_grid_links" <b-checkbox name="${app.appname}.farmos_style_grid_links"

View file

@ -1,4 +1,5 @@
<%inherit file="wuttaweb:templates/base.mako" /> <%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/wuttafarm-components.mako" import="make_wuttafarm_components" />
<%def name="index_title_controls()"> <%def name="index_title_controls()">
${parent.index_title_controls()} ${parent.index_title_controls()}
@ -14,3 +15,8 @@
% endif % endif
</%def> </%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 Misc. utilities for web app
""" """
from pyramid import httpexceptions
from webhelpers2.html import HTML 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): def save_farmos_oauth2_token(request, token):
""" """
Common logic for saving the given OAuth2 token within the user 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 webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
from wuttafarm.db.model import AnimalType, AnimalAsset from wuttafarm.db.model import AnimalType, AnimalAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import AnimalTypeRef from wuttafarm.web.forms.schema import AnimalTypeRef
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
class AnimalTypeView(AssetTypeMasterView): class AnimalTypeView(AssetTypeMasterView):
@ -42,6 +44,8 @@ class AnimalTypeView(AssetTypeMasterView):
route_prefix = "animal_types" route_prefix = "animal_types"
url_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" farmos_refurl_path = "/admin/structure/taxonomy/manage/animal_type/overview"
grid_columns = [ grid_columns = [
@ -58,8 +62,8 @@ class AnimalTypeView(AssetTypeMasterView):
form_fields = [ form_fields = [
"name", "name",
"description", "description",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
has_rows = True has_rows = True
@ -103,6 +107,19 @@ class AnimalTypeView(AssetTypeMasterView):
return buttons 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): def get_row_grid_data(self, animal_type):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()
@ -125,6 +142,7 @@ class AnimalTypeView(AssetTypeMasterView):
# sex # sex
g.set_enum("sex", enum.ANIMAL_SEX) g.set_enum("sex", enum.ANIMAL_SEX)
g.filters["sex"].verbs = ["equal", "not_equal"]
# archived # archived
g.set_renderer("archived", "boolean") g.set_renderer("archived", "boolean")
@ -134,6 +152,55 @@ class AnimalTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, animal, i): def get_row_action_url_view(self, animal, i):
return self.request.route_url("animal_assets.view", uuid=animal.uuid) 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): class AnimalAssetView(AssetMasterView):
""" """
@ -145,9 +212,10 @@ class AnimalAssetView(AssetMasterView):
url_prefix = "/assets/animal" url_prefix = "/assets/animal"
farmos_refurl_path = "/assets/animal" farmos_refurl_path = "/assets/animal"
farmos_bundle = "animal"
labels = { labels = {
"animal_type": "Species/Breed", "animal_type": "Species / Breed",
"is_sterile": "Sterile", "is_sterile": "Sterile",
} }
@ -160,6 +228,9 @@ class AnimalAssetView(AssetMasterView):
"birthdate", "birthdate",
"is_sterile", "is_sterile",
"sex", "sex",
"groups",
"owners",
"locations",
"archived", "archived",
] ]
@ -172,9 +243,12 @@ class AnimalAssetView(AssetMasterView):
"is_sterile", "is_sterile",
"notes", "notes",
"asset_type", "asset_type",
"owners",
"locations",
"groups",
"archived", "archived",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
"thumbnail_url", "thumbnail_url",
"image_url", "image_url",
"thumbnail", "thumbnail",
@ -201,6 +275,7 @@ class AnimalAssetView(AssetMasterView):
# sex # sex
g.set_enum("sex", enum.ANIMAL_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): def render_animal_type_for_grid(self, animal, field, value):
url = self.request.route_url("animal_types.view", uuid=animal.animal_type_uuid) 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)) f.set_node("animal_type", AnimalTypeRef(self.request))
# sex # 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 pass # TODO: dict enum widget does not handle null values well
else: else:
f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX)) f.set_node("sex", WuttaDictEnum(self.request, enum.ANIMAL_SEX))

View file

@ -38,6 +38,7 @@ class AssetTypeView(WuttaFarmMasterView):
grid_columns = [ grid_columns = [
"name", "name",
"drupal_id",
"description", "description",
] ]
@ -50,8 +51,8 @@ class AssetTypeView(WuttaFarmMasterView):
form_fields = [ form_fields = [
"name", "name",
"description", "description",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
def configure_grid(self, grid): def configure_grid(self, grid):
@ -78,6 +79,19 @@ class AssetTypeView(WuttaFarmMasterView):
return buttons 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -25,13 +25,17 @@ Master view for Assets
from collections import OrderedDict from collections import OrderedDict
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import Asset, Log 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.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): def get_asset_type_enum(config):
@ -45,79 +49,6 @@ def get_asset_type_enum(config):
return asset_types 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): class AssetTypeMasterView(WuttaFarmMasterView):
""" """
Base class for "Asset Type" master views. Base class for "Asset Type" master views.
@ -133,6 +64,12 @@ class AssetMasterView(WuttaFarmMasterView):
Base class for Asset master views Base class for Asset master views
""" """
farmos_entity_type = "asset"
labels = {
"groups": "Group Membership",
}
sort_defaults = "asset_name" sort_defaults = "asset_name"
filter_defaults = { filter_defaults = {
@ -175,7 +112,10 @@ class AssetMasterView(WuttaFarmMasterView):
model = self.app.model model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
session = session or self.Session() 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): def configure_grid(self, grid):
g = grid g = grid
@ -200,15 +140,77 @@ class AssetMasterView(WuttaFarmMasterView):
# parents # parents
g.set_renderer("parents", self.render_parents_for_grid) 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 # archived
g.set_renderer("archived", "boolean") g.set_renderer("archived", "boolean")
g.set_sorter("archived", model.Asset.archived) g.set_sorter("archived", model.Asset.archived)
g.set_filter("archived", model.Asset.archived) g.set_filter("archived", model.Asset.archived)
def render_parents_for_grid(self, asset, field, value): 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) 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): def grid_row_class(self, asset, data, i):
""" """ """ """
if asset.archived: if asset.archived:
@ -218,6 +220,7 @@ class AssetMasterView(WuttaFarmMasterView):
def configure_form(self, form): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
asset_handler = self.app.get_asset_handler()
asset = form.model_instance asset = form.model_instance
# asset_type # asset_type
@ -230,12 +233,39 @@ class AssetMasterView(WuttaFarmMasterView):
) )
f.set_readonly("asset_type") 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 # parents
if self.creating or self.editing: if self.creating or self.editing:
f.remove("parents") # TODO: add support for this f.remove("parents") # TODO: add support for this
else: else:
f.set_node("parents", AssetParentRefs(self.request)) 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 # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
@ -266,11 +296,14 @@ class AssetMasterView(WuttaFarmMasterView):
asset = super().objectify(form) asset = super().objectify(form)
if self.creating: if self.creating:
model_class = self.get_model_class() asset.asset_type = self.get_asset_type()
asset.asset_type = model_class.__wutta_hint__["farmos_asset_type"]
return asset 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): def get_farmos_url(self, asset):
return self.app.get_farmos_url(f"/asset/{asset.drupal_id}") return self.app.get_farmos_url(f"/asset/{asset.drupal_id}")
@ -278,7 +311,7 @@ class AssetMasterView(WuttaFarmMasterView):
buttons = super().get_xref_buttons(asset) buttons = super().get_xref_buttons(asset)
if asset.farmos_uuid: 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" route = f"farmos_{asset_type}_assets.view"
url = self.request.route_url(route, uuid=asset.farmos_uuid) url = self.request.route_url(route, uuid=asset.farmos_uuid)
buttons.append( buttons.append(
@ -289,6 +322,21 @@ class AssetMasterView(WuttaFarmMasterView):
return buttons 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): def get_row_grid_data(self, asset):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()
@ -301,7 +349,12 @@ class AssetMasterView(WuttaFarmMasterView):
def configure_row_grid(self, grid): def configure_row_grid(self, grid):
g = grid g = grid
super().configure_row_grid(g) super().configure_row_grid(g)
enum = self.app.enum
model = self.app.model model = self.app.model
session = self.Session()
# status
g.set_enum("status", enum.LOG_STATUS)
# drupal_id # drupal_id
g.set_label("drupal_id", "ID", column_only=True) g.set_label("drupal_id", "ID", column_only=True)
@ -318,16 +371,62 @@ class AssetMasterView(WuttaFarmMasterView):
# log_type # log_type
g.set_sorter("log_type", model.Log.log_type) g.set_sorter("log_type", model.Log.log_type)
g.set_filter("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): def get_row_action_url_view(self, log, i):
return self.request.route_url(f"logs_{log.log_type}.view", uuid=log.uuid) 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): def defaults(config, **kwargs):
base = globals() base = globals()
AssetView = kwargs.get("AssetView", base["AssetView"]) AllAssetView = kwargs.get("AllAssetView", base["AllAssetView"])
AssetView.defaults(config) AllAssetView.defaults(config)
def includeme(config): def includeme(config):

View file

@ -55,9 +55,10 @@ class AuthView(base.AuthView):
return None return None
def get_farmos_oauth2_session(self): def get_farmos_oauth2_session(self):
farmos = self.app.get_farmos_handler()
return OAuth2Session( return OAuth2Session(
client_id="farm", client_id=farmos.get_oauth2_client_id(),
scope="farm_manager", scope=farmos.get_oauth2_scope(),
redirect_uri=self.request.route_url("farmos_oauth_callback"), 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_medical.view",
"farmos_logs_observation.list", "farmos_logs_observation.list",
"farmos_logs_observation.view", "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.list",
"farmos_structure_assets.view", "farmos_structure_assets.view",
"farmos_structure_types.list", "farmos_structure_types.list",
"farmos_structure_types.view", "farmos_structure_types.view",
"farmos_units.list",
"farmos_units.view",
"farmos_users.list", "farmos_users.list",
"farmos_users.view", "farmos_users.view",
"group_assets.create", "group_assets.create",
@ -121,6 +131,7 @@ class CommonView(base.CommonView):
"logs_observation.list", "logs_observation.list",
"logs_observation.view", "logs_observation.view",
"logs_observation.versions", "logs_observation.versions",
"quick.eggs",
"structure_types.list", "structure_types.list",
"structure_types.view", "structure_types.view",
"structure_types.versions", "structure_types.versions",

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,7 @@ View for farmOS Medical Logs
""" """
from wuttafarm.web.views.farmos.logs import LogMasterView from wuttafarm.web.views.farmos.logs import LogMasterView
from wuttafarm.web.grids import SimpleSorter, StringFilter
class MedicalLogView(LogMasterView): class MedicalLogView(LogMasterView):
@ -41,6 +42,35 @@ class MedicalLogView(LogMasterView):
farmos_log_type = "medical" farmos_log_type = "medical"
farmos_refurl_path = "/logs/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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

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

View file

@ -34,7 +34,7 @@ from wuttaweb.views import MasterView
from wuttaweb.forms.schema import WuttaDateTime from wuttaweb.forms.schema import WuttaDateTime
from wuttaweb.forms.widgets import WuttaDateTimeWidget 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 ( from wuttafarm.web.grids import (
ResourceData, ResourceData,
StringFilter, StringFilter,
@ -70,28 +70,12 @@ class FarmOSMasterView(MasterView):
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) 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.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(self.farmos_client) self.normal = self.app.get_normalizer(self.farmos_client)
self.raw_json = None self.raw_json = None
self.farmos_style_grid_links = use_farmos_style_grid_links(self.config) 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): def get_fallback_templates(self, template):
""" """ """ """
templates = super().get_fallback_templates(template) templates = super().get_fallback_templates(template)

View file

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

View file

@ -51,8 +51,8 @@ class LandTypeView(AssetTypeMasterView):
form_fields = [ form_fields = [
"name", "name",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
has_rows = True has_rows = True
@ -129,6 +129,19 @@ class LandTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, land_asset, i): def get_row_action_url_view(self, land_asset, i):
return self.request.route_url("land_assets.view", uuid=land_asset.uuid) 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): class LandAssetView(AssetMasterView):
""" """
@ -139,6 +152,7 @@ class LandAssetView(AssetMasterView):
route_prefix = "land_assets" route_prefix = "land_assets"
url_prefix = "/assets/land" url_prefix = "/assets/land"
farmos_bundle = "land"
farmos_refurl_path = "/assets/land" farmos_refurl_path = "/assets/land"
grid_columns = [ grid_columns = [
@ -159,8 +173,8 @@ class LandAssetView(AssetMasterView):
"is_location", "is_location",
"is_fixed", "is_fixed",
"archived", "archived",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
def configure_grid(self, grid): def configure_grid(self, grid):

View file

@ -26,6 +26,7 @@ Base views for Logs
from collections import OrderedDict from collections import OrderedDict
import colander import colander
from webhelpers2.html import tags, HTML
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.db import Session from wuttaweb.db import Session
@ -33,18 +34,8 @@ from wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views import WuttaFarmMasterView from wuttafarm.web.views import WuttaFarmMasterView
from wuttafarm.db.model import LogType, Log from wuttafarm.db.model import LogType, Log
from wuttafarm.web.forms.schema import LogAssetRefs from wuttafarm.web.forms.schema import AssetRefs, LogQuantityRefs, OwnerRefs
from wuttafarm.util import get_log_type_enum
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
class LogTypeView(WuttaFarmMasterView): class LogTypeView(WuttaFarmMasterView):
@ -70,8 +61,8 @@ class LogTypeView(WuttaFarmMasterView):
form_fields = [ form_fields = [
"name", "name",
"description", "description",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
def configure_grid(self, grid): def configure_grid(self, grid):
@ -99,85 +90,17 @@ class LogTypeView(WuttaFarmMasterView):
return buttons 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): class LogMasterView(WuttaFarmMasterView):
""" """
Base class for Asset master views Base class for Asset master views
""" """
farmos_entity_type = "log"
labels = { labels = {
"message": "Log Name", "message": "Log Name",
"owners": "Owner", "locations": "Location",
"quantities": "Quantity",
} }
grid_columns = [ grid_columns = [
@ -186,8 +109,8 @@ class LogMasterView(WuttaFarmMasterView):
"timestamp", "timestamp",
"message", "message",
"assets", "assets",
# "location", "locations",
"quantity", "quantities",
"is_group_assignment", "is_group_assignment",
"owners", "owners",
] ]
@ -196,21 +119,25 @@ class LogMasterView(WuttaFarmMasterView):
filter_defaults = { filter_defaults = {
"message": {"active": True, "verb": "contains"}, "message": {"active": True, "verb": "contains"},
"status": {"active": True, "verb": "not_equal", "value": "abandoned"},
} }
form_fields = [ form_fields = [
"message", "message",
"timestamp", "timestamp",
"assets", "assets",
"location", "groups",
"quantity", "locations",
"quantities",
"notes", "notes",
"status", "status",
"log_type", "log_type",
"owners", "owners",
"is_movement",
"is_group_assignment", "is_group_assignment",
"farmos_uuid", "quick",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
def get_query(self, session=None): def get_query(self, session=None):
@ -218,16 +145,26 @@ class LogMasterView(WuttaFarmMasterView):
model = self.app.model model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
session = session or self.Session() 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): def configure_grid(self, grid):
g = grid g = grid
super().configure_grid(g) super().configure_grid(g)
model = self.app.model model = self.app.model
enum = self.app.enum
# status # status
g.set_enum("status", enum.LOG_STATUS)
g.set_sorter("status", model.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 # drupal_id
g.set_label("drupal_id", "ID", column_only=True) g.set_label("drupal_id", "ID", column_only=True)
@ -248,13 +185,68 @@ class LogMasterView(WuttaFarmMasterView):
# assets # assets
g.set_renderer("assets", self.render_assets_for_grid) 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): 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): def configure_form(self, form):
f = form f = form
super().configure_form(f) super().configure_form(f)
enum = self.app.enum enum = self.app.enum
session = self.Session()
log = f.model_instance log = f.model_instance
# timestamp # timestamp
@ -267,12 +259,25 @@ class LogMasterView(WuttaFarmMasterView):
if self.creating or self.editing: if self.creating or self.editing:
f.remove("assets") # TODO: need to support this f.remove("assets") # TODO: need to support this
else: else:
f.set_node("assets", LogAssetRefs(self.request)) f.set_node("assets", AssetRefs(self.request))
f.set_default("assets", [a.asset_uuid for a in log.log._assets]) # nb. must explicity declare value for non-standard field
f.set_default("assets", log.assets)
# location # groups
if self.creating or self.editing: 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 # log_type
if self.creating: if self.creating:
@ -280,13 +285,19 @@ class LogMasterView(WuttaFarmMasterView):
else: else:
f.set_node( f.set_node(
"log_type", "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") f.set_readonly("log_type")
# quantity # quantities
if self.creating or self.editing: 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 # notes
f.set_widget("notes", "notes") f.set_widget("notes", "notes")
@ -294,13 +305,23 @@ class LogMasterView(WuttaFarmMasterView):
# owners # owners
if self.creating or self.editing: if self.creating or self.editing:
f.remove("owners") # TODO: need to support this 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 # status
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS)) f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
# is_movement
f.set_node("is_movement", colander.Boolean())
# is_group_assignment # is_group_assignment
f.set_node("is_group_assignment", colander.Boolean()) f.set_node("is_group_assignment", colander.Boolean())
# quick
f.set_readonly("quick") # TODO
def objectify(self, form): def objectify(self, form):
log = super().objectify(form) log = super().objectify(form)
@ -333,6 +354,68 @@ class LogMasterView(WuttaFarmMasterView):
return buttons 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): def defaults(config, **kwargs):
base = globals() base = globals()
@ -340,8 +423,8 @@ def defaults(config, **kwargs):
LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"]) LogTypeView = kwargs.get("LogTypeView", base["LogTypeView"])
LogTypeView.defaults(config) LogTypeView.defaults(config)
LogView = kwargs.get("LogView", base["LogView"]) AllLogView = kwargs.get("AllLogView", base["AllLogView"])
LogView.defaults(config) AllLogView.defaults(config)
def includeme(config): def includeme(config):

View file

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

View file

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

View file

@ -36,8 +36,29 @@ class MedicalLogView(LogMasterView):
route_prefix = "logs_medical" route_prefix = "logs_medical"
url_prefix = "/logs/medical" url_prefix = "/logs/medical"
farmos_bundle = "medical"
farmos_refurl_path = "/logs/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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

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

View file

@ -27,7 +27,7 @@ from webhelpers2.html import tags
from wuttaweb.views import MasterView 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): class WuttaFarmMasterView(MasterView):
@ -36,6 +36,8 @@ class WuttaFarmMasterView(MasterView):
""" """
farmos_refurl_path = None farmos_refurl_path = None
farmos_entity_type = None
farmos_bundle = None
labels = { labels = {
"farmos_uuid": "farmOS UUID", "farmos_uuid": "farmOS UUID",
@ -104,5 +106,42 @@ class WuttaFarmMasterView(MasterView):
f.set_readonly("drupal_id") f.set_readonly("drupal_id")
def persist(self, obj, session=None): def persist(self, obj, session=None):
# save per usual
super().persist(obj, session) 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 Master view for Plants
""" """
from webhelpers2.html import tags
from wuttaweb.forms.schema import WuttaDictEnum from wuttaweb.forms.schema import WuttaDictEnum
from wuttaweb.util import get_form_data
from wuttafarm.db.model import PlantType, PlantAsset from wuttafarm.db.model import PlantType, PlantAsset
from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView from wuttafarm.web.views.assets import AssetTypeMasterView, AssetMasterView
from wuttafarm.web.forms.schema import PlantTypeRefs from wuttafarm.web.forms.schema import PlantTypeRefs
from wuttafarm.web.forms.widgets import ImageWidget from wuttafarm.web.forms.widgets import ImageWidget
from wuttafarm.web.util import get_farmos_client_for_user
class PlantTypeView(AssetTypeMasterView): class PlantTypeView(AssetTypeMasterView):
@ -40,6 +44,8 @@ class PlantTypeView(AssetTypeMasterView):
route_prefix = "plant_types" route_prefix = "plant_types"
url_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" farmos_refurl_path = "/admin/structure/taxonomy/manage/plant_type/overview"
grid_columns = [ grid_columns = [
@ -56,8 +62,8 @@ class PlantTypeView(AssetTypeMasterView):
form_fields = [ form_fields = [
"name", "name",
"description", "description",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
has_rows = True has_rows = True
@ -98,6 +104,19 @@ class PlantTypeView(AssetTypeMasterView):
return buttons 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): def get_row_grid_data(self, plant_type):
model = self.app.model model = self.app.model
session = self.Session() session = self.Session()
@ -126,6 +145,55 @@ class PlantTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, plant, i): def get_row_action_url_view(self, plant, i):
return self.request.route_url("plant_assets.view", uuid=plant.uuid) 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): class PlantAssetView(AssetMasterView):
""" """
@ -136,6 +204,7 @@ class PlantAssetView(AssetMasterView):
route_prefix = "plant_assets" route_prefix = "plant_assets"
url_prefix = "/assets/plant" url_prefix = "/assets/plant"
farmos_bundle = "plant"
farmos_refurl_path = "/assets/plant" farmos_refurl_path = "/assets/plant"
labels = { labels = {
@ -158,8 +227,8 @@ class PlantAssetView(AssetMasterView):
"notes", "notes",
"asset_type", "asset_type",
"archived", "archived",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
"thumbnail_url", "thumbnail_url",
"image_url", "image_url",
"thumbnail", "thumbnail",
@ -171,10 +240,20 @@ class PlantAssetView(AssetMasterView):
super().configure_grid(g) super().configure_grid(g)
# plant_types # 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): def render_plant_types_for_grid(self, plant, field, value):
return ", ".join([t.plant_type.name for t in plant._plant_types]) 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): def configure_form(self, form):
f = form f = form
@ -183,18 +262,38 @@ class PlantAssetView(AssetMasterView):
plant = f.model_instance plant = f.model_instance
# plant_types # plant_types
if self.creating or self.editing: f.set_node("plant_types", PlantTypeRefs(self.request))
f.remove("plant_types") # TODO: add support for this if not self.creating:
else: # nb. must explcitly declare value for non-standard field
f.set_node("plant_types", PlantTypeRefs(self.request)) f.set_default("plant_types", [pt.uuid for pt in plant.plant_types])
f.set_default(
"plant_types", [t.plant_type_uuid for t in plant._plant_types]
)
# season # season
if self.creating or self.editing: if self.creating or self.editing:
f.remove("season") # TODO: add support for this 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

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

View file

@ -27,4 +27,9 @@ from .base import QuickFormView
def includeme(config): 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") 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 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__) log = logging.getLogger(__name__)
@ -42,7 +42,7 @@ class QuickFormView(View):
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) 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.farmos_4x = self.app.is_farmos_4x(self.farmos_client)
self.normal = self.app.get_normalizer(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): def get_template_context(self, context):
return 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 @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config) cls._defaults(config)
@ -151,6 +135,10 @@ class QuickFormView(View):
def _defaults(cls, config): def _defaults(cls, config):
route_slug = cls.get_route_slug() route_slug = cls.get_route_slug()
url_slug = cls.get_url_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_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 wuttaweb.forms.widgets import WuttaDateTimeWidget
from wuttafarm.web.views.quick import QuickFormView from wuttafarm.web.views.quick import QuickFormView
from wuttafarm.web.util import get_farmos_client_for_user
class EggsQuickForm(QuickFormView): class EggsQuickForm(QuickFormView):
@ -118,8 +119,11 @@ class EggsQuickForm(QuickFormView):
if self.app.is_farmos_mirror(): if self.app.is_farmos_mirror():
quantity = json.loads(response["create-quantity"]["body"]) quantity = json.loads(response["create-quantity"]["body"])
self.app.auto_sync_from_farmos(quantity["data"], "StandardQuantity") client = get_farmos_client_for_user(self.request)
self.app.auto_sync_from_farmos(log["data"], "HarvestLog") self.app.auto_sync_from_farmos(
quantity["data"], "StandardQuantity", client=client
)
self.app.auto_sync_from_farmos(log["data"], "HarvestLog", client=client)
return log return log
@ -192,6 +196,7 @@ class EggsQuickForm(QuickFormView):
"type": "log--harvest", "type": "log--harvest",
"attributes": { "attributes": {
"name": f"Collected {data['count']} {unit_name}", "name": f"Collected {data['count']} {unit_name}",
"timestamp": self.app.localtime(data["timestamp"]).timestamp(),
"notes": notes, "notes": notes,
"quick": ["eggs"], "quick": ["eggs"],
}, },

View file

@ -57,10 +57,21 @@ class AppInfoView(base.AppInfoView):
return info return info
def configure_get_simple_settings(self): # pylint: disable=empty-docstring 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 = super().configure_get_simple_settings()
simple_settings.extend( 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", "name": f"{self.app.appname}.farmos_integration_mode",
"default": self.app.get_farmos_integration_mode(), "default": self.app.get_farmos_integration_mode(),

View file

@ -50,8 +50,8 @@ class StructureTypeView(AssetTypeMasterView):
form_fields = [ form_fields = [
"name", "name",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
has_rows = True has_rows = True
@ -128,6 +128,19 @@ class StructureTypeView(AssetTypeMasterView):
def get_row_action_url_view(self, structure, i): def get_row_action_url_view(self, structure, i):
return self.request.route_url("structure_assets.view", uuid=structure.uuid) 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): class StructureAssetView(AssetMasterView):
""" """
@ -138,6 +151,7 @@ class StructureAssetView(AssetMasterView):
route_prefix = "structure_assets" route_prefix = "structure_assets"
url_prefix = "/asset/structures" url_prefix = "/asset/structures"
farmos_bundle = "structure"
farmos_refurl_path = "/assets/structure" farmos_refurl_path = "/assets/structure"
grid_columns = [ grid_columns = [
@ -146,6 +160,7 @@ class StructureAssetView(AssetMasterView):
"asset_name", "asset_name",
"structure_type", "structure_type",
"parents", "parents",
"owners",
"archived", "archived",
] ]
@ -158,8 +173,8 @@ class StructureAssetView(AssetMasterView):
"is_location", "is_location",
"is_fixed", "is_fixed",
"archived", "archived",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
"thumbnail_url", "thumbnail_url",
"image_url", "image_url",
"thumbnail", "thumbnail",

View file

@ -59,6 +59,19 @@ class MeasureView(WuttaFarmMasterView):
# name # name
g.set_link("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): class UnitView(WuttaFarmMasterView):
""" """
@ -69,6 +82,8 @@ class UnitView(WuttaFarmMasterView):
route_prefix = "units" route_prefix = "units"
url_prefix = "/units" url_prefix = "/units"
farmos_entity_type = "taxonomy_term"
farmos_bundle = "unit"
farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview" farmos_refurl_path = "/admin/structure/taxonomy/manage/unit/overview"
grid_columns = [ grid_columns = [
@ -85,8 +100,8 @@ class UnitView(WuttaFarmMasterView):
form_fields = [ form_fields = [
"name", "name",
"description", "description",
"farmos_uuid",
"drupal_id", "drupal_id",
"farmos_uuid",
] ]
def configure_grid(self, grid): def configure_grid(self, grid):

View file

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