Add ability to export select subscriber ids.

- Add `id=[]` query param to `/api/subscribers/export` API.
- Add UI export prompt.
- Add Cypress tests.

Closes #739
This commit is contained in:
Kailash Nadh 2022-03-19 13:44:23 +05:30
parent 8db8ecfccd
commit ef643a14a3
4 changed files with 77 additions and 19 deletions

View file

@ -116,7 +116,7 @@ func handleQuerySubscribers(c echo.Context) error {
) )
// Limit the subscribers to sepcific lists? // Limit the subscribers to sepcific lists?
listIDs, err := getQueryListIDs(c.QueryParams()) listIDs, err := getQueryInts("list_id", c.QueryParams())
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} }
@ -199,7 +199,13 @@ func handleExportSubscribers(c echo.Context) error {
) )
// Limit the subscribers to sepcific lists? // Limit the subscribers to sepcific lists?
listIDs, err := getQueryListIDs(c.QueryParams()) listIDs, err := getQueryInts("list_id", c.QueryParams())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Export only specific subscriber IDs?
subIDs, err := getQueryInts("id", c.QueryParams())
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} }
@ -222,7 +228,7 @@ func handleExportSubscribers(c echo.Context) error {
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.Query(stmt, nil, 0, 1); err != nil { if _, err := tx.Query(stmt, nil, 0, nil, 1); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
} }
@ -253,7 +259,7 @@ func handleExportSubscribers(c echo.Context) error {
loop: loop:
for { for {
var out []models.SubscriberExport var out []models.SubscriberExport
if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil { if err := tx.Select(&out, listIDs, id, subIDs, app.constants.DBBatchSize); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
@ -858,9 +864,9 @@ func sanitizeSQLExp(q string) string {
return q return q
} }
func getQueryListIDs(qp url.Values) (pq.Int64Array, error) { func getQueryInts(param string, qp url.Values) (pq.Int64Array, error) {
out := pq.Int64Array{} out := pq.Int64Array{}
if vals, ok := qp["list_id"]; ok { if vals, ok := qp[param]; ok {
for _, v := range vals { for _, v := range vals {
if v == "" { if v == "" {
continue continue

View file

@ -28,6 +28,27 @@ describe('Subscribers', () => {
}); });
}); });
it('Exports subscribers', () => {
const cases = [
{
listIDs: [], ids: [], query: '', length: 3,
},
{
listIDs: [], ids: [], query: "name ILIKE '%anon%'", length: 2,
},
{
listIDs: [], ids: [], query: "name like 'nope'", length: 1,
},
];
// listIDs[] and ids[] are unused for now as Cypress doesn't support encoding of arrays in `qs`.
cases.forEach((c) => {
cy.request({ url: `${apiUrl}/api/subscribers/export`, qs: { query: c.query, list_id: c.listIDs, id: c.ids } }).then((resp) => {
cy.expect(resp.body.trim().split('\n')).to.have.lengthOf(c.length);
});
});
});
it('Advanced searches subscribers', () => { it('Advanced searches subscribers', () => {
cy.get('[data-cy=btn-advanced-search]').click(); cy.get('[data-cy=btn-advanced-search]').click();
@ -253,24 +274,36 @@ describe('Domain blocklist', () => {
// Add non-banned domain. // Add non-banned domain.
cy.request({ cy.request({
method: 'POST', url: `${apiUrl}/api/subscribers`, failOnStatusCode: true, method: 'POST',
body: { email: 'test1@noban.net', 'name': 'test', 'lists': [1], 'status': 'enabled' } url: `${apiUrl}/api/subscribers`,
failOnStatusCode: true,
body: {
email: 'test1@noban.net', name: 'test', lists: [1], status: 'enabled',
},
}).should((response) => { }).should((response) => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
// Add banned domain. // Add banned domain.
cy.request({ cy.request({
method: 'POST', url: `${apiUrl}/api/subscribers`, failOnStatusCode: false, method: 'POST',
body: { email: 'test1@ban.com', 'name': 'test', 'lists': [1], 'status': 'enabled' } url: `${apiUrl}/api/subscribers`,
failOnStatusCode: false,
body: {
email: 'test1@ban.com', name: 'test', lists: [1], status: 'enabled',
},
}).should((response) => { }).should((response) => {
expect(response.status).to.equal(400); expect(response.status).to.equal(400);
}); });
// Modify an existinb subscriber to a banned domain. // Modify an existinb subscriber to a banned domain.
cy.request({ cy.request({
method: 'PUT', url: `${apiUrl}/api/subscribers/1`, failOnStatusCode: false, method: 'PUT',
body: { email: 'test3@ban.org', 'name': 'test', 'lists': [1], 'status': 'enabled' } url: `${apiUrl}/api/subscribers/1`,
failOnStatusCode: false,
body: {
email: 'test3@ban.org', name: 'test', lists: [1], status: 'enabled',
},
}).should((response) => { }).should((response) => {
expect(response.status).to.equal(400); expect(response.status).to.equal(400);
}); });
@ -305,16 +338,24 @@ describe('Domain blocklist', () => {
// Add banned domain. // Add banned domain.
cy.request({ cy.request({
method: 'POST', url: `${apiUrl}/api/subscribers`, failOnStatusCode: true, method: 'POST',
body: { email: 'test4@BAN.com', 'name': 'test', 'lists': [1], 'status': 'enabled' } url: `${apiUrl}/api/subscribers`,
failOnStatusCode: true,
body: {
email: 'test4@BAN.com', name: 'test', lists: [1], status: 'enabled',
},
}).should((response) => { }).should((response) => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
// Modify an existinb subscriber to a banned domain. // Modify an existinb subscriber to a banned domain.
cy.request({ cy.request({
method: 'PUT', url: `${apiUrl}/api/subscribers/1`, failOnStatusCode: true, method: 'PUT',
body: { email: 'test4@BAN.org', 'name': 'test', 'lists': [1], 'status': 'enabled' } url: `${apiUrl}/api/subscribers/1`,
failOnStatusCode: true,
body: {
email: 'test4@BAN.org', name: 'test', lists: [1], status: 'enabled',
},
}).should((response) => { }).should((response) => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });

View file

@ -82,7 +82,8 @@
<template #top-left> <template #top-left>
<div class="actions"> <div class="actions">
<a class="a" href='' @click.prevent="exportSubscribers"> <a class="a" href='' @click.prevent="exportSubscribers"
data-cy="btn-export-subscribers">
<b-icon icon="cloud-download-outline" size="is-small" /> <b-icon icon="cloud-download-outline" size="is-small" />
{{ $t('subscribers.export') }} {{ $t('subscribers.export') }}
</a> </a>
@ -398,13 +399,22 @@ export default Vue.extend({
}, },
exportSubscribers() { exportSubscribers() {
this.$utils.confirm(this.$t('subscribers.confirmExport', { num: this.subscribers.total }), () => { const num = !this.bulk.all && this.bulk.checked.length > 0
? this.bulk.checked.length : this.subscribers.total;
this.$utils.confirm(this.$t('subscribers.confirmExport', { num }), () => {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.append('query', this.queryParams.queryExp); q.append('query', this.queryParams.queryExp);
if (this.queryParams.listID) { if (this.queryParams.listID) {
q.append('list_id', this.queryParams.listID); q.append('list_id', this.queryParams.listID);
} }
// Export selected subscribers.
if (!this.bulk.all && this.bulk.checked.length > 0) {
this.bulk.checked.map((s) => q.append('id', s.id));
}
document.location.href = `${uris.exportSubscribers}?${q.toString()}`; document.location.href = `${uris.exportSubscribers}?${q.toString()}`;
}); });
}, },

View file

@ -282,8 +282,9 @@ SELECT subscribers.id,
AND sl.subscriber_id = subscribers.id AND sl.subscriber_id = subscribers.id
) )
WHERE sl.list_id = ALL($1::INT[]) AND id > $2 WHERE sl.list_id = ALL($1::INT[]) AND id > $2
AND (CASE WHEN CARDINALITY($3::INT[]) > 0 THEN id=ANY($3) ELSE true END)
%s %s
ORDER BY subscribers.id ASC LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END); ORDER BY subscribers.id ASC LIMIT (CASE WHEN $4 = 0 THEN NULL ELSE $4 END);
-- name: query-subscribers-template -- name: query-subscribers-template
-- raw: true -- raw: true