diff --git a/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py new file mode 100644 index 0000000..8dffce9 --- /dev/null +++ b/src/wuttafarm/db/alembic/versions/47d0ebd84554_add_logowner.py @@ -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") diff --git a/src/wuttafarm/db/model/log.py b/src/wuttafarm/db/model/log.py index 8352a8e..6142229 100644 --- a/src/wuttafarm/db/model/log.py +++ b/src/wuttafarm/db/model/log.py @@ -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, + ) diff --git a/src/wuttafarm/importing/farmos.py b/src/wuttafarm/importing/farmos.py index 5bc351e..c931fb4 100644 --- a/src/wuttafarm/importing/farmos.py +++ b/src/wuttafarm/importing/farmos.py @@ -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 diff --git a/src/wuttafarm/web/forms/schema.py b/src/wuttafarm/web/forms/schema.py index 548ee81..8a80054 100644 --- a/src/wuttafarm/web/forms/schema.py +++ b/src/wuttafarm/web/forms/schema.py @@ -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. diff --git a/src/wuttafarm/web/forms/widgets.py b/src/wuttafarm/web/forms/widgets.py index ae9aa10..d3325e6 100644 --- a/src/wuttafarm/web/forms/widgets.py +++ b/src/wuttafarm/web/forms/widgets.py @@ -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 ```` component. diff --git a/src/wuttafarm/web/views/logs.py b/src/wuttafarm/web/views/logs.py index 245c448..68a06d1 100644 --- a/src/wuttafarm/web/views/logs.py +++ b/src/wuttafarm/web/views/logs.py @@ -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):