diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 303d67c3c..a3cfe4fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,9 @@ jobs: cp webapp/.env.test_e2e .env echo "PYDATALAB_TESTING=true" >> .env echo "PYDATALAB_TESTING=true" >> pydatalab/.env + # Enable the tags feature for the e2e backend (frontend flag is in .env.test_e2e). + echo "PYDATALAB_ENABLE_TAGS=true" >> .env + echo "PYDATALAB_ENABLE_TAGS=true" >> pydatalab/.env - name: Build Docker images uses: docker/bake-action@v7 diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index c66e9dcb3..c03dd1fc6 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -1203,6 +1203,21 @@ }, "description": "A model for representing electrochemical cells.", "properties": { + "tags": { + "description": "Tags applied to this entry: references to managed `tags` entries (by\n`immutable_id`) and/or free-text strings.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntryReference" + }, + { + "type": "string" + } + ] + }, + "title": "Tags", + "type": "array" + }, "blocks_obj": { "additionalProperties": { "$ref": "#/$defs/DataBlockResponse" diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index e0312f30a..d48a93fbd 100644 --- a/pydatalab/schemas/equipment.json +++ b/pydatalab/schemas/equipment.json @@ -242,6 +242,88 @@ "title": "DataBlockResponse", "type": "object" }, + "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.", + "properties": { + "type": { + "title": "Type", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, + "immutable_id": { + "anyOf": [ + { + "format": "objectid", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Immutable Id" + }, + "item_id": { + "anyOf": [ + { + "maxLength": 40, + "minLength": 1, + "pattern": "^(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Item Id" + }, + "refcode": { + "anyOf": [ + { + "maxLength": 40, + "minLength": 1, + "pattern": "^[a-z]{2,10}:(?:[a-zA-Z0-9]+|[a-zA-Z0-9][a-zA-Z0-9._-]+[a-zA-Z0-9])$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refcode" + }, + "chemform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Chemform" + } + }, + "required": [ + "type" + ], + "title": "EntryReference", + "type": "object" + }, "EquipmentStatus": { "description": "An enumeration of the status of equipments", "enum": [ @@ -1039,6 +1121,21 @@ }, "description": "A model for representing an experimental sample.", "properties": { + "tags": { + "description": "Tags applied to this entry: references to managed `tags` entries (by\n`immutable_id`) and/or free-text strings.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntryReference" + }, + { + "type": "string" + } + ] + }, + "title": "Tags", + "type": "array" + }, "blocks_obj": { "additionalProperties": { "$ref": "#/$defs/DataBlockResponse" diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 2d85b667b..3115410d4 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -1312,6 +1312,21 @@ "description": "Free-text details of the procedure applied to synthesise the sample", "title": "Synthesis Description" }, + "tags": { + "description": "Tags applied to this entry: references to managed `tags` entries (by\n`immutable_id`) and/or free-text strings.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntryReference" + }, + { + "type": "string" + } + ] + }, + "title": "Tags", + "type": "array" + }, "blocks_obj": { "additionalProperties": { "$ref": "#/$defs/DataBlockResponse" diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index a096ffb55..f06a8d0d6 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -1313,6 +1313,21 @@ "description": "Free-text details of the procedure applied to synthesise the sample", "title": "Synthesis Description" }, + "tags": { + "description": "Tags applied to this entry: references to managed `tags` entries (by\n`immutable_id`) and/or free-text strings.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EntryReference" + }, + { + "type": "string" + } + ] + }, + "title": "Tags", + "type": "array" + }, "blocks_obj": { "additionalProperties": { "$ref": "#/$defs/DataBlockResponse" diff --git a/pydatalab/src/pydatalab/config.py b/pydatalab/src/pydatalab/config.py index 8ee80d11f..bd2d3491c 100644 --- a/pydatalab/src/pydatalab/config.py +++ b/pydatalab/src/pydatalab/config.py @@ -248,6 +248,11 @@ class ServerConfig(BaseSettings): description="Maximum number of items that can be created in a single batch operation.", ) + ENABLE_TAGS: bool = Field( + False, + description="Whether to enable the (experimental) item tags feature and its `/tags` API routes.", + ) + ASYNC_BLOCK_TYPES: list[str] = Field( [], description="A list of block type slugs (e.g. ['cycle', 'xrd']) that should be processed asynchronously via the task queue. Defaults to no blocks.", diff --git a/pydatalab/src/pydatalab/feature_flags.py b/pydatalab/src/pydatalab/feature_flags.py index 8100b55c9..7053ccf50 100644 --- a/pydatalab/src/pydatalab/feature_flags.py +++ b/pydatalab/src/pydatalab/feature_flags.py @@ -27,6 +27,7 @@ class FeatureFlags(BaseModel): auth_mechanisms: AuthMechanisms = AuthMechanisms() ai_integrations: AIIntegrations = AIIntegrations() email_notifications: bool = False + tags: bool = False FEATURE_FLAGS: FeatureFlags = FeatureFlags() @@ -59,6 +60,8 @@ def check_feature_flags(app): """ + FEATURE_FLAGS.tags = CONFIG.ENABLE_TAGS + if CONFIG.EMAIL_AUTH_SMTP_SETTINGS is None: LOGGER.warning( "No email auth SMTP settings provided, email registration will not be enabled." diff --git a/pydatalab/src/pydatalab/models/__init__.py b/pydatalab/src/pydatalab/models/__init__.py index ea76f4514..723a5a999 100644 --- a/pydatalab/src/pydatalab/models/__init__.py +++ b/pydatalab/src/pydatalab/models/__init__.py @@ -8,6 +8,7 @@ from pydatalab.models.people import Person from pydatalab.models.samples import Sample from pydatalab.models.starting_materials import StartingMaterial +from pydatalab.models.tags import Tag from pydatalab.models.versions import ItemVersion @@ -38,6 +39,7 @@ def generate_schemas() -> dict[str, dict]: "Cell", "Collection", "Equipment", + "Tag", "ItemVersion", "ITEM_MODELS", "ITEM_SCHEMAS", diff --git a/pydatalab/src/pydatalab/models/items.py b/pydatalab/src/pydatalab/models/items.py index d1295d180..851aca9a1 100644 --- a/pydatalab/src/pydatalab/models/items.py +++ b/pydatalab/src/pydatalab/models/items.py @@ -8,6 +8,7 @@ HasBlocks, HasOwner, HasRevisionControl, + HasTags, IsCollectable, ) from pydatalab.models.utils import ( @@ -18,7 +19,7 @@ ) -class Item(Entry, HasOwner, HasRevisionControl, IsCollectable, HasBlocks, abc.ABC): +class Item(Entry, HasOwner, HasRevisionControl, IsCollectable, HasBlocks, HasTags, abc.ABC): """The generic model for data types that will be exposed with their own named endpoints.""" refcode: Refcode | None = None diff --git a/pydatalab/src/pydatalab/models/tags.py b/pydatalab/src/pydatalab/models/tags.py new file mode 100644 index 000000000..582bbe9cb --- /dev/null +++ b/pydatalab/src/pydatalab/models/tags.py @@ -0,0 +1,23 @@ +from typing import Literal + +from pydatalab.models.entries import Entry + + +class Tag(Entry): + """A tag that can be associated to other Entry entities. + + Tags are global: they are created and managed by administrators and can be + used by every user. (Ownership/scope may be reintroduced later, e.g. via + `HasOwner`.) + """ + + type: Literal["tags"] = "tags" + + name: str + """A short, human-readable label for the tag.""" + + description: str | None = None + """An optional description of the tag, either in plain-text or a markup language.""" + + color: str | None = None + """An optional display color for the tag (e.g. a CSS hex string like `#f1c40f`).""" diff --git a/pydatalab/src/pydatalab/models/traits.py b/pydatalab/src/pydatalab/models/traits.py index b7805c984..9627418f8 100644 --- a/pydatalab/src/pydatalab/models/traits.py +++ b/pydatalab/src/pydatalab/models/traits.py @@ -4,7 +4,13 @@ from pydatalab.models.blocks import DataBlockResponse from pydatalab.models.people import Group, Person -from pydatalab.models.utils import BaseModel, Constituent, InlineSubstance, PyObjectId +from pydatalab.models.utils import ( + BaseModel, + Constituent, + EntryReference, + InlineSubstance, + PyObjectId, +) __all__ = ( "HasOwner", @@ -13,6 +19,7 @@ "IsCollectable", "HasSynthesisInfo", "HasSubstanceInfo", + "HasTags", ) @@ -30,6 +37,76 @@ class HasOwner(BaseModel): """Inlined info for the groups with access to this item.""" +class HasTags(BaseModel): + """Trait mixin for models that can be annotated with tags. + + Note: this mixin only provides the stored `tags` field and its coercion. + Inlining current tag names for display (and dropping references to deleted + tags) is a read-time concern handled by + `pydatalab.mongo.resolve_tags_for_docs`, which each entity's read path must + call explicitly on the docs it returns. + """ + + tags: list[EntryReference | str] = Field(default_factory=list) + """Tags applied to this entry: references to managed `tags` entries (by + `immutable_id`) and/or free-text strings.""" + + @field_validator("tags", mode="before") + @classmethod + def coerce_tags(cls, v): + """Coerce raw tag entries into references or strings, and de-duplicate. + + - a plain string is kept as a free-text tag; + - a mapping carrying an `immutable_id` becomes an + `EntryReference` of type ``tags``; + - a mapping carrying only a `name` is treated as a free-text string. + + References are de-duplicated by `immutable_id`, strings by value. + """ + if v is None: + return [] + if not isinstance(v, list): + raise ValueError("`tags` must be a list") + + coerced: list = [] + seen_strings: set[str] = set() + seen_refs: set[PyObjectId | None] = set() + + for tag in v: + if isinstance(tag, str): + name = tag.strip() + if name and name not in seen_strings: + seen_strings.add(name) + coerced.append(name) + continue + + if isinstance(tag, EntryReference): + if tag.immutable_id not in seen_refs: + seen_refs.add(tag.immutable_id) + coerced.append(tag) + continue + + if isinstance(tag, dict): + if tag.get("immutable_id") is not None: + data = dict(tag) + data.setdefault("type", "tags") + ref = EntryReference(**data) + if ref.immutable_id not in seen_refs: + seen_refs.add(ref.immutable_id) + coerced.append(ref) + continue + + name = (tag.get("name") or "").strip() + if name and name not in seen_strings: + seen_strings.add(name) + coerced.append(name) + continue + + raise ValueError(f"Invalid tag entry: {tag!r}") + + return coerced + + class HasRevisionControl(BaseModel): revision: int = 1 """The revision number of the entry.""" diff --git a/pydatalab/src/pydatalab/mongo.py b/pydatalab/src/pydatalab/mongo.py index 509bb2d2c..3f5764a66 100644 --- a/pydatalab/src/pydatalab/mongo.py +++ b/pydatalab/src/pydatalab/mongo.py @@ -5,6 +5,7 @@ from typing import Any import pymongo +from bson import ObjectId from flask_pymongo import PyMongo from pydantic import BaseModel from pymongo.errors import ConnectionFailure @@ -23,11 +24,13 @@ "USERS_FTS_FIELDS", "COLLECTIONS_FTS_FIELDS", "GROUPS_FTS_FIELDS", + "TAGS_FTS_FIELDS", "generate_heuristic_regex_search", "build_search_pipeline", "creators_lookup", "groups_lookup", "files_lookup", + "resolve_tags_for_docs", ) flask_mongo = PyMongo() @@ -75,6 +78,61 @@ def files_lookup() -> dict: } +def resolve_tags_for_docs(docs: list[dict]) -> None: + """Inline managed tag details into each doc's `tags` field, in place. + + Tag references (mappings carrying an `immutable_id`) are resolved against + the `tags` collection with no permission filter: display access is gated + by the parent entry itself, so every tag on a viewable entry resolves. + Free-text string tags pass through unchanged, and references to deleted tags + are dropped. + """ + tag_ids: set[ObjectId] = set() + for doc in docs: + for tag in doc.get("tags") or []: + if isinstance(tag, dict): + tag_ids.add(ObjectId(tag["immutable_id"])) + + if not tag_ids: + return + + resolved = { + tag_doc["_id"]: tag_doc + for tag_doc in flask_mongo.db.tags.find( + {"_id": {"$in": list(tag_ids)}}, + projection={"_id": 1, "name": 1, "description": 1, "color": 1}, + ) + } + + for doc in docs: + tags = doc.get("tags") + if not tags: + continue + resolved_tags: list = [] + for tag in tags: + # Free-text tag. + if isinstance(tag, str): + resolved_tags.append(tag) + continue + if isinstance(tag, dict): + match = resolved.get(ObjectId(tag["immutable_id"])) + # Referenced tag no longer exists: drop it silently. + if match is None: + continue + resolved_tags.append( + { + "type": "tags", + "immutable_id": str(match["_id"]), + "name": match.get("name"), + "description": match.get("description"), + "color": match.get("color"), + } + ) + continue + resolved_tags.append(tag) + doc["tags"] = resolved_tags + + @lru_cache(maxsize=1) def get_items_fts_fields() -> set[str]: """Get all string fields from item models for full-text search.""" @@ -117,6 +175,9 @@ def get_items_fts_fields() -> set[str]: GROUPS_FTS_FIELDS: set[str] = {"group_id", "display_name", "description"} """Fields to search for groups.""" +TAGS_FTS_FIELDS: set[str] = {"name", "description"} +"""Fields to search for tags.""" + def generate_heuristic_regex_search( query: str, fields: set[str], part_length: int = 4 @@ -335,6 +396,12 @@ def create_fts(): weights={"collection_id": 3, "title": 3, "description": 3}, ) + ret += create_or_recreate_text_index( + db.tags, + ["name", "description"], + weights={"name": 3, "description": 1}, + ) + ret += db.items.create_index("type", name="item type", background=background) ret += db.items.create_index( "item_id", unique=True, name="unique item ID", background=background diff --git a/pydatalab/src/pydatalab/routes/v0_1/__init__.py b/pydatalab/src/pydatalab/routes/v0_1/__init__.py index 93b3a22db..b3a03950a 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/__init__.py +++ b/pydatalab/src/pydatalab/routes/v0_1/__init__.py @@ -13,6 +13,7 @@ from .info import INFO from .items import ITEMS from .remotes import REMOTES +from .tags import TAGS from .users import USERS BLUEPRINTS: tuple[Blueprint, ...] = ( @@ -29,6 +30,7 @@ INFO, GRAPHS, EXPORT, + TAGS, ) __all__ = ("BLUEPRINTS", "OAUTH", "__api_version__", "OAUTH_PROXIES") diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index ca7dffe1a..64d7926bc 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -15,6 +15,7 @@ from pydatalab.apps import BLOCK_TYPES from pydatalab.config import CONFIG +from pydatalab.feature_flags import FEATURE_FLAGS from pydatalab.logger import LOGGER from pydatalab.models import ITEM_MODELS, ItemVersion from pydatalab.models.items import Item @@ -32,6 +33,7 @@ flask_mongo, get_items_fts_fields, groups_lookup, + resolve_tags_for_docs, ) from pydatalab.permissions import ( PUBLIC_USER_ID, @@ -278,6 +280,8 @@ def get_samples_summary(match: dict | None = None, project: dict | None = None) "refcode": 1, "status": 1, } + if FEATURE_FLAGS.tags: + _project["tags"] = 1 # Cannot mix 0 and 1 keys in MongoDB project so must loop and check if project: @@ -287,7 +291,7 @@ def get_samples_summary(match: dict | None = None, project: dict | None = None) else: _project[key] = 1 - return list( + samples = list( flask_mongo.db.items.aggregate( [ {"$match": match}, @@ -299,6 +303,10 @@ def get_samples_summary(match: dict | None = None, project: dict | None = None) ] ) ) + if FEATURE_FLAGS.tags: + resolve_tags_for_docs(samples) + + return samples def entry_reference_lookup(item_doc: dict) -> dict: @@ -590,6 +598,25 @@ def _copy_sample_from_id(sample_dict: dict, copy_from_item_id: str) -> dict: return sample_dict +def _strip_tag_display_fields(item: dict) -> None: + """Reduce managed tag references in ``item['tags']`` to the minimal + ``{type, immutable_id}`` link before storage, in place. + + The display fields (name/description/color) are inlined by the client and + re-resolved on every read (`resolve_tags_for_docs`), so persisting them would + be redundant denormalisation. Free-text string tags are left unchanged. + """ + tags = item.get("tags") + if not isinstance(tags, list): + return + item["tags"] = [ + {"type": "tags", "immutable_id": tag["immutable_id"]} + if isinstance(tag, dict) and tag.get("immutable_id") is not None + else tag + for tag in tags + ] + + def _create_sample( sample_dict: dict, copy_from_item_id: str | None = None, @@ -685,9 +712,9 @@ def _create_sample( # TODO: encode this at the model level, via custom schema properties or hard-coded `.store()` methods # the `Entry` model. try: - result = flask_mongo.db.items.insert_one( - data_model.model_dump(exclude={"creators", "collections", "groups"}) - ) + to_store = data_model.model_dump(exclude={"creators", "collections", "groups"}) + _strip_tag_display_fields(to_store) + result = flask_mongo.db.items.insert_one(to_store) except DuplicateKeyError as error: raise Conflict(f"Duplicate key error: {str(error)}.") @@ -1099,6 +1126,9 @@ def get_item_data( try: doc = entry_reference_lookup(doc) + # Resolve tag references for display only (a read-time concern): inline + # current tag names and drop references to deleted tags. + resolve_tags_for_docs([doc]) doc = ItemModel(**doc) except ValidationError as error: # The stored document doesn't validate against its declared schema. @@ -1686,6 +1716,9 @@ def save_item(): item.pop("immutable_id", None) item.pop("files", None) + # Store managed tag references minimally; see `_strip_tag_display_fields`. + _strip_tag_display_fields(item) + # Update the item FIRST (transaction safety: item update before version save) result = flask_mongo.db.items.update_one( {"item_id": item_id, **get_default_permissions(user_only=True)}, diff --git a/pydatalab/src/pydatalab/routes/v0_1/tags.py b/pydatalab/src/pydatalab/routes/v0_1/tags.py new file mode 100644 index 000000000..29537dc63 --- /dev/null +++ b/pydatalab/src/pydatalab/routes/v0_1/tags.py @@ -0,0 +1,235 @@ +import datetime +import json + +from bson import ObjectId +from bson.errors import InvalidId +from flask import Blueprint, abort, jsonify, request +from pydantic import ValidationError + +from pydatalab.feature_flags import FEATURE_FLAGS +from pydatalab.logger import logged_route +from pydatalab.models.tags import Tag +from pydatalab.mongo import ( + TAGS_FTS_FIELDS, + build_search_pipeline, + flask_mongo, + insert_pydantic_model_fork_safe, +) +from pydatalab.permissions import active_users_or_get_only, admin_only + +TAGS = Blueprint("tags", __name__) + + +@TAGS.before_request +def _require_tags_feature(): + """Gate the whole blueprint behind the `tags` feature flag.""" + if not FEATURE_FLAGS.tags: + abort(404) + + +@TAGS.before_request +@active_users_or_get_only +def _(): ... + + +def _parse_object_id(raw: str) -> ObjectId | None: + """Parse a string into an ObjectId, returning None if it is not valid.""" + try: + return ObjectId(raw) + except (InvalidId, TypeError): + return None + + +def _name_conflict_exists(name: str, exclude_id: ObjectId | None = None) -> bool: + """Whether a (global) tag with `name` already exists. + + All tags are global, so names are unique across the whole collection. + `immutable_id` remains the true identity, so references never break on rename. + """ + query: dict = {"name": name} + if exclude_id is not None: + query["_id"] = {"$ne": exclude_id} + + return flask_mongo.db.tags.find_one(query, {"_id": 1}) is not None + + +@TAGS.route("/tags", methods=["PUT"]) +@admin_only +def create_tag(): + """Create a new global tag. Restricted to administrators.""" + request_json = request.get_json() + data = request_json.get("data", {}) + + name = data.get("name") + if not name: + return jsonify(status="error", message="A tag name is required."), 400 + + if _name_conflict_exists(name): + return ( + jsonify(status="error", message=f"A tag named {name!r} already exists."), + 409, # 409: Conflict + ) + + try: + tag = Tag( + name=name, + description=data.get("description"), + color=data.get("color"), + last_modified=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ) + except ValidationError as error: + return ( + jsonify(status="error", message="Unable to create the tag.", output=str(error)), + 400, + ) + + tag.immutable_id = insert_pydantic_model_fork_safe(tag, "tags") + + return jsonify({"status": "success", "data": json.loads(tag.model_dump_json())}), 201 + + +@TAGS.route("/tags", methods=["GET"]) +def get_tags(): + """Return all (global) tags, readable by any user.""" + tags = flask_mongo.db.tags.find({}).sort("name", 1) + data = [Tag(**doc).model_dump(mode="json") for doc in tags] + return jsonify({"status": "success", "data": data}) + + +@TAGS.route("/search-tags", methods=["GET"]) +def search_tags(): + """Perform a free-text search over all tags. + + GET parameters: + query: String with the search terms. + nresults: Maximum number of results (default 100). + + Returns: + A list of `{type, immutable_id, name, description, color}` dictionaries in + order of descending match score, suitable for use as tag references. + """ + query = request.args.get("query", type=str) + nresults = request.args.get("nresults", default=100, type=int) + + if not query: + return jsonify({"status": "error", "message": "No query provided."}), 400 + + pipeline = build_search_pipeline(query, TAGS_FTS_FIELDS, None) + pipeline.append({"$limit": nresults}) + pipeline.append({"$project": {"_id": 1, "name": 1, "description": 1, "color": 1}}) + + data = [ + { + "type": "tags", + "immutable_id": str(doc["_id"]), + "name": doc.get("name"), + "description": doc.get("description"), + "color": doc.get("color"), + } + for doc in flask_mongo.db.tags.aggregate(pipeline) + ] + + return jsonify({"status": "success", "data": data}), 200 + + +@TAGS.route("/tags/", methods=["PATCH"]) +@admin_only +@logged_route +def save_tag(tag_id): + """Update a tag's `name`/`description`/`color`. Restricted to administrators.""" + object_id = _parse_object_id(tag_id) + if object_id is None: + return jsonify(status="error", message=f"Invalid tag ID {tag_id!r}."), 400 + + request_json = request.get_json() + updated_data = request_json.get("data") + + if not updated_data: + return ( + jsonify(status="error", message="No data provided to update the tag with."), + 204, # 204: No content + ) + + # Identity is not editable through this endpoint. + for key in ("_id", "immutable_id", "type"): + updated_data.pop(key, None) + + updated_data["last_modified"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + tag = flask_mongo.db.tags.find_one({"_id": object_id}) + + if not tag: + return ( + jsonify(status="error", message=f"Unable to find a tag with ID {tag_id!r}."), + 400, + ) + + # Keep names globally unique on rename. + if "name" in updated_data and _name_conflict_exists(updated_data["name"], exclude_id=object_id): + return ( + jsonify( + status="error", + message=f"A tag named {updated_data['name']!r} already exists.", + ), + 409, + ) + + tag.update(updated_data) + + try: + tag = Tag(**tag).model_dump(exclude={"immutable_id"}) + except ValidationError as exc: + return ( + jsonify( + status="error", + message=f"Unable to update tag {tag_id!r} with new data {updated_data}.", + output=str(exc), + ), + 400, + ) + + result = flask_mongo.db.tags.update_one({"_id": object_id}, {"$set": tag}) + + if result.modified_count != 1: + return ( + jsonify( + status="error", + message=f"Unable to update tag {tag_id!r}.", + output=result.raw_result, + ), + 400, + ) + + return jsonify(status="success"), 200 + + +@TAGS.route("/tags/", methods=["DELETE"]) +@admin_only +def delete_tag(tag_id: str): + """Delete a tag and drop its references from items. Restricted to administrators.""" + object_id = _parse_object_id(tag_id) + if object_id is None: + return jsonify(status="error", message=f"Invalid tag ID {tag_id!r}."), 400 + + result = flask_mongo.db.tags.delete_one({"_id": object_id}) + + if result.deleted_count != 1: + return ( + jsonify(status="error", message=f"No tag found with ID {tag_id!r}."), + 404, + ) + + # Best-effort cleanup: drop references to the deleted tag from items' `tags` + # arrays. Like collection deletion, this is a raw update that does NOT go + # through the item save route, so it neither bumps `last_modified` nor creates + # a new item version. + # + # This hardcodes `items` as the only `HasTags` collection. Extend it if + # `HasTags` is applied to other entities. Note that references are not deleted + # from item_versions, so a reference to a deleted tag can survive there. + flask_mongo.db.items.update_many( + {"tags": {"$elemMatch": {"immutable_id": object_id, "type": "tags"}}}, + {"$pull": {"tags": {"immutable_id": object_id, "type": "tags"}}}, + ) + + return jsonify(status="success"), 200 diff --git a/pydatalab/tests/server/conftest.py b/pydatalab/tests/server/conftest.py index b00855b89..37eaa9670 100644 --- a/pydatalab/tests/server/conftest.py +++ b/pydatalab/tests/server/conftest.py @@ -58,6 +58,7 @@ def app_config(secret_key, files_directory): "REMOTE_FILESYSTEMS": example_remotes, "FILE_DIRECTORY": str(files_directory), "TESTING": False, + "ENABLE_TAGS": True, "ROOT_PATH": "/", "SECRET_KEY": secret_key, "AUTO_ACTIVATE_ACCOUNTS": False, diff --git a/pydatalab/tests/server/test_tags.py b/pydatalab/tests/server/test_tags.py new file mode 100644 index 000000000..34086e5f4 --- /dev/null +++ b/pydatalab/tests/server/test_tags.py @@ -0,0 +1,265 @@ +"""Tests for the (simplified) tags routes. + +Tags are global: only administrators can create/edit/delete them, but every user +can list, search and apply them. There is no ownership/scope in this stage. +""" + +import pytest +from bson import ObjectId + + +@pytest.fixture(autouse=True) +def _isolate_tags(database): + """Isolate each test. + + The test database is only dropped per-module, so the `tags` collection would + otherwise leak between tests. Clear it before and after each test. + """ + database.tags.delete_many({}) + yield + database.tags.delete_many({}) + + +def _create_tag(client, name, description=None, color=None): + """Helper to PUT a tag and return the response.""" + data = {"name": name} + if description is not None: + data["description"] = description + if color is not None: + data["color"] = color + return client.put("/tags", json={"data": data}) + + +def test_create_tag_requires_admin(client, admin_client, unauthenticated_client): + """Only an administrator can create a tag.""" + # An unauthenticated user is rejected. + assert _create_tag(unauthenticated_client, "unauth-tag").status_code == 401 + + # An authenticated non-admin user is forbidden. + assert _create_tag(client, "user-tag").status_code == 403 + + # An admin can create the tag. + response = _create_tag(admin_client, "admin-tag", "An example", color="#f1c40f") + assert response.status_code == 201, response.json + tag = response.json["data"] + assert tag["type"] == "tags" + assert tag["name"] == "admin-tag" + assert tag["color"] == "#f1c40f" + assert tag["immutable_id"] + # Tags carry no ownership/scope in this stage. + assert "creator_ids" not in tag + assert "group_ids" not in tag + + +def test_duplicate_tag_rejected(admin_client): + """Tag names are globally unique.""" + assert _create_tag(admin_client, "duplicate-tag").status_code == 201 + assert _create_tag(admin_client, "duplicate-tag").status_code == 409 + + +def test_list_and_search_tags(client, another_client, admin_client): + """All users can list and search all tags; results carry no scope/ownership.""" + assert _create_tag(admin_client, "searchable-one", color="#abcdef").status_code == 201 + assert _create_tag(admin_client, "searchable-two", "a description").status_code == 201 + + # Any user sees every tag in the listing. + for c in (client, another_client, admin_client): + response = c.get("/tags") + assert response.status_code == 200, response.json + names = {t["name"] for t in response.json["data"]} + assert {"searchable-one", "searchable-two"} <= names + + # Free-text search returns reference-shaped results with name/description/color. + response = client.get("/search-tags", query_string={"query": "searchable"}) + assert response.status_code == 200, response.json + results = {r["name"]: r for r in response.json["data"]} + assert {"searchable-one", "searchable-two"} <= set(results) + for result in response.json["data"]: + assert result["type"] == "tags" + assert result["immutable_id"] + assert results["searchable-one"]["color"] == "#abcdef" + assert results["searchable-two"]["description"] == "a description" + + # The empty-query case is rejected. + assert client.get("/search-tags", query_string={"query": ""}).status_code == 400 + + +def test_patch_tag(client, admin_client): + """An admin can rename/re-describe a tag; non-admins cannot; names stay unique.""" + tag_id = _create_tag(admin_client, "patchable", "first").json["data"]["immutable_id"] + assert _create_tag(admin_client, "patchable-other").status_code == 201 + + # A non-admin cannot edit a tag. + assert ( + client.patch(f"/tags/{tag_id}", json={"data": {"description": "nope"}}).status_code == 403 + ) + + # An admin can. + response = admin_client.patch(f"/tags/{tag_id}", json={"data": {"description": "updated"}}) + assert response.status_code == 200, response.json + + patched = next(t for t in client.get("/tags").json["data"] if t["immutable_id"] == tag_id) + assert patched["description"] == "updated" + + # Renaming onto an existing name is rejected. + response = admin_client.patch(f"/tags/{tag_id}", json={"data": {"name": "patchable-other"}}) + assert response.status_code == 409, response.json + + # An invalid ID is a 400. + assert ( + admin_client.patch("/tags/not-an-object-id", json={"data": {"name": "x"}}).status_code + == 400 + ) + + +def test_delete_tag(client, admin_client, database): + """Only an admin can delete a tag; its references are pulled from items.""" + tag_id = _create_tag(admin_client, "deletable", color="#abcdef").json["data"]["immutable_id"] + + # Apply the tag to an item to check the reference cleanup on delete. + assert ( + client.post("/new-sample/", json={"type": "samples", "item_id": "tag-delete"}).status_code + == 201 + ) + assert ( + client.post( + "/save-item/", + json={ + "item_id": "tag-delete", + "data": {"tags": [{"type": "tags", "immutable_id": tag_id}]}, + }, + ).status_code + == 200 + ) + + # A non-admin cannot delete the tag. + assert client.delete(f"/tags/{tag_id}").status_code == 403 + + # An admin can. + assert admin_client.delete(f"/tags/{tag_id}").status_code == 200 + + names = {t["name"] for t in client.get("/tags").json["data"]} + assert "deletable" not in names + + # The reference is pulled from the item document in the database. + stored = database.items.find_one({"item_id": "tag-delete"}) + assert [t for t in stored.get("tags", []) if isinstance(t, dict)] == [] + + # Deleting a non-existent tag is a 404. + assert admin_client.delete(f"/tags/{tag_id}").status_code == 404 + + +def test_tags_feature_flag_gate(client, admin_client, monkeypatch): + """When the `tags` feature flag is off, the whole blueprint 404s.""" + from pydatalab.feature_flags import FEATURE_FLAGS + + monkeypatch.setattr(FEATURE_FLAGS, "tags", False) + assert client.get("/tags").status_code == 404 + assert client.get("/search-tags", query_string={"query": "x"}).status_code == 404 + assert _create_tag(admin_client, "flagged-off").status_code == 404 + + +def test_item_tag_resolution(client, admin_client, database): + """Tag references on an item are resolved (and refreshed) on read; deleted tags drop out. + + Also covers the route-level round-trip of the mixed `tags` field (reference + string). + """ + # A global tag (with a color) that the normal user can apply. + tag_id = _create_tag(admin_client, "test-resolve-tag", color="#abcdef").json["data"][ + "immutable_id" + ] + + # Apply it to a sample alongside a custom string, with a deliberately stale inlined name. + assert ( + client.post("/new-sample/", json={"type": "samples", "item_id": "tag-resolve"}).status_code + == 201 + ) + save = client.post( + "/save-item/", + json={ + "item_id": "tag-resolve", + "data": { + "tags": [ + {"type": "tags", "immutable_id": tag_id, "name": "stale name"}, + "test-custom-string", + ] + }, + }, + ) + assert save.status_code == 200, save.json + + # The stored item keeps only the minimal `{type, immutable_id}` reference — + # display fields (name/description/color) are not persisted. + stored = database.items.find_one({"item_id": "tag-resolve"}) + assert [t for t in stored["tags"] if isinstance(t, dict)] == [ + {"type": "tags", "immutable_id": ObjectId(tag_id)} + ] + assert "test-custom-string" in stored["tags"] + + def _get_tags(): + resp = client.get("/get-item-data/tag-resolve") + assert resp.status_code == 200, resp.json + return resp.json["item_data"]["tags"] + + tags = _get_tags() + refs = [t for t in tags if isinstance(t, dict)] + strings = [t for t in tags if isinstance(t, str)] + # The custom string passes through; the reference resolves to the *current* + # name, not the stale stored one. + assert strings == ["test-custom-string"] + assert len(refs) == 1 + assert refs[0]["immutable_id"] == tag_id + assert refs[0]["name"] == "test-resolve-tag" + # The tag's color is inlined on the resolved reference. + assert refs[0]["color"] == "#abcdef" + + # Renaming the tag is reflected on the next read. + assert ( + admin_client.patch( + f"/tags/{tag_id}", json={"data": {"name": "test-resolve-renamed"}} + ).status_code + == 200 + ) + refs = [t for t in _get_tags() if isinstance(t, dict)] + assert refs[0]["name"] == "test-resolve-renamed" + + # A dangling reference (e.g. one surviving in a restored version) is dropped on read. + database.items.update_one( + {"item_id": "tag-resolve"}, + {"$push": {"tags": {"type": "tags", "immutable_id": ObjectId()}}}, + ) + tags = _get_tags() + refs = [t for t in tags if isinstance(t, dict)] + assert [t for t in tags if isinstance(t, str)] == ["test-custom-string"] + assert len(refs) == 1 + assert refs[0]["immutable_id"] == tag_id + + # Deleting the tag removes the reference (the custom string survives). + assert admin_client.delete(f"/tags/{tag_id}").status_code == 200 + assert _get_tags() == ["test-custom-string"] + + +def test_tags_stripped_on_creation(client, admin_client, database): + """Tags provided directly at item creation are stored as minimal references too.""" + tag_id = _create_tag(admin_client, "test-create-tag", color="#abcdef").json["data"][ + "immutable_id" + ] + + response = client.post( + "/new-sample/", + json={ + "type": "samples", + "item_id": "tag-on-create", + "tags": [ + {"type": "tags", "immutable_id": tag_id, "name": "stale", "color": "#abcdef"}, + "create-custom", + ], + }, + ) + assert response.status_code == 201, response.json + + stored = database.items.find_one({"item_id": "tag-on-create"}) + assert [t for t in stored["tags"] if isinstance(t, dict)] == [ + {"type": "tags", "immutable_id": ObjectId(tag_id)} + ] + assert "create-custom" in stored["tags"] diff --git a/pydatalab/tests/test_models.py b/pydatalab/tests/test_models.py index 625ae5d08..d98237020 100644 --- a/pydatalab/tests/test_models.py +++ b/pydatalab/tests/test_models.py @@ -184,6 +184,69 @@ def test_file(): assert sample.files[1].type == "files" +def test_tag_model(): + from pydatalab.models.tags import Tag + + tag = Tag(name="test_tag", description="This is an example", color="#f1c40f") + assert tag.type == "tags" + assert tag.name == "test_tag" + assert tag.description == "This is an example" + assert tag.color == "#f1c40f" + + # Tags carry no ownership/scope in this stage. + assert not hasattr(tag, "creator_ids") + assert not hasattr(tag, "group_ids") + + oid = ObjectId("0123456789ab0123456789ab") + doc = {"_id": oid, "type": "tags", "name": "glovebox"} + stored_tag = Tag(**doc) + assert stored_tag.immutable_id == oid + assert stored_tag.description is None + assert stored_tag.color is None + assert stored_tag.model_dump()["immutable_id"] == oid + + # A name is required. + with pytest.raises(pydantic.ValidationError): + Tag(description="missing a name") + + +def test_item_tags_coercion(): + """The `HasTags` mixin coerces and de-duplicates tags on items.""" + from pydatalab.models.samples import Sample + from pydatalab.models.utils import EntryReference + + oid = ObjectId("0123456789ab0123456789ab") + + sample = Sample( + item_id="tagged", + tags=[ + "custom", + " custom ", # duplicate after stripping -> dropped + {"type": "tags", "immutable_id": str(oid), "name": "Curated"}, + {"type": "tags", "immutable_id": str(oid)}, # same reference by id -> dropped + ], + ) + + assert len(sample.tags) == 2 + assert sample.tags[0] == "custom" + ref = sample.tags[1] + assert isinstance(ref, EntryReference) + assert ref.type == "tags" + assert ref.immutable_id == oid + assert ref.name == "Curated" + + # Default is an empty list, so existing tag-less documents stay valid. + assert Sample(item_id="untagged").tags == [] + + # A reference to a (possibly deleted) tag still validates. + dangling = Sample(item_id="dangling", tags=[{"type": "tags", "immutable_id": str(ObjectId())}]) + assert len(dangling.tags) == 1 + + # Re-validating a dumped item round-trips the mixed tags list. + roundtrip = Sample(**json.loads(sample.model_dump_json())) + assert [type(t).__name__ for t in roundtrip.tags] == ["str", "EntryReference"] + + def test_custom_and_inherited_items(): class TestItem(Item): type: str = "items_custom" diff --git a/webapp/cypress/component/TagColorPickerTest.cy.jsx b/webapp/cypress/component/TagColorPickerTest.cy.jsx new file mode 100644 index 000000000..9a49ed4cc --- /dev/null +++ b/webapp/cypress/component/TagColorPickerTest.cy.jsx @@ -0,0 +1,42 @@ +import TagColorPicker from "@/components/TagColorPicker.vue"; +import { TAG_COLOR_PALETTE } from "@/resources.js"; + +describe("TagColorPicker.vue", () => { + it("renders the preset palette and a no-color option", () => { + cy.mount(TagColorPicker, { props: { modelValue: null } }); + cy.get(".swatch:not(.swatch-none)").should("have.length", TAG_COLOR_PALETTE.length); + cy.get(".swatch-none").should("exist"); + }); + + it("emits the chosen preset color", () => { + cy.mount(TagColorPicker, { + props: { modelValue: null, "onUpdate:modelValue": cy.spy().as("update") }, + }); + const color = TAG_COLOR_PALETTE[0]; + cy.get(`.swatch[title="${color}"]`).click(); + cy.get("@update").should("have.been.calledWith", color); + }); + + it("emits null for the no-color option", () => { + cy.mount(TagColorPicker, { + props: { modelValue: TAG_COLOR_PALETTE[0], "onUpdate:modelValue": cy.spy().as("update") }, + }); + cy.get(".swatch-none").click(); + cy.get("@update").should("have.been.calledWith", null); + }); + + it("marks the active preset as selected", () => { + const color = TAG_COLOR_PALETTE[1]; + cy.mount(TagColorPicker, { props: { modelValue: color } }); + cy.get(`.swatch[title="${color}"]`).should("have.class", "selected"); + }); + + it("emits a custom color from the native picker", () => { + cy.mount(TagColorPicker, { + props: { modelValue: null, "onUpdate:modelValue": cy.spy().as("update") }, + }); + // The native color input normalises to a lowercase 6-digit hex. + cy.get('input[type="color"]').invoke("val", "#123456").trigger("input"); + cy.get("@update").should("have.been.calledWith", "#123456"); + }); +}); diff --git a/webapp/cypress/component/TagFormModalTest.cy.jsx b/webapp/cypress/component/TagFormModalTest.cy.jsx new file mode 100644 index 000000000..4cb753ed8 --- /dev/null +++ b/webapp/cypress/component/TagFormModalTest.cy.jsx @@ -0,0 +1,89 @@ +import TagFormModal from "@/components/TagFormModal.vue"; + +// Mount the modal closed, then open it by flipping `modelValue` so the Modal's open watcher +// (and the form's populate/reset watcher) fire as they do in the app. Returns the test-utils +// wrapper aliased as "wrapper" for emitted assertions. +// +// Note: component tests run without the app's global Bootstrap CSS, so the Modal's backdrop +// overlays the dialog (no .modal z-index). We use { force: true } on interactions to bypass +// that purely-visual actionability check; the request-body and emit assertions are unaffected. +function mountAndOpen({ tag = null } = {}) { + return cy + .mount(TagFormModal, { + props: { modelValue: false, tag }, + }) + .then(({ wrapper }) => { + cy.wrap(wrapper).as("wrapper"); + return wrapper.setProps({ modelValue: true }); + }); +} + +describe("TagFormModal.vue", () => { + describe("create mode", () => { + it("creates a tag with only name/description/color in the payload", () => { + cy.intercept("PUT", "**/tags", { statusCode: 201, body: { status: "success", data: {} } }).as( + "create", + ); + mountAndOpen(); + + cy.get("#tag-name").type("flammable", { force: true }); + cy.get("#tag-description").type("burns", { force: true }); + cy.get('input[type="submit"]').click({ force: true }); + + cy.wait("@create") + .its("request.body") + .should("deep.equal", { + data: { name: "flammable", description: "burns", color: null }, + }); + cy.get("@wrapper").should((wrapper) => { + expect(wrapper.emitted("tag-created")).to.have.length(1); + // The modal asks its parent to close on success. + expect(wrapper.emitted("update:modelValue").at(-1)).to.deep.equal([false]); + }); + }); + + it("shows a name conflict (409) inline instead of an error dialog", () => { + cy.intercept("PUT", "**/tags", { + statusCode: 409, + body: { status: "error", message: "A tag named 'dup' already exists." }, + }).as("create"); + mountAndOpen(); + + cy.get("#tag-name").type("dup", { force: true }); + cy.get('input[type="submit"]').click({ force: true }); + + cy.wait("@create"); + cy.get(".form-error").should("contain", "already exists"); + // The modal stays open on a conflict. + cy.get("@wrapper").should((wrapper) => { + expect(wrapper.emitted("tag-created")).to.be.undefined; + }); + }); + }); + + describe("edit mode", () => { + const existingTag = { + immutable_id: "tag-1", + name: "old-name", + description: "desc", + color: "#abcdef", + }; + + it("pre-fills fields and updates metadata via PATCH /tags/", () => { + cy.intercept("PATCH", "**/tags/*", { statusCode: 200, body: { status: "success" } }).as( + "updateTag", + ); + mountAndOpen({ tag: existingTag }); + + cy.get("#tag-name").should("have.value", "old-name"); + cy.get("#tag-name").clear({ force: true }); + cy.get("#tag-name").type("new-name", { force: true }); + cy.get('input[type="submit"]').click({ force: true }); + + cy.wait("@updateTag").its("request.body.data.name").should("equal", "new-name"); + cy.get("@wrapper").should((wrapper) => { + expect(wrapper.emitted("tag-updated")).to.have.length(1); + }); + }); + }); +}); diff --git a/webapp/cypress/component/TagManagementTableTest.cy.jsx b/webapp/cypress/component/TagManagementTableTest.cy.jsx new file mode 100644 index 000000000..10e89b69d --- /dev/null +++ b/webapp/cypress/component/TagManagementTableTest.cy.jsx @@ -0,0 +1,86 @@ +import TagManagementTable from "@/components/TagManagementTable.vue"; +import StyledTooltip from "@/components/StyledTooltip.vue"; +import PrimeVue from "primevue/config"; +import { createStore } from "vuex"; + +const TAGS = [ + { + immutable_id: "t1", + type: "tags", + name: "flammable", + description: "burns", + color: "#f1c40f", + }, + { + immutable_id: "t2", + type: "tags", + name: "global-tag", + description: null, + color: null, + }, +]; + +function mountTable(role) { + const store = createStore({ + state() { + return { + currentUserID: "self", + currentUserRole: role, + datatablePaginationSettings: { + tags: { page: 0, rows: 20 }, + }, + tag_list: TAGS, + }; + }, + }); + + cy.mount(TagManagementTable, { + global: { + plugins: [store, PrimeVue], + components: { + StyledTooltip, + }, + }, + }); +} + +describe("TagManagementTable Component Tests", () => { + it("renders the expected (scope-free) columns", () => { + mountTable("admin"); + const headers = ["", "Tag", "Description", "Actions"]; + cy.get(".p-datatable-column-header-content").should("have.length", headers.length); + cy.get(".p-datatable-column-header-content").each((header, index) => { + cy.wrap(header).should("contain.text", headers[index]); + }); + }); + + it("displays a badge per tag from the store", () => { + mountTable("admin"); + cy.get(".p-datatable-tbody") + .find("tr") + .eq(0) + .within(() => { + cy.get("td").eq(1).find(".badge").should("contain.text", "flammable"); + }); + cy.get(".p-datatable-tbody") + .find("tr") + .eq(1) + .within(() => { + cy.get("td").eq(1).find(".badge").should("contain.text", "global-tag"); + }); + }); + + it("shows the create button and Edit/Delete on every tag for an admin", () => { + mountTable("admin"); + cy.get('[data-testid="add-tag-button"]').should("exist"); + cy.get('button[title="Edit tag"]').should("have.length", TAGS.length); + cy.get('button[title="Delete tag"]').should("have.length", TAGS.length); + }); + + it("hides create/edit/delete controls for a non-admin", () => { + mountTable("user"); + cy.get('[data-testid="add-tag-button"]').should("not.exist"); + cy.get('button[title="Edit tag"]').should("not.exist"); + cy.get('button[title="Delete tag"]').should("not.exist"); + }); +}); diff --git a/webapp/cypress/component/TagSelectTest.cy.jsx b/webapp/cypress/component/TagSelectTest.cy.jsx new file mode 100644 index 000000000..a9fe3261e --- /dev/null +++ b/webapp/cypress/component/TagSelectTest.cy.jsx @@ -0,0 +1,74 @@ +import TagSelect from "@/components/TagSelect.vue"; + +describe("TagSelect.vue", () => { + const managedTag = { + type: "tags", + immutable_id: "0123456789ab0123456789ab", + name: "test-managed-tag", + description: "reacts with air", + color: "#f1c40f", + }; + + beforeEach(() => { + cy.intercept("GET", "**/search-tags*", { + body: { status: "success", data: [managedTag] }, + }).as("searchTags"); + }); + + it("searches and emits a reference object when a managed tag is selected", () => { + cy.mount(TagSelect, { + props: { modelValue: [], "onUpdate:modelValue": cy.spy().as("update") }, + }); + + cy.get(".vs__search").type("test-man"); + cy.wait("@searchTags"); + // The option shows the tag name and a color swatch. + cy.get(".vs__dropdown-option").contains("test-managed-tag").should("exist"); + cy.get(".vs__dropdown-option .color-swatch").should("exist"); + cy.get(".vs__dropdown-option").contains("test-managed-tag").click(); + + // The reference preserves display fields (color/description). + cy.get("@update").should("have.been.calledWith", [ + { + type: "tags", + immutable_id: managedTag.immutable_id, + name: "test-managed-tag", + color: "#f1c40f", + description: "reacts with air", + }, + ]); + }); + + it("emits a plain string when a free-text tag is created", () => { + cy.mount(TagSelect, { + props: { modelValue: [], "onUpdate:modelValue": cy.spy().as("update") }, + }); + + cy.get(".vs__search").type("test-free-text-tag"); + cy.wait("@searchTags"); + // The typed value, not matching a managed tag, is offered as a free-text tag. + cy.get(".vs__dropdown-option").contains("test-free-text-tag").click(); + + cy.get("@update").should("have.been.calledWith", ["test-free-text-tag"]); + }); + + it("renders existing string and reference tags as selected chips", () => { + cy.mount(TagSelect, { + props: { modelValue: ["test-free-text-tag", managedTag] }, + }); + + cy.get(".vs__selected").should("contain", "test-free-text-tag"); + cy.get(".vs__selected").should("contain", "test-managed-tag"); + }); + + it("labels a typed non-matching value as a free-text tag", () => { + cy.mount(TagSelect, { + props: { modelValue: [] }, + }); + + cy.get(".vs__search").type("brand-new-tag"); + cy.wait("@searchTags"); + // The typed value (no managed match) is offered as a free-text tag. + cy.get(".vs__dropdown-option .free-text-tag-hint").should("contain", "free-text"); + }); +}); diff --git a/webapp/cypress/e2e/tagsManagement.cy.js b/webapp/cypress/e2e/tagsManagement.cy.js new file mode 100644 index 000000000..61dd74146 --- /dev/null +++ b/webapp/cypress/e2e/tagsManagement.cy.js @@ -0,0 +1,141 @@ +// E2e tests for the tag management page (/tags). Needs the dev server + API (:5001) running +// with testing auth AND the tags feature enabled (PYDATALAB_ENABLE_TAGS). +// Managed tags are global and admin-only; admin-user@example.com is an admin by the same +// convention as authenticatedSampleTests.cy.js. + +const adminEmail = "admin-user@example.com"; +const userEmail = "tag-owner@example.com"; // a non-admin user + +describe("Tag management page (admin)", () => { + // Names must not be substrings of each other: cy.contains matches substrings, so a + // "not.exist" check on the original would still match the renamed badge otherwise. + const tagName = "e2e-create-tag"; + const renamedTag = "e2e-renamed-tag"; + + beforeEach(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(tagName); + cy.deleteTagByNameViaAPI(renamedTag); + }); + + after(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(tagName); + cy.deleteTagByNameViaAPI(renamedTag); + }); + + it("creates, edits and deletes a tag", () => { + cy.visit("/tags"); + + // Create + cy.get('[data-testid="add-tag-button"]').click(); + cy.get("#tag-name").type(tagName); + cy.get("#tag-description").type("created in an e2e test"); + cy.get(".swatch").first().click(); + cy.get(".modal-footer input[type=submit]:visible").click(); + // Scope badge assertions to the table: the (closed) edit/create modal keeps a hidden + // TagBadge preview in the DOM (Modal uses display:none), which a document-wide + // `.badge` match would pick up and break the `not.exist` checks below. + cy.get('[data-testid="tags-table"]').contains(".badge", tagName).should("exist"); + + // Edit (rename) + cy.contains("tr", tagName).find('button[title="Edit tag"]').click(); + cy.get("#tag-name").clear(); + cy.get("#tag-name").type(renamedTag); + cy.get(".modal-footer input[type=submit]:visible").click(); + cy.get('[data-testid="tags-table"]').contains(".badge", renamedTag).should("exist"); + cy.get('[data-testid="tags-table"]').contains(".badge", tagName).should("not.exist"); + + // Delete + cy.contains("tr", renamedTag).find('button[title="Delete tag"]').click(); + cy.get('[data-testid="dialog-modal-confirm-button"]').click(); + cy.get('[data-testid="tags-table"]').contains(".badge", renamedTag).should("not.exist"); + }); +}); + +describe("Tag management permissions", () => { + const tagName = "e2e-perm-tag"; + + before(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(tagName); + cy.createTagViaAPI({ name: tagName }); + }); + + after(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(tagName); + }); + + it("hides create/edit/delete controls for a non-admin", () => { + cy.loginViaTestMagicLink(userEmail); + cy.visit("/tags"); + cy.contains("tr", tagName).should("exist"); // the table is visible to everyone + cy.get('[data-testid="add-tag-button"]').should("not.exist"); + cy.contains("tr", tagName).within(() => { + cy.get('button[title="Edit tag"]').should("not.exist"); + cy.get('button[title="Delete tag"]').should("not.exist"); + }); + }); + + it("shows create/edit/delete controls for an admin", () => { + cy.loginViaTestMagicLink(adminEmail); + cy.visit("/tags"); + cy.get('[data-testid="add-tag-button"]').should("exist"); + cy.contains("tr", tagName).within(() => { + cy.get('button[title="Edit tag"]').should("exist"); + }); + }); +}); + +describe("Applying a tag to an item", () => { + const intTag = "e2e-applied-tag"; + const sampleId = "e2e-tag-sample"; + + before(() => { + // Only admins can create managed tags. + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(intTag); + cy.createTagViaAPI({ name: intTag }); + }); + + beforeEach(() => { + cy.loginViaTestMagicLink(userEmail); + cy.deleteSampleViaAPI(sampleId); + cy.visit("/samples"); + cy.createSample(sampleId, "Tag e2e sample"); + }); + + after(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteSampleViaAPI(sampleId); + cy.deleteTagByNameViaAPI(intTag); + }); + + it("applies a managed tag and drops it from the item when the tag is deleted", () => { + cy.intercept("GET", "**/search-tags*").as("searchTags"); + cy.intercept("POST", "**/save-item/").as("save"); + + cy.visit(`/edit/${sampleId}`); + + // Enter edit mode on the Tags field (click the label text, away from the cog link), + // then pick the managed tag from the TagSelect dropdown. + cy.get("#tags").click("left"); + cy.get("#tags").parent().find(".vs__search").type(intTag); + cy.wait("@searchTags"); + cy.get("#tags").parent().contains(".vs__dropdown-option", intTag).click(); + + // Save (Ctrl/Cmd+S) and confirm the tag survives a reload. + cy.get("body").type("{ctrl}s"); + cy.wait("@save"); + cy.reload(); + cy.contains(".badge", intTag).should("exist"); + + // Deleting the tag (as admin) removes the reference from the item on the next read. + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(intTag); + cy.loginViaTestMagicLink(userEmail); + cy.reload(); + cy.contains(".badge", intTag).should("not.exist"); + }); +}); diff --git a/webapp/cypress/support/commands.js b/webapp/cypress/support/commands.js index f50c47a7e..84093f64c 100644 --- a/webapp/cypress/support/commands.js +++ b/webapp/cypress/support/commands.js @@ -115,6 +115,37 @@ Cypress.Commands.add("deleteSampleViaAPI", (item_id) => { }); }); +Cypress.Commands.add("createTagViaAPI", (data) => { + // data: { name, description?, color? }. Managed tags are admin-only, so log in as an admin + // before calling this. Returns the new tag's id. + return cy + .request({ + method: "PUT", + url: API_URL + "/tags", + body: { data }, + failOnStatusCode: false, + }) + .then((response) => response.body?.data?.immutable_id ?? null); +}); + +Cypress.Commands.add("deleteTagByNameViaAPI", (name) => { + // Best-effort cleanup: delete every tag with this name (requires admin auth). + cy.request({ method: "GET", url: API_URL + "/tags", failOnStatusCode: false }).then( + (response) => { + const tags = response.body?.data ?? []; + tags + .filter((tag) => tag.name === name) + .forEach((tag) => { + cy.request({ + method: "DELETE", + url: API_URL + "/tags/" + tag.immutable_id, + failOnStatusCode: false, + }); + }); + }, + ); +}); + Cypress.Commands.add("uploadFileViaAPI", (itemId, path) => { cy.log("Upload a test file via the API: " + path); cy.fixture(path, "binary") diff --git a/webapp/src/components/BaseIconCounter.vue b/webapp/src/components/BaseIconCounter.vue index 2800e39f8..c74b823f6 100644 --- a/webapp/src/components/BaseIconCounter.vue +++ b/webapp/src/components/BaseIconCounter.vue @@ -4,7 +4,7 @@
- {{ displayCount }} + {{ prefix }}{{ displayCount }}
@@ -14,9 +14,7 @@
- - {{ displayCount }} - + {{ prefix }}{{ displayCount }}
@@ -33,6 +31,10 @@ export default { type: Number, default: 0, }, + prefix: { + type: String, + default: "", + }, showIcon: { type: Boolean, default: false, diff --git a/webapp/src/components/CellInformation.vue b/webapp/src/components/CellInformation.vue index 8c0b34a5b..24455375c 100644 --- a/webapp/src/components/CellInformation.vue +++ b/webapp/src/components/CellInformation.vue @@ -43,6 +43,11 @@ +
+
+ +
+
@@ -126,6 +131,7 @@ import ToggleableCollectionFormGroup from "@/components/ToggleableCollectionForm import ToggleableCreatorsFormGroup from "@/components/ToggleableCreatorsFormGroup"; import ToggleableItemStatusFormGroup from "@/components/ToggleableItemStatusFormGroup"; import ToggleableGroupsFormGroup from "@/components/ToggleableGroupsFormGroup"; +import ToggleableTagsFormGroup from "@/components/ToggleableTagsFormGroup"; import { cellFormats } from "@/resources.js"; export default { @@ -140,6 +146,7 @@ export default { ToggleableCreatorsFormGroup, ToggleableItemStatusFormGroup, ToggleableGroupsFormGroup, + ToggleableTagsFormGroup, }, props: { item_id: { @@ -174,7 +181,11 @@ export default { CellFormatDescription: createComputedSetterForItemField("cell_format_description"), CharacteristicMass: createComputedSetterForItemField("characteristic_mass"), Collections: createComputedSetterForItemField("collections"), + Tags: createComputedSetterForItemField("tags"), Status: createComputedSetterForItemField("status"), + enableTags() { + return this.$store.state.serverInfo?.features?.tags ?? false; + }, schema() { return this.$store.state.schemas[this.item?.type]; }, diff --git a/webapp/src/components/DynamicDataTable.vue b/webapp/src/components/DynamicDataTable.vue index a225fd665..5c31436e1 100644 --- a/webapp/src/components/DynamicDataTable.vue +++ b/webapp/src/components/DynamicDataTable.vue @@ -54,6 +54,7 @@ @open-qr-scanner-modal="qrScannerModalIsOpen = true" @open-create-collection-modal="createCollectionModalIsOpen = true" @open-create-equipment-modal="createEquipmentModalIsOpen = true" + @open-create-tag-modal="$emit('open-create-tag-modal')" @open-add-to-collection-modal="addToCollectionModalIsOpen = true" @open-batch-share-modal="batchShareModalIsOpen = true" @delete-selected-items="deleteSelectedItems" @@ -119,6 +120,7 @@ v-bind="getComponentProps(column.body, slotProps.data)" @edit-group="$emit('edit-group', $event)" @group-deleted="$emit('group-deleted')" + @edit-tag="$emit('edit-tag', $event)" /> + + + + diff --git a/webapp/src/components/TagBadge.vue b/webapp/src/components/TagBadge.vue new file mode 100644 index 000000000..d29ed863a --- /dev/null +++ b/webapp/src/components/TagBadge.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/webapp/src/components/TagColorPicker.vue b/webapp/src/components/TagColorPicker.vue new file mode 100644 index 000000000..f45ff722c --- /dev/null +++ b/webapp/src/components/TagColorPicker.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/webapp/src/components/TagFormModal.vue b/webapp/src/components/TagFormModal.vue new file mode 100644 index 000000000..8ba1e8097 --- /dev/null +++ b/webapp/src/components/TagFormModal.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/webapp/src/components/TagList.vue b/webapp/src/components/TagList.vue new file mode 100644 index 000000000..9dfc47b84 --- /dev/null +++ b/webapp/src/components/TagList.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/webapp/src/components/TagManagementTable.vue b/webapp/src/components/TagManagementTable.vue new file mode 100644 index 000000000..340d5b00e --- /dev/null +++ b/webapp/src/components/TagManagementTable.vue @@ -0,0 +1,78 @@ + + + diff --git a/webapp/src/components/TagSelect.vue b/webapp/src/components/TagSelect.vue new file mode 100644 index 000000000..1bef69335 --- /dev/null +++ b/webapp/src/components/TagSelect.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/webapp/src/components/ToggleableTagsFormGroup.vue b/webapp/src/components/ToggleableTagsFormGroup.vue new file mode 100644 index 000000000..88cc0e039 --- /dev/null +++ b/webapp/src/components/ToggleableTagsFormGroup.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/webapp/src/field_utils.js b/webapp/src/field_utils.js index 2b64bf564..6f6960114 100644 --- a/webapp/src/field_utils.js +++ b/webapp/src/field_utils.js @@ -162,3 +162,27 @@ export function validateEntryID(id, takenIds = [], existingIds = []) { } return ""; } + +export function readableTextColor(hexColor) { + // Return a readable text color ("#000" or "#fff") for a given background hex + // color, based on its perceptual luminance. Falls back to black for invalid input. + if (!hexColor || typeof hexColor !== "string") { + return "#000"; + } + let hex = hexColor.trim().replace(/^#/, ""); + if (hex.length === 3) { + hex = hex + .split("") + .map((c) => c + c) + .join(""); + } + if (hex.length !== 6 || /[^0-9a-fA-F]/.test(hex)) { + return "#000"; + } + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + // Perceptual luminance (sRGB weights), normalised to [0, 1]. + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? "#000" : "#fff"; +} diff --git a/webapp/src/resources.js b/webapp/src/resources.js index 17a4a00e4..02d73fddf 100644 --- a/webapp/src/resources.js +++ b/webapp/src/resources.js @@ -145,6 +145,22 @@ export const SAMPLE_TABLE_TYPES = ["samples", "cells"]; export const INVENTORY_TABLE_TYPES = ["starting_materials"]; export const EQUIPMENT_TABLE_TYPES = ["equipment"]; +// Curated palette of distinguishable preset colors offered for tag colors. +export const TAG_COLOR_PALETTE = [ + "#e74c3c", + "#e67e22", + "#f1c40f", + "#2ecc71", + "#1abc9c", + "#3498db", + "#9b59b6", + "#34495e", + "#95a5a6", + "#e84393", + "#00b894", + "#fdcb6e", +]; + export const cellFormats = { coin: "coin", pouch: "pouch", diff --git a/webapp/src/router/index.js b/webapp/src/router/index.js index 3b96fc9a7..80a500c59 100644 --- a/webapp/src/router/index.js +++ b/webapp/src/router/index.js @@ -3,6 +3,7 @@ import Samples from "../views/Samples.vue"; import Equipment from "../views/Equipment.vue"; import StartingMaterials from "../views/StartingMaterials.vue"; import Collections from "@/views/Collections.vue"; +import Tags from "@/views/Tags.vue"; import NotFound from "../views/NotFound.vue"; import EditPage from "../views/EditPage.vue"; import CollectionPage from "../views/CollectionPage.vue"; @@ -13,6 +14,8 @@ import Login from "../views/Login.vue"; import Login2 from "../views/Login2.vue"; import Login3 from "../views/Login3.vue"; import { API_URL } from "@/resources.js"; +import { getInfo } from "@/server_fetch_utils.js"; +import store from "@/store/index.js"; const routes = [ { @@ -73,6 +76,20 @@ const routes = [ name: "collections", component: Collections, }, + { + path: "/tags", + name: "tags", + component: Tags, + // Only reachable when the backend reports the tags feature as enabled. + beforeEnter: async (to, from, next) => { + const serverInfo = store.state.serverInfo ?? (await getInfo()); + if (serverInfo.features?.tags) { + next(); + } else { + next({ path: "/" }); + } + }, + }, { path: "/collections/:id", name: "Collection", diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js index f52ced172..423097fa8 100644 --- a/webapp/src/server_fetch_utils.js +++ b/webapp/src/server_fetch_utils.js @@ -561,6 +561,61 @@ export function searchCollections(query, nresults = 100) { }); } +export function createTag(data) { + // data: { name, description?, color? }. Managed tags are global and admin-only. + // The caller refreshes the list via getTags(). Rejects with the server message + // on error (e.g. 409 duplicate name). + return fetch_put(`${API_URL}/tags`, { data }).then(function (response_json) { + return response_json.data; + }); +} + +export function updateTag(tagId, data) { + // Update a tag's metadata (name/description/color). Rejects with the server message (e.g. 409). + return fetch_patch(`${API_URL}/tags/${tagId}`, { data }); +} + +export function deleteTag(tagId) { + return fetch_delete(`${API_URL}/tags/${tagId}`) + .then(function (response_json) { + if (response_json.status !== "success") { + throw new Error("Failed to delete tag: " + response_json.message); + } + store.commit("deleteFromTagList", tagId); + }) + .catch((error) => { + DialogService.error({ + title: "Unable to delete tag", + message: `Failed to delete tag: ${error}`, + }); + throw error; + }); +} + +export function getTags() { + return fetch_get(`${API_URL}/tags`) + .then(function (response_json) { + store.commit("setTagList", response_json.data); + }) + .catch((error) => { + if (error === "UNAUTHORIZED") { + store.commit("setTagList", []); + } else { + throw error; + } + }); +} + +export function searchTags(query, nresults = 100) { + // construct a url with parameters: + var url = new URL(`${API_URL}/search-tags`); + var params = { query: query, nresults: nresults }; + Object.keys(params).forEach((key) => url.searchParams.append(key, params[key])); + return fetch_get(url).then(function (response_json) { + return response_json.data; + }); +} + export function searchGroups(query, nresults = 100) { // construct a url with parameters: var url = new URL(`${API_URL}/search/groups`); diff --git a/webapp/src/store/index.js b/webapp/src/store/index.js index 03fc9e0d7..f4a7b1a1b 100644 --- a/webapp/src/store/index.js +++ b/webapp/src/store/index.js @@ -18,6 +18,7 @@ export default createStore({ equipment_list: null, starting_material_list: null, collection_list: null, + tag_list: null, groups_list: null, saved_status_items: {}, saved_status_blocks: {}, @@ -74,6 +75,10 @@ export default createStore({ page: 0, rows: 10, }, + tags: { + page: 0, + rows: 20, + }, }, block_errors: {}, block_infos: {}, @@ -100,6 +105,20 @@ export default createStore({ // collectionSummaries is an array of json objects summarizing the available collections state.collection_list = collectionSummaries || []; }, + setTagList(state, tags) { + // tags is an array of managed tag objects + state.tag_list = tags || []; + }, + deleteFromTagList(state, tagId) { + if (state.tag_list === null) return; + + const index = state.tag_list.map((t) => t.immutable_id).indexOf(tagId); + if (index > -1) { + state.tag_list.splice(index, 1); + } else { + console.warn(`deleteFromTagList couldn't find the tag with id ${tagId}`); + } + }, setGroupsList(state, groups) { state.groups_list = groups; }, diff --git a/webapp/src/views/Tags.vue b/webapp/src/views/Tags.vue new file mode 100644 index 000000000..77ebe9a67 --- /dev/null +++ b/webapp/src/views/Tags.vue @@ -0,0 +1,36 @@ + + + + +