diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dc5e33d3..ef0e93f78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,6 +286,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 @@ -323,6 +326,10 @@ jobs: exit 1 fi + - name: Create the test admin user + # The first admin cannot be created over the API, so create one directly. + run: docker compose exec -T api uv run invoke admin.seed-e2e-admin + - name: Run end-to-end tests uses: cypress-io/github-action@v7 with: diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index dc15cedd8..55c7211da 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -1203,6 +1203,14 @@ }, "description": "A model for representing electrochemical cells.\n\nA cell is an electrochemical device assembled from other items, recording its\ncomponents and the format it was built in.", "properties": { + "tags": { + "description": "Tags applied to this entry: references to `tags` entries (by\n`immutable_id`).", + "items": { + "$ref": "#/$defs/EntryReference" + }, + "title": "Tags", + "type": "array" + }, "files": { "anyOf": [ { diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index ab16caa0d..a641f79f1 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,14 @@ }, "description": "A model for representing a piece of equipment.\n\nEquipment represents an instrument or apparatus in the lab, which can be linked to\nthe items measured on it.", "properties": { + "tags": { + "description": "Tags applied to this entry: references to `tags` entries (by\n`immutable_id`).", + "items": { + "$ref": "#/$defs/EntryReference" + }, + "title": "Tags", + "type": "array" + }, "files": { "anyOf": [ { diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 1ec3de769..a7de22c0d 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -1312,6 +1312,14 @@ "description": "Free-text details of the procedure applied to synthesise the sample", "title": "Synthesis Description" }, + "tags": { + "description": "Tags applied to this entry: references to `tags` entries (by\n`immutable_id`).", + "items": { + "$ref": "#/$defs/EntryReference" + }, + "title": "Tags", + "type": "array" + }, "files": { "anyOf": [ { diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index f408f1af1..07e4bfccf 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -1313,6 +1313,14 @@ "description": "Free-text details of the procedure applied to synthesise the sample", "title": "Synthesis Description" }, + "tags": { + "description": "Tags applied to this entry: references to `tags` entries (by\n`immutable_id`).", + "items": { + "$ref": "#/$defs/EntryReference" + }, + "title": "Tags", + "type": "array" + }, "files": { "anyOf": [ { diff --git a/pydatalab/src/pydatalab/config.py b/pydatalab/src/pydatalab/config.py index 57fa57508..1b58a1c29 100644 --- a/pydatalab/src/pydatalab/config.py +++ b/pydatalab/src/pydatalab/config.py @@ -253,6 +253,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 1f56be2e3..09e0ffa8a 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 a405ce086..e91ffd525 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 ( @@ -17,7 +18,9 @@ ) -class Item(Entry, HasOwner, HasRevisionControl, IsCollectable, HasBlocks, HasFiles, abc.ABC): +class Item( + Entry, HasOwner, HasRevisionControl, IsCollectable, HasBlocks, HasFiles, HasTags, abc.ABC +): """The generic model for data types that will be exposed with their own named endpoints. `Item` is the abstract base shared by every physical item type: samples, cells, diff --git a/pydatalab/src/pydatalab/models/tags.py b/pydatalab/src/pydatalab/models/tags.py new file mode 100644 index 000000000..daf178b83 --- /dev/null +++ b/pydatalab/src/pydatalab/models/tags.py @@ -0,0 +1,46 @@ +from typing import Literal + +from pydantic import model_validator + +from pydatalab.models.entries import Entry +from pydatalab.models.utils import AccessScope, PyObjectId + + +class Tag(Entry): + """A tag that can be associated to other Entry entities. + + Tags have a `scope` that controls who can list, use and manage them: + + - `AccessScope.GLOBAL`: available to (and usable by) everyone; created and + managed by administrators only. Global tags have no `owner`. + - `AccessScope.USER`: a user-defined tag owned by exactly one user; only that + user can list, use, edit and delete it. + + Names are only required to be unique within a scope. + """ + + 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`).""" + + scope: AccessScope + """The scope controlling who can list, use and manage this tag (required).""" + + owner: PyObjectId | None = None + """The database ID of the user that owns this tag.""" + + @model_validator(mode="after") + def _check_scope_owner_consistency(self): + """Ensure `scope` and `owner` are mutually consistent.""" + if self.scope == AccessScope.USER and self.owner is None: + raise ValueError("A user-scoped tag must have an owner.") + if self.scope == AccessScope.GLOBAL and self.owner is not None: + raise ValueError("A global tag cannot have an owner.") + return self diff --git a/pydatalab/src/pydatalab/models/traits.py b/pydatalab/src/pydatalab/models/traits.py index 774ec4348..e9ba37300 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, +) if TYPE_CHECKING: pass @@ -16,6 +22,7 @@ "IsCollectable", "HasSynthesisInfo", "HasSubstanceInfo", + "HasTags", ) @@ -35,6 +42,57 @@ 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] = Field(default_factory=list) + """Tags applied to this entry: references to `tags` entries (by + `immutable_id`).""" + + @field_validator("tags", mode="before") + @classmethod + def coerce_tags(cls, v): + """Coerce raw tag entries into references and de-duplicate. + + A mapping carrying an `immutable_id` becomes an `EntryReference` of type + ``tags``. References are de-duplicated by `immutable_id`. + """ + if v is None: + return [] + if not isinstance(v, list): + raise ValueError("`tags` must be a list") + + coerced: list = [] + seen_refs: set[PyObjectId | None] = set() + + for tag in v: + 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) and 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 + + raise ValueError(f"Invalid tag entry: {tag!r}") + + return coerced + + class HasRevisionControl(BaseModel): """Trait mixin for models that track a revision history of their own state.""" diff --git a/pydatalab/src/pydatalab/models/utils.py b/pydatalab/src/pydatalab/models/utils.py index 696ec620c..41c2b40fd 100644 --- a/pydatalab/src/pydatalab/models/utils.py +++ b/pydatalab/src/pydatalab/models/utils.py @@ -155,6 +155,13 @@ class UserRole(str, Enum): MANAGER = "manager" +class AccessScope(str, Enum): + """The scope that controls who can list, use and manage an entity (e.g. a tag).""" + + GLOBAL = "global" + USER = "user" + + class PintType(str): """A WIP attempt to create a custom pydantic field type for Pint quantities. The idea would eventually be to use TypeAlias to create physical/dimensionful pydantic fields. diff --git a/pydatalab/src/pydatalab/mongo.py b/pydatalab/src/pydatalab/mongo.py index 5b988abe7..6c4fe6c8c 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,55 @@ def files_lookup() -> dict: } +def resolve_tags_for_docs(docs: list[dict]) -> None: + """Inline 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. + 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, "scope": 1}, + ) + } + + for doc in docs: + tags = doc.get("tags") + if not tags: + continue + resolved_tags: list = [] + for tag in tags: + if isinstance(tag, dict): + match = resolved.get(ObjectId(tag["immutable_id"])) + # Referenced tag no longer exists: drop it silently. + if match is not None: + resolved_tags.append( + { + "type": "tags", + "immutable_id": str(match["_id"]), + "name": match.get("name"), + "description": match.get("description"), + "color": match.get("color"), + # `scope` lets the UI mark user-defined tags distinctly. + "scope": match.get("scope"), + } + ) + 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.""" @@ -115,6 +167,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 @@ -330,6 +385,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 c2e4d8e6d..f9d281f52 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -11,15 +11,16 @@ from flask_login import current_user from pydantic import ValidationError from pymongo.errors import DuplicateKeyError -from werkzeug.exceptions import BadRequest, Conflict, InternalServerError, NotFound +from werkzeug.exceptions import BadRequest, Conflict, Forbidden, InternalServerError, NotFound 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 from pydatalab.models.relationships import RelationshipType -from pydatalab.models.utils import InlineSubstance, generate_unique_refcode +from pydatalab.models.utils import AccessScope, InlineSubstance, generate_unique_refcode from pydatalab.models.versions import ( CompareVersionsQuery, RestoreVersionRequest, @@ -32,6 +33,7 @@ flask_mongo, get_items_fts_fields, groups_lookup, + resolve_tags_for_docs, ) from pydatalab.permissions import ( PUBLIC_USER_ID, @@ -288,6 +290,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: @@ -297,7 +301,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}, @@ -309,6 +313,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: @@ -602,6 +610,58 @@ 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 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. + """ + tags = item.get("tags") + if not isinstance(tags, list): + return + item["tags"] = [ + {"type": "tags", "immutable_id": tag["immutable_id"]} + for tag in tags + if isinstance(tag, dict) and tag.get("immutable_id") is not None + ] + + +def _tag_immutable_ids(tags) -> set[str]: + """Collect the string `immutable_id`s of the tag references in a `tags` list.""" + if not isinstance(tags, list): + return set() + return { + str(tag["immutable_id"]) + for tag in tags + if isinstance(tag, dict) and tag.get("immutable_id") is not None + } + + +def _authorize_added_tags(tags, existing_tag_ids: set[str]) -> None: + """Reject any newly added user-defined tag not owned by the current user. + + A user may add global tags and their own user-defined tags. + """ + # In testing an unauthenticated "public" user can write. + if CONFIG.TESTING and not current_user.is_authenticated: + return + + user_id = current_user.person.immutable_id + + added_ids = _tag_immutable_ids(tags) - existing_tag_ids + if not added_ids: + return + + for tag_doc in flask_mongo.db.tags.find( + {"_id": {"$in": [ObjectId(i) for i in added_ids]}}, + projection={"_id": 1, "scope": 1, "owner": 1}, + ): + if tag_doc.get("scope") == AccessScope.USER.value and tag_doc.get("owner") != user_id: + raise Forbidden("You cannot add a tag that is owned by another user.") + + def _create_sample( sample_dict: dict, copy_from_item_id: str | None = None, @@ -700,9 +760,10 @@ 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"}) + _authorize_added_tags(to_store.get("tags"), set()) + _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)}.") @@ -1133,6 +1194,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. @@ -1663,6 +1727,10 @@ def save_item(): preserve_relationships = "collections" not in updated_data original_relationships = item.get("relationships", []) if preserve_relationships else None + # Snapshot the tags already on the item so we only authorize newly added + # tags below. + existing_tag_ids = _tag_immutable_ids(item.get("tags")) + item.update(updated_data) try: @@ -1690,6 +1758,12 @@ def save_item(): if isinstance(existing_last_modified, datetime.datetime): existing_last_modified = existing_last_modified.isoformat() + # A user may not add another user's user-defined tag. + _authorize_added_tags(item.get("tags"), existing_tag_ids) + + # Store 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..148024c3c --- /dev/null +++ b/pydatalab/src/pydatalab/routes/v0_1/tags.py @@ -0,0 +1,291 @@ +import datetime +import json + +from bson import ObjectId +from bson.errors import InvalidId +from flask import Blueprint, abort, jsonify, request +from flask_login import current_user +from pydantic import ValidationError +from werkzeug.exceptions import BadRequest, Conflict, Forbidden, NotFound, Unauthorized + +from pydatalab.feature_flags import FEATURE_FLAGS +from pydatalab.logger import logged_route +from pydatalab.models.tags import Tag +from pydatalab.models.utils import AccessScope, UserRole +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 + +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 _is_admin() -> bool: + """Whether the current user is an authenticated administrator.""" + return bool(current_user.is_authenticated and current_user.role == UserRole.ADMIN) + + +def _current_user_id() -> ObjectId | None: + """The immutable ID of the current user, or None if unauthenticated.""" + if current_user.is_authenticated and current_user.person is not None: + return current_user.person.immutable_id + return None + + +def _usable_tags_filter(user_id: ObjectId | None) -> dict: + """The Mongo filter for tags the given user may list and use. + + This is global tags plus the user's own user-defined tags. + """ + if user_id is None: + return {"scope": AccessScope.GLOBAL.value} + return {"$or": [{"scope": AccessScope.GLOBAL.value}, {"owner": user_id}]} + + +def _name_conflict_exists( + name: str, + scope: AccessScope, + owner: ObjectId | None = None, + exclude_id: ObjectId | None = None, +) -> bool: + """Whether a tag with `name` already exists within the given scope. + + The same name may exist across scopes (e.g. a global `x` and a user-defined `x`). + """ + query: dict = {"name": name, "scope": scope.value} + if scope == AccessScope.USER: + query["owner"] = owner + + if exclude_id is not None: + query["_id"] = {"$ne": exclude_id} + + return flask_mongo.db.tags.find_one(query, {"_id": 1}) is not None + + +def _authorize_tag_write(tag_doc: dict) -> bool: + """Whether the current user may edit or delete the given tag document. + + Global tags require an administrator; user-defined tags require the owner. + """ + if tag_doc["scope"] == AccessScope.USER.value: + user_id = _current_user_id() + return user_id is not None and tag_doc.get("owner") == user_id + return _is_admin() + + +@TAGS.route("/tags", methods=["PUT"]) +def create_tag(): + """Create a new tag. + + Anyone logged in can create a user-defined tag. Only administrators can create + global tags. + """ + request_json = request.get_json() + data = request_json.get("data", {}) + + name = data.get("name") + if not name: + raise BadRequest("A tag name is required.") + + try: + scope = AccessScope(data.get("scope") or AccessScope.USER.value) + except ValueError: + raise BadRequest(f"Invalid tag scope {data.get('scope')!r}.") + + if scope == AccessScope.GLOBAL: + if not _is_admin(): + raise Forbidden("Only administrators can create global tags.") + owner = None + else: + owner = _current_user_id() + if owner is None: + raise Unauthorized("You must be logged in to create a user-defined tag.") + + if _name_conflict_exists(name, scope, owner): + raise Conflict(f"A tag named {name!r} already exists.") + + try: + tag = Tag( + name=name, + description=data.get("description"), + color=data.get("color"), + scope=scope, + owner=owner, + last_modified=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ) + except ValidationError as error: + raise BadRequest(f"Unable to create the tag: {error}") + + 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 the tags usable by the current user: global tags plus their own.""" + tags = flask_mongo.db.tags.find(_usable_tags_filter(_current_user_id())).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 full-text search over the tags usable by the current user. + + 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, scope}` + 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: + raise BadRequest("No query provided.") + + pipeline = build_search_pipeline( + query, TAGS_FTS_FIELDS, _usable_tags_filter(_current_user_id()) + ) + pipeline.append({"$limit": nresults}) + pipeline.append({"$project": {"_id": 1, "name": 1, "description": 1, "color": 1, "scope": 1}}) + + data = [ + { + "type": "tags", + "immutable_id": str(doc["_id"]), + "name": doc.get("name"), + "description": doc.get("description"), + "color": doc.get("color"), + "scope": doc["scope"], + } + for doc in flask_mongo.db.tags.aggregate(pipeline) + ] + + return jsonify({"status": "success", "data": data}), 200 + + +@TAGS.route("/tags/", methods=["PATCH"]) +@logged_route +def save_tag(tag_id): + """Update a tag's `name`/`description`/`color`. + + Global tags may only be edited by administrators; user-defined tags only by their + owner. + """ + object_id = _parse_object_id(tag_id) + if object_id is None: + raise BadRequest(f"Invalid tag ID {tag_id!r}.") + + request_json = request.get_json() + updated_data = request_json.get("data") + + if not updated_data: + raise BadRequest("No data provided to update the tag with.") + + tag = flask_mongo.db.tags.find_one({"_id": object_id}) + + if not tag: + raise NotFound(f"Unable to find a tag with ID {tag_id!r}.") + + if not _authorize_tag_write(tag): + raise Forbidden("You are not allowed to modify this tag.") + + # Identity, scope and ownership are not editable through this endpoint. + for key in ("_id", "immutable_id", "type", "scope", "owner"): + updated_data.pop(key, None) + + updated_data["last_modified"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + # Keep names unique within the tag's own scope on rename. + if "name" in updated_data: + scope = AccessScope(tag["scope"]) + if _name_conflict_exists( + updated_data["name"], scope, tag.get("owner"), exclude_id=object_id + ): + raise Conflict(f"A tag named {updated_data['name']!r} already exists.") + + tag.update(updated_data) + + try: + tag = Tag(**tag).model_dump(exclude={"immutable_id"}) + except ValidationError as exc: + raise BadRequest(f"Unable to update tag {tag_id!r} with new data {updated_data}: {exc}") + + 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"]) +def delete_tag(tag_id: str): + """Delete a tag and drop its references from items. + + Global tags may only be deleted by administrators; user-defined tags only by + their owner. + """ + object_id = _parse_object_id(tag_id) + if object_id is None: + raise BadRequest(f"Invalid tag ID {tag_id!r}.") + + tag = flask_mongo.db.tags.find_one({"_id": object_id}) + + if not tag: + raise NotFound(f"No tag found with ID {tag_id!r}.") + + if not _authorize_tag_write(tag): + raise Forbidden("You are not allowed to delete this tag.") + + flask_mongo.db.tags.delete_one({"_id": object_id}) + + # 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/tasks.py b/pydatalab/tasks.py index 8dc57b2b8..888ceda35 100644 --- a/pydatalab/tasks.py +++ b/pydatalab/tasks.py @@ -1,3 +1,4 @@ +# This file was edited with the assistance of an AI model and requires human review from the contributor. import json import os import pathlib @@ -364,6 +365,58 @@ def manually_register_user( admin.add_task(manually_register_user) +@task +def seed_e2e_admin( + _, + display_name: str = "Test Admin", + contact_email: str = "admin-user@example.com", +): + """Ensure a predefined, active admin user exists, for end-to-end testing. + + The tests may need an administrator, but the first admin cannot be created + over the API. This task creates the user with an email identity if it does + not already exist, marks it active, and grants it the admin role. + """ + from pydatalab.models.people import AccountStatus, Identity, Person + from pydatalab.models.utils import UserRole + from pydatalab.mongo import get_database, insert_pydantic_model_fork_safe + + db = get_database() + + user = db.users.find_one( + {"identities.identity_type": "email", "identities.identifier": contact_email} + ) + + if user is None: + new_user = Person( + display_name=display_name, + contact_email=contact_email, + account_status=AccountStatus.ACTIVE, + identities=[ + Identity( + identity_type="email", + identifier=contact_email, + name=contact_email, + verified=True, + ) + ], + ) + user_id = insert_pydantic_model_fork_safe(new_user, "users") + print(f"Created active user {display_name!r} <{contact_email}> ({user_id}).") + else: + user_id = user["_id"] + db.users.update_one( + {"_id": user_id}, {"$set": {"account_status": AccountStatus.ACTIVE.value}} + ) + print(f"User <{contact_email}> already exists ({user_id}); ensured active.") + + db.roles.update_one({"_id": user_id}, {"$set": {"role": UserRole.ADMIN.value}}, upsert=True) + print(f"Granted the admin role to <{contact_email}>.") + + +admin.add_task(seed_e2e_admin) + + @task def repair_files(_, resync: bool = True): """Loop through samples and find any with attached files diff --git a/pydatalab/tests/server/conftest.py b/pydatalab/tests/server/conftest.py index 92917d578..c5ac697cf 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..86bcafe69 --- /dev/null +++ b/pydatalab/tests/server/test_tags.py @@ -0,0 +1,419 @@ +"""Tests for the scoped tags routes. + +Tags have scopes: + +- **global**: available to (and usable by) everyone; only administrators can + create, edit or delete them. +- **user**: a user-defined tag owned by exactly one user; only that user can list, + use, edit or delete it (though it is still *displayed* on any item the viewer + can access). + +Names are unique *within a scope*; the stable identity of a tag is its +`immutable_id`. +""" + +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, scope=None, description=None, color=None): + """Helper to PUT a tag and return the response.""" + data = {"name": name} + if scope is not None: + data["scope"] = scope + if description is not None: + data["description"] = description + if color is not None: + data["color"] = color + return client.put("/tags", json={"data": data}) + + +# --- creation & authoring policy ------------------------------------------------- + + +def test_create_user_defined_tag(client, unauthenticated_client, user_id): + """Any logged-in user can create a user-defined tag they own; the default scope is + ``user``. (Previously tag creation was admin-only; users can now self-serve.)""" + # An unauthenticated user is rejected. + assert _create_tag(unauthenticated_client, "unauth-tag").status_code == 401 + + # A normal user can create a user-defined tag (scope defaults to "user"). + response = _create_tag(client, "my-tag", description="mine", color="#f1c40f") + assert response.status_code == 201, response.json + tag = response.json["data"] + assert tag["type"] == "tags" + assert tag["name"] == "my-tag" + assert tag["color"] == "#f1c40f" + assert tag["scope"] == "user" + assert tag["owner"] == str(user_id) + assert tag["immutable_id"] + + # Scope is modelled without the HasOwner mixin. + assert "creator_ids" not in tag + assert "group_ids" not in tag + + +def test_create_global_tag_admin_only(client, admin_client): + """Only an administrator can create a global tag.""" + # A normal user cannot create a global tag. + assert _create_tag(client, "user-global", scope="global").status_code == 403 + + # An admin can; it has no owner. + response = _create_tag(admin_client, "admin-global", scope="global") + assert response.status_code == 201, response.json + tag = response.json["data"] + assert tag["scope"] == "global" + assert tag["owner"] is None + + +def test_invalid_scope_rejected(client): + """An unknown scope value is a 400.""" + assert _create_tag(client, "weird", scope="team").status_code == 400 + + +# --- visibility ------------------------------------------------------------------ + + +def test_user_defined_tag_visibility(client, another_client, admin_client, user_id): + """A user-defined tag is listed/searchable only by its owner.""" + tag_id = _create_tag(client, "secret-tag", color="#abcdef").json["data"]["immutable_id"] + + # The owner sees it in the listing and search, marked as a user-scoped tag. + listed = {t["name"]: t for t in client.get("/tags").json["data"]} + assert "secret-tag" in listed + assert listed["secret-tag"]["scope"] == "user" + assert listed["secret-tag"]["owner"] == str(user_id) + + found = { + r["name"]: r + for r in client.get("/search-tags", query_string={"query": "secret"}).json["data"] + } + assert found["secret-tag"]["scope"] == "user" + + # Another user (and an admin, without sudo) does not see it at all. + for other in (another_client, admin_client): + names = {t["name"] for t in other.get("/tags").json["data"]} + assert "secret-tag" not in names + search = other.get("/search-tags", query_string={"query": "secret"}).json["data"] + assert all(r["immutable_id"] != tag_id for r in search) + + +def test_global_tag_visible_to_all(client, another_client, admin_client): + """A global tag is listed and searchable by everyone, with scope ``global``.""" + assert ( + _create_tag(admin_client, "shared-tag", scope="global", color="#abcdef").status_code == 201 + ) + + for c in (client, another_client, admin_client): + listed = {t["name"]: t for t in c.get("/tags").json["data"]} + assert "shared-tag" in listed + assert listed["shared-tag"]["scope"] == "global" + + found = { + r["name"]: r + for r in c.get("/search-tags", query_string={"query": "shared"}).json["data"] + } + assert found["shared-tag"]["scope"] == "global" + assert found["shared-tag"]["type"] == "tags" + + # The empty-query case is still rejected. + assert client.get("/search-tags", query_string={"query": ""}).status_code == 400 + + +# --- scope-based name uniqueness ------------------------------------------------- + + +def test_scope_based_name_uniqueness(client, another_client, admin_client): + """Names are unique within a scope, but may repeat across scopes/owners.""" + # A global and a user-defined tag may share a name. + assert _create_tag(admin_client, "flammable", scope="global").status_code == 201 + assert _create_tag(client, "flammable").status_code == 201 + + # Two different users may each own a user-defined "flammable". + assert _create_tag(another_client, "flammable").status_code == 201 + + # The same owner cannot create a second user-defined tag with the same name. + assert _create_tag(client, "flammable").status_code == 409 + + # A second global with the same name is rejected. + assert _create_tag(admin_client, "flammable", scope="global").status_code == 409 + + +# --- editing & deletion ---------------------------------------------------------- + + +def test_patch_tag_owner_only(client, another_client, admin_client): + """User-defined tags are editable only by their owner; global tags only by admins.""" + user_defined_id = _create_tag(client, "editable", description="first").json["data"][ + "immutable_id" + ] + global_id = _create_tag(admin_client, "global-editable", scope="global").json["data"][ + "immutable_id" + ] + + # Another user cannot edit someone else's user-defined tag. + assert ( + another_client.patch( + f"/tags/{user_defined_id}", json={"data": {"description": "nope"}} + ).status_code + == 403 + ) + + # The owner can. + assert ( + client.patch( + f"/tags/{user_defined_id}", json={"data": {"description": "updated"}} + ).status_code + == 200 + ) + patched = next( + t for t in client.get("/tags").json["data"] if t["immutable_id"] == user_defined_id + ) + assert patched["description"] == "updated" + + # A non-admin cannot edit a global tag; an admin can. + assert ( + client.patch(f"/tags/{global_id}", json={"data": {"description": "x"}}).status_code == 403 + ) + assert ( + admin_client.patch(f"/tags/{global_id}", json={"data": {"description": "y"}}).status_code + == 200 + ) + + # Scope/owner are immutable through PATCH. + assert ( + client.patch(f"/tags/{user_defined_id}", json={"data": {"scope": "global"}}).status_code + == 200 + ) + still = next( + t for t in client.get("/tags").json["data"] if t["immutable_id"] == user_defined_id + ) + assert still["scope"] == "user" + + # An invalid ID is a 400; a missing tag is a 404. + assert client.patch("/tags/not-an-object-id", json={"data": {"name": "x"}}).status_code == 400 + assert client.patch(f"/tags/{ObjectId()}", json={"data": {"name": "x"}}).status_code == 404 + + +def test_patch_tag_rename_unique_within_scope(client): + """Renaming a user-defined tag onto another of the owner's names is rejected.""" + tag_id = _create_tag(client, "one").json["data"]["immutable_id"] + assert _create_tag(client, "two").status_code == 201 + + assert client.patch(f"/tags/{tag_id}", json={"data": {"name": "two"}}).status_code == 409 + + +def test_delete_tag_owner_only(client, another_client, admin_client, database): + """User-defined tags are deletable only by their owner; global only by admins; refs + are pulled from items on delete.""" + user_defined_id = _create_tag(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": user_defined_id}]}, + }, + ).status_code + == 200 + ) + + # Another user cannot delete someone else's user-defined tag. + assert another_client.delete(f"/tags/{user_defined_id}").status_code == 403 + + # An admin cannot delete another user's user-defined tag either (admins manage global). + assert admin_client.delete(f"/tags/{user_defined_id}").status_code == 403 + + # The owner can. + assert client.delete(f"/tags/{user_defined_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 client.delete(f"/tags/{user_defined_id}").status_code == 404 + + # Global tags: only admins can delete. + global_id = _create_tag(admin_client, "global-del", scope="global").json["data"]["immutable_id"] + assert client.delete(f"/tags/{global_id}").status_code == 403 + assert admin_client.delete(f"/tags/{global_id}").status_code == 200 + + +# --- applying tags to items (add-authorization) ---------------------------------- + + +def _make_shared_sample(client, database, item_id, extra_owner_id): + """Create a sample via the API, then share write access with a second user by + adding them to `creator_ids` directly (mirrors co-ownership).""" + assert ( + client.post("/new-sample/", json={"type": "samples", "item_id": item_id}).status_code == 201 + ) + database.items.update_one({"item_id": item_id}, {"$addToSet": {"creator_ids": extra_owner_id}}) + + +def _item_tag_ids(client, item_id): + resp = client.get(f"/get-item-data/{item_id}") + assert resp.status_code == 200, resp.json + return [t["immutable_id"] for t in resp.json["item_data"]["tags"] if isinstance(t, dict)] + + +def test_cannot_add_others_user_defined_tag( + client, another_client, admin_client, database, another_user_id +): + """A user may add global and own user-defined tags, but not another user's + user-defined tag; they may keep and remove an already-present foreign tag.""" + a_tag = _create_tag(client, "a-user-defined").json["data"]["immutable_id"] + b_tag = _create_tag(another_client, "b-user-defined").json["data"]["immutable_id"] + g_tag = _create_tag(admin_client, "g-global", scope="global").json["data"]["immutable_id"] + + # A shared sample both users can write. + _make_shared_sample(client, database, "shared-item", another_user_id) + + def save(c, tags): + return c.post( + "/save-item/", + json={"item_id": "shared-item", "data": {"tags": tags}}, + ) + + def ref(tid): + return {"type": "tags", "immutable_id": tid} + + # B can add a global tag and their own user-defined tag. + assert save(another_client, [ref(g_tag), ref(b_tag)]).status_code == 200 + assert set(_item_tag_ids(another_client, "shared-item")) == {g_tag, b_tag} + + # B cannot *introduce* A's user-defined tag (a new addition B does not own). + assert save(another_client, [ref(g_tag), ref(b_tag), ref(a_tag)]).status_code == 403 + # The rejected save left the stored tags unchanged. + assert set(_item_tag_ids(another_client, "shared-item")) == {g_tag, b_tag} + + # A (the owner) can add their own user-defined tag; the item now carries it. + assert save(client, [ref(g_tag), ref(b_tag), ref(a_tag)]).status_code == 200 + assert set(_item_tag_ids(client, "shared-item")) == {g_tag, b_tag, a_tag} + + # B may *keep* A's already-present tag through an unrelated re-save (no new + # foreign tag is introduced), and may *remove* it (removal is never blocked). + assert save(another_client, [ref(g_tag), ref(b_tag), ref(a_tag)]).status_code == 200 + assert save(another_client, [ref(g_tag), ref(b_tag)]).status_code == 200 + assert set(_item_tag_ids(another_client, "shared-item")) == {g_tag, b_tag} + + +def test_cannot_add_foreign_tag_on_creation(client, another_client): + """User-defined tags owned by another user are rejected at item creation too.""" + b_tag = _create_tag(another_client, "b-only").json["data"]["immutable_id"] + + response = client.post( + "/new-sample/", + json={ + "type": "samples", + "item_id": "create-foreign", + "tags": [{"type": "tags", "immutable_id": b_tag}], + }, + ) + assert response.status_code == 403, response.json + + +# --- read-time resolution -------------------------------------------------------- + + +def test_item_tag_resolution_includes_scope( + client, another_client, admin_client, database, another_user_id +): + """Resolved item tags carry `scope`, and another user viewing a shared item + sees the owner's user-defined tag (display is gated by the item, not the tag).""" + user_defined_id = _create_tag(client, "resolve-user-defined", color="#abcdef").json["data"][ + "immutable_id" + ] + global_id = _create_tag(admin_client, "resolve-global", scope="global").json["data"][ + "immutable_id" + ] + + _make_shared_sample(client, database, "resolve-item", another_user_id) + + save = client.post( + "/save-item/", + json={ + "item_id": "resolve-item", + "data": { + "tags": [ + {"type": "tags", "immutable_id": user_defined_id, "name": "stale"}, + {"type": "tags", "immutable_id": global_id}, + ] + }, + }, + ) + assert save.status_code == 200, save.json + + # Stored references are minimal (no display fields, incl. scope). + stored = database.items.find_one({"item_id": "resolve-item"}) + assert {(t["type"], t["immutable_id"]) for t in stored["tags"]} == { + ("tags", ObjectId(user_defined_id)), + ("tags", ObjectId(global_id)), + } + + # Another user viewing the shared item sees both tags, with resolved scope. + tags = { + t["immutable_id"]: t + for t in another_client.get("/get-item-data/resolve-item").json["item_data"]["tags"] + if isinstance(t, dict) + } + assert tags[user_defined_id]["scope"] == "user" + assert tags[user_defined_id]["name"] == "resolve-user-defined" # re-resolved, not stale + assert tags[user_defined_id]["color"] == "#abcdef" + assert tags[global_id]["scope"] == "global" + + +# --- feature flag ---------------------------------------------------------------- + + +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(client, "flagged-off").status_code == 404 + + +def test_tags_stripped_on_creation(client, admin_client, database): + """Tags provided directly at item creation are stored as minimal references.""" + tag_id = _create_tag(admin_client, "create-global", scope="global", 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"}, + ], + }, + ) + assert response.status_code == 201, response.json + + stored = database.items.find_one({"item_id": "tag-on-create"}) + assert stored["tags"] == [{"type": "tags", "immutable_id": ObjectId(tag_id)}] diff --git a/pydatalab/tests/test_models.py b/pydatalab/tests/test_models.py index 625ae5d08..aeaf6bcc3 100644 --- a/pydatalab/tests/test_models.py +++ b/pydatalab/tests/test_models.py @@ -184,6 +184,105 @@ def test_file(): assert sample.files[1].type == "files" +def test_tag_model(): + from pydatalab.models.tags import Tag + from pydatalab.models.utils import AccessScope + + tag = Tag(name="test_tag", description="This is an example", color="#f1c40f", scope="global") + assert tag.type == "tags" + assert tag.name == "test_tag" + assert tag.description == "This is an example" + assert tag.color == "#f1c40f" + assert tag.scope == AccessScope.GLOBAL + assert tag.owner is None + + # Scope is modelled explicitly via `scope`/`owner` (not the `HasOwner` mixin). + assert not hasattr(tag, "creator_ids") + assert not hasattr(tag, "group_ids") + + oid = ObjectId("0123456789ab0123456789ab") + doc = {"_id": oid, "type": "tags", "name": "glovebox", "scope": "global"} + 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 + assert stored_tag.scope == AccessScope.GLOBAL + + # Both `name` and `scope` are required. + with pytest.raises(pydantic.ValidationError): + Tag(scope="global", description="missing a name") + with pytest.raises(pydantic.ValidationError): + Tag(name="missing-a-scope") + + +def test_tag_scope_owner_consistency(): + """A user-scoped tag must have an owner; a global tag must not.""" + from pydatalab.models.tags import Tag + from pydatalab.models.utils import AccessScope + + owner = ObjectId() + + # A valid user-defined tag. + user_defined = Tag(name="mine", scope="user", owner=owner) + assert user_defined.scope == AccessScope.USER + assert user_defined.owner == owner + # `owner` is preserved as an ObjectId in the stored (python-mode) dump. + assert isinstance(user_defined.model_dump(exclude_none=True)["owner"], ObjectId) + # ... and stringified in the JSON dump sent to clients. + assert json.loads(user_defined.model_dump_json())["owner"] == str(owner) + + # A valid global tag has no owner. + glob = Tag(name="shared", scope="global") + assert glob.owner is None + + # A user-scoped tag without an owner is rejected. + with pytest.raises(pydantic.ValidationError): + Tag(name="bad", scope="user") + + # A global tag with an owner is rejected. + with pytest.raises(pydantic.ValidationError): + Tag(name="bad", scope="global", owner=owner) + + +def test_item_tags_coercion(): + """The `HasTags` mixin coerces references 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=[ + {"type": "tags", "immutable_id": str(oid), "name": "Curated"}, + {"type": "tags", "immutable_id": str(oid)}, # same reference by id -> dropped + ], + ) + + assert len(sample.tags) == 1 + ref = sample.tags[0] + 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 + + # Bare string tags are not allowed: only references to tags entries. + with pytest.raises(pydantic.ValidationError): + Sample(item_id="string-tag", tags=["custom"]) + + # Re-validating a dumped item round-trips the reference tags list. + roundtrip = Sample(**json.loads(sample.model_dump_json())) + assert [type(t).__name__ for t in roundtrip.tags] == ["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..ceb976bb4 --- /dev/null +++ b/webapp/cypress/component/TagColorPickerTest.cy.jsx @@ -0,0 +1,33 @@ +import TagColorPicker from "@/components/TagColorPicker.vue"; +import { TAG_COLOR_PALETTE } from "@/resources.js"; + +describe("TagColorPicker.vue", () => { + it("renders the preset palette", () => { + cy.mount(TagColorPicker, { props: { modelValue: null } }); + cy.get(".swatch").should("have.length", TAG_COLOR_PALETTE.length); + }); + + 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("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..e0f6410fd --- /dev/null +++ b/webapp/cypress/component/TagFormModalTest.cy.jsx @@ -0,0 +1,127 @@ +import TagFormModal from "@/components/TagFormModal.vue"; +import { DEFAULT_TAG_COLOR } from "@/resources.js"; +import { createStore } from "vuex"; + +// 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. `role` sets the current +// user's role (the modal offers the "global" scope only to admins). +// +// Emitted events are observed through spies passed as listener props (aliased "tagCreated", +// "tagUpdated" and "updateModelValue") rather than through the test-utils `wrapper.emitted()` +// recorder: `emitted()` is fed by Vue's devtools hook, which is compiled out of production +// builds, and CI runs the component specs with NODE_ENV=production. Listener props are called +// by `emit()` itself, so they record events in either build mode. +// +// 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, role = "user" } = {}) { + const store = createStore({ + state() { + return { currentUserRole: role }; + }, + }); + return cy + .mount(TagFormModal, { + props: { + modelValue: false, + tag, + onTagCreated: cy.spy().as("tagCreated"), + onTagUpdated: cy.spy().as("tagUpdated"), + "onUpdate:modelValue": cy.spy().as("updateModelValue"), + }, + global: { plugins: [store] }, + }) + .then(({ wrapper }) => wrapper.setProps({ modelValue: true })); +} + +describe("TagFormModal.vue", () => { + describe("create mode", () => { + it("creates a user-defined tag by default (non-admin has no scope choice)", () => { + cy.intercept("PUT", "**/tags", { statusCode: 201, body: { status: "success", data: {} } }).as( + "create", + ); + mountAndOpen(); + + // A non-admin cannot choose a scope; the scope field is a disabled "User-defined". + cy.get('[data-testid="tag-scope-select"]').should("not.exist"); + cy.get("#tag-scope").should("be.disabled").and("have.value", "User-defined"); + + cy.get("#tag-name").type("flammable", { force: true }); + cy.get("#tag-description").type("burns", { force: true }); + cy.get('input[type="submit"]').click({ force: true }); + + // The color picker is left untouched, so the payload carries the default tag color, + // and the scope defaults to a user-defined ("user") tag. + cy.wait("@create") + .its("request.body") + .should("deep.equal", { + data: { + name: "flammable", + description: "burns", + color: DEFAULT_TAG_COLOR, + scope: "user", + }, + }); + cy.get("@tagCreated").should("have.been.calledOnce"); + // The modal asks its parent to close on success. + cy.get("@updateModelValue").its("lastCall.args").should("deep.equal", [false]); + }); + + it("lets an admin create a global tag", () => { + cy.intercept("PUT", "**/tags", { statusCode: 201, body: { status: "success", data: {} } }).as( + "create", + ); + mountAndOpen({ role: "admin" }); + + // An admin can choose the scope. (force: true — see mount note: the Modal + // backdrop overlays the dialog without the app's global Bootstrap CSS.) + cy.get('[data-testid="tag-scope-select"]').select("global", { force: true }); + cy.get("#tag-name").type("corrosive", { force: true }); + cy.get('input[type="submit"]').click({ force: true }); + + cy.wait("@create").its("request.body.data.scope").should("equal", "global"); + }); + + 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("@tagCreated").should("not.have.been.called"); + cy.get("@updateModelValue").should("not.have.been.called"); + }); + }); + + 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("@tagUpdated").should("have.been.calledOnce"); + }); + }); +}); diff --git a/webapp/cypress/component/TagManagementTableTest.cy.jsx b/webapp/cypress/component/TagManagementTableTest.cy.jsx new file mode 100644 index 000000000..a0478184c --- /dev/null +++ b/webapp/cypress/component/TagManagementTableTest.cy.jsx @@ -0,0 +1,117 @@ +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", + // A user-defined tag owned by the current user ("self"). + scope: "user", + owner: "self", + }, + { + immutable_id: "t2", + type: "tags", + name: "global-tag", + description: null, + color: null, + scope: "global", + owner: 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 columns (including Scope)", () => { + mountTable("admin"); + const headers = ["", "Tag", "Description", "Scope", "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("shows a scope badge per tag", () => { + mountTable("admin"); + cy.get(".p-datatable-tbody") + .find("tr") + .eq(0) + .find('[data-testid="tag-scope-badge"]') + .should("contain.text", "User-defined"); + cy.get(".p-datatable-tbody") + .find("tr") + .eq(1) + .find('[data-testid="tag-scope-badge"]') + .should("contain.text", "Global"); + }); + + 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", () => { + // An admin owns the user-defined tag ("self") and manages the global tag, so both rows. + 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("lets a non-admin create tags and manage only their own user-defined tags", () => { + // A non-admin can create (user-defined) tags, and manage their own user-defined tag, + // but not the global tag (which only admins manage). + mountTable("user"); + cy.get('[data-testid="add-tag-button"]').should("exist"); + cy.get('button[title="Edit tag"]').should("have.length", 1); + cy.get('button[title="Delete tag"]').should("have.length", 1); + // The controls sit on the user-defined (own) tag row, not the global one. + cy.get(".p-datatable-tbody") + .find("tr") + .eq(0) + .within(() => cy.get('button[title="Edit tag"]').should("exist")); + cy.get(".p-datatable-tbody") + .find("tr") + .eq(1) + .within(() => cy.get('button[title="Edit 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..daab36af7 --- /dev/null +++ b/webapp/cypress/component/TagSelectTest.cy.jsx @@ -0,0 +1,71 @@ +import TagSelect from "@/components/TagSelect.vue"; + +describe("TagSelect.vue", () => { + const tag = { + type: "tags", + immutable_id: "0123456789ab0123456789ab", + name: "test-tag", + description: "reacts with air", + color: "#f1c40f", + scope: "user", + }; + + beforeEach(() => { + cy.intercept("GET", "**/search-tags*", { + body: { status: "success", data: [tag] }, + }).as("searchTags"); + }); + + it("searches and emits a reference object when a 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, a color swatch and a scope pill. + cy.get(".vs__dropdown-option").contains("test-tag").should("exist"); + cy.get(".vs__dropdown-option .color-swatch").should("exist"); + cy.get('.vs__dropdown-option [data-testid="tag-scope-badge"]').should( + "contain", + "user-defined", + ); + cy.get(".vs__dropdown-option").contains("test-tag").click(); + + // The reference preserves display fields (color/description/scope). + cy.get("@update").should("have.been.calledWith", [ + { + type: "tags", + immutable_id: tag.immutable_id, + name: "test-tag", + color: "#f1c40f", + description: "reacts with air", + scope: "user", + }, + ]); + }); + + it("does not offer to create a tag for a typed value with no match", () => { + cy.intercept("GET", "**/search-tags*", { + body: { status: "success", data: [] }, + }).as("searchTagsEmpty"); + + cy.mount(TagSelect, { + props: { modelValue: [], "onUpdate:modelValue": cy.spy().as("update") }, + }); + + cy.get(".vs__search").type("brand-new-tag"); + cy.wait("@searchTagsEmpty"); + // A typed value matching no tag is not selectable (no ad-hoc tag creation). + cy.get(".vs__dropdown-option").should("not.exist"); + cy.get(".vs__no-options").should("contain", "No matching tags"); + }); + + it("renders existing reference tags as selected chips", () => { + cy.mount(TagSelect, { + props: { modelValue: [tag] }, + }); + + cy.get(".vs__selected").should("contain", "test-tag"); + }); +}); diff --git a/webapp/cypress/e2e/tagsManagement.cy.js b/webapp/cypress/e2e/tagsManagement.cy.js new file mode 100644 index 000000000..25b8cbb2e --- /dev/null +++ b/webapp/cypress/e2e/tagsManagement.cy.js @@ -0,0 +1,198 @@ +// 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). +// +// Tags have two scopes: "global" (admin-managed, usable by everyone) and "user" (user-defined, +// owned and managed by a single user). admin-user@example.com is an admin by the same +// convention as authenticatedSampleTests.cy.js. + +// Role is determined server-side by the email (see authenticatedSampleTests.cy.js): +// `admin-user@example.com` is an admin, `test-user@example.com` is a plain user. +const adminEmail = "admin-user@example.com"; +const userEmail = "test-user@example.com"; // a non-admin user + +describe("Tag management page (admin, global tags)", () => { + // 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 global tag", () => { + cy.visit("/tags"); + + // Create (an admin picks the "global" scope). + cy.get('[data-testid="add-tag-button"]').click(); + cy.get('[data-testid="tag-scope-select"]').select("global"); + 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"); + // The row is marked as a global tag. + cy.get('[data-testid="tags-table"]') + .contains("tr", tagName) + .find('[data-testid="tag-scope-badge"]') + .should("contain.text", "Global"); + + // 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 page (user, user-defined tags)", () => { + const tagName = "e2e-user-defined-tag"; + const renamedTag = "e2e-user-defined-renamed"; + + beforeEach(() => { + cy.loginViaTestMagicLink(userEmail); + cy.deleteTagByNameViaAPI(tagName); + cy.deleteTagByNameViaAPI(renamedTag); + }); + + after(() => { + cy.loginViaTestMagicLink(userEmail); + cy.deleteTagByNameViaAPI(tagName); + cy.deleteTagByNameViaAPI(renamedTag); + }); + + it("lets a non-admin create, edit and delete their own user-defined tag", () => { + cy.visit("/tags"); + + // Create. A non-admin has no scope choice; the tag is user-defined by default. + cy.get('[data-testid="add-tag-button"]').click(); + cy.get('[data-testid="tag-scope-select"]').should("not.exist"); + cy.get("#tag-name").type(tagName); + cy.get(".modal-footer input[type=submit]:visible").click(); + + cy.get('[data-testid="tags-table"]').contains(".badge", tagName).should("exist"); + cy.get('[data-testid="tags-table"]') + .contains("tr", tagName) + .find('[data-testid="tag-scope-badge"]') + .should("contain.text", "User-defined"); + + // The owner can edit and delete their own user-defined tag. + 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.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); + // A global tag: everyone can see it, but only admins can edit/delete it. + cy.createTagViaAPI({ name: tagName, scope: "global" }); + }); + + after(() => { + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(tagName); + }); + + it("lets a non-admin create tags but not manage a global tag", () => { + cy.loginViaTestMagicLink(userEmail); + cy.visit("/tags"); + cy.contains("tr", tagName).should("exist"); // the global tag is visible to everyone + // A non-admin can now create their own (user-defined) tags. + cy.get('[data-testid="add-tag-button"]').should("exist"); + // ... but cannot edit or delete a global tag. + 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 edit/delete controls on a global tag 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(() => { + // A global tag so any user can apply it. + cy.loginViaTestMagicLink(adminEmail); + cy.deleteTagByNameViaAPI(intTag); + cy.createTagViaAPI({ name: intTag, scope: "global" }); + }); + + 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 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 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 35c757b8d..7761e7322 100644 --- a/webapp/cypress/support/commands.js +++ b/webapp/cypress/support/commands.js @@ -115,6 +115,38 @@ Cypress.Commands.add("deleteSampleViaAPI", (item_id) => { }); }); +Cypress.Commands.add("createTagViaAPI", (data) => { + // data: { name, description?, color?, scope? }. `scope` defaults to "user" + // (a user-defined tag owned by the logged-in user); pass scope: "global" (as an + // admin) for a tag every user can use. 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 51b5c2889..6cde51393 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" @@ -219,6 +220,41 @@ + + @@ -23,7 +15,7 @@ export default { components: { DynamicDataTable }, data() { return { - sampleColumns: [ + baseSampleColumns: [ { field: "item_id", header: "ID", @@ -75,6 +67,39 @@ export default { }; }, computed: { + enableTags() { + // Tag column only if enabled globally. + return this.$store.state.serverInfo?.features?.tags ?? false; + }, + sampleColumns() { + const columns = [...this.baseSampleColumns]; + if (this.enableTags) { + const insertBeforeBlocks = columns.findIndex((column) => column.field === "blocks"); + columns.splice(insertBeforeBlocks, 0, { + field: "tags", + header: "Tags", + body: "TagList", + filter: true, + label: "Tags", + }); + } + return columns; + }, + sampleGlobalFilterFields() { + const fields = [ + "item_id", + "name", + "refcode", + "chemform", + "creatorsList", + "blocks", + "characteristic_chemical_formula", + ]; + if (this.enableTags) { + fields.push("tagsList"); + } + return fields; + }, samples() { if (!this.$store.state.sample_list) { return null; @@ -90,6 +115,10 @@ export default { .map((collection) => collection.collection_id) .join(", "), creatorsList: sample.creators.map((creator) => creator.display_name).join(", "), + tagsList: (sample.tags || []) + .map((tag) => tag.name) + .filter(Boolean) + .join(", "), }; }); }, diff --git a/webapp/src/components/StartingMaterialInformation.vue b/webapp/src/components/StartingMaterialInformation.vue index 7bb2bdc3f..3fae27e91 100644 --- a/webapp/src/components/StartingMaterialInformation.vue +++ b/webapp/src/components/StartingMaterialInformation.vue @@ -47,6 +47,12 @@
+
+
+ +
+
+
@@ -127,6 +133,7 @@ import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualiz import ToggleableCreatorsFormGroup from "@/components/ToggleableCreatorsFormGroup"; import ToggleableGroupsFormGroup from "@/components/ToggleableGroupsFormGroup"; import LocationInput from "@/components/LocationInput"; +import ToggleableTagsFormGroup from "@/components/ToggleableTagsFormGroup"; import AutoComplete from "primevue/autocomplete"; import { getStartingMaterialList, getEquipmentList } from "@/server_fetch_utils.js"; @@ -148,6 +155,7 @@ export default { ToggleableCreatorsFormGroup, ToggleableGroupsFormGroup, LocationInput, + ToggleableTagsFormGroup, }, props: { item_id: { type: String, required: true }, @@ -176,10 +184,14 @@ export default { Location: createComputedSetterForItemField("location"), ItemDescription: createComputedSetterForItemField("description"), Collections: createComputedSetterForItemField("collections"), + Tags: createComputedSetterForItemField("tags"), Refcode: createComputedSetterForItemField("refcode"), Status: createComputedSetterForItemField("status"), ItemCreators: createComputedSetterForItemField("creators"), ItemGroups: createComputedSetterForItemField("groups"), + enableTags() { + return this.$store.state.serverInfo?.features?.tags ?? false; + }, schema() { return this.$store.state.schemas[this.item?.type]; }, diff --git a/webapp/src/components/TagActionsCell.vue b/webapp/src/components/TagActionsCell.vue new file mode 100644 index 000000000..3b22c4d92 --- /dev/null +++ b/webapp/src/components/TagActionsCell.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/webapp/src/components/TagBadge.vue b/webapp/src/components/TagBadge.vue new file mode 100644 index 000000000..10e6d6faf --- /dev/null +++ b/webapp/src/components/TagBadge.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/webapp/src/components/TagColorPicker.vue b/webapp/src/components/TagColorPicker.vue new file mode 100644 index 000000000..03a82704b --- /dev/null +++ b/webapp/src/components/TagColorPicker.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/webapp/src/components/TagFormModal.vue b/webapp/src/components/TagFormModal.vue new file mode 100644 index 000000000..11a1397c8 --- /dev/null +++ b/webapp/src/components/TagFormModal.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/webapp/src/components/TagList.vue b/webapp/src/components/TagList.vue new file mode 100644 index 000000000..b36a34941 --- /dev/null +++ b/webapp/src/components/TagList.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/webapp/src/components/TagManagementTable.vue b/webapp/src/components/TagManagementTable.vue new file mode 100644 index 000000000..a29d8565d --- /dev/null +++ b/webapp/src/components/TagManagementTable.vue @@ -0,0 +1,85 @@ + + + diff --git a/webapp/src/components/TagScopeBadge.vue b/webapp/src/components/TagScopeBadge.vue new file mode 100644 index 000000000..303a6066a --- /dev/null +++ b/webapp/src/components/TagScopeBadge.vue @@ -0,0 +1,28 @@ + + + diff --git a/webapp/src/components/TagSelect.vue b/webapp/src/components/TagSelect.vue new file mode 100644 index 000000000..0d13b5305 --- /dev/null +++ b/webapp/src/components/TagSelect.vue @@ -0,0 +1,178 @@ + + + + + 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 f17aaa1f1..dffef9fd6 100644 --- a/webapp/src/field_utils.js +++ b/webapp/src/field_utils.js @@ -185,3 +185,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/main.js b/webapp/src/main.js index f75d0aa99..545dade39 100644 --- a/webapp/src/main.js +++ b/webapp/src/main.js @@ -77,6 +77,7 @@ import { faCaretDown, faLock, faClock, + faUser, } from "@fortawesome/free-solid-svg-icons"; import { faPlusSquare } from "@fortawesome/free-regular-svg-icons"; import { faGithub, faOrcid, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons"; @@ -154,6 +155,7 @@ library.add( faCaretDown, faLock, faClock, + faUser, ); // import "@uppy/vue" diff --git a/webapp/src/resources.js b/webapp/src/resources.js index e4ed4d6d1..73a0a7948 100644 --- a/webapp/src/resources.js +++ b/webapp/src/resources.js @@ -143,6 +143,25 @@ 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", +]; + +// The color assigned to a newly created tag. +export const DEFAULT_TAG_COLOR = "#95a5a6"; + 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 d8865df3c..efaf9b556 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?, scope? }. `scope` is "user" (user-defined, + // default) or "global" (admins 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 8dd7cf01e..7ec2e38f4 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: {}, @@ -72,6 +73,10 @@ export default createStore({ page: 0, rows: 10, }, + tags: { + page: 0, + rows: 20, + }, }, block_errors: {}, block_infos: {}, @@ -98,6 +103,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 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 @@ + + + + +