api.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. # Licensed to the Apache Software Foundation (ASF) under one
  2. # or more contributor license agreements. See the NOTICE file
  3. # distributed with this work for additional information
  4. # regarding copyright ownership. The ASF licenses this file
  5. # to you under the Apache License, Version 2.0 (the
  6. # "License"); you may not use this file except in compliance
  7. # with the License. You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing,
  12. # software distributed under the License is distributed on an
  13. # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  14. # KIND, either express or implied. See the License for the
  15. # specific language governing permissions and limitations
  16. # under the License.
  17. import json
  18. import logging
  19. import re
  20. from typing import Dict, List, Optional
  21. from flask import current_app, g, make_response
  22. from flask_appbuilder.api import expose, protect, rison, safe
  23. from flask_appbuilder.models.sqla.interface import SQLAInterface
  24. from flask_babel import lazy_gettext as _, ngettext
  25. from marshmallow import fields, post_load, pre_load, Schema, ValidationError
  26. from marshmallow.validate import Length
  27. from sqlalchemy.exc import SQLAlchemyError
  28. from superset.constants import RouteMethod
  29. from superset.exceptions import SupersetException, SupersetSecurityException
  30. from superset.models.dashboard import Dashboard
  31. from superset.utils import core as utils
  32. from superset.views.base import check_ownership, generate_download_headers
  33. from superset.views.base_api import BaseOwnedModelRestApi
  34. from superset.views.base_schemas import BaseOwnedSchema, validate_owner
  35. from .mixin import DashboardMixin
  36. logger = logging.getLogger(__name__)
  37. get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
  38. class DashboardJSONMetadataSchema(Schema):
  39. timed_refresh_immune_slices = fields.List(fields.Integer())
  40. filter_scopes = fields.Dict()
  41. expanded_slices = fields.Dict()
  42. refresh_frequency = fields.Integer()
  43. default_filters = fields.Str()
  44. stagger_refresh = fields.Boolean()
  45. stagger_time = fields.Integer()
  46. color_scheme = fields.Str()
  47. label_colors = fields.Dict()
  48. def validate_json(value):
  49. try:
  50. utils.validate_json(value)
  51. except SupersetException:
  52. raise ValidationError("JSON not valid")
  53. def validate_json_metadata(value):
  54. if not value:
  55. return
  56. try:
  57. value_obj = json.loads(value)
  58. except json.decoder.JSONDecodeError:
  59. raise ValidationError("JSON not valid")
  60. errors = DashboardJSONMetadataSchema(strict=True).validate(value_obj, partial=False)
  61. if errors:
  62. raise ValidationError(errors)
  63. def validate_slug_uniqueness(value):
  64. # slug is not required but must be unique
  65. if value:
  66. item = (
  67. current_app.appbuilder.get_session.query(Dashboard.id)
  68. .filter_by(slug=value)
  69. .one_or_none()
  70. )
  71. if item:
  72. raise ValidationError("Must be unique")
  73. class BaseDashboardSchema(BaseOwnedSchema):
  74. @pre_load
  75. def pre_load(self, data): # pylint: disable=no-self-use
  76. super().pre_load(data)
  77. data["slug"] = data.get("slug")
  78. data["owners"] = data.get("owners", [])
  79. if data["slug"]:
  80. data["slug"] = data["slug"].strip()
  81. data["slug"] = data["slug"].replace(" ", "-")
  82. data["slug"] = re.sub(r"[^\w\-]+", "", data["slug"])
  83. class DashboardPostSchema(BaseDashboardSchema):
  84. __class_model__ = Dashboard
  85. dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
  86. slug = fields.String(
  87. allow_none=True, validate=[Length(1, 255), validate_slug_uniqueness]
  88. )
  89. owners = fields.List(fields.Integer(validate=validate_owner))
  90. position_json = fields.String(validate=validate_json)
  91. css = fields.String()
  92. json_metadata = fields.String(validate=validate_json_metadata)
  93. published = fields.Boolean()
  94. class DashboardPutSchema(BaseDashboardSchema):
  95. dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
  96. slug = fields.String(allow_none=True, validate=Length(0, 255))
  97. owners = fields.List(fields.Integer(validate=validate_owner))
  98. position_json = fields.String(validate=validate_json)
  99. css = fields.String()
  100. json_metadata = fields.String(validate=validate_json_metadata)
  101. published = fields.Boolean()
  102. @post_load
  103. def make_object(self, data: Dict, discard: Optional[List[str]] = None) -> Dashboard:
  104. self.instance = super().make_object(data, [])
  105. for slc in self.instance.slices:
  106. slc.owners = list(set(self.instance.owners) | set(slc.owners))
  107. return self.instance
  108. get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
  109. class DashboardRestApi(DashboardMixin, BaseOwnedModelRestApi):
  110. datamodel = SQLAInterface(Dashboard)
  111. include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
  112. RouteMethod.EXPORT,
  113. RouteMethod.RELATED,
  114. "bulk_delete", # not using RouteMethod since locally defined
  115. }
  116. resource_name = "dashboard"
  117. allow_browser_login = True
  118. class_permission_name = "DashboardModelView"
  119. show_columns = [
  120. "dashboard_title",
  121. "slug",
  122. "owners.id",
  123. "owners.username",
  124. "position_json",
  125. "css",
  126. "json_metadata",
  127. "published",
  128. "table_names",
  129. "charts",
  130. ]
  131. order_columns = ["dashboard_title", "changed_on", "published", "changed_by_fk"]
  132. list_columns = [
  133. "id",
  134. "dashboard_title",
  135. "url",
  136. "published",
  137. "changed_by.username",
  138. "changed_by_name",
  139. "changed_by_url",
  140. "changed_on",
  141. ]
  142. add_model_schema = DashboardPostSchema()
  143. edit_model_schema = DashboardPutSchema()
  144. order_rel_fields = {
  145. "slices": ("slice_name", "asc"),
  146. "owners": ("first_name", "asc"),
  147. }
  148. filter_rel_fields_field = {"owners": "first_name", "slices": "slice_name"}
  149. @expose("/", methods=["DELETE"])
  150. @protect()
  151. @safe
  152. @rison(get_delete_ids_schema)
  153. def bulk_delete(self, **kwargs): # pylint: disable=arguments-differ
  154. """Delete bulk Dashboards
  155. ---
  156. delete:
  157. parameters:
  158. - in: query
  159. name: q
  160. content:
  161. application/json:
  162. schema:
  163. type: array
  164. items:
  165. type: integer
  166. responses:
  167. 200:
  168. description: Dashboard bulk delete
  169. content:
  170. application/json:
  171. schema:
  172. type: object
  173. properties:
  174. message:
  175. type: string
  176. 401:
  177. $ref: '#/components/responses/401'
  178. 403:
  179. $ref: '#/components/responses/401'
  180. 404:
  181. $ref: '#/components/responses/404'
  182. 422:
  183. $ref: '#/components/responses/422'
  184. 500:
  185. $ref: '#/components/responses/500'
  186. """
  187. item_ids = kwargs["rison"]
  188. query = self.datamodel.session.query(Dashboard).filter(
  189. Dashboard.id.in_(item_ids)
  190. )
  191. items = self._base_filters.apply_all(query).all()
  192. if not items:
  193. return self.response_404()
  194. # Check user ownership over the items
  195. for item in items:
  196. try:
  197. check_ownership(item)
  198. except SupersetSecurityException as e:
  199. logger.warning(
  200. f"Dashboard {item} was not deleted, "
  201. f"because the user ({g.user}) does not own it"
  202. )
  203. return self.response(403, message=_("No dashboards deleted"))
  204. except SQLAlchemyError as e:
  205. logger.error(f"Error checking dashboard ownership {e}")
  206. return self.response_422(message=str(e))
  207. # bulk delete, first delete related data
  208. for item in items:
  209. try:
  210. item.slices = []
  211. item.owners = []
  212. self.datamodel.session.merge(item)
  213. except SQLAlchemyError as e:
  214. logger.error(f"Error bulk deleting related data on dashboards {e}")
  215. self.datamodel.session.rollback()
  216. return self.response_422(message=str(e))
  217. # bulk delete itself
  218. try:
  219. self.datamodel.session.query(Dashboard).filter(
  220. Dashboard.id.in_(item_ids)
  221. ).delete(synchronize_session="fetch")
  222. except SQLAlchemyError as e:
  223. logger.error(f"Error bulk deleting dashboards {e}")
  224. self.datamodel.session.rollback()
  225. return self.response_422(message=str(e))
  226. self.datamodel.session.commit()
  227. return self.response(
  228. 200,
  229. message=ngettext(
  230. f"Deleted %(num)d dashboard",
  231. f"Deleted %(num)d dashboards",
  232. num=len(items),
  233. ),
  234. )
  235. @expose("/export/", methods=["GET"])
  236. @protect()
  237. @safe
  238. @rison(get_export_ids_schema)
  239. def export(self, **kwargs):
  240. """Export dashboards
  241. ---
  242. get:
  243. parameters:
  244. - in: query
  245. name: q
  246. content:
  247. application/json:
  248. schema:
  249. type: array
  250. items:
  251. type: integer
  252. responses:
  253. 200:
  254. description: Dashboard export
  255. content:
  256. text/plain:
  257. schema:
  258. type: string
  259. 400:
  260. $ref: '#/components/responses/400'
  261. 401:
  262. $ref: '#/components/responses/401'
  263. 404:
  264. $ref: '#/components/responses/404'
  265. 422:
  266. $ref: '#/components/responses/422'
  267. 500:
  268. $ref: '#/components/responses/500'
  269. """
  270. query = self.datamodel.session.query(Dashboard).filter(
  271. Dashboard.id.in_(kwargs["rison"])
  272. )
  273. query = self._base_filters.apply_all(query)
  274. ids = [item.id for item in query.all()]
  275. if not ids:
  276. return self.response_404()
  277. export = Dashboard.export_dashboards(ids)
  278. resp = make_response(export, 200)
  279. resp.headers["Content-Disposition"] = generate_download_headers("json")[
  280. "Content-Disposition"
  281. ]
  282. return resp