Skip to content

Add tags to items#8

Open
gpetretto wants to merge 24 commits into
gp/pyd2_origfrom
gp/tags_full
Open

Add tags to items#8
gpetretto wants to merge 24 commits into
gp/pyd2_origfrom
gp/tags_full

Conversation

@gpetretto

Copy link
Copy Markdown

Lets users annotate items (samples, cells, starting materials, equipment) with tags, either free-text strings or references to managed tags stored in a new tags collection. Managed tags have an owner: no owner means global, otherwise owned by a user and optionally shared with groups. This PR includes both the core tagging capability and a full management UI.

Backend

  • New Tag model and tags collection; a HasTags mixin adds a tags field (union of managed references or free-text strings) to all item types.
  • New /tags routes: list, search, create, edit, delete, and permission updates, with an authoring policy (global tags admin-only, group tags manager-only) and scope-based name uniqueness.
  • Read-time resolution: tag names/colors are inlined on read and refreshed automatically, so renaming or recoloring a tag propagates everywhere; references to deleted tags are dropped and pulled from items. Only the minimal {type, immutable_id} link is stored.
  • Text/creator indices for tag search; regenerated JSON schemas.

Frontend

  • New Tags management page (/tags): a table of managed tags with color swatches, scope/owner, and an editable hint, plus a create/edit modal, color picker (preset palette), and delete with confirmation.
  • Tagging on items: a tags field on the sample/cell/starting-material/equipment detail pages (searchable multi-select supporting both managed and free-text tags, colored badges with tooltips, click-to-edit).

Access model

Permissions gate which tags a user can select (global + own + group-shared), but displaying tags on an item is unfiltered — the item itself is the access boundary, so a personal tag stays visible to anyone who can see the item.

Tests

Model and server tests for the tag CRUD/search/permissions/resolution, plus Cypress component tests and an e2e test for the management page.

Branch

Based on the bump-pydantic-final-final branch in trunk.

BenjaminCharmes and others added 24 commits June 7, 2026 15:40
Move back to descriptions in field docstrings and configure this with customised BaseModel

More model updates for pydantic v2
Chatblock patch; set default values in block

More updates for blocks
More updates for models

Fixes for models

Simplify item versioning model

Remove unecessary by_alias=True

@davidwaroquiers davidwaroquiers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I've done a first pass. Not completely finished but I stopped while going through the routes. I have the feeling indeed that there is some complexity that should be removed. Actually, I am wondering if the concept of changing the sharing of a tag is relevant. With items, you share an item/sample with a group and it's read only. With tags you can also share with a group, but then this group should not have another tag that has the same name that moment (same if you share with just another user).
I am wondering if we could still keep some flexibility, i.e. a user can create a new tag for himself but it's only for himself (maybe an admin could make it global). An admin can create global tags. I am thus wondering if the HasOwner is really the right ownership "traits" here. I'm wondering whether a tag should just have one single creator, who is the single owner of that tag, and then an admin may make it globally accessible (and we could keep just user vs global for now and possibly add other scopes later).
What do you think ?
I have made some other comments here and there. Some may not be relevant.

Comment thread pydatalab/src/pydatalab/config.py
Comment thread pydatalab/src/pydatalab/upgrade_demo_pin_refcodes.py
Comment on lines +245 to +247
"EntryReference": {
"additionalProperties": true,
"description": "A reference to a database entry by ID and type.\n\nCan include additional arbitarary metadata useful for\ninlining the item data.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this added in equipment.json ? Does not seem related to the tags PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because apparently EntryReference was never present in the Equipment before.



class Tag(Entry, HasOwner):
"""A tag that can be associated to other entities.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe "to other Entry objects" instead of "to other entities" ? What do you think ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will be done in the simplified version

Comment on lines +41 to +42
TagRef: TypeAlias = EntryReference | str
"""A tag on an entry: either a reference to a managed `tags` entry or a free-text string."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be removed, it is only used in the HasTags.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see below

Comment on lines +253 to +254
elif current_user.is_authenticated:
creator_ids = [current_user.person.immutable_id]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this happen with the above ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed due to simplification of tag ownership

Comment on lines +82 to +86
Policy (see plan "Who may create/edit privileged tags"):
- Admins may author any tag.
- Global tags (empty `creator_ids`) require admin.
- Group tags (`group_ids` set) require admin or a manager of *every* named group.
- Personal tags are allowed for any authenticated user.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to make a short documentation about the tags and how they are used, defined, scoped, etc ...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we are going for the simplified version maybe a detailed documentation can be postponed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Comment on lines +266 to +272
return (
jsonify(
status="error",
message=f"A tag named {name!r} already exists in this scope.",
),
409, # 409: Conflict
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe do the same as for _authorize_tag_ownership ? i.e. have the _name_conflict_exists method return None when there is no conflict and return the jsonified error + error code when there is a conflict ? (I would probably rename the private helpers also)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_name_conflict_exists is used also in save_tag with slightly different approach. In principle can be done, but _authorize_tag_ownership will be removed and _name_conflict_exists would remain as the only one returning a message+error code. For a check function it seems more common to return a bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, let's keep it as such

Comment on lines +179 to +180
@TAGS.route("/search-tags", methods=["GET"])
@TAGS.route("/search/tags", methods=["GET"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why two routes ? I see in items it's only search-items, but for users it's both ...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if there is an additional usage. The implementation was mostly modelled on the other similar entries, like collections, users, files and thus was added here as well. Not sure if it is useful for other routes as well. Should I remove the /search/tags?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no idea ... it's anyway not consistent in other routes right now. I would say let's keep just "/search-tags" for now (it's the same for items, only "search-items")

Comment on lines +251 to +252
if "creator_ids" in data:
creator_ids = [ObjectId(c) for c in data["creator_ids"]]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, i think a user can in principle create a tag for other users without himself being one of the creators. Do we allow this ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in the simplified version.

.catch((error) => {
console.error("Fetch error");
console.error(error);
this.isSearchFetchError = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable is set here, but it looks like we don't use it anywhere in the template (). Right now, if the search fails, the user just sees an empty list and might think there are no tags. Should we add an error message in the template (for example, in the #no-options slot) so the user knows the request actually failed?"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I think your suggestion is correct, however all the other similar components have the same flag which is never used. So here it should be decided if keeping this exactly equivalent to the other components or actually using it. What would you say?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a simple error message to the template for this component. As a next step, I would write everywhere clear error message,
easier to see why it doesn't work

title: "Permission error",
message: "Unable to delete the tag: check that you have the appropriate permissions.",
});
throw error;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, this catch block always shows 'Permission error'. But the error could be something else, like a 404 (tag already deleted) or a 500 server error. Showing 'Permission error' for everything might confuse the user. Can we check the actual error status and show 'Delete failed' if it's not a 403?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I will update the code to surface the true reason of the failure

async debouncedAsyncSearch(query, loading) {
loading(true);
clearTimeout(this.debounceTimeout); // reset the timer
this.debounceTimeout = setTimeout(async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are setting a setTimeout here, but we don't clear it if the component is destroyed. We should add a beforeUnmount hook to run clearTimeout(this.debounceTimeout). This will prevent memory leaks and stop the code from running after the component is gone.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will set the clearTiemout, but worth noticing that again this is basically a copy//paste from other components that suffer from the same issue.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I see now
maybe it the future it will be fully corrected

const groups = this.isGlobal
? []
: this.shareWithGroups.map((g) => ({ immutable_id: g.immutable_id || g._id }));
await updateTagPermissions(tagId, creators, groups);

@DianaAliabieva DianaAliabieva Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If metadataChanged() and ownershipChanged() are both true, this awaits two separate requests sequentially. If updateTag succeeds but updateTagPermissions then fails (e.g. 409, permission denied), the tag is left half-saved — name/color/description updated, but ownership unchanged — while the user only sees a generic "Tag Update Failed" with no indication that part of the edit actually went through.
Suggest running both in parallel and surfacing which part failed, or documenting this as a known limitation if sequential-with-rollback isn't feasible right now:

async submitEdit() {
const tagId = this.tag.immutable_id;
const tasks = [];
if (this.metadataChanged()) {
tasks.push(updateTag(tagId, {
name: this.name.trim(),
description: this.description || null,
color: this.color || null,
}));
}
if (this.ownershipChanged()) {
const creators = this.isGlobal
? []
: this.selectedCreators.map((u) => ({ immutable_id: u.immutable_id }));
const groups = this.isGlobal
? []
: this.shareWithGroups.map((g) => ({ immutable_id: g.immutable_id || g._id }));
tasks.push(updateTagPermissions(tagId, creators, groups));
}
await Promise.all(tasks);
}

This doesn't fully solve atomicity (still two backend calls), but at least it fails together rather than silently leaving a half-applied edit, and Promise.allSettled could be used instead if partial success needs to be reported to the user.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. As mentioned in other comments I will propose a simplified version that removes the handling of different scopes for the tags. This part with multiple sequential checks will not be needed anymore, saving the complication of fixing it.

@gpetretto

Copy link
Copy Markdown
Author

Thanks both for the review. I should have replied to all the comments, and I left a couple of questions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants