feat: add support for log 'owners'

This commit is contained in:
Lance Edgar 2026-02-28 19:52:09 -06:00
parent ae73d2f87f
commit 64e4392a92
6 changed files with 259 additions and 17 deletions

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

@ -154,7 +154,12 @@ 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 = association_proxy(
"_assets", "_assets",
@ -162,6 +167,19 @@ class Log(model.Base):
creator=lambda asset: LogAsset(asset=asset), creator=lambda asset: LogAsset(asset=asset),
) )
_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 ""
@ -192,6 +210,7 @@ def add_log_proxies(subclass):
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", "assets") Log.make_proxy(subclass, "log", "assets")
Log.make_proxy(subclass, "log", "owners")
class LogAsset(model.Base): class LogAsset(model.Base):
@ -216,3 +235,27 @@ class LogAsset(model.Base):
"Asset", "Asset",
foreign_keys=asset_uuid, foreign_keys=asset_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

@ -979,6 +979,7 @@ class LogImporterBase(FromFarmOS, ToWutta):
fields.extend( fields.extend(
[ [
"assets", "assets",
"owners",
] ]
) )
return fields return fields
@ -1004,6 +1005,9 @@ 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 "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):
@ -1011,9 +1015,12 @@ 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 "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):
@ -1026,14 +1033,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"]:
@ -1045,13 +1051,31 @@ class LogImporterBase(FromFarmOS, ToWutta):
.filter(model.Asset.farmos_uuid == farmos_uuid) .filter(model.Asset.farmos_uuid == farmos_uuid)
.one() .one()
) )
asset = ( log.assets.remove(asset)
self.target_session.query(model.LogAsset)
.filter(model.LogAsset.log == log) if "owners" in self.fields:
.filter(model.LogAsset.asset == asset) 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["assets"]:
user = (
self.target_session.query(model.User)
.join(model.WuttaFarmUser)
.filter(model.WuttaFarmUser.farmos_uuid == farmos_uuid)
.one() .one()
) )
self.target_session.delete(asset) 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

View file

@ -371,9 +371,9 @@ class LogAssetRefs(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 {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 LogAssetRefsWidget
@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet):
return LogAssetRefsWidget(self.request, **kwargs) return LogAssetRefsWidget(self.request, **kwargs)
class LogOwnerRefs(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 LogOwnerRefsWidget
return LogOwnerRefsWidget(self.request, **kwargs)
class Notes(colander.String): class Notes(colander.String):
""" """
Custom schema type for "note" fields. Custom schema type for "note" fields.

View file

@ -436,7 +436,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(
@ -454,6 +454,37 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
return super().serialize(field, cstruct, **kw) return super().serialize(field, cstruct, **kw)
class LogOwnerRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for Owners 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:
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): class AnimalTypeRefWidget(ObjectRefWidget):
""" """
Custom widget which uses the ``<animal-type-picker>`` component. Custom widget which uses the ``<animal-type-picker>`` component.

View file

@ -34,7 +34,7 @@ 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 LogAssetRefs, LogOwnerRefs
from wuttafarm.util import get_log_type_enum from wuttafarm.util import get_log_type_enum
@ -99,7 +99,6 @@ class LogMasterView(WuttaFarmMasterView):
labels = { labels = {
"message": "Log Name", "message": "Log Name",
"owners": "Owner",
} }
grid_columns = [ grid_columns = [
@ -181,6 +180,10 @@ class LogMasterView(WuttaFarmMasterView):
# assets # assets
g.set_renderer("assets", self.render_assets_for_grid) g.set_renderer("assets", self.render_assets_for_grid)
# 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):
if self.farmos_style_grid_links: if self.farmos_style_grid_links:
@ -194,6 +197,17 @@ class LogMasterView(WuttaFarmMasterView):
return ", ".join([str(a) for a in log.assets]) return ", ".join([str(a) for a in log.assets])
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): def grid_row_class(self, log, data, i):
if log.status == "pending": if log.status == "pending":
return "has-background-warning" return "has-background-warning"
@ -219,7 +233,8 @@ class LogMasterView(WuttaFarmMasterView):
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", LogAssetRefs(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 # location
if self.creating or self.editing: if self.creating or self.editing:
@ -247,6 +262,10 @@ 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", LogOwnerRefs(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))
@ -331,7 +350,7 @@ class AllLogView(LogMasterView):
"quantity", "quantity",
"groups", "groups",
"is_group_assignment", "is_group_assignment",
"owner", "owners",
] ]
def configure_grid(self, grid): def configure_grid(self, grid):