core.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  1. # pylint: disable=C,R,W
  2. """A collection of ORM sqlalchemy models for Superset"""
  3. from contextlib import closing
  4. from copy import copy, deepcopy
  5. from datetime import datetime
  6. import functools
  7. import json
  8. import logging
  9. import textwrap
  10. from flask import escape, g, Markup, request
  11. from flask_appbuilder import Model
  12. from flask_appbuilder.models.decorators import renders
  13. from flask_appbuilder.security.sqla.models import User
  14. from future.standard_library import install_aliases
  15. import numpy
  16. import pandas as pd
  17. import sqlalchemy as sqla
  18. from sqlalchemy import (
  19. Boolean, Column, create_engine, DateTime, ForeignKey, Integer,
  20. MetaData, String, Table, Text,
  21. )
  22. from sqlalchemy.engine import url
  23. from sqlalchemy.engine.url import make_url
  24. from sqlalchemy.orm import relationship, sessionmaker, subqueryload
  25. from sqlalchemy.orm.session import make_transient
  26. from sqlalchemy.pool import NullPool
  27. from sqlalchemy.schema import UniqueConstraint
  28. from sqlalchemy_utils import EncryptedType
  29. import sqlparse
  30. from superset import app, db, db_engine_specs, security_manager, utils
  31. from superset.connectors.connector_registry import ConnectorRegistry
  32. from superset.legacy import update_time_range
  33. from superset.models.helpers import AuditMixinNullable, ImportMixin
  34. from superset.models.user_attributes import UserAttribute
  35. from superset.utils import MediumText
  36. from superset.viz import viz_types
  37. install_aliases()
  38. from urllib import parse # noqa
  39. config = app.config
  40. custom_password_store = config.get('SQLALCHEMY_CUSTOM_PASSWORD_STORE')
  41. stats_logger = config.get('STATS_LOGGER')
  42. metadata = Model.metadata # pylint: disable=no-member
  43. PASSWORD_MASK = 'X' * 10
  44. def set_related_perm(mapper, connection, target): # noqa
  45. src_class = target.cls_model
  46. id_ = target.datasource_id
  47. if id_:
  48. ds = db.session.query(src_class).filter_by(id=int(id_)).first()
  49. if ds:
  50. target.perm = ds.perm
  51. def copy_dashboard(mapper, connection, target):
  52. dashboard_id = config.get('DASHBOARD_TEMPLATE_ID')
  53. if dashboard_id is None:
  54. return
  55. Session = sessionmaker(autoflush=False)
  56. session = Session(bind=connection)
  57. new_user = session.query(User).filter_by(id=target.id).first()
  58. # copy template dashboard to user
  59. template = session.query(Dashboard).filter_by(id=int(dashboard_id)).first()
  60. dashboard = Dashboard(
  61. dashboard_title=template.dashboard_title,
  62. position_json=template.position_json,
  63. description=template.description,
  64. css=template.css,
  65. json_metadata=template.json_metadata,
  66. slices=template.slices,
  67. owners=[new_user],
  68. )
  69. session.add(dashboard)
  70. session.commit()
  71. # set dashboard as the welcome dashboard
  72. extra_attributes = UserAttribute(
  73. user_id=target.id,
  74. welcome_dashboard_id=dashboard.id,
  75. )
  76. session.add(extra_attributes)
  77. session.commit()
  78. sqla.event.listen(User, 'after_insert', copy_dashboard)
  79. class Url(Model, AuditMixinNullable):
  80. """Used for the short url feature"""
  81. __tablename__ = 'url'
  82. id = Column(Integer, primary_key=True)
  83. url = Column(Text)
  84. class KeyValue(Model):
  85. """Used for any type of key-value store"""
  86. __tablename__ = 'keyvalue'
  87. id = Column(Integer, primary_key=True)
  88. value = Column(Text, nullable=False)
  89. class CssTemplate(Model, AuditMixinNullable):
  90. """CSS templates for dashboards"""
  91. __tablename__ = 'css_templates'
  92. id = Column(Integer, primary_key=True)
  93. template_name = Column(String(250))
  94. css = Column(Text, default='')
  95. slice_user = Table('slice_user', metadata,
  96. Column('id', Integer, primary_key=True),
  97. Column('user_id', Integer, ForeignKey('ab_user.id')),
  98. Column('slice_id', Integer, ForeignKey('slices.id')))
  99. class Slice(Model, AuditMixinNullable, ImportMixin):
  100. """A slice is essentially a report or a view on data"""
  101. __tablename__ = 'slices'
  102. id = Column(Integer, primary_key=True)
  103. slice_name = Column(String(250))
  104. datasource_id = Column(Integer)
  105. datasource_type = Column(String(200))
  106. datasource_name = Column(String(2000))
  107. viz_type = Column(String(250))
  108. params = Column(Text)
  109. description = Column(Text)
  110. cache_timeout = Column(Integer)
  111. perm = Column(String(1000))
  112. owners = relationship(security_manager.user_model, secondary=slice_user)
  113. export_fields = ('slice_name', 'datasource_type', 'datasource_name',
  114. 'viz_type', 'params', 'cache_timeout')
  115. def __repr__(self):
  116. return self.slice_name
  117. @property
  118. def cls_model(self):
  119. return ConnectorRegistry.sources[self.datasource_type]
  120. @property
  121. def datasource(self):
  122. return self.get_datasource
  123. def clone(self):
  124. return Slice(
  125. slice_name=self.slice_name,
  126. datasource_id=self.datasource_id,
  127. datasource_type=self.datasource_type,
  128. datasource_name=self.datasource_name,
  129. viz_type=self.viz_type,
  130. params=self.params,
  131. description=self.description,
  132. cache_timeout=self.cache_timeout)
  133. @datasource.getter
  134. @utils.memoized
  135. def get_datasource(self):
  136. return (
  137. db.session.query(self.cls_model)
  138. .filter_by(id=self.datasource_id)
  139. .first()
  140. )
  141. @renders('datasource_name')
  142. def datasource_link(self):
  143. # pylint: disable=no-member
  144. datasource = self.datasource
  145. return datasource.link if datasource else None
  146. def datasource_name_text(self):
  147. # pylint: disable=no-member
  148. datasource = self.datasource
  149. return datasource.name if datasource else None
  150. @property
  151. def datasource_edit_url(self):
  152. # pylint: disable=no-member
  153. datasource = self.datasource
  154. return datasource.url if datasource else None
  155. @property
  156. @utils.memoized
  157. def viz(self):
  158. d = json.loads(self.params)
  159. viz_class = viz_types[self.viz_type]
  160. # pylint: disable=no-member
  161. return viz_class(self.datasource, form_data=d)
  162. @property
  163. def description_markeddown(self):
  164. return utils.markdown(self.description)
  165. @property
  166. def data(self):
  167. """Data used to render slice in templates"""
  168. d = {}
  169. self.token = ''
  170. try:
  171. d = self.viz.data
  172. self.token = d.get('token')
  173. except Exception as e:
  174. logging.exception(e)
  175. d['error'] = str(e)
  176. return {
  177. 'datasource': self.datasource_name,
  178. 'description': self.description,
  179. 'description_markeddown': self.description_markeddown,
  180. 'edit_url': self.edit_url,
  181. 'form_data': self.form_data,
  182. 'slice_id': self.id,
  183. 'slice_name': self.slice_name,
  184. 'slice_url': self.slice_url,
  185. 'modified': self.modified(),
  186. 'changed_on': self.changed_on.isoformat(),
  187. }
  188. @property
  189. def json_data(self):
  190. return json.dumps(self.data)
  191. @property
  192. def form_data(self):
  193. form_data = {}
  194. try:
  195. form_data = json.loads(self.params)
  196. except Exception as e:
  197. logging.error("Malformed json in slice's params")
  198. logging.exception(e)
  199. form_data.update({
  200. 'slice_id': self.id,
  201. 'viz_type': self.viz_type,
  202. 'datasource': '{}__{}'.format(
  203. self.datasource_id, self.datasource_type),
  204. })
  205. if self.cache_timeout:
  206. form_data['cache_timeout'] = self.cache_timeout
  207. update_time_range(form_data)
  208. return form_data
  209. def get_explore_url(self, base_url='/superset/explore', overrides=None):
  210. overrides = overrides or {}
  211. form_data = {'slice_id': self.id}
  212. form_data.update(overrides)
  213. params = parse.quote(json.dumps(form_data))
  214. return (
  215. '{base_url}/?form_data={params}'.format(**locals()))
  216. @property
  217. def slice_url(self):
  218. """Defines the url to access the slice"""
  219. return self.get_explore_url()
  220. @property
  221. def explore_json_url(self):
  222. """Defines the url to access the slice"""
  223. return self.get_explore_url('/superset/explore_json')
  224. @property
  225. def edit_url(self):
  226. return '/chart/edit/{}'.format(self.id)
  227. @property
  228. def slice_link(self):
  229. url = self.slice_url
  230. name = escape(self.slice_name)
  231. return Markup('<a href="{url}">{name}</a>'.format(**locals()))
  232. def get_viz(self, force=False):
  233. """Creates :py:class:viz.BaseViz object from the url_params_multidict.
  234. :return: object of the 'viz_type' type that is taken from the
  235. url_params_multidict or self.params.
  236. :rtype: :py:class:viz.BaseViz
  237. """
  238. slice_params = json.loads(self.params)
  239. slice_params['slice_id'] = self.id
  240. slice_params['json'] = 'false'
  241. slice_params['slice_name'] = self.slice_name
  242. slice_params['viz_type'] = self.viz_type if self.viz_type else 'table'
  243. return viz_types[slice_params.get('viz_type')](
  244. self.datasource,
  245. form_data=slice_params,
  246. force=force,
  247. )
  248. @classmethod
  249. def import_obj(cls, slc_to_import, slc_to_override, import_time=None):
  250. """Inserts or overrides slc in the database.
  251. remote_id and import_time fields in params_dict are set to track the
  252. slice origin and ensure correct overrides for multiple imports.
  253. Slice.perm is used to find the datasources and connect them.
  254. :param Slice slc_to_import: Slice object to import
  255. :param Slice slc_to_override: Slice to replace, id matches remote_id
  256. :returns: The resulting id for the imported slice
  257. :rtype: int
  258. """
  259. session = db.session
  260. make_transient(slc_to_import)
  261. slc_to_import.dashboards = []
  262. slc_to_import.alter_params(
  263. remote_id=slc_to_import.id, import_time=import_time)
  264. slc_to_import = slc_to_import.copy()
  265. params = slc_to_import.params_dict
  266. slc_to_import.datasource_id = ConnectorRegistry.get_datasource_by_name(
  267. session, slc_to_import.datasource_type, params['datasource_name'],
  268. params['schema'], params['database_name']).id
  269. if slc_to_override:
  270. slc_to_override.override(slc_to_import)
  271. session.flush()
  272. return slc_to_override.id
  273. session.add(slc_to_import)
  274. logging.info('Final slice: {}'.format(slc_to_import.to_json()))
  275. session.flush()
  276. return slc_to_import.id
  277. sqla.event.listen(Slice, 'before_insert', set_related_perm)
  278. sqla.event.listen(Slice, 'before_update', set_related_perm)
  279. dashboard_slices = Table(
  280. 'dashboard_slices', metadata,
  281. Column('id', Integer, primary_key=True),
  282. Column('dashboard_id', Integer, ForeignKey('dashboards.id')),
  283. Column('slice_id', Integer, ForeignKey('slices.id')),
  284. )
  285. dashboard_user = Table(
  286. 'dashboard_user', metadata,
  287. Column('id', Integer, primary_key=True),
  288. Column('user_id', Integer, ForeignKey('ab_user.id')),
  289. Column('dashboard_id', Integer, ForeignKey('dashboards.id')),
  290. )
  291. class Dashboard(Model, AuditMixinNullable, ImportMixin):
  292. """The dashboard object!"""
  293. __tablename__ = 'dashboards'
  294. id = Column(Integer, primary_key=True)
  295. dashboard_title = Column(String(500))
  296. position_json = Column(MediumText())
  297. description = Column(Text)
  298. css = Column(Text)
  299. json_metadata = Column(Text)
  300. slug = Column(String(255), unique=True)
  301. slices = relationship(
  302. 'Slice', secondary=dashboard_slices, backref='dashboards')
  303. owners = relationship(security_manager.user_model, secondary=dashboard_user)
  304. export_fields = ('dashboard_title', 'position_json', 'json_metadata',
  305. 'description', 'css', 'slug')
  306. def __repr__(self):
  307. return self.dashboard_title
  308. @property
  309. def table_names(self):
  310. # pylint: disable=no-member
  311. return ', '.join(
  312. {'{}'.format(s.datasource.full_name) for s in self.slices})
  313. @property
  314. def url(self):
  315. if self.json_metadata:
  316. # add default_filters to the preselect_filters of dashboard
  317. json_metadata = json.loads(self.json_metadata)
  318. default_filters = json_metadata.get('default_filters')
  319. # make sure default_filters is not empty and is valid
  320. if default_filters and default_filters != '{}':
  321. try:
  322. if json.loads(default_filters):
  323. filters = parse.quote(default_filters.encode('utf8'))
  324. return '/superset/dashboard/{}/?preselect_filters={}'.format(
  325. self.slug or self.id, filters)
  326. except Exception:
  327. pass
  328. return '/superset/dashboard/{}/'.format(self.slug or self.id)
  329. @property
  330. def datasources(self):
  331. return {slc.datasource for slc in self.slices}
  332. @property
  333. def sqla_metadata(self):
  334. # pylint: disable=no-member
  335. metadata = MetaData(bind=self.get_sqla_engine())
  336. return metadata.reflect()
  337. def dashboard_link(self):
  338. title = escape(self.dashboard_title)
  339. return Markup(
  340. '<a href="{self.url}">{title}</a>'.format(**locals()))
  341. @property
  342. def data(self):
  343. positions = self.position_json
  344. if positions:
  345. positions = json.loads(positions)
  346. return {
  347. 'id': self.id,
  348. 'metadata': self.params_dict,
  349. 'css': self.css,
  350. 'dashboard_title': self.dashboard_title,
  351. 'slug': self.slug,
  352. 'slices': [slc.data for slc in self.slices],
  353. 'position_json': positions,
  354. }
  355. @property
  356. def params(self):
  357. return self.json_metadata
  358. @params.setter
  359. def params(self, value):
  360. self.json_metadata = value
  361. @property
  362. def position(self):
  363. if self.position_json:
  364. return json.loads(self.position_json)
  365. return {}
  366. @classmethod
  367. def import_obj(cls, dashboard_to_import, import_time=None):
  368. """Imports the dashboard from the object to the database.
  369. Once dashboard is imported, json_metadata field is extended and stores
  370. remote_id and import_time. It helps to decide if the dashboard has to
  371. be overridden or just copies over. Slices that belong to this
  372. dashboard will be wired to existing tables. This function can be used
  373. to import/export dashboards between multiple superset instances.
  374. Audit metadata isn't copied over.
  375. """
  376. def alter_positions(dashboard, old_to_new_slc_id_dict):
  377. """ Updates slice_ids in the position json.
  378. Sample position_json data:
  379. {
  380. "DASHBOARD_VERSION_KEY": "v2",
  381. "DASHBOARD_ROOT_ID": {
  382. "type": "DASHBOARD_ROOT_TYPE",
  383. "id": "DASHBOARD_ROOT_ID",
  384. "children": ["DASHBOARD_GRID_ID"]
  385. },
  386. "DASHBOARD_GRID_ID": {
  387. "type": "DASHBOARD_GRID_TYPE",
  388. "id": "DASHBOARD_GRID_ID",
  389. "children": ["DASHBOARD_CHART_TYPE-2"]
  390. },
  391. "DASHBOARD_CHART_TYPE-2": {
  392. "type": "DASHBOARD_CHART_TYPE",
  393. "id": "DASHBOARD_CHART_TYPE-2",
  394. "children": [],
  395. "meta": {
  396. "width": 4,
  397. "height": 50,
  398. "chartId": 118
  399. }
  400. },
  401. }
  402. """
  403. position_data = json.loads(dashboard.position_json)
  404. position_json = position_data.values()
  405. for value in position_json:
  406. if (isinstance(value, dict) and value.get('meta') and
  407. value.get('meta').get('chartId')):
  408. old_slice_id = value.get('meta').get('chartId')
  409. if old_slice_id in old_to_new_slc_id_dict:
  410. value['meta']['chartId'] = (
  411. old_to_new_slc_id_dict[old_slice_id]
  412. )
  413. dashboard.position_json = json.dumps(position_data)
  414. logging.info('Started import of the dashboard: {}'
  415. .format(dashboard_to_import.to_json()))
  416. session = db.session
  417. logging.info('Dashboard has {} slices'
  418. .format(len(dashboard_to_import.slices)))
  419. # copy slices object as Slice.import_slice will mutate the slice
  420. # and will remove the existing dashboard - slice association
  421. slices = copy(dashboard_to_import.slices)
  422. old_to_new_slc_id_dict = {}
  423. new_filter_immune_slices = []
  424. new_timed_refresh_immune_slices = []
  425. new_expanded_slices = {}
  426. i_params_dict = dashboard_to_import.params_dict
  427. remote_id_slice_map = {
  428. slc.params_dict['remote_id']: slc
  429. for slc in session.query(Slice).all()
  430. if 'remote_id' in slc.params_dict
  431. }
  432. for slc in slices:
  433. logging.info('Importing slice {} from the dashboard: {}'.format(
  434. slc.to_json(), dashboard_to_import.dashboard_title))
  435. remote_slc = remote_id_slice_map.get(slc.id)
  436. new_slc_id = Slice.import_obj(slc, remote_slc, import_time=import_time)
  437. old_to_new_slc_id_dict[slc.id] = new_slc_id
  438. # update json metadata that deals with slice ids
  439. new_slc_id_str = '{}'.format(new_slc_id)
  440. old_slc_id_str = '{}'.format(slc.id)
  441. if ('filter_immune_slices' in i_params_dict and
  442. old_slc_id_str in i_params_dict['filter_immune_slices']):
  443. new_filter_immune_slices.append(new_slc_id_str)
  444. if ('timed_refresh_immune_slices' in i_params_dict and
  445. old_slc_id_str in
  446. i_params_dict['timed_refresh_immune_slices']):
  447. new_timed_refresh_immune_slices.append(new_slc_id_str)
  448. if ('expanded_slices' in i_params_dict and
  449. old_slc_id_str in i_params_dict['expanded_slices']):
  450. new_expanded_slices[new_slc_id_str] = (
  451. i_params_dict['expanded_slices'][old_slc_id_str])
  452. # override the dashboard
  453. existing_dashboard = None
  454. for dash in session.query(Dashboard).all():
  455. if ('remote_id' in dash.params_dict and
  456. dash.params_dict['remote_id'] ==
  457. dashboard_to_import.id):
  458. existing_dashboard = dash
  459. dashboard_to_import.id = None
  460. alter_positions(dashboard_to_import, old_to_new_slc_id_dict)
  461. dashboard_to_import.alter_params(import_time=import_time)
  462. if new_expanded_slices:
  463. dashboard_to_import.alter_params(
  464. expanded_slices=new_expanded_slices)
  465. if new_filter_immune_slices:
  466. dashboard_to_import.alter_params(
  467. filter_immune_slices=new_filter_immune_slices)
  468. if new_timed_refresh_immune_slices:
  469. dashboard_to_import.alter_params(
  470. timed_refresh_immune_slices=new_timed_refresh_immune_slices)
  471. new_slices = session.query(Slice).filter(
  472. Slice.id.in_(old_to_new_slc_id_dict.values())).all()
  473. if existing_dashboard:
  474. existing_dashboard.override(dashboard_to_import)
  475. existing_dashboard.slices = new_slices
  476. session.flush()
  477. return existing_dashboard.id
  478. else:
  479. # session.add(dashboard_to_import) causes sqlachemy failures
  480. # related to the attached users / slices. Creating new object
  481. # allows to avoid conflicts in the sql alchemy state.
  482. copied_dash = dashboard_to_import.copy()
  483. copied_dash.slices = new_slices
  484. session.add(copied_dash)
  485. session.flush()
  486. return copied_dash.id
  487. @classmethod
  488. def export_dashboards(cls, dashboard_ids):
  489. copied_dashboards = []
  490. datasource_ids = set()
  491. for dashboard_id in dashboard_ids:
  492. # make sure that dashboard_id is an integer
  493. dashboard_id = int(dashboard_id)
  494. copied_dashboard = (
  495. db.session.query(Dashboard)
  496. .options(subqueryload(Dashboard.slices))
  497. .filter_by(id=dashboard_id).first()
  498. )
  499. make_transient(copied_dashboard)
  500. for slc in copied_dashboard.slices:
  501. datasource_ids.add((slc.datasource_id, slc.datasource_type))
  502. # add extra params for the import
  503. slc.alter_params(
  504. remote_id=slc.id,
  505. datasource_name=slc.datasource.name,
  506. schema=slc.datasource.name,
  507. database_name=slc.datasource.database.name,
  508. )
  509. copied_dashboard.alter_params(remote_id=dashboard_id)
  510. copied_dashboards.append(copied_dashboard)
  511. eager_datasources = []
  512. for dashboard_id, dashboard_type in datasource_ids:
  513. eager_datasource = ConnectorRegistry.get_eager_datasource(
  514. db.session, dashboard_type, dashboard_id)
  515. eager_datasource.alter_params(
  516. remote_id=eager_datasource.id,
  517. database_name=eager_datasource.database.name,
  518. )
  519. make_transient(eager_datasource)
  520. eager_datasources.append(eager_datasource)
  521. return json.dumps({
  522. 'dashboards': copied_dashboards,
  523. 'datasources': eager_datasources,
  524. }, cls=utils.DashboardEncoder, indent=4)
  525. class Database(Model, AuditMixinNullable, ImportMixin):
  526. """An ORM object that stores Database related information"""
  527. __tablename__ = 'dbs'
  528. type = 'table'
  529. __table_args__ = (UniqueConstraint('database_name'),)
  530. id = Column(Integer, primary_key=True)
  531. verbose_name = Column(String(250), unique=True)
  532. # short unique name, used in permissions
  533. database_name = Column(String(250), unique=True)
  534. sqlalchemy_uri = Column(String(1024))
  535. password = Column(EncryptedType(String(1024), config.get('SECRET_KEY')))
  536. cache_timeout = Column(Integer)
  537. select_as_create_table_as = Column(Boolean, default=False)
  538. expose_in_sqllab = Column(Boolean, default=False)
  539. allow_run_sync = Column(Boolean, default=True)
  540. allow_run_async = Column(Boolean, default=False)
  541. allow_csv_upload = Column(Boolean, default=False)
  542. allow_ctas = Column(Boolean, default=False)
  543. allow_dml = Column(Boolean, default=False)
  544. force_ctas_schema = Column(String(250))
  545. allow_multi_schema_metadata_fetch = Column(Boolean, default=True)
  546. extra = Column(Text, default=textwrap.dedent("""\
  547. {
  548. "metadata_params": {},
  549. "engine_params": {},
  550. "metadata_cache_timeout": {},
  551. "schemas_allowed_for_csv_upload": []
  552. }
  553. """))
  554. perm = Column(String(1000))
  555. impersonate_user = Column(Boolean, default=False)
  556. export_fields = ('database_name', 'sqlalchemy_uri', 'cache_timeout',
  557. 'expose_in_sqllab', 'allow_run_sync', 'allow_run_async',
  558. 'allow_ctas', 'allow_csv_upload', 'extra')
  559. export_children = ['tables']
  560. def __repr__(self):
  561. return self.verbose_name if self.verbose_name else self.database_name
  562. @property
  563. def name(self):
  564. return self.verbose_name if self.verbose_name else self.database_name
  565. @property
  566. def allows_subquery(self):
  567. return self.db_engine_spec.allows_subquery
  568. @property
  569. def data(self):
  570. return {
  571. 'id': self.id,
  572. 'name': self.database_name,
  573. 'backend': self.backend,
  574. 'allow_multi_schema_metadata_fetch':
  575. self.allow_multi_schema_metadata_fetch,
  576. 'allows_subquery': self.allows_subquery,
  577. }
  578. @property
  579. def unique_name(self):
  580. return self.database_name
  581. @property
  582. def url_object(self):
  583. return make_url(self.sqlalchemy_uri_decrypted)
  584. @property
  585. def backend(self):
  586. url = make_url(self.sqlalchemy_uri_decrypted)
  587. return url.get_backend_name()
  588. @classmethod
  589. def get_password_masked_url_from_uri(cls, uri):
  590. url = make_url(uri)
  591. return cls.get_password_masked_url(url)
  592. @classmethod
  593. def get_password_masked_url(cls, url):
  594. url_copy = deepcopy(url)
  595. if url_copy.password is not None and url_copy.password != PASSWORD_MASK:
  596. url_copy.password = PASSWORD_MASK
  597. return url_copy
  598. def set_sqlalchemy_uri(self, uri):
  599. conn = sqla.engine.url.make_url(uri.strip())
  600. if conn.password != PASSWORD_MASK and not custom_password_store:
  601. # do not over-write the password with the password mask
  602. self.password = conn.password
  603. conn.password = PASSWORD_MASK if conn.password else None
  604. self.sqlalchemy_uri = str(conn) # hides the password
  605. def get_effective_user(self, url, user_name=None):
  606. """
  607. Get the effective user, especially during impersonation.
  608. :param url: SQL Alchemy URL object
  609. :param user_name: Default username
  610. :return: The effective username
  611. """
  612. effective_username = None
  613. if self.impersonate_user:
  614. effective_username = url.username
  615. if user_name:
  616. effective_username = user_name
  617. elif (
  618. hasattr(g, 'user') and hasattr(g.user, 'username') and
  619. g.user.username is not None
  620. ):
  621. effective_username = g.user.username
  622. return effective_username
  623. @utils.memoized(
  624. watch=('impersonate_user', 'sqlalchemy_uri_decrypted', 'extra'))
  625. def get_sqla_engine(self, schema=None, nullpool=True, user_name=None):
  626. extra = self.get_extra()
  627. url = make_url(self.sqlalchemy_uri_decrypted)
  628. url = self.db_engine_spec.adjust_database_uri(url, schema)
  629. effective_username = self.get_effective_user(url, user_name)
  630. # If using MySQL or Presto for example, will set url.username
  631. # If using Hive, will not do anything yet since that relies on a
  632. # configuration parameter instead.
  633. self.db_engine_spec.modify_url_for_impersonation(
  634. url,
  635. self.impersonate_user,
  636. effective_username)
  637. masked_url = self.get_password_masked_url(url)
  638. logging.info('Database.get_sqla_engine(). Masked URL: {0}'.format(masked_url))
  639. params = extra.get('engine_params', {})
  640. if nullpool:
  641. params['poolclass'] = NullPool
  642. # If using Hive, this will set hive.server2.proxy.user=$effective_username
  643. configuration = {}
  644. configuration.update(
  645. self.db_engine_spec.get_configuration_for_impersonation(
  646. str(url),
  647. self.impersonate_user,
  648. effective_username))
  649. if configuration:
  650. params['connect_args'] = {'configuration': configuration}
  651. DB_CONNECTION_MUTATOR = config.get('DB_CONNECTION_MUTATOR')
  652. if DB_CONNECTION_MUTATOR:
  653. url, params = DB_CONNECTION_MUTATOR(
  654. url, params, effective_username, security_manager)
  655. return create_engine(url, **params)
  656. def get_reserved_words(self):
  657. return self.get_dialect().preparer.reserved_words
  658. def get_quoter(self):
  659. return self.get_dialect().identifier_preparer.quote
  660. def get_df(self, sql, schema):
  661. sqls = [str(s).strip().strip(';') for s in sqlparse.parse(sql)]
  662. engine = self.get_sqla_engine(schema=schema)
  663. def needs_conversion(df_series):
  664. if df_series.empty:
  665. return False
  666. if isinstance(df_series[0], (list, dict)):
  667. return True
  668. return False
  669. with closing(engine.raw_connection()) as conn:
  670. with closing(conn.cursor()) as cursor:
  671. for sql in sqls[:-1]:
  672. self.db_engine_spec.execute(cursor, sql)
  673. cursor.fetchall()
  674. self.db_engine_spec.execute(cursor, sqls[-1])
  675. if cursor.description is not None:
  676. columns = [col_desc[0] for col_desc in cursor.description]
  677. else:
  678. columns = []
  679. df = pd.DataFrame.from_records(
  680. data=list(cursor.fetchall()),
  681. columns=columns,
  682. coerce_float=True,
  683. )
  684. for k, v in df.dtypes.items():
  685. if v.type == numpy.object_ and needs_conversion(df[k]):
  686. df[k] = df[k].apply(utils.json_dumps_w_dates)
  687. return df
  688. def compile_sqla_query(self, qry, schema=None):
  689. engine = self.get_sqla_engine(schema=schema)
  690. sql = str(
  691. qry.compile(
  692. engine,
  693. compile_kwargs={'literal_binds': True},
  694. ),
  695. )
  696. if engine.dialect.identifier_preparer._double_percents:
  697. sql = sql.replace('%%', '%')
  698. return sql
  699. def select_star(
  700. self, table_name, schema=None, limit=100, show_cols=False,
  701. indent=True, latest_partition=False, cols=None):
  702. """Generates a ``select *`` statement in the proper dialect"""
  703. eng = self.get_sqla_engine(schema=schema)
  704. return self.db_engine_spec.select_star(
  705. self, table_name, schema=schema, engine=eng,
  706. limit=limit, show_cols=show_cols,
  707. indent=indent, latest_partition=latest_partition, cols=cols)
  708. def apply_limit_to_sql(self, sql, limit=1000):
  709. return self.db_engine_spec.apply_limit_to_sql(sql, limit, self)
  710. def safe_sqlalchemy_uri(self):
  711. return self.sqlalchemy_uri
  712. @property
  713. def inspector(self):
  714. engine = self.get_sqla_engine()
  715. return sqla.inspect(engine)
  716. def all_table_names(self, schema=None, force=False):
  717. if not schema:
  718. if not self.allow_multi_schema_metadata_fetch:
  719. return []
  720. tables_dict = self.db_engine_spec.fetch_result_sets(
  721. self, 'table', force=force)
  722. return tables_dict.get('', [])
  723. return sorted(
  724. self.db_engine_spec.get_table_names(schema, self.inspector))
  725. def all_view_names(self, schema=None, force=False):
  726. if not schema:
  727. if not self.allow_multi_schema_metadata_fetch:
  728. return []
  729. views_dict = self.db_engine_spec.fetch_result_sets(
  730. self, 'view', force=force)
  731. return views_dict.get('', [])
  732. views = []
  733. try:
  734. views = self.inspector.get_view_names(schema)
  735. except Exception:
  736. pass
  737. return views
  738. def all_schema_names(self, force_refresh=False):
  739. extra = self.get_extra()
  740. medatada_cache_timeout = extra.get('metadata_cache_timeout', {})
  741. schema_cache_timeout = medatada_cache_timeout.get('schema_cache_timeout')
  742. enable_cache = 'schema_cache_timeout' in medatada_cache_timeout
  743. return sorted(self.db_engine_spec.get_schema_names(
  744. inspector=self.inspector,
  745. enable_cache=enable_cache,
  746. cache_timeout=schema_cache_timeout,
  747. db_id=self.id,
  748. force=force_refresh))
  749. @property
  750. def db_engine_spec(self):
  751. return db_engine_specs.engines.get(
  752. self.backend, db_engine_specs.BaseEngineSpec)
  753. @classmethod
  754. def get_db_engine_spec_for_backend(cls, backend):
  755. return db_engine_specs.engines.get(backend, db_engine_specs.BaseEngineSpec)
  756. def grains(self):
  757. """Defines time granularity database-specific expressions.
  758. The idea here is to make it easy for users to change the time grain
  759. form a datetime (maybe the source grain is arbitrary timestamps, daily
  760. or 5 minutes increments) to another, "truncated" datetime. Since
  761. each database has slightly different but similar datetime functions,
  762. this allows a mapping between database engines and actual functions.
  763. """
  764. return self.db_engine_spec.get_time_grains()
  765. def grains_dict(self):
  766. """Allowing to lookup grain by either label or duration
  767. For backward compatibility"""
  768. d = {grain.duration: grain for grain in self.grains()}
  769. d.update({grain.label: grain for grain in self.grains()})
  770. return d
  771. def get_extra(self):
  772. extra = {}
  773. if self.extra:
  774. try:
  775. extra = json.loads(self.extra)
  776. except Exception as e:
  777. logging.error(e)
  778. raise e
  779. return extra
  780. def get_table(self, table_name, schema=None):
  781. extra = self.get_extra()
  782. meta = MetaData(**extra.get('metadata_params', {}))
  783. return Table(
  784. table_name, meta,
  785. schema=schema or None,
  786. autoload=True,
  787. autoload_with=self.get_sqla_engine())
  788. def get_columns(self, table_name, schema=None):
  789. return self.inspector.get_columns(table_name, schema)
  790. def get_indexes(self, table_name, schema=None):
  791. return self.inspector.get_indexes(table_name, schema)
  792. def get_pk_constraint(self, table_name, schema=None):
  793. return self.inspector.get_pk_constraint(table_name, schema)
  794. def get_foreign_keys(self, table_name, schema=None):
  795. return self.inspector.get_foreign_keys(table_name, schema)
  796. def get_schema_access_for_csv_upload(self):
  797. return self.get_extra().get('schemas_allowed_for_csv_upload', [])
  798. @property
  799. def sqlalchemy_uri_decrypted(self):
  800. conn = sqla.engine.url.make_url(self.sqlalchemy_uri)
  801. if custom_password_store:
  802. conn.password = custom_password_store(conn)
  803. else:
  804. conn.password = self.password
  805. return str(conn)
  806. @property
  807. def sql_url(self):
  808. return '/superset/sql/{}/'.format(self.id)
  809. def get_perm(self):
  810. return (
  811. '[{obj.database_name}].(id:{obj.id})').format(obj=self)
  812. def has_table(self, table):
  813. engine = self.get_sqla_engine()
  814. return engine.has_table(
  815. table.table_name, table.schema or None)
  816. @utils.memoized
  817. def get_dialect(self):
  818. sqla_url = url.make_url(self.sqlalchemy_uri_decrypted)
  819. return sqla_url.get_dialect()()
  820. sqla.event.listen(Database, 'after_insert', security_manager.set_perm)
  821. sqla.event.listen(Database, 'after_update', security_manager.set_perm)
  822. class Log(Model):
  823. """ORM object used to log Superset actions to the database"""
  824. __tablename__ = 'logs'
  825. id = Column(Integer, primary_key=True)
  826. action = Column(String(512))
  827. user_id = Column(Integer, ForeignKey('ab_user.id'))
  828. dashboard_id = Column(Integer)
  829. slice_id = Column(Integer)
  830. json = Column(Text)
  831. user = relationship(
  832. security_manager.user_model, backref='logs', foreign_keys=[user_id])
  833. dttm = Column(DateTime, default=datetime.utcnow)
  834. duration_ms = Column(Integer)
  835. referrer = Column(String(1024))
  836. @classmethod
  837. def log_this(cls, f):
  838. """Decorator to log user actions"""
  839. @functools.wraps(f)
  840. def wrapper(*args, **kwargs):
  841. user_id = None
  842. if g.user:
  843. user_id = g.user.get_id()
  844. d = request.form.to_dict() or {}
  845. # request parameters can overwrite post body
  846. request_params = request.args.to_dict()
  847. d.update(request_params)
  848. d.update(kwargs)
  849. slice_id = d.get('slice_id')
  850. dashboard_id = d.get('dashboard_id')
  851. try:
  852. slice_id = int(
  853. slice_id or json.loads(d.get('form_data')).get('slice_id'))
  854. except (ValueError, TypeError):
  855. slice_id = 0
  856. stats_logger.incr(f.__name__)
  857. start_dttm = datetime.now()
  858. value = f(*args, **kwargs)
  859. duration_ms = (datetime.now() - start_dttm).total_seconds() * 1000
  860. # bulk insert
  861. try:
  862. explode_by = d.get('explode')
  863. records = json.loads(d.get(explode_by))
  864. except Exception:
  865. records = [d]
  866. referrer = request.referrer[:1000] if request.referrer else None
  867. logs = []
  868. for record in records:
  869. try:
  870. json_string = json.dumps(record)
  871. except Exception:
  872. json_string = None
  873. log = cls(
  874. action=f.__name__,
  875. json=json_string,
  876. dashboard_id=dashboard_id,
  877. slice_id=slice_id,
  878. duration_ms=duration_ms,
  879. referrer=referrer,
  880. user_id=user_id)
  881. logs.append(log)
  882. sesh = db.session()
  883. sesh.bulk_save_objects(logs)
  884. sesh.commit()
  885. return value
  886. return wrapper
  887. class FavStar(Model):
  888. __tablename__ = 'favstar'
  889. id = Column(Integer, primary_key=True)
  890. user_id = Column(Integer, ForeignKey('ab_user.id'))
  891. class_name = Column(String(50))
  892. obj_id = Column(Integer)
  893. dttm = Column(DateTime, default=datetime.utcnow)
  894. class DatasourceAccessRequest(Model, AuditMixinNullable):
  895. """ORM model for the access requests for datasources and dbs."""
  896. __tablename__ = 'access_request'
  897. id = Column(Integer, primary_key=True)
  898. datasource_id = Column(Integer)
  899. datasource_type = Column(String(200))
  900. ROLES_BLACKLIST = set(config.get('ROBOT_PERMISSION_ROLES', []))
  901. @property
  902. def cls_model(self):
  903. return ConnectorRegistry.sources[self.datasource_type]
  904. @property
  905. def username(self):
  906. return self.creator()
  907. @property
  908. def datasource(self):
  909. return self.get_datasource
  910. @datasource.getter
  911. @utils.memoized
  912. def get_datasource(self):
  913. # pylint: disable=no-member
  914. ds = db.session.query(self.cls_model).filter_by(
  915. id=self.datasource_id).first()
  916. return ds
  917. @property
  918. def datasource_link(self):
  919. return self.datasource.link # pylint: disable=no-member
  920. @property
  921. def roles_with_datasource(self):
  922. action_list = ''
  923. perm = self.datasource.perm # pylint: disable=no-member
  924. pv = security_manager.find_permission_view_menu('datasource_access', perm)
  925. for r in pv.role:
  926. if r.name in self.ROLES_BLACKLIST:
  927. continue
  928. url = (
  929. '/superset/approve?datasource_type={self.datasource_type}&'
  930. 'datasource_id={self.datasource_id}&'
  931. 'created_by={self.created_by.username}&role_to_grant={r.name}'
  932. .format(**locals())
  933. )
  934. href = '<a href="{}">Grant {} Role</a>'.format(url, r.name)
  935. action_list = action_list + '<li>' + href + '</li>'
  936. return '<ul>' + action_list + '</ul>'
  937. @property
  938. def user_roles(self):
  939. action_list = ''
  940. for r in self.created_by.roles: # pylint: disable=no-member
  941. url = (
  942. '/superset/approve?datasource_type={self.datasource_type}&'
  943. 'datasource_id={self.datasource_id}&'
  944. 'created_by={self.created_by.username}&role_to_extend={r.name}'
  945. .format(**locals())
  946. )
  947. href = '<a href="{}">Extend {} Role</a>'.format(url, r.name)
  948. if r.name in self.ROLES_BLACKLIST:
  949. href = '{} Role'.format(r.name)
  950. action_list = action_list + '<li>' + href + '</li>'
  951. return '<ul>' + action_list + '</ul>'