Reports halfway through

Datatable now correctly handles the situation when user is not logged in and access protected resources
This commit is contained in:
Tomas Bures 2017-07-09 23:16:47 +02:00
parent aba42d94ac
commit 3f7b428546
28 changed files with 421 additions and 471 deletions

11
app.js
View file

@ -39,17 +39,17 @@ const blacklist = require('./routes/blacklist');
const editorapi = require('./routes/editorapi');
const grapejs = require('./routes/grapejs');
const mosaico = require('./routes/mosaico');
const reports = require('./routes/reports');
const namespaces = require('./routes/rest/namespaces');
const users = require('./routes/rest/users');
const account = require('./routes/rest/account');
const reportTemplates = require('./routes/rest/report-templates');
const reports = require('./routes/rest/reports');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration');
const accountLegacyIntegration = require('./routes/account-legacy-integration');
const reportTemplatesLegacyIntegration = require('./routes/report-templates-legacy-integration');
const reportsLegacyIntegration = require('./routes/reports-legacy-integration');
const interoperableErrors = require('./shared/interoperable-errors');
@ -246,7 +246,7 @@ app.use('/namespaces', namespacesLegacyIntegration);
app.use('/account', accountLegacyIntegration);
if (config.reports && config.reports.enabled === true) {
app.use('/report-templates', reportTemplatesLegacyIntegration);
app.use('/reports', reportsLegacyIntegration);
}
/* ------------------------------------------------------------------- */
@ -263,10 +263,7 @@ app.use('/rest', account);
if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplates);
}
if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports);
app.use('/rest', reports);
}
// catch 404 and forward to error handler

View file

@ -101,7 +101,7 @@ export default class Account extends Component {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.InvalidToken) {
if (error instanceof interoperableErrors.InvalidTokenError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your password cannot be reset.')}</strong>{' '}

View file

@ -9,6 +9,7 @@ import interoperableErrors from '../../../shared/interoperable-errors';
import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import { TreeTable, TreeSelectMode } from './tree';
import { Table, TableSelectMode } from './table';
import brace from 'brace';
import AceEditor from 'react-ace';
@ -62,6 +63,17 @@ class Form extends Component {
return;
}
if (error instanceof interoperableErrors.NotFoundError) {
owner.disableForm();
owner.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
</span>
);
return;
}
throw error;
}
}
@ -368,7 +380,46 @@ class TreeTableSelect extends Component {
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label, props.help,
<TreeTable data={this.props.data} dataUrl={this.props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
<TreeTable data={props.data} dataUrl={props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
);
}
}
class TableSelect extends Component {
static propTypes = {
dataUrl: PropTypes.string,
data: PropTypes.array,
columns: PropTypes.array,
selectionKeyIndex: PropTypes.number,
selectMode: PropTypes.number,
withHeader: PropTypes.bool,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
}
static defaultProps = {
selectMode: TableSelectMode.SINGLE
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
async onSelectionChangedAsync(sel) {
const owner = this.context.formStateOwner;
owner.updateFormValue(this.props.id, sel);
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label, props.help,
<Table data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
);
}
}
@ -727,6 +778,8 @@ export {
ButtonRow,
Button,
TreeTableSelect,
TableSelect,
TableSelectMode,
ACEEditor,
FormSendMethod
}

View file

@ -1,8 +1,8 @@
.mt-button-row > button {
.mt-button-row > * {
margin-right: 15px;
}
.mt-button-row > button:last-child {
.mt-button-row > *:last-child {
margin-right: 0px;
}

View file

@ -52,6 +52,7 @@ class Table extends Component {
columns: PropTypes.array,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
selectionKeyIndex: PropTypes.number,
onSelectionChangedAsync: PropTypes.func,
actionLinks: PropTypes.array,
withHeader: PropTypes.bool
@ -61,6 +62,10 @@ class Table extends Component {
return this.props.selection !== nextProps.selection || this.props.data != nextProps.data || this.props.dataUrl != nextProps.dataUrl;
}
static defaultProps = {
selectionKeyIndex: 0
}
componentDidMount() {
const columns = this.props.columns.slice();
@ -108,10 +113,7 @@ class Table extends Component {
dtOptions.data = this.props.data;
} else {
dtOptions.serverSide = true;
dtOptions.ajax = {
url: this.props.dataUrl,
type: 'POST'
};
dtOptions.ajax = ::this.fetchData;
}
this.table = jQuery(this.domTable).DataTable(dtOptions);
@ -122,6 +124,13 @@ class Table extends Component {
this.updateSelection();
}
@withAsyncErrorHandler
async fetchData(data, callback) {
// This custom ajax fetch function allows us to properly handle the case when the user is not authenticated.
const response = await axios.post(this.props.dataUrl, data);
callback(response.data);
}
componentDidUpdate() {
if (this.props.data) {
this.table.clear();
@ -132,23 +141,35 @@ class Table extends Component {
}
updateSelection() {
/*
const tree = this.tree;
if (this.selectMode === TableSelectMode.MULTI) {
const selectSet = new Set(this.props.selection);
tree.enableUpdate(false);
tree.visit(node => node.setSelected(selectSet.has(node.key)));
tree.enableUpdate(true);
} else if (this.selectMode === TableSelectMode.SINGLE) {
this.tree.activateKey(this.props.selection);
let selArray = [];
if (this.selectMode === TableSelectMode.SINGLE) {
selArray = [this.props.selection];
} else if (this.selectMode === TableSelectMode.MULTI) {
selArray = this.props.selection;
}
*/
const selSet = new Set(selArray);
const selectionKeyIndex = this.props.selectionKeyIndex;
this.table.rows({ selected: true }).every(function() {
const key = this.data()[selectionKeyIndex];
if (!selSet.has(key)) {
this.deselect();
}
selSet.delete(key);
});
this.table.rows((idx, data, node) => selSet.has(data[selectionKeyIndex])).select();
}
async onSelect(event, data) {
const sel = this.table.rows( { selected: true } ).data();
let sel = this.table.rows( { selected: true } ).data().toArray().map(item => item[this.props.selectionKeyIndex]);
if (this.selectMode === TableSelectMode.SINGLE) {
sel = sel.length ? sel[0] : null;
}
if (this.props.onSelectionChangedAsync) {
await this.props.onSelectionChangedAsync(sel);

View file

@ -144,11 +144,20 @@ export default class CUD extends Component {
} catch (error) {
if (error instanceof interoperableErrors.LoopDetectedError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
{t('There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')}
</span>
);
return;
}
if (error instanceof interoperableErrors.DependencyNotFoundError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that the parent namespace has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')}
</span>
);
return;

View file

@ -3,10 +3,11 @@
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../lib/form';
import { withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components';
import moment from 'moment';
@translate()
@withForm
@ -31,191 +32,18 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/report-templates/${this.state.entityId}`);
await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`);
}
componentDidMount() {
if (this.props.edit) {
this.loadFormValues();
} else {
const wizard = this.props.match.params.wizard;
if (wizard === 'subscribers-all') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report listing all subscribers along with their statistics.',
mime_type: 'text/html',
user_fields:
'[\n' +
' {\n' +
' "id": "campaign",\n' +
' "name": "Campaign",\n' +
' "type": "campaign",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\n' +
']',
js:
'campaigns.results(inputs.campaign, ["*"], "", (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'\n' +
' const data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
'<div class="table-responsive">\n' +
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1" data-paging="false">\n' +
' <thead>\n' +
' <th>\n' +
' {{#translate}}Email{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Tracker Count{{/translate}}\n' +
' </th>\n' +
' </thead>\n' +
' {{#if results}}\n' +
' <tbody>\n' +
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{email}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{tracker_count}}\n' +
' </td>\n' +
' </tr>\n' +
' {{/each}}\n' +
' </tbody>\n' +
' {{/if}}\n' +
' </table>\n' +
'</div>'
});
} else if (wizard === 'subscribers-grouped') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report with results are aggregated by some "Country" custom field.',
mime_type: 'text/html',
user_fields:
'[\n' +
' {\n' +
' "id": "campaign",\n' +
' "name": "Campaign",\n' +
' "type": "campaign",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\n' +
']',
js:
'campaigns.results(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'\n' +
' for (let row of results) {\n' +
' row["percentage"] = Math.round((row.count_opened / row.count_all) * 100);\n' +
' }\n' +
'\n' +
' let data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
'<div class="table-responsive">\n' +
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' +
' <thead>\n' +
' <th>\n' +
' {{#translate}}Country{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Opened{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}All{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Percentage{{/translate}}\n' +
' </th>\n' +
' </thead>\n' +
' {{#if results}}\n' +
' <tbody>\n' +
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{custom_country}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{count_opened}}\n' +
' </td>\n' +
' <td style="width: 20%;">\n' +
' {{count_all}}\n' +
' </td>\n' +
' <td style="width: 20%;">\n' +
' {{percentage}}%\n' +
' </td>\n' +
' </tr>\n' +
' {{/each}}\n' +
' </tbody>\n' +
' {{/if}}\n' +
' </table>\n' +
'</div>'
});
} else if (wizard === 'export-list-csv') {
this.populateFormValues({
name: '',
description: 'Exports a list as a CSV file.',
mime_type: 'text/csv',
user_fields:
'[\n' +
' {\n' +
' "id": "list",\n' +
' "name": "List",\n' +
' "type": "list",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\n' +
']',
js:
'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'\n' +
' let data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
hbs:
'{{#each results}}\n' +
'{{firstName}},{{lastName}},{{email}}\n' +
'{{/each}}'
});
} else {
this.populateFormValues({
name: '',
description: '',
mime_type: 'text/html',
user_fields: '',
js: '',
hbs: ''
});
}
this.populateFormValues({
report_template: null,
name: '',
description: ''
});
}
}
@ -229,41 +57,24 @@ export default class CUD extends Component {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['mime_type', 'value'])) {
state.setIn(['mime_type', 'error'], t('MIME Type must be selected'));
if (!state.getIn(['report_template', 'value'])) {
state.setIn(['report_template', 'error'], t('Report template must be selected'));
} else {
state.setIn(['mime_type', 'error'], null);
}
try {
const userFields = JSON.parse(state.getIn(['user_fields', 'value']));
state.setIn(['user_fields', 'error'], null);
} catch (err) {
if (err instanceof SyntaxError) {
state.setIn(['user_fields', 'error'], t('Syntax error in the user fields specification'));
}
state.setIn(['report_template', 'error'], null);
}
}
async submitAndStay() {
await Form.handleChangedError(this, async () => await this.doSubmit(true));
}
async submitAndLeave() {
await Form.handleChangedError(this, async () => await this.doSubmit(false));
}
async doSubmit(stay) {
async submitHandler() {
const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/rest/report-templates/${this.state.entityId}`
url = `/rest/reports/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/report-templates'
url = '/rest/reports'
}
this.disableForm();
@ -274,12 +85,7 @@ export default class CUD extends Component {
});
if (submitSuccessful) {
if (stay) {
this.enableForm();
this.setFormStatusMessage('success', t('Report template saved'));
} else {
this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template saved'));
}
this.navigateToWithFlashMessage('/reports', 'success', t('Report saved'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
@ -287,11 +93,11 @@ export default class CUD extends Component {
}
async showDeleteModal() {
this.navigateTo(`/report-templates/edit/${this.state.entityId}/delete`);
this.navigateTo(`/reports/edit/${this.state.entityId}/delete`);
}
async hideDeleteModal() {
this.navigateTo(`/report-templates/edit/${this.state.entityId}`);
this.navigateTo(`/reports/edit/${this.state.entityId}`);
}
async performDelete() {
@ -300,17 +106,23 @@ export default class CUD extends Component {
await this.hideDeleteModal();
this.disableForm();
this.setFormStatusMessage('info', t('Deleting report template...'));
this.setFormStatusMessage('info', t('Deleting report...'));
await axios.delete(`/rest/report-templates/${this.state.entityId}`);
await axios.delete(`/rest/reports/${this.state.entityId}`);
this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template deleted'));
this.navigateToWithFlashMessage('/reports', 'success', t('Report deleted'));
}
render() {
const t = this.props.t;
const edit = this.props.edit;
const userId = this.getFormValue('id');
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }
];
return (
<div>
@ -319,31 +131,22 @@ export default class CUD extends Component {
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
]}>
{t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})}
{t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})}
</ModalDialog>
}
<Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title>
<Title>{edit ? t('Edit Report') : t('Create Report')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<Dropdown id="mime_type" label={t('Type')} options={[{key: 'text/html', label: t('HTML')}, {key: 'text/csv', label: t('CSV')}]}/>
<ACEEditor id="user_fields" height="250px" mode="json" label={t('User selectable fields')} help={t('JSON specification of user selectable fields.')}/>
<ACEEditor id="js" height="700px" mode="javascript" label={t('Data processing code')} help={<Trans>Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
<ACEEditor id="hbs" height="700px" mode="handlebars" label={t('Rendering template')} help={<Trans>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
{edit ?
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
<Button className="btn-danger" icon="remove" label={t('Delete Template')} onClickAsync={::this.showDeleteModal}/>
</ButtonRow>
:
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
</ButtonRow>
}
<TableSelect id="report_template" label={t('Report Template')} withHeader dataUrl="/rest/report-templates-table" columns={columns} />
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete Report')} onClickAsync={::this.showDeleteModal}/>}
</ButtonRow>
</Form>
</div>
);

View file

@ -2,8 +2,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { DropdownMenu } from '../lib/bootstrap-components';
import { Title, Toolbar, DropdownLink } from '../lib/page';
import { Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import moment from 'moment';
@ -14,30 +13,27 @@ export default class List extends Component {
const actionLinks = [{
label: 'Edit',
link: data => '/report-templates/edit/' + data[0]
link: data => '/reports/edit/' + data[0]
}];
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }
{ data: 2, title: t('Template') },
{ data: 3, title: t('Description') },
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() }
];
return (
<div>
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Report Template')}>
<DropdownLink to="/report-templates/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/report-templates/create/subscribers-all">{t('All Subscribers')}</DropdownLink>
<DropdownLink to="/report-templates/create/subscribers-grouped">{t('Grouped Subscribers')}</DropdownLink>
<DropdownLink to="/report-templates/create/export-list-csv">{t('Export List as CSV')}</DropdownLink>
</DropdownMenu>
<NavButton linkTo="/reports/create" className="btn-primary" icon="plus" label={t('Create Report')}/>
<NavButton linkTo="/reports/templates" className="btn-primary" label={t('Report Templates')}/>
</Toolbar>
<Title>{t('Users')}</Title>
<Title>{t('Reports')}</Title>
<Table withHeader dataUrl="/rest/report-templates-table" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/rest/reports-table" columns={columns} actionLinks={actionLinks} />
</div>
);
}

View file

@ -6,8 +6,10 @@ import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import CUD from './CUD'
import List from './List'
import ReportsCUD from './CUD'
import ReportsList from './List'
import ReportTemplatesCUD from './templates/CUD'
import ReportTemplatesList from './templates/List'
const getStructure = t => {
const subPaths = {};
@ -17,23 +19,39 @@ const getStructure = t => {
title: t('Home'),
externalLink: '/',
children: {
'report-templates': {
title: t('Report Templates'),
link: '/report-templates',
component: List,
'reports': {
title: t('Reports'),
link: '/reports',
component: ReportsList,
children: {
edit: {
title: t('Edit Report Template'),
title: t('Edit Report'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
render: props => (<ReportsCUD edit {...props} />)
},
create: {
title: t('Create Report Template'),
params: [':wizard?'],
render: props => (<CUD {...props} />)
title: t('Create Report'),
render: props => (<ReportsCUD {...props} />)
},
'templates': {
title: t('Templates'),
link: '/reports/templates',
component: ReportTemplatesList,
children: {
edit: {
title: t('Edit Report Template'),
params: [':id', ':action?'],
render: props => (<ReportTemplatesCUD edit {...props} />)
},
create: {
title: t('Create Report Template'),
params: [':wizard?'],
render: props => (<ReportTemplatesCUD {...props} />)
}
}
}
}
}
},
}
}
}
@ -41,7 +59,7 @@ const getStructure = t => {
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/report-templates' structure={getStructure}/></I18nextProvider>,
<I18nextProvider i18n={ i18n }><Section root='/reports' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -2,11 +2,11 @@
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components';
import { withPageHelpers, Title } from '../../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../../lib/form';
import axios from '../../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { ModalDialog } from '../../lib/bootstrap-components';
@translate()
@withForm

View file

@ -2,9 +2,9 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { DropdownMenu } from '../lib/bootstrap-components';
import { Title, Toolbar, DropdownLink } from '../lib/page';
import { Table } from '../lib/table';
import { DropdownMenu } from '../../lib/bootstrap-components';
import { Title, Toolbar, DropdownLink } from '../../lib/page';
import { Table } from '../../lib/table';
import moment from 'moment';
@translate()

View file

@ -6,7 +6,7 @@ module.exports = {
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
users: ['babel-polyfill', './src/users/root.js'],
account: ['babel-polyfill', './src/account/root.js'],
reportTemplates: ['babel-polyfill', './src/report-templates/root.js']
reports: ['babel-polyfill', './src/reports/root.js']
},
output: {
library: 'MailtrainReactBody',

View file

@ -126,10 +126,9 @@ server.on('listening', () => {
triggers(() => {
spawnSenders(() => {
feedcheck(() => {
postfixBounceServer(() => {
reportProcessor.init(() => {
log.info('Service', 'All services started');
});
postfixBounceServer(async () => {
await reportProcessor.init();
log.info('Service', 'All services started');
});
});
});

View file

@ -13,7 +13,7 @@ function _getConfig(context) {
}
}
function registerRootRoute(router, title, entryPoint) {
function registerRootRoute(router, entryPoint, title) {
router.get('/*', passport.csrfProtection, (req, res) => {
res.render('react-root', {
title,

View file

@ -1,7 +1,7 @@
'use strict';
const log = require('npmlog');
const reports = require('./models/reports');
const reports = require('../models/reports');
const executor = require('./executor');
let runningWorkersCount = 0;
@ -11,12 +11,12 @@ let workers = {};
function startWorker(report) {
function onStarted(tid) {
async function onStarted(tid) {
log.info('ReportProcessor', 'Worker process for "%s" started with tid %s. Current worker count is %s.', report.name, tid, runningWorkersCount);
workers[report.id] = tid;
}
function onFinished(code, signal) {
async function onFinished(code, signal) {
runningWorkersCount--;
log.info('ReportProcessor', 'Worker process for "%s" (tid %s) exited with code %s signal %s. Current worker count is %s.', report.name, workers[report.id], code, signal, runningWorkersCount);
delete workers[report.id];
@ -24,21 +24,20 @@ function startWorker(report) {
const fields = {};
if (code === 0) {
fields.state = reports.ReportState.FINISHED;
fields.lastRun = new Date();
fields.last_run = new Date();
} else {
fields.state = reports.ReportState.FAILED;
}
reports.updateFields(report.id, fields, err => {
if (err) {
log.error('ReportProcessor', err);
}
setImmediate(startWorkers);
});
try {
await reports.update(report.id, fields);
setImmediate(tryStartWorkers);
} catch (err) {
log.error('ReportProcessor', err);
}
}
function onFailed(msg) {
async function onFailed(msg) {
runningWorkersCount--;
log.error('ReportProcessor', 'Executing worker process for "%s" (tid %s) failed with message "%s". Current worker count is %s.', report.name, workers[report.id], msg, runningWorkersCount);
delete workers[report.id];
@ -47,13 +46,12 @@ function startWorker(report) {
state: reports.ReportState.FAILED
};
reports.updateFields(report.id, fields, err => {
if (err) {
log.error('ReportProcessor', err);
}
setImmediate(startWorkers);
});
try {
await reports.update(report.id, fields);
setImmediate(tryStartWorkers);
} catch (err) {
log.error('ReportProcessor', err);
}
}
const reportData = {
@ -65,83 +63,68 @@ function startWorker(report) {
executor.start('report-processor-worker', reportData, onStarted, onFinished, onFailed);
}
function startWorkers() {
reports.listWithState(reports.ReportState.SCHEDULED, 0, maxWorkersCount - runningWorkersCount, (err, reportList) => {
if (err) {
log.error('ReportProcessor', err);
return;
}
let isStartingWorkers = false;
for (let report of reportList) {
reports.updateFields(report.id, { state: reports.ReportState.PROCESSING }, err => {
if (err) {
log.error('ReportProcessor', err);
return;
}
async function tryStartWorkers() {
if (isStartingWorkers) {
// Generally it is possible that this function is invoked simultaneously multiple times. This is to prevent it.
return;
}
isStartingWorkers = true;
try {
while (runningWorkersCount < maxWorkersCount) {
log.info('ReportProcessor', 'Trying to start worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
const reportList = await reports.listByState(reports.ReportState.SCHEDULED, 1);
if (reportList.length > 0) {
log.info('ReportProcessor', 'Starting worker');
const report = reportList[0];
await report.updateFields(report.id, {state: reports.ReportState.PROCESSING});
startWorker(report);
});
} else {
log.info('ReportProcessor', 'No more report to start a worker for');
break;
}
}
});
} catch (err) {
log.error('ReportProcessor', err);
}
isStartingWorkers = false;
}
module.exports.start = (reportId, callback) => {
module.exports.start = async reportId => {
if (!workers[reportId]) {
log.info('ReportProcessor', 'Scheduling report id: %s', reportId);
reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null}, err => {
if (err) {
return callback(err);
}
if (runningWorkersCount < maxWorkersCount) {
log.info('ReportProcessor', 'Starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
startWorkers();
} else {
log.info('ReportProcessor', 'Not starting worker because runningWorkersCount=%s maxWorkersCount=%s', runningWorkersCount, maxWorkersCount);
}
callback(null);
});
await reports.updateFields(reportId, { state: reports.ReportState.SCHEDULED, lastRun: null});
tryStartWorkers();
} else {
log.info('ReportProcessor', 'Worker for report id: %s is already running.', reportId);
}
};
module.exports.stop = (reportId, callback) => {
module.exports.stop = async reportId => {
const tid = workers[reportId];
if (tid) {
log.info('ReportProcessor', 'Killing worker for report id: %s', reportId);
executor.stop(tid);
reports.updateFields(reportId, { state: reports.ReportState.FAILED}, callback);
await reports.updateFields(reportId, { state: reports.ReportState.FAILED });
} else {
log.info('ReportProcessor', 'No running worker found for report id: %s', reportId);
}
};
module.exports.init = callback => {
reports.listWithState(reports.ReportState.PROCESSING, 0, 0, (err, reportList) => {
if (err) {
log.error('ReportProcessor', err);
}
function scheduleReport() {
if (reportList.length > 0) {
const report = reportList.shift();
reports.updateFields(report.id, { state: reports.ReportState.SCHEDULED}, err => {
if (err) {
log.error('ReportProcessor', err);
}
scheduleReport();
});
}
startWorkers();
return callback();
}
scheduleReport();
});
module.exports.init = async () => {
try {
await reports.bulkChangeState(reports.ReportState.PROCESSING, reports.ReportState.SCHEDULED);
} catch (err) {
log.error('ReportProcessor', err);
}
};

View file

@ -11,60 +11,74 @@ async function list() {
return await knex('namespaces');
}
function hash(ns) {
return hasher.hash(filterObject(ns, allowedKeys));
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(nsId) {
const ns = await knex('namespaces').where('id', nsId).first();
if (!ns) {
async function getById(id) {
const entity = await knex('namespaces').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
return ns;
return entity;
}
async function create(ns) {
const nsId = await knex('namespaces').insert(filterObject(ns, allowedKeys));
return nsId;
async function create(entity) {
await knex.transaction(async tx => {
const id = await tx('namespaces').insert(filterObject(entity, allowedKeys));
if (entity.parent) {
if (!await tx('namespaces').select(['id']).where('id', entity.parent).first()) {
throw new interoperableErrors.DependencyNotFoundError();
}
}
return id;
});
}
async function updateWithConsistencyCheck(ns) {
enforce(ns.id !== 1 || ns.parent === null, 'Cannot assign a parent to the root namespace.');
async function updateWithConsistencyCheck(entity) {
enforce(entity.id !== 1 || entity.parent === null, 'Cannot assign a parent to the root namespace.');
await knex.transaction(async tx => {
const existingNs = await tx('namespaces').where('id', ns.id).first();
if (!ns) {
const existing = await tx('namespaces').where('id', entity.id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
const existingNsHash = hash(existingNs);
if (existingNsHash != ns.originalHash) {
const existingHash = hash(existing);
if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
let iter = ns;
let iter = entity;
while (iter.parent != null) {
iter = await tx('namespaces').where('id', iter.parent).first();
if (iter.id == ns.id) {
if (!iter) {
throw new interoperableErrors.DependencyNotFoundError();
}
if (iter.id == entity.id) {
throw new interoperableErrors.LoopDetectedError();
}
}
await tx('namespaces').where('id', ns.id).update(filterObject(ns, allowedKeys));
await tx('namespaces').where('id', entity.id).update(filterObject(entity, allowedKeys));
});
}
async function remove(nsId) {
enforce(nsId !== 1, 'Cannot delete the root namespace.');
async function remove(id) {
enforce(id !== 1, 'Cannot delete the root namespace.');
await knex.transaction(async tx => {
const childNs = await tx('namespaces').where('parent', nsId).first();
const childNs = await tx('namespaces').where('parent', id).first();
if (childNs) {
throw new interoperableErrors.ChildDetectedError();
}
await tx('namespaces').where('id', nsId).del();
await tx('namespaces').where('id', id).del();
});
}

View file

@ -8,37 +8,37 @@ const interoperableErrors = require('../shared/interoperable-errors');
const allowedKeys = new Set(['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']);
function hash(ns) {
return hasher.hash(filterObject(ns, allowedKeys));
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(templateId) {
const template = await knex('report_templates').where('id', templateId).first();
if (!template) {
async function getById(id) {
const entity = await knex('report_templates').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
return template;
return entity;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('report_templates'), ['report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created']);
}
async function create(template) {
const templateId = await knex('report_templates').insert(filterObject(template, allowedKeys));
return templateId;
async function create(entity) {
const id = await knex('report_templates').insert(filterObject(entity, allowedKeys));
return id;
}
async function updateWithConsistencyCheck(template) {
await knex.transaction(async tx => {
const existingTemplate = await tx('report_templates').where('id', template.id).first();
const existing = await tx('report_templates').where('id', template.id).first();
if (!template) {
throw new interoperableErrors.NotFoundError();
}
const existingNsHash = hash(existingTemplate);
if (existingNsHash != template.originalHash) {
const existingHash = hash(existing);
if (existingHash != template.originalHash) {
throw new interoperableErrors.ChangedError();
}
@ -46,8 +46,8 @@ async function updateWithConsistencyCheck(template) {
});
}
async function remove(templateId) {
await knex('report_templates').where('id', templateId).del();
async function remove(id) {
await knex('report_templates').where('id', id).del();
}
module.exports = {

View file

@ -6,55 +6,93 @@ const { enforce, filterObject } = require('../lib/helpers');
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const allowedKeys = new Set(['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']);
const allowedKeys = new Set(['name', 'description', 'report_template', 'params']);
function hash(ns) {
return hasher.hash(filterObject(ns, allowedKeys));
const ReportState = {
SCHEDULED: 0,
PROCESSING: 1,
FINISHED: 2,
FAILED: 3,
MAX: 4
};
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(templateId) {
const template = await knex('report_templates').where('id', templateId).first();
if (!template) {
async function getById(id) {
const entity = await knex('reports').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
return template;
return entity;
}
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('report_templates'), ['report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created']);
return await dtHelpers.ajaxList(params, tx => tx('reports').innerJoin('report_templates', 'reports.report_template', 'report_templates.id'), ['reports.id', 'reports.name', 'report_templates.name', 'reports.description', 'reports.last_run', 'reports.state']);
}
async function create(template) {
const templateId = await knex('report_templates').insert(filterObject(template, allowedKeys));
return templateId;
}
async function updateWithConsistencyCheck(template) {
async function create(entity) {
await knex.transaction(async tx => {
const existingTemplate = await tx('report_templates').where('id', template.id).first();
if (!template) {
throw new interoperableErrors.NotFoundError();
const id = await tx('reports').insert(filterObject(entity, allowedKeys));
if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) {
throw new interoperableErrors.DependencyNotFoundError();
}
const existingNsHash = hash(existingTemplate);
if (existingNsHash != template.originalHash) {
throw new interoperableErrors.ChangedError();
}
await tx('report_templates').where('id', template.id).update(filterObject(template, allowedKeys));
return id;
});
}
async function remove(templateId) {
await knex('report_templates').where('id', templateId).del();
async function updateWithConsistencyCheck(entity) {
await knex.transaction(async tx => {
const existing = await tx('reports').where('id', entity.id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
const existingHash = hash(existing);
if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) {
throw new interoperableErrors.DependencyNotFoundError();
}
await tx('reports').where('id', entity.id).update(filterObject(entity, allowedKeys));
});
}
async function remove(id) {
await knex('reports').where('id', id).del();
}
async function updateFields(id, fields) {
return await knex('reports').where('id', id).update(fields);
}
async function listByState(state, limit) {
return await knex('reports').where('state', state).limit(limit);
}
async function bulkChangeState(oldState, newState) {
return await knex('reports').where('state', oldState).update('state', newState);
}
module.exports = {
ReportState,
hash,
getById,
listDTAjax,
create,
updateWithConsistencyCheck,
remove
remove,
updateFields,
listByState,
bulkChangeState
};

View file

@ -30,8 +30,8 @@ const hashKeys = new Set(['username', 'name', 'email']);
const passport = require('../lib/passport');
function hash(user) {
return hasher.hash(filterObject(user, hashKeys));
function hash(entity) {
return hasher.hash(filterObject(entity, hashKeys));
}
async function _getBy(key, value, extraColumns) {
@ -50,8 +50,8 @@ async function _getBy(key, value, extraColumns) {
return user;
}
async function getById(userId) {
return await _getBy('id', userId);
async function getById(id) {
return await _getBy('id', id);
}
async function serverValidate(data, isOwnAccount) {
@ -309,7 +309,7 @@ async function resetPassword(username, resetToken, password) {
reset_expire: null
});
} else {
throw new interoperableErrors.InvalidToken();
throw new interoperableErrors.InvalidTokenError();
}
});
}

View file

@ -10,6 +10,6 @@ router.get('/logout', (req, res) => {
res.redirect('/');
});
clientHelpers.registerRootRoute(router, _('Account'), 'account');
clientHelpers.registerRootRoute(router, 'account', _('Account'));
module.exports = router;

View file

@ -5,6 +5,6 @@ const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, _('Namespaces'), 'namespaces');
clientHelpers.registerRootRoute(router, 'namespaces', _('Namespaces'));
module.exports = router;

View file

@ -5,7 +5,7 @@ const passport = require('../lib/passport');
const router = new express.Router();
const _ = require('../lib/translate')._;
const reportTemplates = require('../lib/models/report-templates');
const reports = require('../lib/models/reports');
const reports = require('../lib/models/reports-REMOVE');
const reportProcessor = require('../lib/report-processor');
const campaigns = require('../lib/models/campaigns');
const lists = require('../lib/models/lists');

View file

@ -5,6 +5,6 @@ const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, _('Report Templates'), 'reportTemplates');
clientHelpers.registerRootRoute(router, 'reports', _('Reports'));
module.exports = router;

View file

@ -27,7 +27,7 @@ router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passpo
});
router.deleteAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.remove(req.context, req.params.reportTemplateId);
await reportTemplates.remove(req.params.reportTemplateId);
return res.json();
});

View file

@ -2,38 +2,50 @@
const passport = require('../../lib/passport');
const _ = require('../../lib/translate')._;
const reportTemplates = require('../../models/report-templates');
const reports = require('../../models/reports');
const reportProcessor = require('../../lib/report-processor');
const router = require('../../lib/router-async').create();
router.getAsync('/report-templates/:reportTemplateId', passport.loggedIn, async (req, res) => {
const reportTemplate = await reportTemplates.getById(req.params.reportTemplateId);
reportTemplate.hash = reportTemplates.hash(reportTemplate);
return res.json(reportTemplate);
router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
const report = await reports.getById(req.params.reportId);
report.hash = reports.hash(report);
return res.json(report);
});
router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.create(req.body);
router.postAsync('/reports', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reports.create(req.body);
return res.json();
});
router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const reportTemplate = req.body;
reportTemplate.id = parseInt(req.params.reportTemplateId);
router.putAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
const report = req.body;
report.id = parseInt(req.params.reportId);
await reportTemplates.updateWithConsistencyCheck(reportTemplate);
await reports.updateWithConsistencyCheck(report);
return res.json();
});
router.deleteAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportTemplates.remove(req.context, req.params.reportTemplateId);
router.deleteAsync('/reports/:reportId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reports.remove(req.params.reportId);
return res.json();
});
router.postAsync('/report-templates-table', passport.loggedIn, async (req, res) => {
return res.json(await reportTemplates.listDTAjax(req.body));
router.postAsync('/reports-table', passport.loggedIn, async (req, res) => {
return res.json(await reports.listDTAjax(req.body));
});
router.postAsync('/report-start/:id', passport.loggedIn, passport.csrfProtection, async (req, res) => {
await reportProcessor.start(req.params.id);
// TODO
});
router.postAsync('/report-stop/:id', async (req, res) => {
await reportProcessor.stop(req.params.id);
// TODO
});
module.exports = router;

View file

@ -5,6 +5,6 @@ const clientHelpers = require('../lib/client-helpers');
const router = require('../lib/router-async').create();
clientHelpers.registerRootRoute(router, _('Users'), 'users');
clientHelpers.registerRootRoute(router, 'users', _('Users'));
module.exports = router;

View file

@ -56,9 +56,15 @@ class IncorrectPasswordError extends InteroperableError {
}
}
class InvalidToken extends InteroperableError {
class InvalidTokenError extends InteroperableError {
constructor(msg, data) {
super('InvalidToken', msg, data);
super('InvalidTokenError', msg, data);
}
}
class DependencyNotFoundError extends InteroperableError {
constructor(msg, data) {
super('DependencyNotFound', msg, data);
}
}
@ -72,7 +78,8 @@ const errorTypes = {
DuplicitNameError,
DuplicitEmailError,
IncorrectPasswordError,
InvalidToken
InvalidTokenError,
DependencyNotFoundError
};
function deserialize(errorObj) {

View file

@ -1,6 +1,6 @@
'use strict';
const reports = require('../../lib/models/reports');
const reports = require('../../lib/models/reports-REMOVE');
const reportTemplates = require('../../lib/models/report-templates');
const lists = require('../../lib/models/lists');
const subscriptions = require('../../lib/models/subscriptions');