Skip to content
Snippets Groups Projects
Commit 937eb5e3 authored by Tom Robinson's avatar Tom Robinson
Browse files

Merge branch 'master' of github.com:metabase/metabase into global-and-field-formatting-settings

parents e68e52ec d9d94ddb
No related branches found
No related tags found
No related merge requests found
Showing
with 37684 additions and 394 deletions
...@@ -116,8 +116,10 @@ You can change the application database to use Postgres using a few simple envir ...@@ -116,8 +116,10 @@ You can change the application database to use Postgres using a few simple envir
export MB_DB_HOST=localhost export MB_DB_HOST=localhost
java -jar metabase.jar java -jar metabase.jar
This will tell Metabase to look for its application database using the supplied Postgres connection information. This will tell Metabase to look for its application database using the supplied Postgres connection information. Metabase also supports providing a full JDBC connection URI if you have additional parameters:
export MB_DB_CONNECTION_URI="postgres://localhost:5432/metabase?user=<username>&password=<password>"
java -jar metabase.jar
#### [MySQL](http://www.mysql.com/) #### [MySQL](http://www.mysql.com/)
If you prefer to use MySQL we've got you covered. You can change the application database to use MySQL using these environment variables. For example: If you prefer to use MySQL we've got you covered. You can change the application database to use MySQL using these environment variables. For example:
...@@ -130,7 +132,10 @@ If you prefer to use MySQL we've got you covered. You can change the applicatio ...@@ -130,7 +132,10 @@ If you prefer to use MySQL we've got you covered. You can change the applicatio
export MB_DB_HOST=localhost export MB_DB_HOST=localhost
java -jar metabase.jar java -jar metabase.jar
This will tell Metabase to look for its application database using the supplied MySQL connection information. This will tell Metabase to look for its application database using the supplied MySQL connection information. Metabase also supports providing a full JDBC connection URI if you have additional parameters:
export MB_DB_CONNECTION_URI="mysql://localhost:3306/metabase?user=<username>&password=<password>"
java -jar metabase.jar
# Migrating from using the H2 database to MySQL or Postgres # Migrating from using the H2 database to MySQL or Postgres
......
...@@ -21,25 +21,9 @@ global.makeCellBackgroundGetter = function( ...@@ -21,25 +21,9 @@ global.makeCellBackgroundGetter = function(
const cols = JSON.parse(colsJSON); const cols = JSON.parse(colsJSON);
const settings = JSON.parse(settingsJSON); const settings = JSON.parse(settingsJSON);
try { try {
const getter = makeCellBackgroundGetter(rows, cols, settings); return makeCellBackgroundGetter(rows, cols, settings);
return (value, rowIndex, colName) => {
const color = getter(value, rowIndex, colName);
if (color) {
return roundColor(color);
}
return null;
};
} catch (e) { } catch (e) {
print("ERROR", e); print("ERROR", e);
return () => null; return () => null;
} }
}; };
// HACK: d3 may return rgb values with decimals but the rendering engine used for pulses doesn't support that
function roundColor(color) {
return color.replace(
/rgba\((\d+(?:\.\d+)),\s*(\d+(?:\.\d+)),\s*(\d+(?:\.\d+)),\s*(\d+\.\d+)\)/,
(_, r, g, b, a) =>
`rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${a})`,
);
}
...@@ -13,7 +13,7 @@ const NumericInput = ({ value, onChange, ...props }: Props) => ( ...@@ -13,7 +13,7 @@ const NumericInput = ({ value, onChange, ...props }: Props) => (
<InputBlurChange <InputBlurChange
value={value == null ? "" : String(value)} value={value == null ? "" : String(value)}
onBlurChange={({ target: { value } }) => { onBlurChange={({ target: { value } }) => {
value = value ? parseInt(value, 10) : null; value = value ? parseFloat(value) : null;
if (!isNaN(value)) { if (!isNaN(value)) {
onChange(value); onChange(value);
} }
......
import React, { Component } from "react"; import React, { Component } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { CSSTransitionGroup } from "react-transition-group";
import OnClickOutsideWrapper from "./OnClickOutsideWrapper"; import OnClickOutsideWrapper from "./OnClickOutsideWrapper";
import Tether from "tether"; import Tether from "tether";
...@@ -12,9 +11,6 @@ import cx from "classnames"; ...@@ -12,9 +11,6 @@ import cx from "classnames";
import "./Popover.css"; import "./Popover.css";
const POPOVER_TRANSITION_ENTER = 100;
const POPOVER_TRANSITION_LEAVE = 100;
// space we should leave berween page edge and popover edge // space we should leave berween page edge and popover edge
const PAGE_PADDING = 10; const PAGE_PADDING = 10;
// Popover padding and border // Popover padding and border
...@@ -103,13 +99,11 @@ export default class Popover extends Component { ...@@ -103,13 +99,11 @@ export default class Popover extends Component {
} }
if (this._popoverElement) { if (this._popoverElement) {
this._renderPopover(false); this._renderPopover(false);
setTimeout(() => { ReactDOM.unmountComponentAtNode(this._popoverElement);
ReactDOM.unmountComponentAtNode(this._popoverElement); if (this._popoverElement.parentNode) {
if (this._popoverElement.parentNode) { this._popoverElement.parentNode.removeChild(this._popoverElement);
this._popoverElement.parentNode.removeChild(this._popoverElement); }
} delete this._popoverElement;
delete this._popoverElement;
}, POPOVER_TRANSITION_LEAVE);
clearInterval(this._timer); clearInterval(this._timer);
delete this._timer; delete this._timer;
} }
...@@ -278,17 +272,7 @@ export default class Popover extends Component { ...@@ -278,17 +272,7 @@ export default class Popover extends Component {
const popoverElement = this._getPopoverElement(); const popoverElement = this._getPopoverElement();
ReactDOM.unstable_renderSubtreeIntoContainer( ReactDOM.unstable_renderSubtreeIntoContainer(
this, this,
<CSSTransitionGroup <span>{isOpen ? this._popoverComponent() : null}</span>,
transitionName="Popover"
transitionAppear
transitionEnter
transitionLeave
transitionAppearTimeout={POPOVER_TRANSITION_ENTER}
transitionEnterTimeout={POPOVER_TRANSITION_ENTER}
transitionLeaveTimeout={POPOVER_TRANSITION_LEAVE}
>
{isOpen ? this._popoverComponent() : null}
</CSSTransitionGroup>,
popoverElement, popoverElement,
); );
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
--color-accent2: #a989c5; --color-accent2: #a989c5;
--color-accent3: #ef8c8c; --color-accent3: #ef8c8c;
--color-accent4: #f9d45c; --color-accent4: #f9d45c;
--color-accent5: #f1b556; --color-accent5: #f2a86f;
--color-accent6: #a6e7f3; --color-accent6: #a6e7f3;
--color-accent7: #7172ad; --color-accent7: #7172ad;
--color-white: #ffffff; --color-white: #ffffff;
......
...@@ -19,7 +19,7 @@ const colors = { ...@@ -19,7 +19,7 @@ const colors = {
accent2: "#A989C5", accent2: "#A989C5",
accent3: "#EF8C8C", accent3: "#EF8C8C",
accent4: "#F9D45C", accent4: "#F9D45C",
accent5: "#F1B556", accent5: "#F2A86F",
accent6: "#A6E7F3", accent6: "#A6E7F3",
accent7: "#7172AD", accent7: "#7172AD",
white: "#FFFFFF", white: "#FFFFFF",
...@@ -132,6 +132,16 @@ export const getColorScale = ( ...@@ -132,6 +132,16 @@ export const getColorScale = (
.range(colors); .range(colors);
}; };
// HACK: d3 may return rgb values with decimals but certain rendering engines
// don't support that (e.x. Safari and CSSBox)
export function roundColor(color: ColorString): ColorString {
return color.replace(
/rgba\((\d+(?:\.\d+)),\s*(\d+(?:\.\d+)),\s*(\d+(?:\.\d+)),\s*(\d+\.\d+)\)/,
(_, r, g, b, a) =>
`rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${a})`,
);
}
export const alpha = (color: ColorString, alpha: number): ColorString => export const alpha = (color: ColorString, alpha: number): ColorString =>
Color(color) Color(color)
.alpha(alpha) .alpha(alpha)
......
...@@ -9,24 +9,42 @@ import Radio from "metabase/components/Radio"; ...@@ -9,24 +9,42 @@ import Radio from "metabase/components/Radio";
import Toggle from "metabase/components/Toggle"; import Toggle from "metabase/components/Toggle";
import ColorPicker from "metabase/components/ColorPicker"; import ColorPicker from "metabase/components/ColorPicker";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
import NumericInput from "metabase/components/NumericInput";
import { SortableContainer, SortableElement } from "react-sortable-hoc"; import { SortableContainer, SortableElement } from "react-sortable-hoc";
import MetabaseAnalytics from "metabase/lib/analytics"; import MetabaseAnalytics from "metabase/lib/analytics";
import { formatNumber, capitalize } from "metabase/lib/formatting"; import { isNumeric, isString } from "metabase/lib/schema_metadata";
import { isNumeric } from "metabase/lib/schema_metadata";
import _ from "underscore"; import _ from "underscore";
import d3 from "d3"; import d3 from "d3";
import cx from "classnames"; import cx from "classnames";
const OPERATOR_NAMES = { const NUMBER_OPERATOR_NAMES = {
"<": t`less than`, "<": t`is less than`,
">": t`greater than`, ">": t`is greater than`,
"<=": t`less than or equal to`, "<=": t`is less than or equal to`,
">=": t`greater than or equal to`, ">=": t`is greater than or equal to`,
"=": t`equal to`, "=": t`is equal to`,
"!=": t`not equal to`, "!=": t`is not equal to`,
"is-null": t`is null`,
"not-null": t`is not null`,
};
const STRING_OPERATOR_NAMES = {
"=": t`is equal to`,
"!=": t`is not equal to`,
"is-null": t`is null`,
"not-null": t`is not null`,
contains: t`contains`,
"does-not-contain": t`does not contain`,
"starts-with": t`starts with`,
"ends-with": t`ends with`,
};
const ALL_OPERATOR_NAMES = {
...NUMBER_OPERATOR_NAMES,
...STRING_OPERATOR_NAMES,
}; };
import colors, { desaturated, getColorScale } from "metabase/lib/colors"; import colors, { desaturated, getColorScale } from "metabase/lib/colors";
...@@ -46,8 +64,8 @@ const DEFAULTS_BY_TYPE = { ...@@ -46,8 +64,8 @@ const DEFAULTS_BY_TYPE = {
single: { single: {
columns: [], columns: [],
type: "single", type: "single",
operator: ">", operator: "=",
value: 0, value: "",
color: COLORS[0], color: COLORS[0],
highlight_row: false, highlight_row: false,
}, },
...@@ -63,7 +81,9 @@ const DEFAULTS_BY_TYPE = { ...@@ -63,7 +81,9 @@ const DEFAULTS_BY_TYPE = {
}; };
// predicate for columns that can be formatted // predicate for columns that can be formatted
export const isFormattable = isNumeric; export const isFormattable = field => isNumeric(field) || isString(field);
const INPUT_CLASSNAME = "AdminSelect input mt1 full";
export default class ChartSettingsTableFormatting extends React.Component { export default class ChartSettingsTableFormatting extends React.Component {
state = { state = {
...@@ -276,140 +296,183 @@ const RuleDescription = ({ rule }) => ( ...@@ -276,140 +296,183 @@ const RuleDescription = ({ rule }) => (
{rule.type === "range" {rule.type === "range"
? t`Cells in this column will be tinted based on their values.` ? t`Cells in this column will be tinted based on their values.`
: rule.type === "single" : rule.type === "single"
? jt`When a cell in these columns is ${( ? jt`When a cell in these columns ${(
<span className="text-bold"> <span className="text-bold">
{OPERATOR_NAMES[rule.operator]} {formatNumber(rule.value)} {ALL_OPERATOR_NAMES[rule.operator]} {rule.value}
</span> </span>
)} it will be tinted this color.` )} it will be tinted this color.`
: null} : null}
</span> </span>
); );
const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => ( const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => {
<div> const selectedColumns = rule.columns.map(name => _.findWhere(cols, { name }));
<h3 className="mb1">{t`Which columns should be affected?`}</h3> const isStringRule =
<Select selectedColumns.length > 0 && _.all(selectedColumns, isString);
value={rule.columns} const isNumericRule =
onChange={e => onChange({ ...rule, columns: e.target.value })} selectedColumns.length > 0 && _.all(selectedColumns, isNumeric);
isInitiallyOpen={rule.columns.length === 0}
placeholder="Choose a column" const hasOperand =
multiple rule.operator !== "is-null" && rule.operator !== "not-null";
>
{cols.map(col => <Option value={col.name}>{col.display_name}</Option>)} return (
</Select> <div>
<h3 className="mt3 mb1">{t`Formatting style`}</h3> <h3 className="mb1">{t`Which columns should be affected?`}</h3>
<Radio <Select
value={rule.type} value={rule.columns}
options={[ onChange={e => onChange({ ...rule, columns: e.target.value })}
{ name: t`Single color`, value: "single" }, isInitiallyOpen={rule.columns.length === 0}
{ name: t`Color range`, value: "range" }, placeholder="Choose a column"
]} multiple
onChange={type => onChange({ ...DEFAULTS_BY_TYPE[type], ...rule, type })} >
vertical {cols.map(col => (
/> <Option
{rule.type === "single" ? ( value={col.name}
<div> disabled={
<h3 className="mt3 mb1">{t`When a cell in this column is…`}</h3> (isStringRule && !isString(col)) ||
<Select (isNumericRule && !isNumeric(col))
value={rule.operator} }
onChange={e => onChange({ ...rule, operator: e.target.value })} >
> {col.display_name}
{Object.entries(OPERATOR_NAMES).map(([operator, operatorName]) => ( </Option>
<Option value={operator}>{capitalize(operatorName)}</Option> ))}
))} </Select>
</Select> {isNumericRule && (
<NumericInput <div>
value={rule.value} <h3 className="mt3 mb1">{t`Formatting style`}</h3>
onChange={value => onChange({ ...rule, value })} <Radio
/> value={rule.type}
<h3 className="mt3 mb1">{t`…turn its background this color:`}</h3> options={[
<ColorPicker { name: t`Single color`, value: "single" },
value={rule.color} { name: t`Color range`, value: "range" },
colors={COLORS} ]}
onChange={color => onChange({ ...rule, color })} onChange={type =>
/> onChange({ ...DEFAULTS_BY_TYPE[type], ...rule, type })
<h3 className="mt3 mb1">{t`Highlight the whole row`}</h3> }
<Toggle vertical
value={rule.highlight_row}
onChange={highlight_row => onChange({ ...rule, highlight_row })}
/>
</div>
) : rule.type === "range" ? (
<div>
<h3 className="mt3 mb1">{t`Colors`}</h3>
<ColorRangePicker
colors={rule.colors}
onChange={colors => onChange({ ...rule, colors })}
/>
<h3 className="mt3 mb1">{t`Start the range at`}</h3>
<Radio
value={rule.min_type}
onChange={min_type => onChange({ ...rule, min_type })}
options={(rule.columns.length <= 1
? [{ name: t`Smallest value in this column`, value: null }]
: [
{ name: t`Smallest value in each column`, value: null },
{
name: t`Smallest value in all of these columns`,
value: "all",
},
]
).concat([{ name: t`Custom value`, value: "custom" }])}
vertical
/>
{rule.min_type === "custom" && (
<NumericInput
value={rule.min_value}
onChange={min_value => onChange({ ...rule, min_value })}
/> />
)} </div>
<h3 className="mt3 mb1">{t`End the range at`}</h3> )}
<Radio {rule.type === "single" ? (
value={rule.max_type} <div>
onChange={max_type => onChange({ ...rule, max_type })} <h3 className="mt3 mb1">{t`When a cell in this column…`}</h3>
options={(rule.columns.length <= 1 <Select
? [{ name: t`Largest value in this column`, value: null }] value={rule.operator}
: [ onChange={e => onChange({ ...rule, operator: e.target.value })}
{ name: t`Largest value in each column`, value: null }, >
{ {Object.entries(
name: t`Largest value in all of these columns`, isNumericRule ? NUMBER_OPERATOR_NAMES : STRING_OPERATOR_NAMES,
value: "all", ).map(([operator, operatorName]) => (
}, <Option value={operator}>{operatorName}</Option>
] ))}
).concat([{ name: t`Custom value`, value: "custom" }])} </Select>
vertical {hasOperand && isNumericRule ? (
/> <NumericInput
{rule.max_type === "custom" && ( className={INPUT_CLASSNAME}
<NumericInput type="number"
value={rule.max_value} value={rule.value}
onChange={max_value => onChange({ ...rule, max_value })} onChange={value => onChange({ ...rule, value })}
/>
) : hasOperand ? (
<input
className={INPUT_CLASSNAME}
value={rule.value}
onChange={e => onChange({ ...rule, value: e.target.value })}
/>
) : null}
<h3 className="mt3 mb1">{t`…turn its background this color:`}</h3>
<ColorPicker
value={rule.color}
colors={COLORS}
onChange={color => onChange({ ...rule, color })}
/>
<h3 className="mt3 mb1">{t`Highlight the whole row`}</h3>
<Toggle
value={rule.highlight_row}
onChange={highlight_row => onChange({ ...rule, highlight_row })}
/>
</div>
) : rule.type === "range" ? (
<div>
<h3 className="mt3 mb1">{t`Colors`}</h3>
<ColorRangePicker
colors={rule.colors}
onChange={colors => onChange({ ...rule, colors })}
/> />
<h3 className="mt3 mb1">{t`Start the range at`}</h3>
<Radio
value={rule.min_type}
onChange={min_type => onChange({ ...rule, min_type })}
options={(rule.columns.length <= 1
? [{ name: t`Smallest value in this column`, value: null }]
: [
{ name: t`Smallest value in each column`, value: null },
{
name: t`Smallest value in all of these columns`,
value: "all",
},
]
).concat([{ name: t`Custom value`, value: "custom" }])}
vertical
/>
{rule.min_type === "custom" && (
<NumericInput
className={INPUT_CLASSNAME}
type="number"
value={rule.min_value}
onChange={min_value => onChange({ ...rule, min_value })}
/>
)}
<h3 className="mt3 mb1">{t`End the range at`}</h3>
<Radio
value={rule.max_type}
onChange={max_type => onChange({ ...rule, max_type })}
options={(rule.columns.length <= 1
? [{ name: t`Largest value in this column`, value: null }]
: [
{ name: t`Largest value in each column`, value: null },
{
name: t`Largest value in all of these columns`,
value: "all",
},
]
).concat([{ name: t`Custom value`, value: "custom" }])}
vertical
/>
{rule.max_type === "custom" && (
<NumericInput
className={INPUT_CLASSNAME}
type="number"
value={rule.max_value}
onChange={max_value => onChange({ ...rule, max_value })}
/>
)}
</div>
) : null}
<div className="mt4">
{rule.columns.length === 0 ? (
<Button
primary
onClick={onRemove}
data-metabase-event={`Chart Settings;Table Formatting;`}
>
{isNew ? t`Cancel` : t`Delete`}
</Button>
) : (
<Button
primary
onClick={onDone}
data-metabase-event={`Chart Setttings;Table Formatting;${
isNew ? "Add Rule" : "Update Rule"
};Rule Type ${rule.type} Color`}
>
{isNew ? t`Add rule` : t`Update rule`}
</Button>
)} )}
</div> </div>
) : null}
<div className="mt4">
{rule.columns.length === 0 ? (
<Button
primary
onClick={onRemove}
data-metabase-event={`Chart Settings;Table Formatting;`}
>
{isNew ? t`Cancel` : t`Delete`}
</Button>
) : (
<Button
primary
onClick={onDone}
data-metabase-event={`Chart Setttings;Table Formatting;${
isNew ? "Add Rule" : "Update Rule"
};Rule Type ${rule.type} Color`}
>
{isNew ? t`Add rule` : t`Update rule`}
</Button>
)}
</div> </div>
</div> );
); };
const ColorRangePicker = ({ colors, onChange, className, style }) => ( const ColorRangePicker = ({ colors, onChange, className, style }) => (
<PopoverWithTrigger <PopoverWithTrigger
...@@ -446,12 +509,3 @@ const ColorRangePicker = ({ colors, onChange, className, style }) => ( ...@@ -446,12 +509,3 @@ const ColorRangePicker = ({ colors, onChange, className, style }) => (
)} )}
</PopoverWithTrigger> </PopoverWithTrigger>
); );
const NumericInput = ({ value, onChange }) => (
<input
className="AdminSelect input mt1 full"
type="number"
value={value}
onChange={e => onChange(e.target.value)}
/>
);
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// NOTE: this file is used on the frontend and backend and there are some // NOTE: this file is used on the frontend and backend and there are some
// limitations. See frontend/src/metabase-shared/color_selector for details // limitations. See frontend/src/metabase-shared/color_selector for details
import { alpha, getColorScale } from "metabase/lib/colors"; import { alpha, getColorScale, roundColor } from "metabase/lib/colors";
const CELL_ALPHA = 0.65; const CELL_ALPHA = 0.65;
const ROW_ALPHA = 0.2; const ROW_ALPHA = 0.2;
...@@ -21,8 +21,20 @@ type SingleFormat = { ...@@ -21,8 +21,20 @@ type SingleFormat = {
type: "single", type: "single",
columns: ColumnName[], columns: ColumnName[],
color: Color, color: Color,
operator: "<" | ">" | "<=" | ">=" | "=" | "!=", operator:
value: number, | "<"
| ">"
| "<="
| ">="
| "="
| "!="
| "is-null"
| "not-null"
| "contains"
| "does-not-contain"
| "starts-with"
| "ends-with",
value: number | string,
highlight_row: boolean, highlight_row: boolean,
}; };
...@@ -127,17 +139,49 @@ function compileFormatter( ...@@ -127,17 +139,49 @@ function compileFormatter(
} }
switch (operator) { switch (operator) {
case "<": case "<":
return v => (v < value ? color : null); return v => (typeof value === "number" && v < value ? color : null);
case "<=": case "<=":
return v => (v <= value ? color : null); return v => (typeof value === "number" && v <= value ? color : null);
case ">=": case ">=":
return v => (v >= value ? color : null); return v => (typeof value === "number" && v >= value ? color : null);
case ">": case ">":
return v => (v > value ? color : null); return v => (typeof value === "number" && v > value ? color : null);
case "=": case "=":
return v => (v === value ? color : null); return v => (v === value ? color : null);
case "!=": case "!=":
return v => (v !== value ? color : null); return v => (v !== value ? color : null);
case "is-null":
return v => (v === null ? color : null);
case "not-null":
return v => (v !== null ? color : null);
case "contains":
return v =>
typeof value === "string" &&
typeof v === "string" &&
v.indexOf(value) >= 0
? color
: null;
case "does-not-contain":
return v =>
typeof value === "string" &&
typeof v === "string" &&
v.indexOf(value) < 0
? color
: null;
case "starts-with":
return v =>
typeof value === "string" &&
typeof v === "string" &&
v.startsWith(value)
? color
: null;
case "ends-with":
return v =>
typeof value === "string" &&
typeof v === "string" &&
v.endsWith(value)
? color
: null;
} }
} else if (format.type === "range") { } else if (format.type === "range") {
const columnMin = name => const columnMin = name =>
...@@ -149,14 +193,14 @@ function compileFormatter( ...@@ -149,14 +193,14 @@ function compileFormatter(
const min = const min =
format.min_type === "custom" format.min_type === "custom"
? format.min_value ? parseFloat(format.min_value)
: format.min_type === "all" : format.min_type === "all"
? // $FlowFixMe ? // $FlowFixMe
Math.min(...format.columns.map(columnMin)) Math.min(...format.columns.map(columnMin))
: columnMin(columnName); : columnMin(columnName);
const max = const max =
format.max_type === "custom" format.max_type === "custom"
? format.max_value ? parseFloat(format.max_value)
: format.max_type === "all" : format.max_type === "all"
? // $FlowFixMe ? // $FlowFixMe
Math.max(...format.columns.map(columnMax)) Math.max(...format.columns.map(columnMax))
...@@ -167,10 +211,11 @@ function compileFormatter( ...@@ -167,10 +211,11 @@ function compileFormatter(
return () => null; return () => null;
} }
return getColorScale( const scale = getColorScale(
[min, max], [min, max],
format.colors.map(c => alpha(c, GRADIENT_ALPHA)), format.colors.map(c => alpha(c, GRADIENT_ALPHA)),
).clamp(true); ).clamp(true);
return value => roundColor(scale(value));
} else { } else {
console.warn("Unknown format type", format.type); console.warn("Unknown format type", format.type);
return () => null; return () => null;
......
This diff is collapsed.
...@@ -616,7 +616,7 @@ msgstr "Utiliser la valeur d'origine" ...@@ -616,7 +616,7 @@ msgstr "Utiliser la valeur d'origine"
#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:481 #: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:481
msgid "Use foreign key" msgid "Use foreign key"
msgstr "Utiliser une clé étrangère" msgstr "Utiliser la clé étrangère"
#: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:482 #: frontend/src/metabase/admin/datamodel/containers/FieldApp.jsx:482
msgid "Custom mapping" msgid "Custom mapping"
...@@ -2047,7 +2047,7 @@ msgstr "Se connecter" ...@@ -2047,7 +2047,7 @@ msgstr "Se connecter"
#. faute d'accord #. faute d'accord
#: frontend/src/metabase/auth/containers/LoginApp.jsx:230 #: frontend/src/metabase/auth/containers/LoginApp.jsx:230
msgid "I seem to have forgotten my password" msgid "I seem to have forgotten my password"
msgstr "Il semble que j'aie oublié mon mot de passe" msgstr "Je semble avoir oublié mon mot de passe"
#: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:102 #: frontend/src/metabase/auth/containers/PasswordResetApp.jsx:102
msgid "request a new reset email" msgid "request a new reset email"
...@@ -3910,7 +3910,7 @@ msgstr "doit avoir une taille de" ...@@ -3910,7 +3910,7 @@ msgstr "doit avoir une taille de"
#: frontend/src/metabase/lib/settings.js:105 #: frontend/src/metabase/lib/settings.js:105
#: frontend/src/metabase/lib/settings.js:106 #: frontend/src/metabase/lib/settings.js:106
msgid "characters long" msgid "characters long"
msgstr "caractères" msgstr "caractères de longueur"
#: frontend/src/metabase/lib/settings.js:106 #: frontend/src/metabase/lib/settings.js:106
msgid "Must be" msgid "Must be"
...@@ -4098,6 +4098,7 @@ msgstr "est une marque de" ...@@ -4098,6 +4098,7 @@ msgstr "est une marque de"
msgid "and is built with care in San Francisco, CA" msgid "and is built with care in San Francisco, CA"
msgstr "et est fabriqué avec soin à San Francisco, CA" msgstr "et est fabriqué avec soin à San Francisco, CA"
#. C'est le texte dans le coin haut gauche du panneau d'administration
#: frontend/src/metabase/nav/containers/Navbar.jsx:195 #: frontend/src/metabase/nav/containers/Navbar.jsx:195
msgid "Metabase Admin" msgid "Metabase Admin"
msgstr "Admin Metabase" msgstr "Admin Metabase"
...@@ -4441,9 +4442,10 @@ msgstr "Le canal {0} ne recevra plus ce pulse {1}" ...@@ -4441,9 +4442,10 @@ msgstr "Le canal {0} ne recevra plus ce pulse {1}"
msgid "Edit pulse" msgid "Edit pulse"
msgstr "Modifier le pulse" msgstr "Modifier le pulse"
#. Qu'est ce --> Qu'est-ce ?
#: frontend/src/metabase/pulse/components/PulseEdit.jsx:131 #: frontend/src/metabase/pulse/components/PulseEdit.jsx:131
msgid "What's a Pulse?" msgid "What's a Pulse?"
msgstr "Qu'est ce qu'un Pulse ?" msgstr "Qu'est-ce qu'un Pulse ?"
#: frontend/src/metabase/pulse/components/PulseEdit.jsx:141 #: frontend/src/metabase/pulse/components/PulseEdit.jsx:141
msgid "Got it" msgid "Got it"
...@@ -4935,9 +4937,10 @@ msgstr "Sélectionner une table" ...@@ -4935,9 +4937,10 @@ msgstr "Sélectionner une table"
msgid "No tables found in this database." msgid "No tables found in this database."
msgstr "Aucune table trouvé dans cette base de données" msgstr "Aucune table trouvé dans cette base de données"
#. Manque-t-il ? https://www.lalanguefrancaise.com/orthographe/y-a-t-il-orthographe/
#: frontend/src/metabase/query_builder/components/DataSelector.jsx:822 #: frontend/src/metabase/query_builder/components/DataSelector.jsx:822
msgid "Is a question missing?" msgid "Is a question missing?"
msgstr "Manque-t'il une question ?" msgstr "Manque-t-il une question ?"
#: frontend/src/metabase/query_builder/components/DataSelector.jsx:826 #: frontend/src/metabase/query_builder/components/DataSelector.jsx:826
msgid "Learn more about nested queries" msgid "Learn more about nested queries"
...@@ -5495,12 +5498,13 @@ msgstr "{0} crée une variable dans ce patron SQL nommée \"variable_name\". Les ...@@ -5495,12 +5498,13 @@ msgstr "{0} crée une variable dans ce patron SQL nommée \"variable_name\". Les
msgid "Field Filters" msgid "Field Filters"
msgstr "Filtres de champ" msgstr "Filtres de champ"
#. générateur de requête devraient être au pluriel comme dans les autres traduction --> générateur de requêtes
#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:123 #: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:123
msgid "Giving a variable the \"Field Filter\" type allows you to link SQL cards to dashboard filter widgets or use more types of filter widgets on your SQL question. A Field Filter variable inserts SQL similar to that generated by the GUI query builder when adding filters on existing columns." msgid "Giving a variable the \"Field Filter\" type allows you to link SQL cards to dashboard filter widgets or use more types of filter widgets on your SQL question. A Field Filter variable inserts SQL similar to that generated by the GUI query builder when adding filters on existing columns."
msgstr "Donner à une variable le type \"Filtre de champ\" vous permet :\n" msgstr "Donner à une variable le type \"Filtre de champ\" vous permet :\n"
"- dans un tableau de bord, de lier une carte contenant la question SQL à un filtre\n" "- dans un tableau de bord, de lier une carte contenant la question SQL à un filtre\n"
"- dans la question, d'élargir le choix des types de filtre. \n" "- dans la question, d'élargir le choix des types de filtre. \n"
"Une variable de type filtre de champ insère du SQL similaire à celui du générateur de requête lors de l'ajout d'un filtre sur une colonne existante." "Une variable de type filtre de champ insère du SQL similaire à celui du générateur de requêtes lors de l'ajout d'un filtre sur une colonne existante."
#: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:126 #: frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp.jsx:126
msgid "When adding a Field Filter variable, you'll need to map it to a specific field. You can then choose to display a filter widget on your question, but even if you don't, you can now map your Field Filter variable to a dashboard filter when adding this question to a dashboard. Field Filters should be used inside of a \"WHERE\" clause." msgid "When adding a Field Filter variable, you'll need to map it to a specific field. You can then choose to display a filter widget on your question, but even if you don't, you can now map your Field Filter variable to a dashboard filter when adding this question to a dashboard. Field Filters should be used inside of a \"WHERE\" clause."
...@@ -5654,7 +5658,7 @@ msgstr "Choses dont il faut être conscient à propos de ce/cette {0}" ...@@ -5654,7 +5658,7 @@ msgstr "Choses dont il faut être conscient à propos de ce/cette {0}"
#: frontend/src/metabase/reference/segments/SegmentDetail.jsx:229 #: frontend/src/metabase/reference/segments/SegmentDetail.jsx:229
#: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:239 #: frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx:239
msgid "Nothing to be aware of yet" msgid "Nothing to be aware of yet"
msgstr "Rien à savoir pour l'instant" msgstr "Rien de connu pour l'instant"
#: frontend/src/metabase/reference/components/GuideDetail.jsx:103 #: frontend/src/metabase/reference/components/GuideDetail.jsx:103
msgid "Explore this metric" msgid "Explore this metric"
...@@ -5685,9 +5689,10 @@ msgstr "Quoi d'utile ou d'intéressant à propos de ce/cette {0} ?" ...@@ -5685,9 +5689,10 @@ msgstr "Quoi d'utile ou d'intéressant à propos de ce/cette {0} ?"
msgid "Write something helpful here" msgid "Write something helpful here"
msgstr "Ecrivez quelque chose d'utile ici" msgstr "Ecrivez quelque chose d'utile ici"
#. Je propose "Y a-t-il" à la place de Y a t'il https://www.lalanguefrancaise.com/orthographe/y-a-t-il-orthographe/
#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:169 #: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:169
msgid "Is there anything users of this dashboard should be aware of?" msgid "Is there anything users of this dashboard should be aware of?"
msgstr "Y a t'il quelque chose dont les utilisateurs de ce tableau de bord devraient être conscients ?" msgstr "Y a-t-il quelque chose dont les utilisateurs de ce tableau de bord devraient être conscients ?"
#: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:170 #: frontend/src/metabase/reference/components/GuideDetailEditor.jsx:170
msgid "Anything users should be aware of about this {0}?" msgid "Anything users should be aware of about this {0}?"
...@@ -5932,9 +5937,10 @@ msgstr "Aider les nouveaux utilisateurs de Metabase à trouver leur chemin." ...@@ -5932,9 +5937,10 @@ msgstr "Aider les nouveaux utilisateurs de Metabase à trouver leur chemin."
msgid "The Getting Started guide highlights the dashboard, metrics, segments, and tables that matter most, and informs your users of important things they should know before digging into the data." msgid "The Getting Started guide highlights the dashboard, metrics, segments, and tables that matter most, and informs your users of important things they should know before digging into the data."
msgstr "Le guide de démarrage met en avant les tableaux de bord, métriques, segments et tables qui comptent le plus, et fournit des informations importantes aux utilisateurs avant qu'ils ne commencent à creuser les données." msgstr "Le guide de démarrage met en avant les tableaux de bord, métriques, segments et tables qui comptent le plus, et fournit des informations importantes aux utilisateurs avant qu'ils ne commencent à creuser les données."
#. Existe-t-il ? https://www.lalanguefrancaise.com/orthographe/y-a-t-il-orthographe/
#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:258 #: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:258
msgid "Is there an important dashboard for your team?" msgid "Is there an important dashboard for your team?"
msgstr "Existe t'il un tableau de bord important pour votre équipe ?" msgstr "Existe-t-il un tableau de bord important pour votre équipe ?"
#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:260 #: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:260
msgid "Create a dashboard now" msgid "Create a dashboard now"
...@@ -5988,9 +5994,10 @@ msgstr "Que devrait savoir un utilisateur de cette donnée avant de commencer à ...@@ -5988,9 +5994,10 @@ msgstr "Que devrait savoir un utilisateur de cette donnée avant de commencer à
msgid "E.g., expectations around data privacy and use, common pitfalls or misunderstandings, information about data warehouse performance, legal notices, etc." msgid "E.g., expectations around data privacy and use, common pitfalls or misunderstandings, information about data warehouse performance, legal notices, etc."
msgstr "Par exemple : Règles d'usage et de confidentialité, erreurs et malentendus fréquents, temps de réponse de l'entrepôt, mentions légales, etc." msgstr "Par exemple : Règles d'usage et de confidentialité, erreurs et malentendus fréquents, temps de réponse de l'entrepôt, mentions légales, etc."
#. Y a-t-il ? https://www.lalanguefrancaise.com/orthographe/y-a-t-il-orthographe/
#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:448 #: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:448
msgid "Is there someone your users could contact for help if they're confused about this guide?" msgid "Is there someone your users could contact for help if they're confused about this guide?"
msgstr "Y a t'il une personne que vos utilisateurs pourraient contacter s'ils ont besoin d'éclaircissements à propos de ce guide ?" msgstr "Y a-t-il une personne que vos utilisateurs pourraient contacter s'ils ont besoin d'éclaircissements à propos de ce guide ?"
#: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:457 #: frontend/src/metabase/reference/guide/GettingStartedGuideEditForm.jsx:457
msgid "Who should users contact for help if they're confused about this data?" msgid "Who should users contact for help if they're confused about this data?"
...@@ -6301,7 +6308,7 @@ msgstr "Bienvenue dans le générateur de requêtes!" ...@@ -6301,7 +6308,7 @@ msgstr "Bienvenue dans le générateur de requêtes!"
#. proposition pour fluidifier la formulation #. proposition pour fluidifier la formulation
#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:22 #: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:22
msgid "The Query Builder lets you assemble questions (or \"queries\") to ask about your data." msgid "The Query Builder lets you assemble questions (or \"queries\") to ask about your data."
msgstr "Le Générateur de requêtes vous permet d'assembler des questions (ou des «requêtes») pour interroger vos données." msgstr "Le générateur de requêtes vous permet d'assembler des questions (ou des «requêtes») pour interroger vos données."
#: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:26 #: frontend/src/metabase/tutorial/QueryBuilderTutorial.jsx:26
msgid "Tell me more" msgid "Tell me more"
...@@ -9200,7 +9207,7 @@ msgstr "Nom de domaine Windows" ...@@ -9200,7 +9207,7 @@ msgstr "Nom de domaine Windows"
#: frontend/src/metabase/visualizations/lib/settings/graph.js:428 #: frontend/src/metabase/visualizations/lib/settings/graph.js:428
#: frontend/src/metabase/visualizations/lib/settings/graph.js:437 #: frontend/src/metabase/visualizations/lib/settings/graph.js:437
msgid "Labels" msgid "Labels"
msgstr "Libellées" msgstr "Libellés"
#: frontend/src/metabase/admin/people/components/GroupDetail.jsx:329 #: frontend/src/metabase/admin/people/components/GroupDetail.jsx:329
msgid "Add members" msgid "Add members"
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -105,6 +105,7 @@ ...@@ -105,6 +105,7 @@
locale-negotiator ; Binds *locale* for i18n locale-negotiator ; Binds *locale* for i18n
wrap-cookies ; Parses cookies in the request map and assocs as :cookies wrap-cookies ; Parses cookies in the request map and assocs as :cookies
wrap-session ; reads in current HTTP session and sets :session/key wrap-session ; reads in current HTTP session and sets :session/key
mb-middleware/add-content-type ; Adds a Content-Type header for any response that doesn't already have one
wrap-gzip)) ; GZIP response if client can handle it wrap-gzip)) ; GZIP response if client can handle it
;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP ;; ▲▲▲ PRE-PROCESSING ▲▲▲ happens from BOTTOM-TO-TOP
......
...@@ -127,7 +127,7 @@ ...@@ -127,7 +127,7 @@
(result-set-read-column [x _ _] (PersistentVector/adopt x))) (result-set-read-column [x _ _] (PersistentVector/adopt x)))
(def ^:dynamic ^:private database-id->connection-pool (def ^:private database-id->connection-pool
"A map of our currently open connection pools, keyed by Database `:id`." "A map of our currently open connection pools, keyed by Database `:id`."
(atom {})) (atom {}))
...@@ -310,19 +310,25 @@ ...@@ -310,19 +310,25 @@
(with-resultset-open [rs-seq (.getSchemas metadata)] (with-resultset-open [rs-seq (.getSchemas metadata)]
(let [all-schemas (set (map :table_schem rs-seq)) (let [all-schemas (set (map :table_schem rs-seq))
schemas (set/difference all-schemas (excluded-schemas driver))] schemas (set/difference all-schemas (excluded-schemas driver))]
(set (for [schema schemas (set (for [schema schemas
table-name (mapv :table_name (get-tables metadata schema))] table (get-tables metadata schema)]
{:name table-name (let [remarks (:remarks table)]
:schema schema}))))) {:name (:table_name table)
:schema schema
:description (when-not (str/blank? remarks)
remarks)}))))))
(defn post-filtered-active-tables (defn post-filtered-active-tables
"Alternative implementation of `ISQLDriver/active-tables` best suited for DBs with little or no support for schemas. "Alternative implementation of `ISQLDriver/active-tables` best suited for DBs with little or no support for schemas.
Fetch *all* Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side." Fetch *all* Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side."
[driver, ^DatabaseMetaData metadata] [driver, ^DatabaseMetaData metadata]
(set (for [table (filter #(not (contains? (excluded-schemas driver) (:table_schem %))) (set (for [table (filter #(not (contains? (excluded-schemas driver) (:table_schem %)))
(get-tables metadata nil))] (get-tables metadata nil))]
{:name (:table_name table) (let [remarks (:remarks table)]
:schema (:table_schem table)}))) {:name (:table_name table)
:schema (:table_schem table)
:description (when-not (str/blank? remarks)
remarks)}))))
(defn- database-type->base-type (defn- database-type->base-type
"Given a `database-type` (e.g. `VARCHAR`) return the mapped Metabase type (e.g. `:type/Text`)." "Given a `database-type` (e.g. `VARCHAR`) return the mapped Metabase type (e.g. `:type/Text`)."
...@@ -342,10 +348,12 @@ ...@@ -342,10 +348,12 @@
(defn- describe-table-fields [^DatabaseMetaData metadata, driver, {schema :schema, table-name :name}] (defn- describe-table-fields [^DatabaseMetaData metadata, driver, {schema :schema, table-name :name}]
(with-resultset-open [rs-seq (.getColumns metadata nil schema table-name nil)] (with-resultset-open [rs-seq (.getColumns metadata nil schema table-name nil)]
(set (for [{database-type :type_name, column-name :column_name} rs-seq] (set (for [{database-type :type_name, column-name :column_name, remarks :remarks} rs-seq]
(merge {:name column-name (merge {:name column-name
:database-type database-type :database-type database-type
:base-type (database-type->base-type driver database-type)} :base-type (database-type->base-type driver database-type)}
(when (not (str/blank? remarks))
{:field-comment remarks})
(when-let [special-type (calculated-special-type driver column-name database-type)] (when-let [special-type (calculated-special-type driver column-name database-type)]
{:special-type special-type})))))) {:special-type special-type}))))))
......
...@@ -27,17 +27,7 @@ ...@@ -27,17 +27,7 @@
(defn- api-call? (defn- api-call?
"Is this ring request an API call (does path start with `/api`)?" "Is this ring request an API call (does path start with `/api`)?"
[{:keys [^String uri]}] [{:keys [^String uri]}]
(and (>= (count uri) 4) (str/starts-with? uri "/api"))
(= (.substring uri 0 4) "/api")))
(defn- index?
"Is this ring request one that will serve `index.html` or `init.html`?"
[{:keys [uri]}]
(or (zero? (count uri))
(not (or (re-matches #"^/app/.*$" uri)
(re-matches #"^/api/.*$" uri)
(re-matches #"^/public/.*$" uri)
(re-matches #"^/favicon.ico$" uri)))))
(defn- public? (defn- public?
"Is this ring request one that will serve `public.html`?" "Is this ring request one that will serve `public.html`?"
...@@ -49,6 +39,7 @@ ...@@ -49,6 +39,7 @@
[{:keys [uri]}] [{:keys [uri]}]
(re-matches #"^/embed/.*$" uri)) (re-matches #"^/embed/.*$" uri))
;;; ------------------------------------------- AUTH & SESSION MANAGEMENT -------------------------------------------- ;;; ------------------------------------------- AUTH & SESSION MANAGEMENT --------------------------------------------
(def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID") (def ^:private ^:const ^String metabase-session-cookie "metabase.SESSION_ID")
...@@ -225,13 +216,8 @@ ...@@ -225,13 +216,8 @@
(when-let [k (ssl-certificate-public-key)] (when-let [k (ssl-certificate-public-key)]
{"Public-Key-Pins" (format "pin-sha256=\"base64==%s\"; max-age=31536000" k)})) {"Public-Key-Pins" (format "pin-sha256=\"base64==%s\"; max-age=31536000" k)}))
(defn- api-security-headers [] ; don't need to include all the nonsense we include with index.html (defn- security-headers [& {:keys [allow-iframes?]
(merge (cache-prevention-headers) :or {allow-iframes? false}}]
strict-transport-security-header
#_(public-key-pins-header)))
(defn- html-page-security-headers [& {:keys [allow-iframes?]
:or {allow-iframes? false}}]
(merge (merge
(cache-prevention-headers) (cache-prevention-headers)
strict-transport-security-header strict-transport-security-header
...@@ -248,15 +234,28 @@ ...@@ -248,15 +234,28 @@
"X-Content-Type-Options" "nosniff"})) "X-Content-Type-Options" "nosniff"}))
(defn add-security-headers (defn add-security-headers
"Add HTTP headers to tell browsers not to cache API responses." "Add HTTP security and cache-busting headers."
[handler]
(fn [request]
(let [response (handler request)]
;; add security headers to all responses, but allow iframes on public & embed responses
(update response :headers merge (security-headers :allow-iframes? ((some-fn public? embed?) request))))))
(defn add-content-type
"Add an appropriate Content-Type header to response if it doesn't already have one. Most responses should already
have one, so this is a fallback for ones that for one reason or another do not."
[handler] [handler]
(fn [request] (fn [request]
(let [response (handler request)] (let [response (handler request)]
(update response :headers merge (cond (update-in
(api-call? request) (api-security-headers) response
(public? request) (html-page-security-headers, :allow-iframes? true) [:headers "Content-Type"]
(embed? request) (html-page-security-headers, :allow-iframes? true) (fn [content-type]
(index? request) (html-page-security-headers)))))) (or content-type
(when (api-call? request)
(if (string? (:body response))
"text/plain"
"application/json; charset=utf-8"))))))))
;;; ------------------------------------------------ SETTING SITE-URL ------------------------------------------------ ;;; ------------------------------------------------ SETTING SITE-URL ------------------------------------------------
...@@ -412,10 +411,7 @@ ...@@ -412,10 +411,7 @@
(str/split (with-out-str (jdbc/print-sql-exception-chain e)) (str/split (with-out-str (jdbc/print-sql-exception-chain e))
#"\s*\n\s*")}))))] #"\s*\n\s*")}))))]
{:status (or status-code 500) {:status (or status-code 500)
:headers (cond-> (html-page-security-headers) :headers (security-headers)
(or (string? body)
(ui18n/localized-string? body))
(assoc "Content-Type" "text/plain"))
:body body})) :body body}))
(defn catch-api-exceptions (defn catch-api-exceptions
......
...@@ -68,16 +68,20 @@ ...@@ -68,16 +68,20 @@
;; schedule the Database sync tasks ;; schedule the Database sync tasks
(schedule-tasks! database))) (schedule-tasks! database)))
(defn- db->driver [{:keys [engine] :as db}]
((resolve 'metabase.driver/engine->driver) engine))
(defn- post-select [{:keys [engine] :as database}] (defn- post-select [{:keys [engine] :as database}]
(if-not engine database (if-not engine database
(assoc database :features (set (when-let [driver ((resolve 'metabase.driver/engine->driver) engine)] (assoc database :features (set (when-let [driver (db->driver database)]
((resolve 'metabase.driver/features) driver)))))) ((resolve 'metabase.driver/features) driver))))))
(defn- pre-delete [{id :id, :as database}] (defn- pre-delete [{id :id, :as database}]
(unschedule-tasks! database) (unschedule-tasks! database)
(db/delete! 'Card :database_id id) (db/delete! 'Card :database_id id)
(db/delete! 'Permissions :object [:like (str (perms/object-path id) "%")]) (db/delete! 'Permissions :object [:like (str (perms/object-path id) "%")])
(db/delete! 'Table :db_id id)) (db/delete! 'Table :db_id id)
((resolve 'metabase.driver/notify-database-updated) (db->driver database) database))
;; TODO - this logic would make more sense in post-update if such a method existed ;; TODO - this logic would make more sense in post-update if such a method existed
(defn- pre-update [{new-metadata-schedule :metadata_sync_schedule, new-fieldvalues-schedule :cache_field_values_schedule, :as database}] (defn- pre-update [{new-metadata-schedule :metadata_sync_schedule, new-fieldvalues-schedule :cache_field_values_schedule, :as database}]
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"Settings related to checking token validity and accessing the MetaStore." "Settings related to checking token validity and accessing the MetaStore."
(:require [cheshire.core :as json] (:require [cheshire.core :as json]
[clojure.core.memoize :as memoize] [clojure.core.memoize :as memoize]
[clojure.string :as str]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[environ.core :refer [env]] [environ.core :refer [env]]
[metabase [metabase
...@@ -23,59 +24,80 @@ ...@@ -23,59 +24,80 @@
(or (or
;; only enable the changing the store url during dev because we don't want people switching it out in production! ;; only enable the changing the store url during dev because we don't want people switching it out in production!
(when config/is-dev? (when config/is-dev?
(env :metastore-dev-server-url)) (some-> (env :metastore-dev-server-url)
;; remove trailing slashes
(str/replace #"/$" "")))
"https://store.metabase.com")) "https://store.metabase.com"))
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
;;; | TOKEN VALIDATION | ;;; | TOKEN VALIDATION |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(defn- token-status-url [token] (defn- token-status-url [token]
(when (seq token) (when (seq token)
(format "%s/api/%s/status" store-url token))) (format "%s/api/%s/v2/status" store-url token)))
(def ^:private ^:const fetch-token-status-timeout-ms 10000) ; 10 seconds (def ^:private ^:const fetch-token-status-timeout-ms 10000) ; 10 seconds
(s/defn ^:private fetch-token-status :- {:valid s/Bool, :status su/NonBlankString} (def ^:private TokenStatus
"Fetch info about the validity of TOKEN from the MetaStore. " {:valid s/Bool
:status su/NonBlankString
(s/optional-key :features) [su/NonBlankString]})
(s/defn ^:private fetch-token-status* :- TokenStatus
"Fetch info about the validity of `token` from the MetaStore."
[token :- ValidToken] [token :- ValidToken]
(try ;; attempt to query the metastore API about the status of this token. If the request doesn't complete in a
;; attempt to query the metastore API about the status of this token. If the request doesn't complete in a ;; reasonable amount of time throw a timeout exception
;; reasonable amount of time throw a timeout exception
(deref (future
(try (some-> (token-status-url token)
slurp
(json/parse-string keyword))
;; slurp will throw a FileNotFoundException for 404s, so in that case just return an appropriate
;; 'Not Found' message
(catch java.io.FileNotFoundException e
{:valid false, :status (str (tru "Unable to validate token."))})
;; if there was any other error fetching the token, log it and return a generic message about the
;; token being invalid. This message will get displayed in the Settings page in the admin panel so
;; we do not want something complicated
(catch Throwable e
(log/error e (trs "Error fetching token status:"))
{:valid false, :status (str (tru "There was an error checking whether this token was valid."))})))
fetch-token-status-timeout-ms
{:valid false, :status (str (tru "Token validation timed out."))})))
(defn- check-embedding-token-is-valid* [token]
(when (s/check ValidToken token)
(throw (Exception. (str (trs "Invalid token: token isn't in the right format.")))))
(log/info (trs "Checking with the MetaStore to see whether {0} is valid..." token)) (log/info (trs "Checking with the MetaStore to see whether {0} is valid..." token))
(let [{:keys [valid status]} (fetch-token-status token)] (deref
(or valid (future
;; if token isn't valid throw an Exception with the `:status` message (println (u/format-color 'green (trs "Using this URL to check token: {0}" (token-status-url token))))
(throw (Exception. ^String status))))) (try (some-> (token-status-url token)
slurp
(json/parse-string keyword))
;; slurp will throw a FileNotFoundException for 404s, so in that case just return an appropriate
;; 'Not Found' message
(catch java.io.FileNotFoundException e
{:valid false, :status (tru "Unable to validate token: 404 not found.")})
;; if there was any other error fetching the token, log it and return a generic message about the
;; token being invalid. This message will get displayed in the Settings page in the admin panel so
;; we do not want something complicated
(catch Throwable e
(log/error e (trs "Error fetching token status:"))
{:valid false, :status (str (tru "There was an error checking whether this token was valid:")
" "
(.getMessage e))})))
fetch-token-status-timeout-ms
{:valid false, :status (tru "Token validation timed out.")}))
(def ^:private ^{:arglists '([token])} fetch-token-status
"TTL-memoized version of `fetch-token-status*`. Caches API responses for 5 minutes. This is important to avoid making
too many API calls to the Store, which will throttle us if we make too many requests; putting in a bad token could
otherwise put us in a state where `valid-token->features*` made API calls over and over, never itself getting cached
because checks failed. "
(memoize/ttl
fetch-token-status*
:ttl/threshold (* 1000 60 5)))
(s/defn ^:private valid-token->features* :- #{su/NonBlankString}
[token :- ValidToken]
(let [{:keys [valid status features]} (fetch-token-status token)]
;; if token isn't valid throw an Exception with the `:status` message
(when-not valid
(throw (Exception. ^String status)))
;; otherwise return the features this token supports
(set features)))
(def ^:private ^:const valid-token-recheck-interval-ms (def ^:private ^:const valid-token-recheck-interval-ms
"Amount of time to cache the status of a valid embedding token before forcing a re-check" "Amount of time to cache the status of a valid embedding token before forcing a re-check"
(* 1000 60 60 24)) ; once a day (* 1000 60 60 24)) ; once a day
(def ^:private ^{:arglists '([token])} check-embedding-token-is-valid (def ^:private ^{:arglists '([token])} valid-token->features
"Check whether TOKEN is valid. Throws an Exception if not." "Check whether `token` is valid. Throws an Exception if not. Returns a set of supported features if it is."
;; this is just `check-embedding-token-is-valid*` with some light caching ;; this is just `valid-token->features*` with some light caching
(memoize/ttl check-embedding-token-is-valid* (memoize/ttl valid-token->features*
:ttl/threshold valid-token-recheck-interval-ms)) :ttl/threshold valid-token-recheck-interval-ms))
...@@ -83,24 +105,55 @@ ...@@ -83,24 +105,55 @@
;;; | SETTING & RELATED FNS | ;;; | SETTING & RELATED FNS |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
;; TODO - better docstring (defsetting premium-embedding-token ; TODO - rename this to premium-features-token?
(defsetting premium-embedding-token (tru "Token for premium features. Go to the MetaStore to get yours!")
(tru "Token for premium embedding. Go to the MetaStore to get yours!") :setter
:setter (fn [new-value] (fn [new-value]
;; validate the new value if we're not unsetting it ;; validate the new value if we're not unsetting it
(try (try
(when (seq new-value) (when (seq new-value)
(check-embedding-token-is-valid new-value) (when (s/check ValidToken new-value)
(log/info (trs "Token is valid."))) (throw (ex-info (tru "Token format is invalid. Token should be 64 hexadecimal characters.")
(setting/set-string! :premium-embedding-token new-value) {:status-code 400})))
(catch Throwable e (valid-token->features new-value)
(log/error e (trs "Error setting premium embedding token")) (log/info (trs "Token is valid.")))
(throw (ex-info (.getMessage e) {:status-code 400})))))) (setting/set-string! :premium-embedding-token new-value)
(catch Throwable e
(log/error e (trs "Error setting premium features token"))
(throw (ex-info (.getMessage e) {:status-code 400}))))))
(s/defn ^:private token-features :- #{su/NonBlankString}
"Get the features associated with the system's premium features token."
[]
(try
(or (some-> (premium-embedding-token) valid-token->features)
#{})
(catch Throwable e
(log/error (trs "Error validating token:") (.getMessage e))
#{})))
(defn hide-embed-branding? (defn hide-embed-branding?
"Should we hide the 'Powered by Metabase' attribution on the embedding pages? `true` if we have a valid premium "Should we hide the 'Powered by Metabase' attribution on the embedding pages? `true` if we have a valid premium
embedding token." embedding token."
[] []
(boolean (boolean ((token-features) "embedding")))
(u/ignore-exceptions
(check-embedding-token-is-valid (premium-embedding-token))))) (defn enable-whitelabeling?
"Should we allow full whitelabel embedding (reskinning the entire interface?)"
[]
(boolean ((token-features) "whitelabel")))
(defn enable-audit-app?
"Should we allow use of the audit app?"
[]
(boolean ((token-features) "audit-app")))
(defn enable-sandboxes?
"Should we enable data sandboxes (row and column-level permissions?"
[]
(boolean ((token-features) "sandboxes")))
(defn enable-sso?
"Should we enable SAML/JWT sign-in?"
[]
(boolean ((token-features) "sso")))
(ns metabase.sync.analyze.fingerprint.insights (ns metabase.sync.analyze.fingerprint.insights
"Deeper statistical analysis of results." "Deeper statistical analysis of results."
(:require [kixi.stats.core :as stats] (:require [kixi.stats.core :as stats]
[metabase.models.field :as field]
[metabase.sync.analyze.fingerprint.fingerprinters :as f] [metabase.sync.analyze.fingerprint.fingerprinters :as f]
[redux.core :as redux])) [redux.core :as redux]))
...@@ -77,6 +78,7 @@ ...@@ -77,6 +78,7 @@
(cond (cond
(datetime-truncated-to-year? field) :datetimes (datetime-truncated-to-year? field) :datetimes
(metabase.util.date/date-extract-units unit) :numbers (metabase.util.date/date-extract-units unit) :numbers
(field/unix-timestamp? field) :datetimes
(isa? base_type :type/Number) :numbers (isa? base_type :type/Number) :numbers
(isa? base_type :type/DateTime) :datetimes (isa? base_type :type/DateTime) :datetimes
:else :others))))] :else :others))))]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment