diff --git a/config/plugins/tours/core.galaxy_ui.yaml b/config/plugins/tours/core.galaxy_ui.yaml index e981beb72fef..dfc9e56f418f 100644 --- a/config/plugins/tours/core.galaxy_ui.yaml +++ b/config/plugins/tours/core.galaxy_ui.yaml @@ -1,4 +1,3 @@ -id: galaxy_ui name: Galaxy UI description: A gentle introduction to the Galaxy User Interface title_default: "Welcome to Galaxy" diff --git a/config/plugins/tours/core.scratchbook.yaml b/config/plugins/tours/core.scratchbook.yaml index 6b878772257c..b3111a98088c 100644 --- a/config/plugins/tours/core.scratchbook.yaml +++ b/config/plugins/tours/core.scratchbook.yaml @@ -1,6 +1,6 @@ name: Scratchbook - Introduction -title_default: "Scratchbook Introduction" description: "An introduction on how to display multiple datasets and visualizations next to each other." +title_default: "Scratchbook Introduction" tags: - "core" - "UI" diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py index 97c6b971a7be..d773de8a734a 100644 --- a/lib/galaxy/app.py +++ b/lib/galaxy/app.py @@ -43,7 +43,7 @@ from galaxy.tools.data_manager.manager import DataManagers from galaxy.tools.error_reports import ErrorReports from galaxy.tools.special_tools import load_lib_tools -from galaxy.tours import ToursRegistry +from galaxy.tours import build_tours_registry from galaxy.util import ( ExecutionTimer, heartbeat, @@ -173,7 +173,7 @@ def __init__(self, **kwargs): directories_setting=self.config.visualization_plugins_directory, template_cache_dir=self.config.template_cache_path) # Tours registry - self.tour_registry = ToursRegistry(self.config.tour_config_dir) + self.tour_registry = build_tours_registry(self.config.tour_config_dir) # Webhooks registry self.webhooks_registry = WebhooksRegistry(self.config.webhooks_dir) # Load security policy. diff --git a/lib/galaxy/schema/__init__.py b/lib/galaxy/schema/__init__.py index 0533680cb97e..174c87af56d9 100644 --- a/lib/galaxy/schema/__init__.py +++ b/lib/galaxy/schema/__init__.py @@ -1,12 +1,15 @@ -import typing +from typing import ( + Dict, + Optional, +) from pydantic import BaseModel class BootstrapAdminUser(BaseModel): id = 0 - email: typing.Optional[str] = None - preferences: typing.Dict[str, str] = {} + email: Optional[str] = None + preferences: Dict[str, str] = {} bootstrap_admin_user = True def all_roles(*args) -> list: diff --git a/lib/galaxy/tours/__init__.py b/lib/galaxy/tours/__init__.py index 2239caf48042..b8549dc9a45d 100644 --- a/lib/galaxy/tours/__init__.py +++ b/lib/galaxy/tours/__init__.py @@ -1,87 +1,17 @@ -""" -This module manages loading/etc of Galaxy interactive tours. -""" -import logging -import os - -import yaml - -from galaxy import util - -log = logging.getLogger(__name__) - - -def tour_loader(contents_dict): - # Some of this can be done on the clientside. Maybe even should? - title_default = contents_dict.get('title_default', None) - for step in contents_dict['steps']: - if 'intro' in step: - step['content'] = step.pop('intro') - if 'position' in step: - step['placement'] = step.pop('position') - if 'element' not in step: - step['orphan'] = True - if title_default and 'title' not in step: - step['title'] = title_default - return contents_dict - - -class ToursRegistry: - def __init__(self, tour_directories): - self.tour_directories = util.config_directories_from_setting(tour_directories) - self.load_tours() - - def tours_by_id_with_description(self): - return [{'id': k, - 'description': self.tours[k].get('description', None), - 'name': self.tours[k].get('name', None), - 'tags': self.tours[k].get('tags', None)} - for k in self.tours.keys()] - - def load_tour(self, tour_id): - for tour_dir in self.tour_directories: - tour_path = os.path.join(tour_dir, tour_id + ".yaml") - if not os.path.exists(tour_path): - tour_path = os.path.join(tour_dir, tour_id + ".yml") - if os.path.exists(tour_path): - return self._load_tour_from_path(tour_path) - - def load_tours(self): - self.tours = {} - for tour_dir in self.tour_directories: - for filename in os.listdir(tour_dir): - if self._is_yaml(filename): - self._load_tour_from_path(os.path.join(tour_dir, filename)) - return self.tours_by_id_with_description() - - def reload_tour(self, path): - # We may safely assume that the path is within the tour directory - filename = os.path.basename(path) - if self._is_yaml(filename): - self._load_tour_from_path(path) - - def tour_contents(self, tour_id): - # Extra format translation could happen here (like the previous intro_to_tour) - # For now just return the loaded contents. - return self.tours.get(tour_id, None) - - def _is_yaml(self, filename): - return filename.endswith('.yaml') or filename.endswith('.yml') - - def _load_tour_from_path(self, tour_path): - filename = os.path.basename(tour_path) - tour_id = os.path.splitext(filename)[0] - try: - with open(tour_path) as handle: - conf = yaml.safe_load(handle) - tour = tour_loader(conf) - self.tours[tour_id] = tour_loader(conf) - log.info("Loaded tour '%s'" % tour_id) - return tour - except OSError: - log.exception("Tour '%s' could not be loaded, error reading file.", tour_id) - except yaml.error.YAMLError: - log.exception("Tour '%s' could not be loaded, error within file. Please check your yaml syntax.", tour_id) - except TypeError: - log.exception("Tour '%s' could not be loaded, error within file. Possibly spacing related. Please check your yaml syntax.", tour_id) - return None +from ._impl import build_tours_registry +from ._interface import ToursRegistry +from ._schema import Tour +from ._schema import TourCore +from ._schema import TourDetails +from ._schema import TourList +from ._schema import TourStep + +__all__ = [ + 'build_tours_registry', + 'ToursRegistry', + 'Tour', + 'TourCore', + 'TourDetails', + 'TourList', + 'TourStep' +] diff --git a/lib/galaxy/tours/_impl.py b/lib/galaxy/tours/_impl.py new file mode 100644 index 000000000000..be413f9d4989 --- /dev/null +++ b/lib/galaxy/tours/_impl.py @@ -0,0 +1,115 @@ +""" +This module manages loading/etc of Galaxy interactive tours. +""" +import logging +import os + +import yaml +from pydantic import parse_obj_as + +from galaxy.util import config_directories_from_setting +from ._interface import ToursRegistry +from ._schema import TourList + + +log = logging.getLogger(__name__) + + +def build_tours_registry(tour_directories: str): + return ToursRegistryImpl(tour_directories) + + +def load_tour_steps(contents_dict): + # Some of this can be done on the clientside. Maybe even should? + title_default = contents_dict.get('title_default') + for step in contents_dict['steps']: + if 'intro' in step: + step['content'] = step.pop('intro') + if 'position' in step: + step['placement'] = step.pop('position') + if 'element' not in step: + step['orphan'] = True + if title_default and 'title' not in step: + step['title'] = title_default + + +@ToursRegistry.register +class ToursRegistryImpl: + + def __init__(self, tour_directories): + self.tour_directories = config_directories_from_setting(tour_directories) + self._extensions = ('.yml', '.yaml') + self._load_tours() + + def get_tours(self): + """Return list of tours.""" + tours = [] + for k in self.tours.keys(): + tourdata = { + 'id': k, + 'name': self.tours[k].get('name'), + 'description': self.tours[k].get('description'), + 'tags': self.tours[k].get('tags') + } + tours.append(tourdata) + return parse_obj_as(TourList, tours) + + def tour_contents(self, tour_id): + """Return tour contents.""" + # Extra format translation could happen here (like the previous intro_to_tour) + # For now just return the loaded contents. + return self.tours.get(tour_id) + + def load_tour(self, tour_id): + """Reload tour and return its contents.""" + tour_path = self._get_path_from_tour_id(tour_id) + self._load_tour_from_path(tour_path) + return self.tours.get(tour_id) + + def reload_tour(self, path): + """Reload tour.""" + # We may safely assume that the path is within the tour directory + filename = os.path.basename(path) + if self._is_yaml(filename): + self._load_tour_from_path(path) + + def _load_tours(self): + self.tours = {} + for tour_dir in self.tour_directories: + for filename in os.listdir(tour_dir): + if self._is_yaml(filename): + tour_path = os.path.join(tour_dir, filename) + self._load_tour_from_path(tour_path) + + def _is_yaml(self, filename): + for ext in self._extensions: + if filename.endswith(ext): + return True + + def _load_tour_from_path(self, tour_path): + tour_id = self._get_tour_id_from_path(tour_path) + try: + with open(tour_path) as f: + tour = yaml.safe_load(f) + load_tour_steps(tour) + self.tours[tour_id] = tour + log.info("Loaded tour '%s'" % tour_id) + except OSError: + log.exception("Tour '%s' could not be loaded, error reading file." % tour_id) + except yaml.error.YAMLError: + log.exception("Tour '%s' could not be loaded, error within file." + " Please check your yaml syntax." % tour_id) + except TypeError: + log.exception("Tour '%s' could not be loaded, error within file." + " Possibly spacing related. Please check your yaml syntax." % tour_id) + + def _get_tour_id_from_path(self, tour_path): + filename = os.path.basename(tour_path) + return os.path.splitext(filename)[0] + + def _get_path_from_tour_id(self, tour_id): + for tour_dir in self.tour_directories: + for ext in self._extensions: + tour_path = os.path.join(tour_dir, tour_id + ext) + if os.path.exists(tour_path): + return tour_path diff --git a/lib/galaxy/tours/_interface.py b/lib/galaxy/tours/_interface.py new file mode 100644 index 000000000000..33cd2f76168a --- /dev/null +++ b/lib/galaxy/tours/_interface.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from ._schema import ( + TourDetails, + TourList, +) + + +class ToursRegistry(ABC): + + @abstractmethod + def get_tours(self) -> TourList: + """Return list of tours.""" + + @abstractmethod + def tour_contents(self, tour_id: str) -> TourDetails: + """Return tour contents.""" + + @abstractmethod + def load_tour(self, tour_id: str) -> TourDetails: + """Reload tour and return its contents.""" diff --git a/lib/galaxy/tours/_schema.py b/lib/galaxy/tours/_schema.py new file mode 100644 index 000000000000..bf5d1274fef8 --- /dev/null +++ b/lib/galaxy/tours/_schema.py @@ -0,0 +1,82 @@ +from typing import ( + List, + Optional, +) + +from pydantic import BaseModel, Field + + +class TourCore(BaseModel): + name: str = Field( + title='Name', + description='Name of tour' + ) + description: str = Field( + title='Description', + description='Tour description' + ) + tags: List[str] = Field( + title='Tags', + description='Topic topic tags' + ) + + +class Tour(TourCore): + id: str = Field( + title='Identifier', + description='Tour identifier' + ) + + +class TourList(BaseModel): + __root__: List[Tour] = Field( + title='List of tours', + default=[] + ) + + +class TourStep(BaseModel): + title: Optional[str] = Field( + title='Title', + description='Title displayed in the header of the step container' + ) + content: Optional[str] = Field( + title='Content', + description='Text shown to the user' + ) + element: Optional[str] = Field( + title='Element', + description='JQuery selector for the element to be described/clicked' + ) + placement: Optional[str] = Field( + title='Placement', + description='Placement of the text box relative to the selected element' + ) + preclick: Optional[list] = Field( + title='Pre-click', + description='Elements that receive a click() event before the step is shown' + ) + postclick: Optional[list] = Field( + title='Post-click', + description='Elements that receive a click() event after the step is shown' + ) + textinsert: Optional[str] = Field( + title='Text-insert', + description='Text to insert if element is a text box (e.g. tool search or upload)' + ) + backdrop: Optional[bool] = Field( + title='Backdrop', + description=('Show a dark backdrop behind the popover and its element,' + 'highlighting the current step') + ) + + +class TourDetails(TourCore): + title_default: Optional[str] = Field( + title='Default title', + description='Default title for each step' + ) + steps: List[TourStep] = Field( + title='Steps', + description='Tour steps' + ) diff --git a/lib/galaxy/webapps/galaxy/api/tours.py b/lib/galaxy/webapps/galaxy/api/tours.py index 2837959644c4..fa0b126642cf 100644 --- a/lib/galaxy/webapps/galaxy/api/tours.py +++ b/lib/galaxy/webapps/galaxy/api/tours.py @@ -3,16 +3,56 @@ """ import logging +from fastapi import Depends +from fastapi_utils.cbv import cbv +from fastapi_utils.inferring_router import InferringRouter as APIRouter + +from galaxy.tours import ( + TourDetails, + TourList, + ToursRegistry, +) from galaxy.web import ( expose_api_anonymous_and_sessionless, legacy_expose_api, require_admin ) from galaxy.webapps.base.controller import BaseAPIController +from . import ( + get_admin_user, + get_app, +) log = logging.getLogger(__name__) +router = APIRouter(tags=['tours']) + + +def get_tours_registry(app=Depends(get_app)) -> ToursRegistry: + return app.tour_registry + + +@cbv(router) +class FastAPITours: + registry: ToursRegistry = Depends(get_tours_registry) + + @router.get('/api/tours') + def index(self) -> TourList: + """Return list of available tours.""" + return self.registry.get_tours() + + @router.get('/api/tours/{tour_id}') + def show(self, tour_id: str) -> TourDetails: + """Return a tour definition.""" + return self.registry.tour_contents(tour_id) + + @router.post('/api/tours/{tour_id}', dependencies=[Depends(get_admin_user)]) + def update_tour(self, tour_id: str) -> TourDetails: + """Return a tour definition.""" + return self.registry.load_tour(tour_id) + + class ToursController(BaseAPIController): def __init__(self, app): @@ -24,7 +64,7 @@ def index(self, trans, **kwd): *GET /api/tours/ Displays available tours """ - return self.app.tour_registry.tours_by_id_with_description() + return self.app.tour_registry.get_tours() @expose_api_anonymous_and_sessionless def show(self, trans, tour_id, **kwd): diff --git a/lib/galaxy/webapps/galaxy/fast_app.py b/lib/galaxy/webapps/galaxy/fast_app.py index 64ad1e293296..069011c178d8 100644 --- a/lib/galaxy/webapps/galaxy/fast_app.py +++ b/lib/galaxy/webapps/galaxy/fast_app.py @@ -16,6 +16,10 @@ "name": "licenses", "description": "Operations with [SPDX licenses](https://spdx.org/licenses/).", }, + { + "name": "tours", + "description": "Operations with interactive tours.", + }, ] @@ -49,12 +53,14 @@ def initialize_fast_app(gx_app): from galaxy.webapps.galaxy.api import ( job_lock, jobs, + licenses, roles, - licenses + tours, ) app.include_router(jobs.router) app.include_router(job_lock.router) - app.include_router(roles.router) app.include_router(licenses.router) + app.include_router(roles.router) + app.include_router(tours.router) app.mount('/', wsgi_handler) return app diff --git a/lib/galaxy_test/api/test_tours.py b/lib/galaxy_test/api/test_tours.py index b15a1e714424..6c2ecde6836e 100644 --- a/lib/galaxy_test/api/test_tours.py +++ b/lib/galaxy_test/api/test_tours.py @@ -9,9 +9,20 @@ def test_index(self): tours = response.json() tour_keys = map(lambda t: t["id"], tours) assert "core.history" in tour_keys + for tour in tours: + self._assert_has_keys(tour, "id", "name", "description", "tags") def test_show(self): response = self._get("tours/core.history") self._assert_status_code_is(response, 200) tour = response.json() + self._assert_tour(tour) + + def test_update(self): + response = self._post("tours/core.history", admin=True) + self._assert_status_code_is(response, 200) + tour = response.json() + self._assert_tour(tour) + + def _assert_tour(self, tour): self._assert_has_keys(tour, "name", "description", "title_default", "steps")