Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions pydatalab/schemas/cell.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
97 changes: 97 additions & 0 deletions pydatalab/schemas/equipment.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions pydatalab/schemas/sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions pydatalab/schemas/startingmaterial.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions pydatalab/src/pydatalab/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions pydatalab/src/pydatalab/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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."
Expand Down
2 changes: 2 additions & 0 deletions pydatalab/src/pydatalab/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -38,6 +39,7 @@ def generate_schemas() -> dict[str, dict]:
"Cell",
"Collection",
"Equipment",
"Tag",
"ItemVersion",
"ITEM_MODELS",
"ITEM_SCHEMAS",
Expand Down
3 changes: 2 additions & 1 deletion pydatalab/src/pydatalab/models/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
HasBlocks,
HasOwner,
HasRevisionControl,
HasTags,
IsCollectable,
)
from pydatalab.models.utils import (
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions pydatalab/src/pydatalab/models/tags.py
Original file line number Diff line number Diff line change
@@ -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`)."""
79 changes: 78 additions & 1 deletion pydatalab/src/pydatalab/models/traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -13,6 +19,7 @@
"IsCollectable",
"HasSynthesisInfo",
"HasSubstanceInfo",
"HasTags",
)


Expand All @@ -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."""
Expand Down
Loading
Loading