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",
@ -162,6 +167,19 @@ class Log(model.Base):
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):
return self.message or ""
@ -192,6 +210,7 @@ def add_log_proxies(subclass):
Log.make_proxy(subclass, "log", "status")
Log.make_proxy(subclass, "log", "notes")
Log.make_proxy(subclass, "log", "assets")
Log.make_proxy(subclass, "log", "owners")
class LogAsset(model.Base):
@ -216,3 +235,27 @@ class LogAsset(model.Base):
"Asset",
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(
[
"assets",
"owners",
]
)
return fields
@ -1004,6 +1005,9 @@ class LogImporterBase(FromFarmOS, ToWutta):
(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
def normalize_target_object(self, log):
@ -1011,9 +1015,12 @@ class LogImporterBase(FromFarmOS, ToWutta):
if "assets" in self.fields:
data["assets"] = [
(a.asset.asset_type, a.asset.farmos_uuid) for a in log.log._assets
(asset.asset_type, asset.farmos_uuid) for asset in log.assets
]
if "owners" in self.fields:
data["owners"] = [user.farmos_uuid for user in log.owners]
return data
def update_target_object(self, log, source_data, target_data=None):
@ -1026,14 +1033,13 @@ class LogImporterBase(FromFarmOS, ToWutta):
for key in source_data["assets"]:
asset_type, farmos_uuid = key
if not target_data or key not in target_data["assets"]:
self.target_session.flush()
asset = (
self.target_session.query(model.Asset)
.filter(model.Asset.asset_type == asset_type)
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
log.log._assets.append(model.LogAsset(asset=asset))
log.assets.append(asset)
if target_data:
for key in target_data["assets"]:
@ -1045,13 +1051,31 @@ class LogImporterBase(FromFarmOS, ToWutta):
.filter(model.Asset.farmos_uuid == farmos_uuid)
.one()
)
asset = (
self.target_session.query(model.LogAsset)
.filter(model.LogAsset.log == log)
.filter(model.LogAsset.asset == asset)
log.assets.remove(asset)
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["assets"]:
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()
)
self.target_session.delete(asset)
log.owners.remove(user)
return log

View file

@ -371,9 +371,9 @@ class LogAssetRefs(WuttaSet):
def serialize(self, node, appstruct):
if not appstruct:
appstruct = []
uuids = [u.hex for u in appstruct]
return json.dumps(uuids)
return colander.null
return {asset.uuid for asset in appstruct}
def widget_maker(self, **kwargs):
from wuttafarm.web.forms.widgets import LogAssetRefsWidget
@ -381,6 +381,23 @@ class LogAssetRefs(WuttaSet):
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):
"""
Custom schema type for "note" fields.

View file

@ -436,7 +436,7 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
readonly = kw.get("readonly", self.readonly)
if readonly:
assets = []
for uuid in json.loads(cstruct):
for uuid in cstruct or []:
asset = session.get(model.Asset, uuid)
assets.append(
HTML.tag(
@ -454,6 +454,37 @@ class LogAssetRefsWidget(WuttaCheckboxChoiceWidget):
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):
"""
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.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
@ -99,7 +99,6 @@ class LogMasterView(WuttaFarmMasterView):
labels = {
"message": "Log Name",
"owners": "Owner",
}
grid_columns = [
@ -181,6 +180,10 @@ class LogMasterView(WuttaFarmMasterView):
# assets
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):
if self.farmos_style_grid_links:
@ -194,6 +197,17 @@ class LogMasterView(WuttaFarmMasterView):
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):
if log.status == "pending":
return "has-background-warning"
@ -219,7 +233,8 @@ class LogMasterView(WuttaFarmMasterView):
f.remove("assets") # TODO: need to support this
else:
f.set_node("assets", LogAssetRefs(self.request))
f.set_default("assets", [a.asset_uuid for a in log.log._assets])
# nb. must explicity declare value for non-standard field
f.set_default("assets", log.assets)
# location
if self.creating or self.editing:
@ -247,6 +262,10 @@ class LogMasterView(WuttaFarmMasterView):
# owners
if self.creating or self.editing:
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
f.set_node("status", WuttaDictEnum(self.request, enum.LOG_STATUS))
@ -331,7 +350,7 @@ class AllLogView(LogMasterView):
"quantity",
"groups",
"is_group_assignment",
"owner",
"owners",
]
def configure_grid(self, grid):