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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions pydatalab/schemas/cell.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
90 changes: 90 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,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": [
{
Expand Down
8 changes: 8 additions & 0 deletions pydatalab/schemas/sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
8 changes: 8 additions & 0 deletions pydatalab/schemas/startingmaterial.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
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 @@ -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.",
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
5 changes: 4 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 @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions pydatalab/src/pydatalab/models/tags.py
Original file line number Diff line number Diff line change
@@ -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
60 changes: 59 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,
)

if TYPE_CHECKING:
pass
Expand All @@ -16,6 +22,7 @@
"IsCollectable",
"HasSynthesisInfo",
"HasSubstanceInfo",
"HasTags",
)


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

Expand Down
7 changes: 7 additions & 0 deletions pydatalab/src/pydatalab/models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading