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
60 changes: 59 additions & 1 deletion pydatalab/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,64 @@ Currently, there are two mechanisms for accessing remote files:

## Customisation and branding

Deployments can customise the look and feel of their *datalab* instance by providing files under a `public/custom/` directory in the web app.
Deployments can customise the main navigation through the server configuration and customise the look and feel of their *datalab* instance by providing files under a `public/custom/` directory in the web app.
This directory can be a symlink to a folder in your deployment repository, or mounted as a volume in Docker.
It should be made available at `webapp/public/custom/` relative to the *datalab* source tree.

The following customisations are supported:

### Main navigation

The [`NAVIGATION`][pydatalab.config.ServerConfig.NAVIGATION] setting controls which links appear in the main navigation, their order, labels, optional icons, and the landing view used for `/`.
Each entry has a required `view` ID and optional `label`, `icon`, and `default` values.
The server configuration is the source of this default and is returned to the web app by the `/info` endpoint.
For example, the following configuration renames Inventory to Starting Materials, moves About to the end, and omits Collections:

```json
{
"NAVIGATION": [
{"view": "samples"},
{
"view": "starting-materials",
"label": "Starting Materials",
"icon": "vials",
"default": true
},
{"view": "equipment"},
{"view": "item-graph", "label": "Graph View", "icon": "project-diagram"},
{"view": "about"}
]
}
```

The same value can be supplied as JSON in the server's `.env` file or process environment:

```dotenv
PYDATALAB_NAVIGATION='[{"view":"samples"},{"view":"starting-materials","label":"Starting Materials","default":true},{"view":"about"}]'
```

The built-in views are:

| View ID | Default label | Route |
|---|---|---|
| `about` | About | `/about` |
| `samples` | Samples | `/samples` |
| `collections` | Collections | `/collections` |
| `starting-materials` | Inventory | `/starting-materials` |
| `equipment` | Equipment | `/equipment` |
| `item-graph` | Graph View | `/item-graph` |

Entries are shown in the configured order, and omitted entries are only hidden from the navigation: their routes remain available unless the corresponding feature is disabled separately.
Set `default` to `true` on the entry that should open when a user visits `/`; only one entry can be marked as the default.
If no entry is explicitly marked as the default, the first entry is used.
The built-in navigation marks Samples as the default to preserve the existing landing page.
The navigation must contain at least one entry, and duplicate view IDs, multiple defaults, and malformed entries are rejected during server configuration.
View IDs unknown to the installed web app are ignored by it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't it also give an error maybe. The most likely cause for seems a typo and it could be difficult to identify. Not sure how easy it is to implement.

@davidwaroquiers davidwaroquiers Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I would keep it like that for now, it's not so straightforward to do it at deployment time (at least for when there will be custom views).


The `icon` value must name a Font Awesome solid icon already registered by the web app, such as `vials` or `project-diagram`.
Unknown icons are omitted without hiding the link.
The default configuration preserves the current six links and the Graph View icon; within a custom configuration, an omitted `icon` means that entry has no icon.

### CSS overrides (`public/custom/override.css`)

A CSS file that is loaded globally before the app styles, allowing you to override any default styling.
Expand Down Expand Up @@ -255,6 +307,12 @@ public/custom/
show_root_heading: true
show_source: false

::: pydatalab.config.NavigationEntry
options:
heading_level: 2
show_root_heading: true
show_source: false

::: pydatalab.config.SMTPSettings
options:
heading_level: 2
Expand Down
74 changes: 73 additions & 1 deletion pydatalab/src/pydatalab/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
from pydatalab.models import Person
from pydatalab.models.utils import RandomAlphabeticalRefcodeFactory, RefCodeFactory

__all__ = ("CONFIG", "ServerConfig", "DeploymentMetadata", "RemoteFilesystem")
__all__ = (
"CONFIG",
"ServerConfig",
"DeploymentMetadata",
"NavigationEntry",
"RemoteFilesystem",
)

config_logger = logging.getLogger("pydatalab.config")

Expand Down Expand Up @@ -128,6 +134,49 @@ class SMTPSettings(BaseModel):
)


class NavigationEntry(BaseModel):
"""A configured entry in the web application's main navigation."""

view: str = Field(description="Stable identifier of a view known to the web application.")
label: str | None = Field(
None,
description="Optional navigation label. The view's default label is used when omitted.",
)
icon: str | None = Field(
None,
description="Optional name of a Font Awesome icon registered by the web application.",
)
default: bool = Field(
False,
description="Whether this entry is the landing view for the web application.",
)

model_config = ConfigDict(extra="forbid")

@field_validator("view", "label", "icon")
@classmethod
def strip_non_empty_strings(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
raise ValueError("navigation values must not be blank")
return value


def default_navigation() -> list[NavigationEntry]:
"""Return the navigation used when a deployment provides no customisation."""

return [
NavigationEntry(view="about"),
NavigationEntry(view="samples", default=True),
NavigationEntry(view="collections"),
NavigationEntry(view="starting-materials"),
NavigationEntry(view="equipment"),
NavigationEntry(view="item-graph", icon="project-diagram"),
]


class ServerConfig(BaseSettings):
"""A model that provides settings for deploying the API."""

Expand Down Expand Up @@ -279,6 +328,11 @@ class ServerConfig(BaseSettings):
description="A list of dotted import paths ('package.module:ClassName') to custom `Item` subclasses to register as additional item types served through the generic item endpoints, e.g. ['mypackage.models:MySample']. Each model must declare its own unique `type` literal.",
)

NAVIGATION: list[NavigationEntry] = Field(
default_factory=default_navigation,
description="Ordered views to display in the web application's main navigation.",
)

PREDEFINED_LOCATIONS: set[str] = Field(
default_factory=set,
description="A list of additional lab locations to populate in the /locations endpoint that will be suggested globally for autocompletion. Use '>' to indicate location hierarchy",
Expand Down Expand Up @@ -400,6 +454,24 @@ def check_predefined_locations(cls, v):
construct_location_hierarchy(v)
return v

@field_validator("NAVIGATION")
@classmethod
def validate_navigation(cls, value: list[NavigationEntry]) -> list[NavigationEntry]:
if not value:
raise ValueError("NAVIGATION must contain at least one entry")

views = [entry.view for entry in value]
if len(views) != len(set(views)):
raise ValueError("NAVIGATION must not contain duplicate view IDs")

default_indices = [index for index, entry in enumerate(value) if entry.default]
if len(default_indices) > 1:
raise ValueError("NAVIGATION must not contain multiple default views")
if not default_indices:
value[0] = value[0].model_copy(update={"default": True})

return value

@field_validator("LOG_FILE", mode="before")
@classmethod
def make_missing_log_directory(cls, v):
Expand Down
4 changes: 3 additions & 1 deletion pydatalab/src/pydatalab/routes/v0_1/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from pydatalab import __version__
from pydatalab.apps import BLOCK_TYPES
from pydatalab.config import CONFIG
from pydatalab.config import CONFIG, NavigationEntry
from pydatalab.deployment_stats import (
STATS_REFRESH_INTERVAL,
build_stats_summary,
Expand Down Expand Up @@ -117,6 +117,7 @@ class Info(Attributes, Meta):
identifier_prefix: str
features: FeatureFlags | None = None
max_upload_bytes: int
navigation: list[NavigationEntry]

@field_validator("maintainer", mode="before")
@classmethod
Expand All @@ -139,6 +140,7 @@ def _get_deployment_metadata_once() -> dict:
"identifier_prefix": identifier_prefix,
"max_upload_bytes": CONFIG.MAX_CONTENT_LENGTH,
"features": FEATURE_FLAGS,
"navigation": CONFIG.NAVIGATION,
}
)
return metadata
Expand Down
51 changes: 51 additions & 0 deletions pydatalab/tests/server/test_info_and_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,54 @@ def test_info_endpoint_includes_max_upload_bytes(client, app):
assert isinstance(attributes["max_upload_bytes"], int)
assert attributes["max_upload_bytes"] > 0
assert attributes["max_upload_bytes"] == 10 * 1000 * 1000


def test_info_endpoint_includes_navigation(client):
response = client.get("/info")

assert response.status_code == 200
assert response.json["data"]["attributes"]["navigation"] == [
{"view": "about", "label": None, "icon": None, "default": False},
{"view": "samples", "label": None, "icon": None, "default": True},
{"view": "collections", "label": None, "icon": None, "default": False},
{"view": "starting-materials", "label": None, "icon": None, "default": False},
{"view": "equipment", "label": None, "icon": None, "default": False},
{
"view": "item-graph",
"label": None,
"icon": "project-diagram",
"default": False,
},
]


def test_info_endpoint_includes_custom_navigation(client, monkeypatch):
from pydatalab.config import CONFIG, ServerConfig
from pydatalab.routes.v0_1.info import _get_deployment_metadata_once

custom_config = ServerConfig(
TESTING=True,
_env_file=None,
NAVIGATION=[
{"view": "equipment"},
{"view": "starting-materials", "label": "Materials", "default": True},
],
)
monkeypatch.setattr(CONFIG, "NAVIGATION", custom_config.NAVIGATION)
_get_deployment_metadata_once.cache_clear()

try:
response = client.get("/info")
finally:
_get_deployment_metadata_once.cache_clear()

assert response.status_code == 200
assert response.json["data"]["attributes"]["navigation"] == [
{"view": "equipment", "label": None, "icon": None, "default": False},
{
"view": "starting-materials",
"label": "Materials",
"icon": None,
"default": True,
},
]
Loading
Loading