diff --git a/.gitignore b/.gitignore index 00b1aa5fc9a72..700ce0ca3ab2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc +babel .DS_Store .coverage _build diff --git a/.landscape.yml b/.landscape.yml new file mode 100644 index 0000000000000..72de64fbf2427 --- /dev/null +++ b/.landscape.yml @@ -0,0 +1,22 @@ +doc-warnings: yes +test-warnings: no +strictness: medium +max-line-length: 90 +uses: + - flask +autodetect: yes +pylint: + disable: + - cyclic-import + options: + docstring-min-length: 10 +ignore-paths: + - docs + - panoramix/migrations/env.py + - panoramix/ascii_art.py +ignore-patterns: + - ^example/doc_.*\.py$ + - (^|/)docs(/|$) +python-targets: + - 2 + - 3 diff --git a/babel/babel.cfg b/babel/babel.cfg deleted file mode 100644 index 70e23ac634f1a..0000000000000 --- a/babel/babel.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[python: **.py] -[jinja2: **/templates/**.html] -encoding = utf-8 diff --git a/babel/messages.pot b/babel/messages.pot deleted file mode 100644 index 8b137891791fe..0000000000000 --- a/babel/messages.pot +++ /dev/null @@ -1 +0,0 @@ - diff --git a/panoramix/__init__.py b/panoramix/__init__.py index e23539f692c93..ff9baaa61fb28 100644 --- a/panoramix/__init__.py +++ b/panoramix/__init__.py @@ -33,4 +33,4 @@ def index(self): sm = appbuilder.sm get_session = appbuilder.get_session -from panoramix import config, views +from panoramix import config, views # noqa diff --git a/panoramix/assets/javascripts/featured.js b/panoramix/assets/javascripts/featured.js index 8fa875098cbb4..cd549dcc9de23 100644 --- a/panoramix/assets/javascripts/featured.js +++ b/panoramix/assets/javascripts/featured.js @@ -1,9 +1,10 @@ var $ = window.$ = require('jquery'); var jQuery = window.jQuery = $; +var px = require('./modules/panoramix.js'); +require('bootstrap'); require('datatables'); require('../node_modules/datatables-bootstrap3-plugin/media/css/datatables-bootstrap3.css'); -require('bootstrap'); $(document).ready(function () { $('#dataset-table').DataTable({ @@ -13,5 +14,6 @@ $(document).ready(function () { ] }); $('#dataset-table_info').remove(); + //$('input[type=search]').addClass('form-control'); # TODO get search box to look nice $('#dataset-table').show(); }); diff --git a/panoramix/bin/panoramix b/panoramix/bin/panoramix index a95084ad9b530..e6d7a37231b15 100755 --- a/panoramix/bin/panoramix +++ b/panoramix/bin/panoramix @@ -7,6 +7,7 @@ from subprocess import Popen from flask.ext.script import Manager from panoramix import app from flask.ext.migrate import MigrateCommand +import panoramix from panoramix import db from panoramix import data, utils @@ -49,7 +50,7 @@ def runserver(debug, port, timeout, workers): @manager.command def init(): """Inits the Panoramix application""" - utils.init() + utils.init(panoramix) @manager.option( '-s', '--sample', action='store_true', @@ -58,6 +59,8 @@ def load_examples(sample): """Loads a set of Slices and Dashboards and a supporting dataset """ print("Loading examples into {}".format(db)) + data.load_css_templates() + print("Loading [World Bank's Health Nutrition and Population Stats]") data.load_world_bank_health_n_pop() diff --git a/panoramix/config.py b/panoramix/config.py index ed5314e75cb86..4980a06cf8629 100644 --- a/panoramix/config.py +++ b/panoramix/config.py @@ -25,7 +25,7 @@ # --------------------------------------------------------- # Your App secret key -SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' +SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' # noqa # The SQLAlchemy connection string. SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/panoramix.db' @@ -48,7 +48,7 @@ APP_NAME = "Panoramix" # Uncomment to setup Setup an App icon -APP_ICON = "/static/img/chaudron_white.png" +# APP_ICON = "/static/img/something.png" # Druid query timezone # tz.tzutc() : Using utc timezone @@ -113,6 +113,6 @@ # IMG_SIZE = (300, 200, True) try: - from panoramix_config import * + from panoramix_config import * # noqa except Exception: pass diff --git a/panoramix/data/__init__.py b/panoramix/data/__init__.py index 08a0e95ebfbcc..9ba1c87556827 100644 --- a/panoramix/data/__init__.py +++ b/panoramix/data/__init__.py @@ -274,33 +274,14 @@ def load_world_bank_health_n_pop(): dash = Dash( dashboard_title=dash_name, position_json=json.dumps(l, indent=4), + slug="world_health", ) for s in slices: dash.slices.append(s) db.session.commit() -def load_birth_names(): - session = db.session - with gzip.open(os.path.join(DATA_FOLDER, 'birth_names.json.gz')) as f: - pdf = pd.read_json(f) - pdf.ds = pd.to_datetime(pdf.ds, unit='ms') - pdf.to_sql( - 'birth_names', - db.engine, - if_exists='replace', - chunksize=500, - dtype={ - 'ds': DateTime, - 'gender': String(16), - 'state': String(10), - 'name': String(255), - }, - index=False) - l = [] - print("Done loading table!") - print("-" * 80) - +def load_css_templates(): print('Creating default CSS templates') CSS = models.CssTemplate @@ -400,6 +381,27 @@ def load_birth_names(): db.session.merge(obj) db.session.commit() + +def load_birth_names(): + with gzip.open(os.path.join(DATA_FOLDER, 'birth_names.json.gz')) as f: + pdf = pd.read_json(f) + pdf.ds = pd.to_datetime(pdf.ds, unit='ms') + pdf.to_sql( + 'birth_names', + db.engine, + if_exists='replace', + chunksize=500, + dtype={ + 'ds': DateTime, + 'gender': String(16), + 'state': String(10), + 'name': String(255), + }, + index=False) + l = [] + print("Done loading table!") + print("-" * 80) + print("Creating table reference") obj = db.session.query(TBL).filter_by(table_name='birth_names').first() if not obj: @@ -500,12 +502,15 @@ def load_birth_names(): defaults, viz_type="markup", markup_type="html", code="""\ -
-

Birth Names Dashboard

-

The source dataset came from [here]

- -
- """ +
+

Birth Names Dashboard

+

+ The source dataset came from + [here] +

+ +
+""" )), Slice( slice_name="Name Cloud", @@ -531,7 +536,7 @@ def load_birth_names(): merge_slice(slc) print("Creating a dashboard") - dash = session.query(Dash).filter_by(dashboard_title="Births").first() + dash = db.session.query(Dash).filter_by(dashboard_title="Births").first() if dash: db.session.delete(dash) @@ -608,7 +613,8 @@ def load_birth_names(): dash = Dash( dashboard_title="Births", position_json=json.dumps(l, indent=4), + slug="births", ) for s in slices: dash.slices.append(s) - session.commit() + db.session.commit() diff --git a/panoramix/data/countries.py b/panoramix/data/countries.py index 0a39d316b85ef..f81ef32df27fc 100644 --- a/panoramix/data/countries.py +++ b/panoramix/data/countries.py @@ -1,3 +1,7 @@ +""" +This module contains data related to countries and is used for geo mapping +""" + countries = [ { "name": "Angola", @@ -490,11 +494,11 @@ "cca3": "THA" }, { - "name": "S\u00e3o Tom\u00e9 and Pr\u00edncipe", + "name": "Sao Tome and Principe", "area": 964, "cioc": "STP", "cca2": "ST", - "capital": "S\u00e3o Tom\u00e9", + "capital": "Sao Tome", "lat": 1, "lng": 7, "cca3": "STP" @@ -684,7 +688,7 @@ "area": 4167, "cioc": "", "cca2": "PF", - "capital": u"Papeet\u0113", + "capital": "Papeete", "lat": -15, "lng": -140, "cca3": "PYF" @@ -754,7 +758,7 @@ "area": 56785, "cioc": "TOG", "cca2": "TG", - "capital": u"Lom\u00e9", + "capital": "Lome", "lat": 8, "lng": 1.16666666, "cca3": "TGO" @@ -774,7 +778,7 @@ "area": 549, "cioc": "GUM", "cca2": "GU", - "capital": u"Hag\u00e5t\u00f1a", + "capital": "Hagatna", "lat": 13.46666666, "lng": 144.78333333, "cca3": "GUM" @@ -834,7 +838,7 @@ "area": 51100, "cioc": "CRC", "cca2": "CR", - "capital": u"San Jos\u00e9", + "capital": "San Jose", "lat": 10, "lng": -84, "cca3": "CRI" @@ -844,7 +848,7 @@ "area": 475442, "cioc": "CMR", "cca2": "CM", - "capital": u"Yaound\u00e9", + "capital": "Yaounde", "lat": 6, "lng": 12, "cca3": "CMR" @@ -1070,7 +1074,7 @@ "cca3": "BLR" }, { - "name": u"Saint Barth\u00e9lemy", + "name": "Saint Barthelemy", "area": 21, "cioc": "", "cca2": "BL", @@ -1274,7 +1278,7 @@ "area": 7747, "cioc": "", "cca2": "TF", - "capital": u"Port-aux-Fran\u00e7ais", + "capital": "Port-aux-Francais", "lat": -49.25, "lng": 69.167, "cca3": "ATF" @@ -1380,7 +1384,7 @@ "cca3": "PER" }, { - "name": u"R\u00e9union", + "name": "Reunion", "area": 2511, "cioc": "", "cca2": "RE", @@ -1484,7 +1488,7 @@ "area": 1141748, "cioc": "COL", "cca2": "CO", - "capital": u"Bogot\u00e1", + "capital": "Bogota", "lat": 4, "lng": -72, "cca3": "COL" @@ -1534,7 +1538,7 @@ "area": 33846, "cioc": "MDA", "cca2": "MD", - "capital": u"Chi\u0219in\u0103u", + "capital": "Chisinau", "lat": 47, "lng": 29, "cca3": "MDA" @@ -1594,7 +1598,7 @@ "area": 300, "cioc": "MDV", "cca2": "MV", - "capital": u"Mal\u00e9", + "capital": "Male", "lat": 3.25, "lng": 73, "cca3": "MDV" @@ -1620,7 +1624,7 @@ "cca3": "SPM" }, { - "name": u"Cura\u00e7ao", + "name": "Curacao", "area": 444, "cioc": "", "cca2": "CW", @@ -1704,7 +1708,7 @@ "area": 1393, "cioc": "", "cca2": "FO", - "capital": u"T\u00f3rshavn", + "capital": "Torshavn", "lat": 62, "lng": -7, "cca3": "FRO" @@ -1860,7 +1864,7 @@ "cca3": "TUV" }, { - "name": u"\u00c5land Islands", + "name": "Aland Islands", "area": 1580, "cioc": "", "cca2": "AX", @@ -1914,7 +1918,7 @@ "area": 8515767, "cioc": "BRA", "cca2": "BR", - "capital": u"Bras\u00edlia", + "capital": "Brasilia", "lat": -10, "lng": -55, "cca3": "BRA" @@ -2334,7 +2338,7 @@ "area": 266000, "cioc": "", "cca2": "EH", - "capital": u"El Aai\u00fan", + "capital": "El Aaiun", "lat": 24.5, "lng": -13, "cca3": "ESH" @@ -2394,7 +2398,7 @@ "area": 18575, "cioc": "", "cca2": "NC", - "capital": u"Noum\u00e9a", + "capital": "Noumea", "lat": -21.5, "lng": 165.5, "cca3": "NCL" diff --git a/panoramix/forms.py b/panoramix/forms.py index 1d8bf565a0f1c..e58c63ee2cbad 100644 --- a/panoramix/forms.py +++ b/panoramix/forms.py @@ -1,20 +1,21 @@ from wtforms import ( - Field, Form, SelectMultipleField, SelectField, TextField, TextAreaField, + Form, SelectMultipleField, SelectField, TextField, TextAreaField, BooleanField, IntegerField, HiddenField) from wtforms import validators, widgets from copy import copy from panoramix import app -from six import string_types from collections import OrderedDict config = app.config class BetterBooleanField(BooleanField): + """ Fixes behavior of html forms omitting non checked (which doesn't distinguish False from NULL/missing ) If value is unchecked, this hidden fills in False value """ + def __call__(self, **kwargs): html = super(BetterBooleanField, self).__call__(**kwargs) html += u''.format(self.name) @@ -22,9 +23,9 @@ def __call__(self, **kwargs): class SelectMultipleSortableField(SelectMultipleField): - """ - Works along with select2sortable to preserves the sort order - """ + + """Works along with select2sortable to preserves the sort order""" + def iter_choices(self): d = OrderedDict() for value, label in self.choices: @@ -39,6 +40,9 @@ def iter_choices(self): class FreeFormSelect(widgets.Select): + + """A WTF widget that allows for free form entry""" + def __call__(self, field, **kwargs): kwargs.setdefault('id', field.id) if self.multiple: @@ -54,13 +58,20 @@ def __call__(self, field, **kwargs): html.append('') return widgets.HTMLString(''.join(html)) + class FreeFormSelectField(SelectField): + + """ A WTF SelectField that allows for free form input """ + widget = FreeFormSelect() def pre_validate(self, form): return class OmgWtForm(Form): + + """Panoramixification of the WTForm Form object""" + fieldsets = {} css_classes = dict() @@ -74,6 +85,7 @@ def field_css_classes(self, fieldname): class FormFactory(object): + """Used to create the forms in the explore view dynamically""" series_limits = [0, 5, 10, 25, 50, 100, 500] fieltype_class = { SelectField: 'select2', @@ -231,12 +243,15 @@ def __init__(self, viz): ]), description="Charge in the force layout"), 'granularity_sqla': SelectField( - 'Time Column', default=datasource.main_dttm_col, + 'Time Column', + default=datasource.main_dttm_col or datasource.any_dttm_col, choices=self.choicify(datasource.dttm_cols), description=( - "The time granularity for the visualization. Note that you " + "The time column for the visualization. Note that you " "can define arbitrary expression that return a DATETIME " - "column in the table editor")), + "column in the table editor. Also note that the " + "filter bellow is applied against this column or " + "expression")), 'resample_rule': FreeFormSelectField( 'Resample Rule', default='', choices=self.choicify(('1T', '1H', '1D', '7D', '1M', '1AS')), @@ -347,7 +362,9 @@ def __init__(self, viz): "complex expression, parenthesis and anything else " "supported by the backend it is directed towards.")), 'compare_lag': TextField('Comparison Period Lag', - description="Based on granularity, number of time periods to compare against"), + description=( + "Based on granularity, number of time periods to " + "compare against")), 'compare_suffix': TextField('Comparison suffix', description="Suffix to apply after the percentage display"), 'x_axis_format': FreeFormSelectField('X axis format', @@ -356,7 +373,8 @@ def __init__(self, viz): ('smart_date', 'Adaptative formating'), ("%m/%d/%Y", '"%m/%d/%Y" | 01/14/2019'), ("%Y-%m-%d", '"%Y-%m-%d" | 2019-01-14'), - ("%Y-%m-%d %H:%M:%S", '"%Y-%m-%d %H:%M:%S" | 2019-01-14 01:32:10'), + ("%Y-%m-%d %H:%M:%S", + '"%Y-%m-%d %H:%M:%S" | 2019-01-14 01:32:10'), ("%H:%M:%S", '"%H:%M:%S" | 01:32:10'), ], description="D3 format syntax for y axis " @@ -474,12 +492,10 @@ def __init__(self, viz): def choicify(l): return [("{}".format(obj), "{}".format(obj)) for obj in l] - def get_form(self, previous=False): - px_form_fields = self.field_dict + def get_form(self): viz = self.viz - datasource = viz.datasource field_css_classes = {} - for name, obj in px_form_fields.items(): + for name, obj in self.field_dict.items(): field_css_classes[name] = ['form-control'] s = self.fieltype_class.get(obj.field_class) if s: @@ -489,7 +505,7 @@ def get_form(self, previous=False): field_css_classes[field] += ['input-sm'] class QueryForm(OmgWtForm): - fieldsets = copy(viz.fieldsetizer()) + fieldsets = copy(viz.fieldsets) css_classes = field_css_classes standalone = HiddenField() async = HiddenField() @@ -501,7 +517,7 @@ class QueryForm(OmgWtForm): collapsed_fieldsets = HiddenField() viz_type = self.field_dict.get('viz_type') - filter_cols = datasource.filterable_column_names or [''] + filter_cols = viz.datasource.filterable_column_names or [''] for i in range(10): setattr(QueryForm, 'flt_col_' + str(i), SelectField( 'Filter 1', @@ -514,18 +530,16 @@ class QueryForm(OmgWtForm): setattr( QueryForm, 'flt_eq_' + str(i), TextField("Super", default='')) - for fieldset in viz.fieldsetizer(): - for ff in fieldset['fields']: - if ff: - if isinstance(ff, string_types): - ff = [ff] - for s in ff: - if s: - setattr(QueryForm, s, px_form_fields[s]) + for field in viz.flat_form_fields(): + setattr(QueryForm, field, self.field_dict[field]) + + def add_to_form(attrs): + for attr in attrs: + setattr(QueryForm, attr, self.field_dict[attr]) # datasource type specific form elements - if datasource.__class__.__name__ == 'SqlaTable': + if viz.datasource.__class__.__name__ == 'SqlaTable': QueryForm.fieldsets += ({ 'label': 'SQL', 'fields': ['where', 'having'], @@ -533,12 +547,36 @@ class QueryForm(OmgWtForm): "This section exposes ways to include snippets of " "SQL in your query"), },) - setattr(QueryForm, 'where', px_form_fields['where']) - setattr(QueryForm, 'having', px_form_fields['having']) - - if 'granularity' in viz.flat_form_fields(): - setattr( - QueryForm, - 'granularity', px_form_fields['granularity_sqla']) - field_css_classes['granularity'] = ['form-control', 'select2'] + add_to_form(('where', 'having')) + grains = viz.datasource.database.grains() + + if not viz.datasource.any_dttm_col: + return QueryForm + if grains: + time_fields = ('granularity_sqla', 'time_grain_sqla') + self.field_dict['time_grain_sqla'] = SelectField( + 'Time Grain', + choices=self.choicify((grain.name for grain in grains)), + default="Time Column", + description=( + "The time granularity for the visualization. This " + "applies a date transformation to alter " + "your time column and defines a new time granularity." + "The options here are defined on a per database " + "engine basis in the Panoramix source code")) + add_to_form(time_fields) + field_css_classes['time_grain_sqla'] = ['form-control', 'select2'] + else: + time_fields = 'granularity_sqla' + add_to_form((time_fields, )) + add_to_form(('since', 'until')) + QueryForm.fieldsets = ({ + 'label': 'Time', + 'fields': ( + time_fields, + ('since', 'until'), + ), + 'description': "Time related form attributes", + },) + tuple(QueryForm.fieldsets) + field_css_classes['granularity'] = ['form-control', 'select2'] return QueryForm diff --git a/panoramix/migrations/versions/18e88e1cc004_making_audit_nullable.py b/panoramix/migrations/versions/18e88e1cc004_making_audit_nullable.py index bd5f850cce7ce..0143aad58722b 100644 --- a/panoramix/migrations/versions/18e88e1cc004_making_audit_nullable.py +++ b/panoramix/migrations/versions/18e88e1cc004_making_audit_nullable.py @@ -15,85 +15,84 @@ def upgrade(): - ### commands auto generated by Alembic - please adjust! ### - op.alter_column('clusters', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('clusters', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - try: + op.alter_column( + 'clusters', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column( + 'clusters', 'created_on', + existing_type=sa.DATETIME(), nullable=True) op.drop_constraint(None, 'columns', type_='foreignkey') op.drop_constraint(None, 'columns', type_='foreignkey') op.drop_column('columns', 'created_on') op.drop_column('columns', 'created_by_fk') op.drop_column('columns', 'changed_on') op.drop_column('columns', 'changed_by_fk') + op.alter_column('css_templates', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('css_templates', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('dashboards', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('dashboards', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.create_unique_constraint(None, 'dashboards', ['slug']) + op.alter_column('datasources', 'changed_by_fk', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('datasources', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('datasources', 'created_by_fk', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('datasources', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('dbs', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('dbs', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('slices', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('slices', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('sql_metrics', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('sql_metrics', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('table_columns', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('table_columns', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('tables', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('tables', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('url', 'changed_on', + existing_type=sa.DATETIME(), + nullable=True) + op.alter_column('url', 'created_on', + existing_type=sa.DATETIME(), + nullable=True) + ### end Alembic commands ### except: pass - op.alter_column('css_templates', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('css_templates', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('dashboards', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('dashboards', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.create_unique_constraint(None, 'dashboards', ['slug']) - op.alter_column('datasources', 'changed_by_fk', - existing_type=sa.INTEGER(), - nullable=True) - op.alter_column('datasources', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('datasources', 'created_by_fk', - existing_type=sa.INTEGER(), - nullable=True) - op.alter_column('datasources', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('dbs', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('dbs', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('slices', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('slices', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('sql_metrics', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('sql_metrics', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('table_columns', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('table_columns', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('tables', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('tables', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('url', 'changed_on', - existing_type=sa.DATETIME(), - nullable=True) - op.alter_column('url', 'created_on', - existing_type=sa.DATETIME(), - nullable=True) - ### end Alembic commands ### def downgrade(): diff --git a/panoramix/models.py b/panoramix/models.py index 410d0c905416a..05f4a670dae9c 100644 --- a/panoramix/models.py +++ b/panoramix/models.py @@ -1,6 +1,7 @@ from copy import deepcopy, copy from collections import namedtuple from datetime import timedelta, datetime +import functools import json import logging from six import string_types @@ -8,7 +9,7 @@ import requests from dateutil.parser import parse -from flask import flash +from flask import flash, request, g from flask.ext.appbuilder import Model from flask.ext.appbuilder.models.mixins import AuditMixin import pandas as pd @@ -39,31 +40,39 @@ class AuditMixinNullable(AuditMixin): changed_on = Column( DateTime, default=datetime.now, onupdate=datetime.now, nullable=True) + @declared_attr def created_by_fk(cls): return Column(Integer, ForeignKey('ab_user.id'), default=cls.get_user_id, nullable=True) + @declared_attr def changed_by_fk(cls): return Column(Integer, ForeignKey('ab_user.id'), default=cls.get_user_id, onupdate=cls.get_user_id, nullable=True) + @property def created_by_(self): return '{}'.format(self.created_by or '') - @property + + @property # noqa def changed_by_(self): return '{}'.format(self.changed_by or '') class Url(Model, AuditMixinNullable): + """Used for the short url feature""" + __tablename__ = 'url' id = Column(Integer, primary_key=True) url = Column(Text) class CssTemplate(Model, AuditMixinNullable): + """CSS templates for dashboards""" + __tablename__ = 'css_templates' id = Column(Integer, primary_key=True) template_name = Column(String(250)) @@ -71,7 +80,9 @@ class CssTemplate(Model, AuditMixinNullable): class Slice(Model, AuditMixinNullable): + """A slice is essentially a report or a view on data""" + __tablename__ = 'slices' id = Column(Integer, primary_key=True) slice_name = Column(String(250)) @@ -154,17 +165,6 @@ def slice_link(self): return '{self.slice_name}'.format( url=url, self=self) - @property - def js_files(self): - return viz_types[self.viz_type].js_files - - @property - def css_files(self): - return viz_types[self.viz_type].css_files - - def get_viz(self): - pass - dashboard_slices = Table('dashboard_slices', Model.metadata, Column('id', Integer, primary_key=True), @@ -174,7 +174,9 @@ def get_viz(self): class Dashboard(Model, AuditMixinNullable): + """A dash to slash""" + __tablename__ = 'dashboards' id = Column(Integer, primary_key=True) dashboard_title = Column(String(500)) @@ -203,20 +205,6 @@ def metadata_dejson(self): def dashboard_link(self): return '{self.dashboard_title}'.format(self=self) - @property - def js_files(self): - l = [] - for o in self.slices: - l += [f for f in o.js_files if f not in l] - return l - - @property - def css_files(self): - l = [] - for o in self.slices: - l += o.css_files - return list(set(l)) - @property def json_data(self): d = { @@ -267,6 +255,37 @@ def get_sqla_engine(self): def safe_sqlalchemy_uri(self): return self.sqlalchemy_uri + def grains(self): + + """Defines time granularity database-specific expressions. The idea + here is to make it easy for users to change the time grain form a + datetime (maybe the source grain is arbitrary timestamps, daily + or 5 minutes increments) to another, "truncated" datetime. Since + each database has slightly different but similar datetime functions, + this allows a mapping between database engines and actual functions. + """ + + Grain = namedtuple('Grain', 'name function') + DB_TIME_GRAINS = { + 'presto': ( + Grain('Time Column', '{col}'), + Grain('week', "date_trunc('week', {col})"), + Grain('month', "date_trunc('month', {col})"), + ), + 'mysql': ( + Grain('Time Column', '{col}'), + Grain('day', 'DATE({col})'), + Grain('week', 'DATE_SUB({col}, INTERVAL DAYOFWEEK({col}) - 1 DAY)'), + Grain('month', 'DATE_SUB({col}, INTERVAL DAYOFMONTH({col}) - 1 DAY)'), + ), + } + for db_type, grains in DB_TIME_GRAINS.items(): + if self.sqlalchemy_uri.startswith(db_type): + return grains + + def grains_dict(self): + return {grain.name: grain for grain in self.grains()} + def get_table(self, table_name): meta = MetaData() return Table( @@ -345,6 +364,12 @@ def dttm_cols(self): l.append(self.main_dttm_col) return l + @property + def any_dttm_col(self): + cols = self.dttm_cols + if cols: + return cols[0] + @property def html(self): t = ((c.column_name, c.type) for c in self.columns) @@ -386,8 +411,7 @@ def query( self, groupby, metrics, granularity, from_dttm, to_dttm, - limit_spec=None, - filter=None, + filter=None, # noqa is_timeseries=True, timeseries_limit=15, row_limit=None, inner_from_dttm=None, inner_to_dttm=None, @@ -400,14 +424,21 @@ def query( cols = {col.column_name: col for col in self.columns} qry_start_dttm = datetime.now() - if not self.main_dttm_col: + + if not granularity and is_timeseries: raise Exception( - "Datetime column not provided as part table configuration") - dttm_expr = cols[granularity].expression - if dttm_expr: + "Datetime column not provided as part table configuration " + "and is required by this type of chart") + if granularity: + dttm_expr = cols[granularity].expression or granularity + + # Transforming time grain into an expression based on configuration + time_grain_sqla = extras.get('time_grain_sqla') + if time_grain_sqla: + udf = self.database.grains_dict().get(time_grain_sqla, '{col}') + dttm_expr = udf.function.format(col=dttm_expr) timestamp = literal_column(dttm_expr).label('timestamp') - else: - timestamp = literal_column(granularity).label('timestamp') + metrics_exprs = [ literal_column(m.expression).label(m.metric_name) for m in self.metrics if m.metric_name in metrics] @@ -455,16 +486,17 @@ def query( if not columns: qry = qry.group_by(*groupby_exprs) - tf = '%Y-%m-%d %H:%M:%S.%f' - time_filter = [ - timestamp >= from_dttm.strftime(tf), - timestamp <= to_dttm.strftime(tf), - ] - inner_time_filter = copy(time_filter) - if inner_from_dttm: - inner_time_filter[0] = timestamp >= inner_from_dttm.strftime(tf) - if inner_to_dttm: - inner_time_filter[1] = timestamp <= inner_to_dttm.strftime(tf) + if granularity: + tf = '%Y-%m-%d %H:%M:%S.%f' + time_filter = [ + timestamp >= from_dttm.strftime(tf), + timestamp <= to_dttm.strftime(tf), + ] + inner_time_filter = copy(time_filter) + if inner_from_dttm: + inner_time_filter[0] = timestamp >= inner_from_dttm.strftime(tf) + if inner_to_dttm: + inner_time_filter[1] = timestamp <= inner_to_dttm.strftime(tf) where_clause_and = [] having_clause_and = [] for col, op, eq in filter: @@ -483,7 +515,8 @@ def query( where_clause_and += [text(extras['where'])] if extras and 'having' in extras: having_clause_and += [text(extras['having'])] - qry = qry.where(and_(*(time_filter + where_clause_and))) + if granularity: + qry = qry.where(and_(*(time_filter + where_clause_and))) qry = qry.having(and_(*having_clause_and)) if groupby: qry = qry.order_by(desc(main_metric_expr)) @@ -813,8 +846,7 @@ def query( self, groupby, metrics, granularity, from_dttm, to_dttm, - limit_spec=None, - filter=None, + filter=None, # noqa is_timeseries=True, timeseries_limit=None, row_limit=None, @@ -888,7 +920,9 @@ def query( pre_qry['limit_spec'] = { "type": "default", "limit": timeseries_limit, - 'intervals': inner_from_dttm.isoformat() + '/' + inner_to_dttm.isoformat(), + 'intervals': ( + inner_from_dttm.isoformat() + '/' + + inner_to_dttm.isoformat()), "columns": [{ "dimension": metrics[0] if metrics else self.metrics[0], "direction": "descending", @@ -902,7 +936,7 @@ def query( if df is not None and not df.empty: dims = qry['dimensions'] filters = [] - for index, row in df.iterrows(): + for _, row in df.iterrows(): fields = [] for dim in dims: f = Filter.build_filter(Dimension(dim) == row[dim]) @@ -970,6 +1004,29 @@ class Log(Model): user = relationship('User', backref='logs', foreign_keys=[user_id]) dttm = Column(DateTime, default=func.now()) + @classmethod + def log_this(cls, f): + """Decorator to log user actions""" + @functools.wraps(f) + def wrapper(*args, **kwargs): + user_id = None + if g.user: + user_id = g.user.id + d = request.args.to_dict() + d.update(kwargs) + log = cls( + action=f.__name__, + json=json.dumps(d), + dashboard_id=d.get('dashboard_id') or None, + slice_id=d.get('slice_id') or None, + user_id=user_id) + db.session.add(log) + db.session.commit() + return f(*args, **kwargs) + return wrapper + + + class DruidMetric(Model): __tablename__ = 'metrics' diff --git a/panoramix/static/favicon.png b/panoramix/static/favicon.png new file mode 100644 index 0000000000000..50c8c9a458303 Binary files /dev/null and b/panoramix/static/favicon.png differ diff --git a/panoramix/static/img/chaudron.png b/panoramix/static/img/chaudron.png deleted file mode 100644 index c1dd4ed7495fd..0000000000000 Binary files a/panoramix/static/img/chaudron.png and /dev/null differ diff --git a/panoramix/static/img/chaudron_white.png b/panoramix/static/img/chaudron_white.png deleted file mode 100644 index 8e634c8b2814a..0000000000000 Binary files a/panoramix/static/img/chaudron_white.png and /dev/null differ diff --git a/panoramix/static/img/panoramix.jpg b/panoramix/static/img/panoramix.jpg deleted file mode 100644 index b6eb231caed83..0000000000000 Binary files a/panoramix/static/img/panoramix.jpg and /dev/null differ diff --git a/panoramix/static/img/panoramix.png b/panoramix/static/img/panoramix.png deleted file mode 100644 index 3ce04c348b74c..0000000000000 Binary files a/panoramix/static/img/panoramix.png and /dev/null differ diff --git a/panoramix/static/img/tux_panoramix.png b/panoramix/static/img/tux_panoramix.png deleted file mode 100644 index 3e2d5f10256bc..0000000000000 Binary files a/panoramix/static/img/tux_panoramix.png and /dev/null differ diff --git a/panoramix/templates/panoramix/base.html b/panoramix/templates/panoramix/base.html index 03ab1a42e3552..b075d52be60db 100644 --- a/panoramix/templates/panoramix/base.html +++ b/panoramix/templates/panoramix/base.html @@ -3,6 +3,7 @@ {% block head_css %} {{super()}} + {% endblock %} {% block head_js %} diff --git a/panoramix/templates/panoramix/basic.html b/panoramix/templates/panoramix/basic.html index dcd4ca8329aa2..8d0cba178ab4c 100644 --- a/panoramix/templates/panoramix/basic.html +++ b/panoramix/templates/panoramix/basic.html @@ -8,6 +8,7 @@ {% block head_meta %}{% endblock %} {% block head_css %} + {% endblock %} {% block head_js %} diff --git a/panoramix/templates/panoramix/featured.html b/panoramix/templates/panoramix/featured.html index 0b33f6ed9fbf5..36e37a9e7b699 100644 --- a/panoramix/templates/panoramix/featured.html +++ b/panoramix/templates/panoramix/featured.html @@ -1,4 +1,10 @@ {% extends "panoramix/basic.html" %} + +{% block head_js %} + {{ super() }} + +{% endblock %} + {% block body %}
@@ -34,7 +40,3 @@

{{ dataset.table_name }}

{% endblock %} -{% block tail_js %} - {{ super() }} - -{% endblock %} diff --git a/panoramix/utils.py b/panoramix/utils.py index dee3704411a13..d244092643c5e 100644 --- a/panoramix/utils.py +++ b/panoramix/utils.py @@ -1,16 +1,14 @@ from datetime import datetime -import functools import hashlib +import functools import json import logging from dateutil.parser import parse from sqlalchemy.types import TypeDecorator, TEXT -from flask import g, request, Markup from markdown import markdown as md import parsedatetime - -from panoramix import db +from flask_appbuilder.security.sqla import models as ab_models class memoized(object): @@ -64,12 +62,14 @@ def parse_human_datetime(s): True >>> date.today() - timedelta(1) == parse_human_datetime('yesterday').date() True - >>> parse_human_datetime('one year ago').date() == (datetime.now() - relativedelta(years=1) ).date() + >>> year_ago_1 = parse_human_datetime('one year ago').date() + >>> year_ago_2 = (datetime.now() - relativedelta(years=1) ).date() + >>> year_ago_1 == year_ago_2 True """ try: dttm = parse(s) - except: + except Exception: try: cal = parsedatetime.Calendar() dttm = dttm_from_timtuple(cal.parse(s)[0]) @@ -154,14 +154,13 @@ def get(self, s): return self.BNB_COLORS[i % len(self.BNB_COLORS)] -def init(): +def init(panoramix): """ Inits the Panoramix application with security roles and such """ - from panoramix import appbuilder - from panoramix import models - from flask_appbuilder.security.sqla import models as ab_models - sm = appbuilder.sm + db = panoramix.db + models = panoramix.models + sm = panoramix.appbuilder.sm alpha = sm.add_role("Alpha") admin = sm.add_role("Admin") @@ -178,7 +177,6 @@ def init(): sm.add_permission_role(admin, perm) gamma = sm.add_role("Gamma") for perm in perms: - s = perm.permission.name if( perm.view_menu.name not in ( 'ResetPasswordView', @@ -205,30 +203,6 @@ def init(): merge_perm(sm, 'datasource_access', table_perm) -def log_this(f): - ''' - Decorator to log user actions - ''' - @functools.wraps(f) - def wrapper(*args, **kwargs): - user_id = None - if g.user: - user_id = g.user.id - from panoramix import models - d = request.args.to_dict() - d.update(kwargs) - log = models.Log( - action=f.__name__, - json=json.dumps(d), - dashboard_id=d.get('dashboard_id') or None, - slice_id=d.get('slice_id') or None, - user_id=user_id) - db.session.add(log) - db.session.commit() - return f(*args, **kwargs) - return wrapper - - def datetime_f(dttm): """ Formats datetime to take less room is recent diff --git a/panoramix/views.py b/panoramix/views.py index 6951b65dc812e..9518bb8c8a748 100644 --- a/panoramix/views.py +++ b/panoramix/views.py @@ -20,14 +20,15 @@ from panoramix import appbuilder, db, models, viz, utils, app, sm, ascii_art config = app.config +log_this = models.Log.log_this -def validate_json(form, field): +def validate_json(form, field): # noqa try: json.loads(field.data) except Exception as e: logging.exception(e) - raise ValidationError("Json isn't valid") + raise ValidationError("json isn't valid") class DeleteMixin(object): @@ -161,7 +162,9 @@ class TableModelView(PanoramixModelView, DeleteMixin): base_order = ('changed_on','desc') description_columns = { 'offset': "Timezone offset (in hours) for this datasource", - 'description': Markup("Supports markdown"), + 'description': Markup( + "Supports " + "markdown"), } def post_add(self, table): @@ -303,7 +306,9 @@ class DruidDatasourceModelView(PanoramixModelView, DeleteMixin): base_order = ('datasource_name', 'asc') description_columns = { 'offset': "Timezone offset (in hours) for this datasource", - 'description': Markup("Supports markdown"), + 'description': Markup( + "Supports markdown"), } def post_add(self, datasource): @@ -332,7 +337,7 @@ def ping(): class R(BaseView): - @utils.log_this + @log_this @expose("/") def index(self, url_id): url = db.session.query(models.Url).filter_by(id=url_id).first() @@ -343,7 +348,7 @@ def index(self, url_id): flash("URL to nowhere...", "danger") return redirect('/') - @utils.log_this + @log_this @expose("/shortner/", methods=['POST', 'GET']) def shortner(self): url = request.form.get('data') @@ -361,7 +366,7 @@ class Panoramix(BaseView): @has_access @expose("/explore///") @expose("/datasource///") # Legacy url - @utils.log_this + @log_this def explore(self, datasource_type, datasource_id): if datasource_type == "table": datasource = ( @@ -561,8 +566,8 @@ def dashboard(self, dashboard_id): dash = qry.first() # Hack to log the dashboard_id properly, even when getting a slug - @utils.log_this - def dashboard(**kwargs): + @log_this + def dashboard(**kwargs): # noqa pass dashboard(dashboard_id=dash.id) @@ -578,7 +583,7 @@ def dashboard(**kwargs): @has_access @expose("/sql//") - @utils.log_this + @log_this def sql(self, database_id): mydb = db.session.query( models.Database).filter_by(id=database_id).first() @@ -594,7 +599,7 @@ def sql(self, database_id): @has_access @expose("/table///") - @utils.log_this + @log_this def table(self, database_id, table_name): mydb = db.session.query( models.Database).filter_by(id=database_id).first() @@ -612,7 +617,7 @@ def table(self, database_id, table_name): @has_access @expose("/select_star///") - @utils.log_this + @log_this def select_star(self, database_id, table_name): mydb = db.session.query( models.Database).filter_by(id=database_id).first() @@ -627,7 +632,7 @@ def select_star(self, database_id, table_name): @has_access @expose("/runsql/", methods=['POST', 'GET']) - @utils.log_this + @log_this def runsql(self): session = db.session() limit = 1000 diff --git a/panoramix/viz.py b/panoramix/viz.py index 3c7fa77096392..1973aefca7e2c 100644 --- a/panoramix/viz.py +++ b/panoramix/viz.py @@ -26,8 +26,6 @@ class BaseViz(object): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'metrics', 'groupby', ) },) @@ -81,23 +79,16 @@ def get_form_override(self, fieldname, attr): s = Markup(s) return s - def fieldsetizer(self): - """ - Makes form_fields support either a list approach or a fieldsets - approach - """ - return self.fieldsets - @classmethod def flat_form_fields(cls): l = set() for d in cls.fieldsets: for obj in d['fields']: - if isinstance(obj, (tuple, list)): - l |= {a for a in obj} + if obj and isinstance(obj, (tuple, list)): + l |= {a for a in obj if a} elif obj: l.add(obj) - return l + return tuple(l) def reassignments(self): pass @@ -111,7 +102,7 @@ def get_url(self, **kwargs): d.update(kwargs) # Remove unchecked checkboxes because HTML is weird like that for key in d.keys(): - if d[key] == False: + if d[key] is False: del d[key] href = Href( '/panoramix/explore/{self.datasource.type}/' @@ -174,7 +165,8 @@ def query_obj(self): form_data = self.form_data groupby = form_data.get("groupby") or [] metrics = form_data.get("metrics") or ['count'] - granularity = form_data.get("granularity") + granularity = \ + form_data.get("granularity") or form_data.get("granularity_sqla") limit = int(form_data.get("limit", 0)) row_limit = int( form_data.get("row_limit", config.get("ROW_LIMIT"))) @@ -193,6 +185,7 @@ def query_obj(self): extras = { 'where': form_data.get("where", ''), 'having': form_data.get("having", ''), + 'time_grain_sqla': form_data.get("time_grain_sqla", ''), } d = { 'granularity': granularity, @@ -259,10 +252,8 @@ class TableViz(BaseViz): verbose_name = "Table View" fieldsets = ( { - 'label': None, + 'label': "Chart Options", 'fields': ( - 'granularity', - ('since', 'until'), 'row_limit', ('include_search', None), ) @@ -322,8 +313,6 @@ class PivotTableViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'groupby', 'columns', 'metrics', @@ -409,8 +398,6 @@ class WordCloudViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'series', 'metric', 'limit', ('size_from', 'size_to'), 'rotation', @@ -447,8 +434,6 @@ class BubbleViz(NVD3Viz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'series', 'entity', 'x', 'y', 'size', 'limit', @@ -515,8 +500,6 @@ class BigNumberViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'metric', 'compare_lag', 'compare_suffix', @@ -567,7 +550,6 @@ class NVD3TimeSeriesViz(NVD3Viz): { 'label': None, 'fields': ( - 'granularity', ('since', 'until'), 'metrics', 'groupby', 'limit', ), @@ -743,8 +725,6 @@ class DistributionPieViz(NVD3Viz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'metrics', 'groupby', 'limit', ('donut', 'show_legend'), @@ -777,12 +757,6 @@ class DistributionBarViz(DistributionPieViz): is_timeseries = False fieldsets = ( { - 'label': None, - 'fields': ( - 'granularity', - ('since', 'until'), - ) - }, { 'label': 'Chart Options', 'fields': ( 'groupby', @@ -803,7 +777,7 @@ class DistributionBarViz(DistributionPieViz): } def query_obj(self): - d = super(DistributionPieViz, self).query_obj() + d = super(DistributionPieViz, self).query_obj() # noqa fd = self.form_data d['is_timeseries'] = False gb = fd.get('groupby') or [] @@ -818,7 +792,7 @@ def query_obj(self): return d def get_df(self, query_obj=None): - df = super(DistributionPieViz, self).get_df(query_obj) + df = super(DistributionPieViz, self).get_df(query_obj) # noqa fd = self.form_data row = df.groupby(self.groupby).sum()[self.metrics[0]].copy() @@ -863,8 +837,6 @@ class SunburstViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'groupby', 'metric', 'secondary_metric', 'row_limit', @@ -925,8 +897,6 @@ class SankeyViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'groupby', 'metric', 'row_limit', @@ -962,8 +932,6 @@ class DirectedForceViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'groupby', 'metric', 'row_limit', @@ -1004,8 +972,6 @@ class WorldMapViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'entity', 'country_fieldtype', 'metric', @@ -1077,8 +1043,6 @@ class FilterBoxViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'groupby', 'metric', ) @@ -1138,8 +1102,6 @@ class ParallelCoordinatesViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'series', 'metrics', 'secondary_metric', @@ -1170,8 +1132,6 @@ class HeatmapViz(BaseViz): { 'label': None, 'fields': ( - 'granularity', - ('since', 'until'), 'all_columns_x', 'all_columns_y', 'metric', diff --git a/tests/core_tests.py b/tests/core_tests.py index 7101b5dd79b95..8fae4f55d1870 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -5,6 +5,7 @@ os.environ['PANORAMIX_CONFIG'] = 'tests.panoramix_test_config' from flask.ext.testing import LiveServerTestCase, TestCase +import panoramix from panoramix import app, db, models, utils BASE_DIR = app.config.get("BASE_DIR") cli = imp.load_source('cli', BASE_DIR + "/bin/panoramix") @@ -21,7 +22,7 @@ def setUp(self): pass def test_init(self): - utils.init() + utils.init(panoramix) def test_load_examples(self): cli.load_examples(sample=True)