diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index cf799974dffe..aca69c68ce12 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -4687,6 +4687,35 @@ export interface paths { /** Returns visualizations for the current user. */ get: operations["index_api_visualizations_get"]; put?: never; + /** + * Create a new visualization. + * @description POST /api/visualizations + * creates a new visualization using the given payload and does not require the import_id field + * + * POST /api/visualizations?import_id={encoded_visualization_id} + * imports a copy of an existing visualization into the user's workspace and does not require the rest of the payload + */ + post: operations["create_api_visualizations_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/visualizations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a visualization by ID. + * @description Return the visualization. + */ + get: operations["show_api_visualizations__id__get"]; + /** Update a visualization. */ + put: operations["update_api_visualizations__id__put"]; post?: never; delete?: never; options?: never; @@ -16803,6 +16832,238 @@ export interface components { }; /** Visualization */ Visualization: Record; + /** VisualizationCreatePayload */ + VisualizationCreatePayload: { + /** + * Annotation + * @description The annotation of the visualization. + */ + annotation?: string | null; + /** + * Config + * @description The config of the visualization. + * @default {} + */ + config: Record | null; + /** + * DbKey + * @description The database key of the visualization. + */ + dbkey?: string | null; + /** + * Save + * @description Whether to save the visualization. + * @default true + */ + save: boolean | null; + /** + * Slug + * @description The slug of the visualization. + */ + slug?: string | null; + /** + * Title + * @description The name of the visualization. + * @default Untitled Visualization + */ + title: string | null; + /** + * Type + * @description The type of the visualization. + */ + type?: string | null; + }; + /** VisualizationCreateResponse */ + VisualizationCreateResponse: { + /** + * ID + * @description Encoded ID of the Visualization. + * @example 0123456789ABCDEF + */ + id: string; + }; + /** VisualizationPluginResponse */ + VisualizationPluginResponse: { + /** + * Description + * @description The description of the plugin. + */ + description: string; + /** + * Embeddable + * @description Whether the plugin is embeddable. + */ + embeddable: boolean; + /** + * Entry Point + * @description The entry point of the plugin. + */ + entry_point: Record; + /** + * Groups + * @description The groups of the plugin. + */ + groups?: Record[] | null; + /** + * Href + * @description The href of the plugin. + */ + href: string; + /** + * HTML + * @description The HTML of the plugin. + */ + html: string; + /** + * Logo + * @description The logo of the plugin. + */ + logo?: string | null; + /** + * Name + * @description The name of the plugin. + */ + name: string; + /** + * Settings + * @description The settings of the plugin. + */ + settings: Record[]; + /** + * Specs + * @description The specs of the plugin. + */ + specs?: Record | null; + /** + * Target + * @description The target of the plugin. + */ + target: string; + /** + * Title + * @description The title of the plugin. + */ + title?: string | null; + }; + /** VisualizationRevisionResponse */ + VisualizationRevisionResponse: { + /** + * Config + * @description The config of the visualization revision. + */ + config: Record; + /** + * DbKey + * @description The database key of the visualization. + */ + dbkey?: string | null; + /** + * ID + * @description Encoded ID of the Visualization Revision. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Model class + * @description The name of the database model class. + * @constant + * @enum {string} + */ + model_class: "VisualizationRevision"; + /** + * Title + * @description The name of the visualization revision. + */ + title: string; + /** + * Visualization ID + * @description Encoded ID of the Visualization. + * @example 0123456789ABCDEF + */ + visualization_id: string; + }; + /** VisualizationShowResponse */ + VisualizationShowResponse: { + /** + * Annotation + * @description The annotation of this Visualization. + */ + annotation?: string | null; + /** + * DbKey + * @description The database key of the visualization. + */ + dbkey?: string | null; + /** + * Email Hash + * @description The hash of the email of the user owning this Visualization. + */ + email_hash: string; + /** + * ID + * @description Encoded ID of the Visualization. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Latest Revision + * @description The latest revision of this Visualization. + */ + latest_revision: components["schemas"]["VisualizationRevisionResponse"]; + /** + * Model class + * @description The name of the database model class. + * @constant + * @enum {string} + */ + model_class: "Visualization"; + /** + * Plugin + * @description The plugin of this Visualization. + */ + plugin?: components["schemas"]["VisualizationPluginResponse"] | null; + /** + * Revisions + * @description A list of encoded IDs of the revisions of this Visualization. + */ + revisions: string[]; + /** + * Slug + * @description The slug of the visualization. + */ + slug?: string | null; + /** + * Tags + * @description A list of tags to add to this item. + */ + tags?: components["schemas"]["TagCollection"] | null; + /** + * Title + * @description The name of the visualization. + */ + title: string; + /** + * Type + * @description The type of the visualization. + */ + type: string; + /** + * URL + * @description The URL of the visualization. + */ + url: string; + /** + * User ID + * @description The ID of the user owning this Visualization. + * @example 0123456789ABCDEF + */ + user_id: string; + /** + * Username + * @description The name of the user owning this Visualization. + */ + username: string; + }; /** VisualizationSummary */ VisualizationSummary: { /** @@ -16874,6 +17135,46 @@ export interface components { * @default [] */ VisualizationSummaryList: components["schemas"]["VisualizationSummary"][]; + /** VisualizationUpdatePayload */ + VisualizationUpdatePayload: { + /** + * Config + * @description The config of the visualization. + * @default {} + */ + config: Record | string | null; + /** + * DbKey + * @description The database key of the visualization. + */ + dbkey?: string | null; + /** + * Deleted + * @description Whether this Visualization has been deleted. + * @default false + */ + deleted: boolean | null; + /** + * Title + * @description The name of the visualization. + */ + title?: string | null; + }; + /** VisualizationUpdateResponse */ + VisualizationUpdateResponse: { + /** + * ID + * @description Encoded ID of the Visualization. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Revision + * @description Encoded ID of the Visualization Revision. + * @example 0123456789ABCDEF + */ + revision: string; + }; /** WorkflowInput */ WorkflowInput: { /** @@ -32291,6 +32592,146 @@ export interface operations { }; }; }; + create_api_visualizations_post: { + parameters: { + query?: { + /** @description The encoded database identifier of the Visualization to import. */ + import_id?: string | null; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VisualizationCreatePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VisualizationCreateResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + show_api_visualizations__id__get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the Visualization. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VisualizationShowResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + update_api_visualizations__id__put: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the Visualization. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VisualizationUpdatePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VisualizationUpdateResponse"] | null; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; disable_link_access_api_visualizations__id__disable_link_access_put: { parameters: { query?: never; diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 427777fa319d..9ce7a5341ca4 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -28,6 +28,7 @@ RootModel, UUID4, ) +from pydantic_core import core_schema from typing_extensions import ( Annotated, Literal, @@ -48,6 +49,7 @@ OffsetNaiveDatetime, RelativeUrl, ) +from galaxy.util.sanitize_html import sanitize_html USER_MODEL_CLASS = Literal["User"] GROUP_MODEL_CLASS = Literal["Group"] @@ -3814,3 +3816,23 @@ class PageSummaryList(RootModel): class MessageExceptionModel(BaseModel): err_msg: str err_code: int + + +class SanitizedString(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, value): + if isinstance(value, str): + return cls(sanitize_html(value)) + raise TypeError("string required") + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + return core_schema.no_info_after_validator_function( + cls.validate, + core_schema.str_schema(), + serialization=core_schema.to_string_ser_schema(), + ) diff --git a/lib/galaxy/schema/visualization.py b/lib/galaxy/schema/visualization.py index 0488bf249c9e..e1dcc0d50147 100644 --- a/lib/galaxy/schema/visualization.py +++ b/lib/galaxy/schema/visualization.py @@ -1,7 +1,9 @@ from datetime import datetime from typing import ( + Dict, List, Optional, + Union, ) from pydantic import ( @@ -14,16 +16,22 @@ from galaxy.schema.fields import ( DecodedDatabaseIdField, EncodedDatabaseIdField, + ModelClassField, ) from galaxy.schema.schema import ( CreateTimeField, Model, + SanitizedString, TagCollection, UpdateTimeField, + WithModelClass, ) VisualizationSortByEnum = Literal["create_time", "title", "update_time", "username"] +VISUALIZATION_MODEL_CLASS = Literal["Visualization"] +VISUALIZATION_REVISION_MODEL_CLASS = Literal["VisualizationRevision"] + class VisualizationIndexQueryPayload(Model): deleted: bool = False @@ -100,3 +108,251 @@ class VisualizationSummaryList(RootModel): default=[], title="List with detailed information of Visualizations.", ) + + +class VisualizationRevisionResponse(Model, WithModelClass): + model_class: VISUALIZATION_REVISION_MODEL_CLASS = ModelClassField(VISUALIZATION_REVISION_MODEL_CLASS) + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="Encoded ID of the Visualization Revision.", + ) + visualization_id: EncodedDatabaseIdField = Field( + ..., + title="Visualization ID", + description="Encoded ID of the Visualization.", + ) + title: str = Field( + ..., + title="Title", + description="The name of the visualization revision.", + ) + dbkey: Optional[str] = Field( + None, + title="DbKey", + description="The database key of the visualization.", + ) + config: Dict = Field( + ..., + title="Config", + description="The config of the visualization revision.", + ) + + +class VisualizationPluginResponse(Model): + name: str = Field( + ..., + title="Name", + description="The name of the plugin.", + ) + html: str = Field( + ..., + title="HTML", + description="The HTML of the plugin.", + ) + description: str = Field( + ..., + title="Description", + description="The description of the plugin.", + ) + logo: Optional[str] = Field( + None, + title="Logo", + description="The logo of the plugin.", + ) + title: Optional[str] = Field( + None, + title="Title", + description="The title of the plugin.", + ) + target: str = Field( + ..., + title="Target", + description="The target of the plugin.", + ) + embeddable: bool = Field( + ..., + title="Embeddable", + description="Whether the plugin is embeddable.", + ) + entry_point: Dict = Field( + ..., + title="Entry Point", + description="The entry point of the plugin.", + ) + settings: List[Dict] = Field( + ..., + title="Settings", + description="The settings of the plugin.", + ) + groups: Optional[List[Dict]] = Field( + None, + title="Groups", + description="The groups of the plugin.", + ) + specs: Optional[Dict] = Field( + None, + title="Specs", + description="The specs of the plugin.", + ) + href: str = Field( + ..., + title="Href", + description="The href of the plugin.", + ) + + +class VisualizationShowResponse(Model, WithModelClass): + model_class: VISUALIZATION_MODEL_CLASS = ModelClassField(VISUALIZATION_MODEL_CLASS) + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="Encoded ID of the Visualization.", + ) + title: str = Field( + ..., + title="Title", + description="The name of the visualization.", + ) + type: str = Field( + ..., + title="Type", + description="The type of the visualization.", + ) + user_id: EncodedDatabaseIdField = Field( + ..., + title="User ID", + description="The ID of the user owning this Visualization.", + ) + dbkey: Optional[str] = Field( + None, + title="DbKey", + description="The database key of the visualization.", + ) + slug: Optional[str] = Field( + None, + title="Slug", + description="The slug of the visualization.", + ) + latest_revision: VisualizationRevisionResponse = Field( + ..., + title="Latest Revision", + description="The latest revision of this Visualization.", + ) + revisions: List[EncodedDatabaseIdField] = Field( + ..., + title="Revisions", + description="A list of encoded IDs of the revisions of this Visualization.", + ) + url: str = Field( + ..., + title="URL", + description="The URL of the visualization.", + ) + username: str = Field( + ..., + title="Username", + description="The name of the user owning this Visualization.", + ) + email_hash: str = Field( + ..., + title="Email Hash", + description="The hash of the email of the user owning this Visualization.", + ) + tags: Optional[TagCollection] = Field( + None, + title="Tags", + description="A list of tags to add to this item.", + ) + annotation: Optional[str] = Field( + None, + title="Annotation", + description="The annotation of this Visualization.", + ) + plugin: Optional[VisualizationPluginResponse] = Field( + None, + title="Plugin", + description="The plugin of this Visualization.", + ) + + +class VisualizationCreateResponse(Model): + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="Encoded ID of the Visualization.", + ) + + +class VisualizationUpdateResponse(Model): + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="Encoded ID of the Visualization.", + ) + revision: EncodedDatabaseIdField = Field( + ..., + title="Revision", + description="Encoded ID of the Visualization Revision.", + ) + + +class VisualizationCreatePayload(Model): + type: Optional[SanitizedString] = Field( + None, + title="Type", + description="The type of the visualization.", + ) + title: Optional[SanitizedString] = Field( + SanitizedString("Untitled Visualization"), + title="Title", + description="The name of the visualization.", + ) + dbkey: Optional[SanitizedString] = Field( + None, + title="DbKey", + description="The database key of the visualization.", + ) + slug: Optional[SanitizedString] = Field( + None, + title="Slug", + description="The slug of the visualization.", + ) + annotation: Optional[SanitizedString] = Field( + None, + title="Annotation", + description="The annotation of the visualization.", + ) + config: Optional[dict] = Field( + {}, + title="Config", + description="The config of the visualization.", + ) + save: Optional[bool] = Field( + True, + title="Save", + description="Whether to save the visualization.", + ) + + +class VisualizationUpdatePayload(Model): + title: Optional[SanitizedString] = Field( + None, + title="Title", + description="The name of the visualization.", + ) + dbkey: Optional[SanitizedString] = Field( + None, + title="DbKey", + description="The database key of the visualization.", + ) + deleted: Optional[bool] = Field( + False, + title="Deleted", + description="Whether this Visualization has been deleted.", + ) + config: Optional[Union[dict, bytes]] = Field( + {}, + title="Config", + description="The config of the visualization.", + ) diff --git a/lib/galaxy/webapps/base/controller.py b/lib/galaxy/webapps/base/controller.py index 83c3ae620560..59bb189d1952 100644 --- a/lib/galaxy/webapps/base/controller.py +++ b/lib/galaxy/webapps/base/controller.py @@ -613,139 +613,6 @@ class UsesVisualizationMixin(UsesLibraryMixinItems): slug_builder = SlugBuilder() - def get_visualization(self, trans, id, check_ownership=True, check_accessible=False): - """ - Get a Visualization from the database by id, verifying ownership. - """ - # Load workflow from database - try: - visualization = trans.sa_session.get(model.Visualization, trans.security.decode_id(id)) - except TypeError: - visualization = None - if not visualization: - error("Visualization not found") - else: - return self.security_check(trans, visualization, check_ownership, check_accessible) - - def get_visualization_dict(self, visualization): - """ - Return a set of detailed attributes for a visualization in dictionary form. - The visualization's latest_revision is returned in its own sub-dictionary. - NOTE: that encoding ids isn't done here should happen at the caller level. - """ - return { - "model_class": "Visualization", - "id": visualization.id, - "title": visualization.title, - "type": visualization.type, - "user_id": visualization.user.id, - "dbkey": visualization.dbkey, - "slug": visualization.slug, - # to_dict only the latest revision (allow older to be fetched elsewhere) - "latest_revision": self.get_visualization_revision_dict(visualization.latest_revision), - "revisions": [r.id for r in visualization.revisions], - } - - def get_visualization_revision_dict(self, revision): - """ - Return a set of detailed attributes for a visualization in dictionary form. - NOTE: that encoding ids isn't done here should happen at the caller level. - """ - return { - "model_class": "VisualizationRevision", - "id": revision.id, - "visualization_id": revision.visualization.id, - "title": revision.title, - "dbkey": revision.dbkey, - "config": revision.config, - } - - def import_visualization(self, trans, id, user=None): - """ - Copy the visualization with the given id and associate the copy - with the given user (defaults to trans.user). - - Raises `ItemAccessibilityException` if `user` is not passed and - the current user is anonymous, and if the visualization is not `importable`. - Raises `ItemDeletionException` if the visualization has been deleted. - """ - # default to trans.user, error if anon - if not user: - if not trans.user: - raise exceptions.ItemAccessibilityException("You must be logged in to import Galaxy visualizations") - user = trans.user - - # check accessibility - visualization = self.get_visualization(trans, id, check_ownership=False) - if not visualization.importable: - raise exceptions.ItemAccessibilityException( - "The owner of this visualization has disabled imports via this link." - ) - if visualization.deleted: - raise exceptions.ItemDeletionException("You can't import this visualization because it has been deleted.") - - # copy vis and alter title - # TODO: need to handle custom db keys. - imported_visualization = visualization.copy(user=user, title=f"imported: {visualization.title}") - trans.sa_session.add(imported_visualization) - with transaction(trans.sa_session): - trans.sa_session.commit() - return imported_visualization - - def create_visualization( - self, - trans, - type, - title="Untitled Visualization", - slug=None, - dbkey=None, - annotation=None, - config=None, - save=True, - ): - """ - Create visualiation and first revision. - """ - config = config or {} - visualization = self._create_visualization(trans, title, type, dbkey, slug, annotation, save) - # TODO: handle this error structure better either in _create or here - if isinstance(visualization, dict): - err_dict = visualization - raise ValueError(err_dict["title_err"] or err_dict["slug_err"]) - - # Create and save first visualization revision - revision = trans.model.VisualizationRevision( - visualization=visualization, title=title, config=config, dbkey=dbkey - ) - visualization.latest_revision = revision - - if save: - session = trans.sa_session - session.add(revision) - with transaction(session): - session.commit() - - return visualization - - def add_visualization_revision(self, trans, visualization, config, title, dbkey): - """ - Adds a new `VisualizationRevision` to the given `visualization` with - the given parameters and set its parent visualization's `latest_revision` - to the new revision. - """ - # precondition: only add new revision on owned vis's - # TODO:?? should we default title, dbkey, config? to which: visualization or latest_revision? - revision = trans.model.VisualizationRevision( - visualization=visualization, title=title, dbkey=dbkey, config=config - ) - - visualization.latest_revision = revision - # TODO:?? does this automatically add revision to visualzation.revisions? - trans.sa_session.add(revision) - with transaction(trans.sa_session): - trans.sa_session.commit() - return revision - def save_visualization(self, trans, config, type, id=None, title=None, dbkey=None, slug=None, annotation=None): session = trans.sa_session @@ -1016,44 +883,6 @@ def get_hda(self, trans, dataset_id, check_ownership=True, check_accessible=Fals ) return data - # -- Helper functions -- - - def _create_visualization(self, trans, title, type, dbkey=None, slug=None, annotation=None, save=True): - """Create visualization but not first revision. Returns Visualization object.""" - user = trans.get_user() - - # Error checking. - title_err = slug_err = "" - if not title: - title_err = "visualization name is required" - elif slug and not managers_base.is_valid_slug(slug): - slug_err = "visualization identifier must consist of only lowercase letters, numbers, and the '-' character" - elif slug and slug_exists(trans.sa_session, trans.model.Visualization, user, slug, ignore_deleted=True): - slug_err = "visualization identifier must be unique" - - if title_err or slug_err: - return {"title_err": title_err, "slug_err": slug_err} - - # Create visualization - visualization = trans.model.Visualization(user=user, title=title, dbkey=dbkey, type=type) - if slug: - visualization.slug = slug - else: - self.slug_builder.create_item_slug(trans.sa_session, visualization) - if annotation: - annotation = sanitize_html(annotation) - # TODO: if this is to stay in the mixin, UsesAnnotations should be added to the superclasses - # right now this is depending on the classes that include this mixin to have UsesAnnotations - self.add_item_annotation(trans.sa_session, trans.user, visualization, annotation) - - if save: - session = trans.sa_session - session.add(visualization) - with transaction(session): - session.commit() - - return visualization - def _get_genome_data(self, trans, dataset, dbkey=None): """ Returns genome-wide data for dataset if available; if not, message is returned. diff --git a/lib/galaxy/webapps/galaxy/api/visualizations.py b/lib/galaxy/webapps/galaxy/api/visualizations.py index 2e8283b03dcd..39c53b2a6c8b 100644 --- a/lib/galaxy/webapps/galaxy/api/visualizations.py +++ b/lib/galaxy/webapps/galaxy/api/visualizations.py @@ -5,7 +5,6 @@ may change often. """ -import json import logging from typing import Optional @@ -18,14 +17,7 @@ ) from typing_extensions import Annotated -from galaxy import ( - exceptions, - util, - web, -) from galaxy.managers.context import ProvidesUserContext -from galaxy.model.base import transaction -from galaxy.model.item_attrs import UsesAnnotations from galaxy.schema.fields import DecodedDatabaseIdField from galaxy.schema.schema import ( SetSlugPayload, @@ -34,16 +26,16 @@ SharingStatus, ) from galaxy.schema.visualization import ( + VisualizationCreatePayload, + VisualizationCreateResponse, VisualizationIndexQueryPayload, + VisualizationShowResponse, VisualizationSortByEnum, VisualizationSummaryList, + VisualizationUpdatePayload, + VisualizationUpdateResponse, ) -from galaxy.util.hash_util import md5_hash_str -from galaxy.web import expose_api -from galaxy.webapps.base.controller import UsesVisualizationMixin -from galaxy.webapps.base.webapp import GalaxyWebTransaction from galaxy.webapps.galaxy.api import ( - BaseGalaxyAPIController, depends, DependsOnTrans, IndexQueryTag, @@ -239,175 +231,47 @@ def set_slug( self.service.shareable_service.set_slug(trans, id, payload) return Response(status_code=status.HTTP_204_NO_CONTENT) + @router.get( + "/api/visualizations/{id}", + summary="Get a visualization by ID.", + ) + def show( + self, + id: VisualizationIdPathParam, + trans: ProvidesUserContext = DependsOnTrans, + ) -> VisualizationShowResponse: + """Return the visualization.""" + return self.service.show(trans, id) -class VisualizationsController(BaseGalaxyAPIController, UsesVisualizationMixin, UsesAnnotations): - """ - RESTful controller for interactions with visualizations. - """ - - service: VisualizationsService = depends(VisualizationsService) - - @expose_api - def show(self, trans: GalaxyWebTransaction, id: str, **kwargs): - """ - GET /api/visualizations/{viz_id} - """ - # TODO: revisions should be a contents/nested controller like viz/xxx/r/xxx)? - # the important thing is the config - # TODO:?? /api/visualizations/registry -> json of registry.listings? - visualization = self.get_visualization(trans, id, check_ownership=False, check_accessible=True) - dictionary = trans.security.encode_dict_ids(self.get_visualization_dict(visualization)) - dictionary["url"] = web.url_for( - controller="visualization", - action="display_by_username_and_slug", - username=visualization.user.username, - slug=visualization.slug, - ) - dictionary["username"] = visualization.user.username - dictionary["email_hash"] = md5_hash_str(visualization.user.email) - dictionary["tags"] = visualization.make_tag_string_list() - dictionary["annotation"] = self.get_item_annotation_str(trans.sa_session, trans.user, visualization) - # need to encode ids in revisions as well - encoded_revisions = [] - for revision in dictionary["revisions"]: - # NOTE: does not encode ids inside the configs - encoded_revisions.append(trans.security.encode_id(revision)) - dictionary["revisions"] = encoded_revisions - dictionary["latest_revision"] = trans.security.encode_dict_ids(dictionary["latest_revision"]) - if trans.app.visualizations_registry: - visualization = trans.app.visualizations_registry.get_plugin(dictionary["type"]) - dictionary["plugin"] = visualization.to_dict() - return dictionary - - @expose_api - def create(self, trans: GalaxyWebTransaction, payload: dict, **kwargs): + @router.post( + "/api/visualizations", + summary="Create a new visualization.", + ) + def create( + self, + payload: VisualizationCreatePayload = Body(...), + import_id: Optional[DecodedDatabaseIdField] = Query( + None, title="Import ID", description="The encoded database identifier of the Visualization to import." + ), + trans: ProvidesUserContext = DependsOnTrans, + ) -> VisualizationCreateResponse: """ POST /api/visualizations - creates a new visualization using the given payload + creates a new visualization using the given payload and does not require the import_id field POST /api/visualizations?import_id={encoded_visualization_id} - imports a copy of an existing visualization into the user's workspace - """ - rval = None - - if "import_id" in payload: - import_id = payload["import_id"] - visualization = self.import_visualization(trans, import_id, user=trans.user) - - else: - payload = self._validate_and_parse_payload(payload) - # must have a type (I've taken this to be the visualization name) - if "type" not in payload: - raise exceptions.RequestParameterMissingException("key/value 'type' is required") - vis_type = payload.pop("type", False) - - payload["save"] = True - try: - # generate defaults - this will err if given a weird key? - visualization = self.create_visualization(trans, vis_type, **payload) - except ValueError as val_err: - raise exceptions.RequestParameterMissingException(str(val_err)) - - rval = {"id": trans.security.encode_id(visualization.id)} - - return rval - - @expose_api - def update(self, trans: GalaxyWebTransaction, id: str, payload: dict, **kwargs): - """ - PUT /api/visualizations/{encoded_visualization_id} - """ - rval = None - payload = self._validate_and_parse_payload(payload) - - # there's a differentiation here between updating the visualization and creating a new revision - # that needs to be handled clearly here or alternately, using a different controller - # like e.g. PUT /api/visualizations/{id}/r/{id} - - # TODO: consider allowing direct alteration of revisions title (without a new revision) - # only create a new revsion on a different config - - # only update owned visualizations - visualization = self.get_visualization(trans, id, check_ownership=True) - title = payload.get("title", visualization.latest_revision.title) - dbkey = payload.get("dbkey", visualization.latest_revision.dbkey) - deleted = payload.get("deleted", visualization.deleted) - config = payload.get("config", visualization.latest_revision.config) - - latest_config = visualization.latest_revision.config - if ( - (title != visualization.latest_revision.title) - or (dbkey != visualization.latest_revision.dbkey) - or (json.dumps(config) != json.dumps(latest_config)) - ): - revision = self.add_visualization_revision(trans, visualization, config, title, dbkey) - rval = {"id": id, "revision": revision.id} - - # allow updating vis title - visualization.title = title - visualization.deleted = deleted - with transaction(trans.sa_session): - trans.sa_session.commit() - - return rval - - def _validate_and_parse_payload(self, payload): + imports a copy of an existing visualization into the user's workspace and does not require the rest of the payload """ - Validate and parse incomming data payload for a visualization. - """ - # This layer handles (most of the stricter idiot proofing): - # - unknown/unallowed keys - # - changing data keys from api key to attribute name - # - protection against bad data form/type - # - protection against malicious data content - # all other conversions and processing (such as permissions, etc.) should happen down the line - - # keys listed here don't error when attempting to set, but fail silently - # this allows PUT'ing an entire model back to the server without attribute errors on uneditable attrs - valid_but_uneditable_keys = ( - "id", - "model_class", - # TODO: fill out when we create to_dict, get_dict, whatevs - ) - # TODO: importable - ValidationError = exceptions.RequestParameterInvalidException - - validated_payload = {} - for key, val in payload.items(): - # TODO: validate types in VALID_TYPES/registry names at the mixin/model level? - if key == "type": - if not isinstance(val, str): - raise ValidationError(f"{key} must be a string or unicode: {str(type(val))}") - val = util.sanitize_html.sanitize_html(val) - elif key == "config": - if not isinstance(val, dict): - raise ValidationError(f"{key} must be a dictionary: {str(type(val))}") - elif key == "annotation": - if not isinstance(val, str): - raise ValidationError(f"{key} must be a string or unicode: {str(type(val))}") - val = util.sanitize_html.sanitize_html(val) - elif key == "deleted": - if not isinstance(val, bool): - raise ValidationError(f"{key} must be a bool: {str(type(val))}") - - # these are keys that actually only be *updated* at the revision level and not here - # (they are still valid for create, tho) - elif key == "title": - if not isinstance(val, str): - raise ValidationError(f"{key} must be a string or unicode: {str(type(val))}") - val = util.sanitize_html.sanitize_html(val) - elif key == "slug": - if not isinstance(val, str): - raise ValidationError(f"{key} must be a string: {str(type(val))}") - val = util.sanitize_html.sanitize_html(val) - elif key == "dbkey": - if not isinstance(val, str): - raise ValidationError(f"{key} must be a string or unicode: {str(type(val))}") - val = util.sanitize_html.sanitize_html(val) - - elif key not in valid_but_uneditable_keys: - continue - # raise AttributeError( 'unknown key: %s' %( str( key ) ) ) + return self.service.create(trans, import_id, payload) - validated_payload[key] = val - return validated_payload + @router.put( + "/api/visualizations/{id}", + summary="Update a visualization.", + ) + def update( + self, + id: VisualizationIdPathParam, + payload: VisualizationUpdatePayload = Body(...), + trans: ProvidesUserContext = DependsOnTrans, + ) -> Optional[VisualizationUpdateResponse]: + return self.service.update(trans, id, payload) diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 1e40c414f740..951926dcc35b 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -589,7 +589,6 @@ def populate_api_routes(webapp, app): conditions=dict(method=["POST"]), ) - webapp.mapper.resource("visualization", "visualizations", path_prefix="/api") webapp.mapper.resource("plugins", "plugins", path_prefix="/api") webapp.mapper.connect("/api/workflows/build_module", action="build_module", controller="workflows") webapp.mapper.connect( diff --git a/lib/galaxy/webapps/galaxy/services/visualizations.py b/lib/galaxy/webapps/galaxy/services/visualizations.py index 0138de78d9ff..5eb9469609e0 100644 --- a/lib/galaxy/webapps/galaxy/services/visualizations.py +++ b/lib/galaxy/webapps/galaxy/services/visualizations.py @@ -1,16 +1,53 @@ +import json import logging -from typing import Tuple +from typing import ( + cast, + Optional, + Tuple, + Union, +) from galaxy import exceptions +from galaxy.managers.base import ( + is_valid_slug, + security_check, +) +from galaxy.managers.context import ProvidesUserContext +from galaxy.managers.sharable import ( + slug_exists, + SlugBuilder, +) from galaxy.managers.visualizations import ( VisualizationManager, VisualizationSerializer, ) +from galaxy.model import ( + Visualization, + VisualizationRevision, +) +from galaxy.model.base import transaction +from galaxy.model.item_attrs import ( + add_item_annotation, + get_item_annotation_str, +) +from galaxy.schema.fields import DecodedDatabaseIdField from galaxy.schema.visualization import ( + VisualizationCreatePayload, + VisualizationCreateResponse, VisualizationIndexQueryPayload, + VisualizationRevisionResponse, + VisualizationShowResponse, VisualizationSummaryList, + VisualizationUpdatePayload, + VisualizationUpdateResponse, ) from galaxy.security.idencoding import IdEncodingHelper +from galaxy.structured_app import StructuredApp +from galaxy.util.hash_util import md5_hash_str +from galaxy.util.sanitize_html import sanitize_html +from galaxy.visualization.plugins.plugin import VisualizationPlugin +from galaxy.visualization.plugins.registry import VisualizationsRegistry +from galaxy.web import url_for from galaxy.webapps.galaxy.services.base import ServiceBase from galaxy.webapps.galaxy.services.notifications import NotificationService from galaxy.webapps.galaxy.services.sharable import ShareableService @@ -41,7 +78,7 @@ def __init__( def index( self, - trans, + trans: ProvidesUserContext, payload: VisualizationIndexQueryPayload, include_total_count: bool = False, ) -> Tuple[VisualizationSummaryList, int]: @@ -60,3 +97,284 @@ def index( VisualizationSummaryList(root=[entry.to_dict() for entry in entries]), total_matches, ) + + def show( + self, + trans: ProvidesUserContext, + visualization_id: DecodedDatabaseIdField, + ) -> VisualizationShowResponse: + """Return a dictionary containing the Visualization's details + + :rtype: dictionary + :returns: Visualization details + """ + # TODO: revisions should be a contents/nested controller like viz/xxx/r/xxx)? + # the important thing is the config + # TODO:?? /api/visualizations/registry -> json of registry.listings? + visualization = self._get_visualization(trans, visualization_id, check_ownership=False, check_accessible=True) + dictionary = { + "model_class": "Visualization", + "id": visualization.id, + "title": visualization.title, + "type": visualization.type, + "user_id": visualization.user.id, + "dbkey": visualization.dbkey, + "slug": visualization.slug, + # to_dict only the latest revision (allow older to be fetched elsewhere) + "latest_revision": ( + self._get_visualization_revision(visualization.latest_revision) + if visualization.latest_revision + else None + ), + # need to encode ids in revisions as well + # NOTE: does not encode ids inside the configs + "revisions": [r.id for r in visualization.revisions], + } + # replace with trans.url_builder if possible + dictionary["url"] = url_for( + controller="visualization", + action="display_by_username_and_slug", + username=visualization.user.username, + slug=visualization.slug, + ) + dictionary["username"] = visualization.user.username + dictionary["email_hash"] = md5_hash_str(visualization.user.email) + dictionary["tags"] = visualization.make_tag_string_list() + dictionary["annotation"] = get_item_annotation_str(trans.sa_session, trans.user, visualization) + app = cast(StructuredApp, trans.app) + if app.visualizations_registry: + visualizations_registry = cast(VisualizationsRegistry, app.visualizations_registry) + visualization_plugin = cast(VisualizationPlugin, visualizations_registry.get_plugin(dictionary["type"])) + dictionary["plugin"] = visualization_plugin.to_dict() + return VisualizationShowResponse(**dictionary) + + def create( + self, + trans: ProvidesUserContext, + import_id: Optional[DecodedDatabaseIdField], + payload: VisualizationCreatePayload, + ) -> VisualizationCreateResponse: + """Returns a dictionary of the created visualization + + :rtype: dictionary + :returns: dictionary containing Visualization details + """ + + if import_id: + visualization = self._import_visualization(trans, import_id) + else: + # must have a type (I've taken this to be the visualization name) + if not payload.type: + raise exceptions.RequestParameterMissingException("key/value 'type' is required") + type = payload.type + title = payload.title + slug = payload.slug + dbkey = payload.dbkey + annotation = payload.annotation + config = payload.config + save = payload.save + + # generate defaults - this will err if given a weird key? + visualization = self._create_visualization(trans, type, title, dbkey, slug, annotation, save) + + # Create and save first visualization revision + revision = VisualizationRevision(visualization=visualization, title=title, config=config, dbkey=dbkey) + visualization.latest_revision = revision + + if save: + session = trans.sa_session + session.add(revision) + with transaction(session): + session.commit() + + return VisualizationCreateResponse(id=str(visualization.id)) + + def update( + self, + trans: ProvidesUserContext, + visualization_id: DecodedDatabaseIdField, + payload: VisualizationUpdatePayload, + ) -> Optional[VisualizationUpdateResponse]: + """ + Update a visualization + + :rtype: dictionary + :returns: dictionary containing Visualization details + """ + rval = None + + # there's a differentiation here between updating the visualization and creating a new revision + # that needs to be handled clearly here or alternately, using a different controller + # like e.g. PUT /api/visualizations/{visualization_id}/r/{revision_id} + + # TODO: consider allowing direct alteration of revisions title (without a new revision) + # only create a new revsion on a different config + + # only update owned visualizations + visualization = self._get_visualization(trans, visualization_id, check_ownership=True) + latest_revision = cast(VisualizationRevision, visualization.latest_revision) + title = payload.title or latest_revision.title + dbkey = payload.dbkey or latest_revision.dbkey + deleted = payload.deleted or visualization.deleted + config = payload.config or latest_revision.config + + latest_config = latest_revision.config + if ( + (title != latest_revision.title) + or (dbkey != latest_revision.dbkey) + or (json.dumps(config) != json.dumps(latest_config)) + ): + revision = self._add_visualization_revision(trans, visualization, config, title, dbkey) + rval = {"id": str(visualization_id), "revision": str(revision.id)} + + # allow updating vis title + visualization.title = title + visualization.deleted = deleted + with transaction(trans.sa_session): + trans.sa_session.commit() + + return VisualizationUpdateResponse(**rval) if rval else None + + def _get_visualization( + self, + trans: ProvidesUserContext, + visualization_id: DecodedDatabaseIdField, + check_ownership=True, + check_accessible=False, + ) -> Visualization: + """ + Get a Visualization from the database by id, verifying ownership. + """ + try: + visualization = trans.sa_session.get(Visualization, visualization_id) + except TypeError: + visualization = None + if not visualization: + raise exceptions.ObjectNotFound("Visualization not found") + else: + return security_check(trans, visualization, check_ownership, check_accessible) + + def _get_visualization_revision( + self, + revision: VisualizationRevision, + ) -> VisualizationRevisionResponse: + """ + Return a set of detailed attributes for a visualization in dictionary form. + NOTE: that encoding ids isn't done here should happen at the caller level. + """ + revision_dict = { + "model_class": "VisualizationRevision", + "id": revision.id, + "visualization_id": revision.visualization.id, + "title": revision.title, + "dbkey": revision.dbkey, + "config": revision.config, + } + return VisualizationRevisionResponse(**revision_dict) + + def _add_visualization_revision( + self, + trans: ProvidesUserContext, + visualization: Visualization, + config: Optional[Union[dict, bytes]], + title: Optional[str], + dbkey: Optional[str], + ) -> VisualizationRevision: + """ + Adds a new `VisualizationRevision` to the given `visualization` with + the given parameters and set its parent visualization's `latest_revision` + to the new revision. + """ + # precondition: only add new revision on owned vis's + # TODO:?? should we default title, dbkey, config? to which: visualization or latest_revision? + revision = VisualizationRevision(visualization=visualization, title=title, dbkey=dbkey, config=config) + + visualization.latest_revision = revision + # TODO:?? does this automatically add revision to visualzation.revisions? + trans.sa_session.add(revision) + with transaction(trans.sa_session): + trans.sa_session.commit() + return revision + + def _create_visualization( + self, + trans: ProvidesUserContext, + type: str, + title: Optional[str] = None, + dbkey: Optional[str] = None, + slug: Optional[str] = None, + annotation: Optional[str] = None, + save: Optional[bool] = True, + ) -> Visualization: + """Create visualization but not first revision. Returns Visualization object.""" + user = trans.get_user() + + # Error checking. + title_err = slug_err = "" + if not title: + title_err = "visualization name is required" + elif slug and not is_valid_slug(slug): + slug_err = "visualization identifier must consist of only lowercase letters, numbers, and the '-' character" + elif slug and slug_exists(trans.sa_session, Visualization, user, slug, ignore_deleted=True): + slug_err = "visualization identifier must be unique" + + if title_err or slug_err: + # TODO: handle this error structure better + val_err = str(title_err or slug_err) + raise exceptions.RequestParameterMissingException(val_err) + + # Create visualization + visualization = Visualization(user=user, title=title, dbkey=dbkey, type=type) + if slug: + visualization.slug = slug + else: + slug_builder = SlugBuilder() + slug_builder.create_item_slug(trans.sa_session, visualization) + if annotation: + annotation = sanitize_html(annotation) + # TODO: if this is to stay in the mixin, UsesAnnotations should be added to the superclasses + # right now this is depending on the classes that include this mixin to have UsesAnnotations + add_item_annotation(trans.sa_session, trans.user, visualization, annotation) + + if save: + session = trans.sa_session + session.add(visualization) + with transaction(session): + session.commit() + + return visualization + + def _import_visualization( + self, + trans: ProvidesUserContext, + visualization_id: DecodedDatabaseIdField, + ) -> Visualization: + """ + Copy the visualization with the given id and associate the copy + with the given user (defaults to trans.user). + + Raises `ItemAccessibilityException` if `user` is not passed and + the current user is anonymous, and if the visualization is not `importable`. + Raises `ItemDeletionException` if the visualization has been deleted. + """ + # default to trans.user, error if anon + if not trans.user: + raise exceptions.ItemAccessibilityException("You must be logged in to import Galaxy visualizations") + user = trans.user + + # check accessibility + visualization = self._get_visualization(trans, visualization_id, check_ownership=False) + if not visualization.importable: + raise exceptions.ItemAccessibilityException( + "The owner of this visualization has disabled imports via this link." + ) + if visualization.deleted: + raise exceptions.ItemDeletionException("You can't import this visualization because it has been deleted.") + + # copy vis and alter title + # TODO: need to handle custom db keys. + imported_visualization = visualization.copy(user=user, title=f"imported: {visualization.title}") + trans.sa_session.add(imported_visualization) + with transaction(trans.sa_session): + trans.sa_session.commit() + return imported_visualization diff --git a/lib/galaxy_test/api/test_tags.py b/lib/galaxy_test/api/test_tags.py index 776f95db147d..035f675aecb5 100644 --- a/lib/galaxy_test/api/test_tags.py +++ b/lib/galaxy_test/api/test_tags.py @@ -155,12 +155,10 @@ def create_item(self) -> str: title = f"Test Visualization {uuid_str}" slug = f"test-visualization-{uuid_str}" - config = json.dumps( - { - "x": 10, - "y": 12, - } - ) + config = { + "x": 10, + "y": 12, + } create_payload = { "title": title, "slug": slug, @@ -169,7 +167,7 @@ def create_item(self) -> str: "annotation": "this is a test visualization for tags", "config": config, } - response = self._post("visualizations", data=create_payload) + response = self._post("visualizations", data=create_payload, json=True) self._assert_status_code_is(response, 200) viz = response.json() return viz["id"] diff --git a/lib/galaxy_test/api/test_visualizations.py b/lib/galaxy_test/api/test_visualizations.py index 989fc25f2489..0a2577c086ca 100644 --- a/lib/galaxy_test/api/test_visualizations.py +++ b/lib/galaxy_test/api/test_visualizations.py @@ -1,8 +1,5 @@ -import json import uuid -from requests import put - from galaxy_test.api.sharable import SharingApiTests from galaxy_test.base.api_asserts import assert_has_keys from ._framework import ApiTestCase @@ -89,7 +86,7 @@ def test_sharing(self): def test_update_title(self): viz_id, viz = self._create_viz() update_url = self._api_url(f"visualizations/{viz_id}", use_key=True) - response = put(update_url, {"title": "New Name"}) + response = self._put(update_url, {"title": "New Name"}, json=True) self._assert_status_code_is(response, 200) updated_viz = self._show_viz(viz_id) assert updated_viz["title"] == "New Name" @@ -114,12 +111,10 @@ def _new_viz(self, title=None, slug=None, config=None): config = ( config if config is not None - else json.dumps( - { - "x": 10, - "y": 12, - } - ) + else { + "x": 10, + "y": 12, + } ) create_payload = { "title": title, @@ -129,7 +124,7 @@ def _new_viz(self, title=None, slug=None, config=None): "annotation": "this is a test of the emergency visualization system", "config": config, } - response = self._post("visualizations", data=create_payload) + response = self._post("visualizations", data=create_payload, json=True) return response def _publish_viz(self, id): diff --git a/test/integration/test_notifications.py b/test/integration/test_notifications.py index d44fcaea8da6..89077357f824 100644 --- a/test/integration/test_notifications.py +++ b/test/integration/test_notifications.py @@ -356,7 +356,7 @@ def test_sharing_items_creates_notifications_when_expected(self): "type": "example", "dbkey": "hg17", } - response = self._post("visualizations", data=create_payload).json() + response = self._post("visualizations", data=create_payload, json=True).json() visualization_id = response["id"] payload = {"user_ids": user_ids} sharing_response = self._put(f"visualizations/{visualization_id}/share_with_users", data=payload, json=True)