├── tests ├── unit │ ├── __init__.py │ ├── test_emails.py │ ├── test_dbops.py │ ├── conftest.py │ └── test_forms.py └── integration │ ├── __init__.py │ ├── conftest.py │ ├── test_store_users_and_orgs.py │ └── test_analysis.py ├── app ├── static │ ├── charts │ │ └── .gitkeep │ ├── scss │ │ ├── etc │ │ │ ├── _fonts.scss │ │ │ └── _variables.scss │ │ ├── components │ │ │ ├── _tables.scss │ │ │ ├── _footer.scss │ │ │ ├── _structure.scss │ │ │ ├── _popovers.scss │ │ │ ├── _shared.scss │ │ │ ├── _nav.scss │ │ │ ├── _switches.scss │ │ │ └── _forms.scss │ │ ├── pages │ │ │ ├── _api-key-form.scss │ │ │ ├── _get-lists.scss │ │ │ ├── _basic-form.scss │ │ │ ├── _index.scss │ │ │ └── _org-form.scss │ │ ├── bootstrap │ │ │ ├── _custom-toggle.scss │ │ │ ├── _bootstrap-components.scss │ │ │ ├── _override-variables.scss │ │ │ └── _custom-modal.scss │ │ └── main.scss │ ├── img │ │ ├── logo.png │ │ ├── light-bulb.png │ │ └── newsletter-guide-logo.png │ ├── favicon │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ └── safari-pinned-tab.svg │ ├── es │ │ ├── admin.js │ │ ├── apiKeyForm.js │ │ ├── basicForm.js │ │ ├── orgForm.js │ │ ├── helpers.js │ │ ├── listsTable.js │ │ └── charts.js │ └── js │ │ └── scripts.min.js ├── templates │ ├── error-email-internal.html │ ├── confirmation.html │ ├── activated-email.html │ ├── select-list.html │ ├── contact.html │ ├── email-base.html │ ├── error-email.html │ ├── admin.html │ ├── user-form.html │ ├── about.html │ ├── privacy.html │ ├── faq.html │ ├── terms.html │ ├── org-form.html │ ├── enter-api-key.html │ └── report-email.html ├── __init__.py ├── emails.py ├── dbops.py ├── logs.py ├── models.py ├── forms.py └── visualizations.py ├── app.py ├── migrations ├── README ├── script.py.mako ├── alembic.ini ├── versions │ ├── 704e947b2c9d_add_creation_date_column_to_email_list.py │ └── e1150a91b8d1_release_version_3_0.py └── env.py ├── deploy └── restart.sh ├── appspec.yml ├── .gitignore ├── .eslintrc ├── .coveragerc ├── package.json ├── LICENSE ├── celery_app.py ├── config.py ├── requirements.txt ├── gulpfile.js ├── .circleci └── config.yml └── README.md /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/static/charts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from app import app as application -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /app/static/scss/etc/_fonts.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Montserrat:400,500,700') -------------------------------------------------------------------------------- /app/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/img/logo.png -------------------------------------------------------------------------------- /app/static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/favicon.ico -------------------------------------------------------------------------------- /app/static/img/light-bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/img/light-bulb.png -------------------------------------------------------------------------------- /app/static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /app/static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /app/static/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /app/static/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /app/static/img/newsletter-guide-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/img/newsletter-guide-logo.png -------------------------------------------------------------------------------- /app/static/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /app/static/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShorensteinCenter/Benchmarks-Program/HEAD/app/static/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /deploy/restart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sudo systemctl restart app 4 | sudo /etc/init.d/celeryd restart 1>/dev/null 5 | sudo /etc/init.d/celerybeat restart 1>/dev/null 6 | -------------------------------------------------------------------------------- /app/static/scss/components/_tables.scss: -------------------------------------------------------------------------------- 1 | thead { 2 | color: $crimson !important; 3 | 4 | th { 5 | border-top: none !important; 6 | border-bottom: 2.5px solid $light-grey !important; 7 | } 8 | } -------------------------------------------------------------------------------- /app/static/scss/pages/_api-key-form.scss: -------------------------------------------------------------------------------- 1 | #api-key-form #newsorg-input-wrapper.invalid:after { 2 | content: 'Please select a news organization.' 3 | } 4 | 5 | #api-key-input-wrapper.invalid:after { 6 | content: 'Please enter a valid API key.'; 7 | } -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 2 | os: linux 3 | files: 4 | - source: / 5 | destination: /home/ubuntu/benchmarks-project 6 | hooks: 7 | AfterInstall: 8 | - location: deploy/restart.sh 9 | timeout: 300 10 | runas: root -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.exe 3 | __pycache__/ 4 | *.pyc 5 | *.db 6 | *.log 7 | celerybeat-schedule.* 8 | .elasticbeanstalk/ 9 | *.zip 10 | .pytest_cache/ 11 | benchmarks-env/ 12 | *.dmg 13 | .coverage 14 | .DS_Store 15 | scratch/ 16 | app/static/charts/*.png 17 | -------------------------------------------------------------------------------- /app/templates/error-email-internal.html: -------------------------------------------------------------------------------- 1 | {% for k, v in error_details.items() %} 2 | {% if k == 'Stack Trace' %} 3 | {% for item in v %} 4 | {{ item }}
5 | {% endfor %} 6 | {% else %} 7 | {{ k }}: {{ v }}
8 | {% endif %} 9 | {% endfor %} -------------------------------------------------------------------------------- /app/static/scss/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: auto; 5 | margin-bottom: 18px; 6 | line-height: 1.65; 7 | 8 | a { 9 | margin-left: 0.5rem; 10 | margin-right: 0.5rem; 11 | } 12 | } -------------------------------------------------------------------------------- /app/static/scss/pages/_get-lists.scss: -------------------------------------------------------------------------------- 1 | .analyze-link-column { 2 | text-align: center; 3 | } 4 | 5 | .analyze-link { 6 | display: inline-flex; 7 | align-items: center; 8 | font-weight: 700; 9 | } 10 | 11 | .analyze-link-text { 12 | margin-right: 3px; 13 | margin-bottom: 2px; 14 | } -------------------------------------------------------------------------------- /app/templates/confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

{{ title }}

8 |

{{ body }}

9 |
10 |
11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /app/static/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #b91d47 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/static/scss/bootstrap/_custom-toggle.scss: -------------------------------------------------------------------------------- 1 | $switch-height: calc(#{$input-height} * .8); 2 | $switch-border-radius: $switch-height; 3 | $switch-bg: $medium-grey; 4 | $switch-checked-bg: $form-valid-msg-color; 5 | $switch-thumb-bg: $white; 6 | $switch-thumb-border-radius: 50%; 7 | $switch-thumb-padding: 2px; 8 | $switch-transition: .2s all; -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2018 4 | }, 5 | "env": { 6 | "es6": true, 7 | "browser": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "rules": { 11 | "no-console": ["error", { allow: ["warn", "error"] }], 12 | "no-unused-vars": ["warn"], 13 | "no-undef": ["warn"] 14 | } 15 | } -------------------------------------------------------------------------------- /app/static/scss/pages/_basic-form.scss: -------------------------------------------------------------------------------- 1 | #name-input-wrapper.invalid:after { 2 | content: 'Please enter a name.'; 3 | } 4 | 5 | #email-input-wrapper.invalid:after { 6 | content: 'Please enter a valid email address.'; 7 | } 8 | 9 | #basic-info-form #newsorg-input-wrapper.invalid:after { 10 | content: 'Please enter a news organization.'; 11 | } -------------------------------------------------------------------------------- /app/templates/activated-email.html: -------------------------------------------------------------------------------- 1 | {% extends "email-base.html" %} 2 | 3 | {% block content %} 4 |

Your unique link is {{ url_for('benchmarks', _external=True, user=email_hash) }}?utm_source=activation_email&utm_medium=email.

5 | {% endblock %} -------------------------------------------------------------------------------- /app/static/scss/pages/_index.scss: -------------------------------------------------------------------------------- 1 | #index { 2 | .flow-overview { 3 | margin-left: 1.5rem; 4 | margin-right: 1.5rem; 5 | } 6 | 7 | .index-icon { 8 | height: $index-svg-height; 9 | fill: $crimson 10 | } 11 | 12 | .col-md-1 { 13 | height: $index-svg-height; 14 | } 15 | 16 | .arrow { 17 | fill: $medium-dark-grey; 18 | } 19 | 20 | .main-svg { 21 | max-width: 100%; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/static/scss/pages/_org-form.scss: -------------------------------------------------------------------------------- 1 | #financial-classification-wrapper.invalid:after, 2 | #coverage-scope-wrapper.invalid:after, 3 | #coverage-focus-wrapper.invalid:after, 4 | #platform-wrapper.invalid:after, 5 | #employee-range-wrapper.invalid:after, 6 | #budget-wrapper.invalid:after { 7 | content: 'Please select a value.'; 8 | } 9 | 10 | #other-affiliation-name-wrapper.invalid:after { 11 | content: 'Please enter a value.'; 12 | } -------------------------------------------------------------------------------- /app/static/scss/components/_structure.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .has-bottom-margin { 11 | margin-bottom: $std-bottom-margin; 12 | } 13 | 14 | .content-block { 15 | margin-bottom: $content-block-margin-bottom; 16 | } 17 | 18 | .bordered { 19 | padding: 2.5rem; 20 | border: 0.5px solid $medium-dark-grey; 21 | border-radius: $border-radius; 22 | } -------------------------------------------------------------------------------- /app/templates/select-list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
ListSubscribersAnalysis Time
15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /app/static/scss/bootstrap/_bootstrap-components.scss: -------------------------------------------------------------------------------- 1 | @import "functions"; 2 | @import "variables"; 3 | @import "mixins"; 4 | @import "root"; 5 | @import "reboot"; 6 | @import "type"; 7 | @import "images"; 8 | @import "grid"; 9 | @import "tables"; 10 | @import "forms"; 11 | @import "nav"; 12 | @import "navbar"; 13 | @import "media"; 14 | @import "utilities"; 15 | @import "print"; 16 | @import "modal"; 17 | @import "popover"; 18 | @import "custom-forms"; 19 | @import "transitions"; -------------------------------------------------------------------------------- /app/static/scss/components/_popovers.scss: -------------------------------------------------------------------------------- 1 | .info-svg { 2 | width: $info-svg-dim; 3 | height: $info-svg-dim; 4 | cursor: pointer; 5 | fill: $dark-grey; 6 | margin-left: 3px; 7 | margin-bottom: $form-label-bottom-margin; 8 | display: inline; 9 | position: relative; 10 | 11 | &:focus { 12 | outline: none; 13 | } 14 | } 15 | 16 | .custom-control-label ~ .info-svg { 17 | margin-left: 9px; 18 | } 19 | 20 | .popover { 21 | border-color: $medium-grey; 22 | border-radius: $border-radius; 23 | } -------------------------------------------------------------------------------- /app/templates/contact.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Contact

8 |

The Shorenstein Center on Media, Politics and Public Policy
John F. Kennedy School of Government, Harvard University
124 Mt. Auburn Street, 2nd Floor, South Elevators
Cambridge, MA 02138
contact@emailbenchmarking.com

9 |
10 |
11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /app/static/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/static/favicon/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/static/favicon/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/templates/email-base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

9 | logo 10 |

11 |

{{ title }}

12 | {% block content %}{% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain about missing debug-only code: 12 | def __repr__ 13 | if self\.debug 14 | 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Don't complain if non-runnable code isn't run: 20 | if 0: 21 | if __name__ == .__main__.: 22 | 23 | ignore_errors = True 24 | 25 | [html] 26 | directory = coverage_html_report -------------------------------------------------------------------------------- /app/static/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "etc/fonts"; 2 | @import "etc/variables"; 3 | @import "bootstrap/override-variables"; 4 | 5 | @import "bootstrap/bootstrap-components"; 6 | @import "bootstrap/custom-modal"; 7 | @import "bootstrap/custom-toggle"; 8 | 9 | @import "components/nav"; 10 | @import "components/footer"; 11 | 12 | @import "components/structure"; 13 | @import "components/forms"; 14 | @import "components/switches"; 15 | @import "components/tables"; 16 | @import "components/shared"; 17 | @import "components/popovers"; 18 | 19 | @import "pages/index"; 20 | @import "pages/basic-form"; 21 | @import "pages/org-form"; 22 | @import "pages/api-key-form"; 23 | @import "pages/get-lists"; -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from celery import current_app 3 | from app import app, db 4 | 5 | @pytest.fixture 6 | def test_app(): 7 | """Sets up a test app.""" 8 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' 9 | app.config['TESTING'] = True 10 | app.config['WTF_CSRF_ENABLED'] = False 11 | app.config['NO_EMAIL'] = True 12 | app.config['SES_DEFAULT_EMAIL_SOURCE'] = 'testing@testing.com' 13 | current_app.conf.update(CELERY_ALWAYS_EAGER=True) 14 | with app.app_context(): 15 | db.create_all() 16 | yield app 17 | db.drop_all() 18 | 19 | @pytest.fixture 20 | def client(test_app): 21 | """Sets up a test client.""" 22 | client = test_app.test_client() 23 | yield client 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmarks", 3 | "version": "3.1.0", 4 | "description": "Analytics for MailChimp Lists", 5 | "main": "app.py", 6 | "scripts": { 7 | "gulp": "./node_modules/.bin/gulp", 8 | "lint": "./node_modules/.bin/gulp lint" 9 | }, 10 | "author": "William Hakim", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "bootstrap": "^4.3.0", 14 | "gulp": "^4.0.0", 15 | "gulp-autoprefixer": "^6.0.0", 16 | "gulp-cli": "^2.0.1", 17 | "gulp-concat": "^2.6.1", 18 | "gulp-eslint": "^5.0.0", 19 | "gulp-rename": "^1.4.0", 20 | "gulp-sass": "^4.0.2", 21 | "gulp-terser": "^1.1.7", 22 | "gulp-util": "^3.0.8" 23 | }, 24 | "dependencies": { 25 | "electron": "^4.0.4", 26 | "orca": "^1.2.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/templates/error-email.html: -------------------------------------------------------------------------------- 1 | {% extends "email-base.html" %} 2 | 3 | {% block content %} 4 |

5 | Error Details:
6 | {% for k, v in error_details.items() %} 7 | {{ k }}: {{ v }}
8 | {% endfor %} 9 |

10 |

Please try your request again, and consider whether your organization's ongoing API usage may be close to saturating MailChimp's connection limits. If problems persist, contact the Shorenstein Center.

11 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | 6 | 7 | {% for col in cols %} 8 | 9 | {% endfor %} 10 | 11 | 12 | 13 | {% for user in users %} 14 | 15 | {% set ns = namespace(id=1) %} 16 | {% for attr in user %} 17 | {% if attr[0] == 'id' %} 18 | {% set ns.id = attr[1] %} 19 | {% endif %} 20 | {% if attr[0] == 'approved' %} 21 | 27 | {% else %} 28 | 29 | {% endif %} 30 | {% endfor %} 31 | 32 | {% endfor %} 33 | 34 |
{{ col }}
22 | 23 | 24 | 25 | 26 | {{ attr[1] }}
35 | {% endblock %} -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /app/static/es/admin.js: -------------------------------------------------------------------------------- 1 | const toggles = document.querySelectorAll('span.switch'); 2 | 3 | const changeActivationStatus = async e => { 4 | const toggle = e.currentTarget; 5 | toggle.removeEventListener('change', changeActivationStatus); 6 | disable(toggle); 7 | const 8 | id = toggle.getAttribute('switch-id'), 9 | headers = new Headers({'X-CSRFToken': csrfToken}), 10 | payload = { 11 | method: 'GET', 12 | credentials: 'same-origin', 13 | headers: headers 14 | }, 15 | request = new Request('/activate-user?user=' + id, 16 | payload); 17 | try { 18 | const response = await fetch(request); 19 | if (!response.ok) 20 | throw new Error(response.statusText); 21 | else { 22 | enable(toggle); 23 | toggle.addEventListener('change', changeActivationStatus); 24 | } 25 | } 26 | catch(e) { 27 | console.error(e); 28 | } 29 | } 30 | 31 | if (toggles) { 32 | for (let i = 0; i < toggles.length; ++i) { 33 | const toggle = toggles[i]; 34 | toggle.addEventListener('change', changeActivationStatus); 35 | } 36 | } -------------------------------------------------------------------------------- /migrations/versions/704e947b2c9d_add_creation_date_column_to_email_list.py: -------------------------------------------------------------------------------- 1 | """add creation date column to email list 2 | 3 | Revision ID: 704e947b2c9d 4 | Revises: e1150a91b8d1 5 | Create Date: 2019-02-06 15:53:51.122427 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '704e947b2c9d' 14 | down_revision = 'e1150a91b8d1' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | with op.batch_alter_table('email_list', schema=None) as batch_op: 22 | batch_op.add_column(sa.Column('creation_timestamp', sa.DateTime(), nullable=True)) 23 | 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | with op.batch_alter_table('email_list', schema=None) as batch_op: 30 | batch_op.drop_column('creation_timestamp') 31 | 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /app/static/scss/components/_shared.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: $crimson; 3 | font-size: $h1-size; 4 | font-weight: $title-weight; 5 | padding-bottom: $h1-extra-padding; 6 | text-align: center; 7 | 8 | @include media-breakpoint-up(sm) { 9 | font-size: $h1-size-sm; 10 | padding-bottom: $h1-extra-padding-sm; 11 | } 12 | 13 | @include media-breakpoint-up(xxl) { 14 | font-size: $h1-size-xxl; 15 | padding-bottom: $h1-extra-padding-xxl; 16 | } 17 | } 18 | 19 | h2 { 20 | color: $crimson; 21 | font-size: $h2-size; 22 | font-weight: $title-weight; 23 | 24 | @include media-breakpoint-up(sm) { 25 | font-size: $h2-size-sm; 26 | } 27 | 28 | @include media-breakpoint-up(xxl) { 29 | font-size: $h2-size-xxl; 30 | } 31 | } 32 | 33 | strong { 34 | color: $crimson; 35 | font-weight: 500; 36 | } 37 | 38 | .disabled-elt { 39 | opacity: 0.5; 40 | pointer-events: none; 41 | background-image: none; 42 | } 43 | 44 | .small { 45 | font-size: $small-text-size; 46 | 47 | @include media-breakpoint-up(xxl) { 48 | font-size: $small-text-size-xxl; 49 | } 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shorenstein Center on Media, Politics and Public Policy at Harvard Kennedy School 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from flask import Flask 4 | from flask_wtf.csrf import CSRFProtect 5 | from flask_sqlalchemy import SQLAlchemy 6 | from flask_migrate import Migrate 7 | from flask_talisman import Talisman 8 | from config import Config 9 | from celery_app import make_celery 10 | 11 | app = Flask(__name__) 12 | app.config.from_object(Config) 13 | 14 | from app import logs 15 | 16 | # Set up logging 17 | logs.setup_logging() 18 | 19 | # Set up flask-talisman to prevent xss and other attacks 20 | csp = { 21 | 'default-src': '\'self\'', 22 | 'script-src': ['\'self\'', 'cdnjs.cloudflare.com', 'cdn.jsdelivr.net', 23 | 'www.googletagmanager.com', 'cdn.plot.ly'], 24 | 'style-src': ['\'self\'', 'fonts.googleapis.com', 25 | '\'unsafe-inline\'', 'cdn.jsdelivr.net'], 26 | 'font-src': ['\'self\'', 'fonts.gstatic.com'], 27 | 'img-src': ['\'self\'', 'www.google-analytics.com', 'data:']} 28 | Talisman(app, content_security_policy=csp, 29 | content_security_policy_nonce_in=['script-src']) 30 | 31 | csrf = CSRFProtect(app) 32 | db = SQLAlchemy(app) 33 | migrate = Migrate(app, db, render_as_batch=True) 34 | celery = make_celery(app) 35 | 36 | from app import routes, models 37 | -------------------------------------------------------------------------------- /app/static/scss/bootstrap/_override-variables.scss: -------------------------------------------------------------------------------- 1 | $body-color: $dark-grey; 2 | 3 | $font-family-sans-serif: "Montserrat", sans-serif; 4 | 5 | $table-cell-padding: 1.33rem; 6 | 7 | $link-color: $crimson; 8 | $link-hover-decoration: none; 9 | $link-hover-color: lighten($link-color, 25%); 10 | $navbar-light-color: $link-color; 11 | $navbar-light-hover-color: lighten($link-color, 25%); 12 | 13 | $grid-breakpoints: ( 14 | xs: 0, 15 | sm: 576px, 16 | md: 768px, 17 | lg: 992px, 18 | xl: 1200px, 19 | xxl: 1475px 20 | ); 21 | 22 | $container-max-widths: ( 23 | sm: 540px, 24 | md: 720px, 25 | lg: 960px, 26 | xl: 1140px, 27 | xxl: 1450px 28 | ); 29 | 30 | $custom-control-gutter: 1.9rem; 31 | $custom-control-indicator-bg: #fff; 32 | $custom-control-indicator-size: 1.2rem; 33 | $custom-control-indicator-checked-color: $crimson; 34 | $custom-control-indicator-checked-bg: #fff; 35 | $custom-control-indicator-active-bg: #fff; 36 | $custom-checkbox-indicator-border-radius: 1px; 37 | $custom-control-indicator-checked-border-color: $medium-dark-grey; 38 | $custom-control-indicator-active-border-color: $medium-dark-grey; 39 | $custom-control-indicator-focus-border-color: $medium-dark-grey; 40 | $custom-control-indicator-focus-box-shadow: none; 41 | 42 | $transition-fade: opacity .15s ease; 43 | -------------------------------------------------------------------------------- /celery_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import traceback 3 | from collections import OrderedDict 4 | from celery import Celery 5 | 6 | def make_celery(app): 7 | celery = Celery( 8 | app.import_name, 9 | broker=app.config['CELERY_BROKER_URI'] 10 | ) 11 | celery.conf.update(app.config) 12 | TaskBase = celery.Task 13 | 14 | class ContextTask(TaskBase): 15 | abstract = True 16 | 17 | def __call__(self, *args, **kwargs): 18 | with app.app_context(): 19 | return TaskBase.__call__(self, *args, **kwargs) 20 | 21 | def on_failure(self, exc, task_id, args, kwargs, einfo): 22 | from app.emails import send_email 23 | error_details = OrderedDict( 24 | [('Exception', exc), 25 | ('Task ID', task_id), 26 | ('Args', args), 27 | ('Kwargs', kwargs), 28 | ('Stack Trace', traceback.format_exception( 29 | None, exc, einfo.tb))]) 30 | send_email( 31 | 'Application Error (Celery Task)', 32 | [os.environ.get('ADMIN_EMAIL')], 33 | 'error-email-internal.html', 34 | {'error_details': error_details}, 35 | error=True) 36 | 37 | celery.Task = ContextTask 38 | 39 | return celery 40 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from celery.schedules import crontab 3 | 4 | class Config(): 5 | SECRET_KEY = os.environ.get('SECRET_KEY') 6 | CELERY_BROKER_URI = (os.environ.get('CELERY_BROKER_URI') or 7 | 'amqp://guest:guest@localhost:5672/') 8 | TASK_SERIALIZER = 'json' 9 | CELERYBEAT_SCHEDULE = { 10 | 'update_stored_data': { 11 | 'task': 'app.tasks.update_stored_data', 12 | 'schedule': crontab(minute='0', hour='0', day_of_month='*'), 13 | 'args': () 14 | }, 15 | 'send_monthly_reports': { 16 | 'task': 'app.tasks.send_monthly_reports', 17 | 'schedule': crontab(minute='0', hour='0', day_of_month='1'), 18 | 'args': () 19 | } 20 | } 21 | SQLALCHEMY_DATABASE_URI = ( 22 | os.environ.get('SQLALCHEMY_DATABASE_URI') or 23 | ('sqlite:///' + os.path.join( 24 | os.path.abspath(os.path.dirname(__file__)), 'app.db'))) 25 | SQLALCHEMY_TRACK_MODIFICATIONS = False 26 | SERVER_NAME = os.environ.get('SERVER_NAME') or '127.0.0.1:5000' 27 | AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') 28 | AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') 29 | SES_REGION_NAME = os.environ.get('SES_REGION_NAME') or 'us-west-2' 30 | SES_DEFAULT_EMAIL_SOURCE = os.environ.get('SES_DEFAULT_EMAIL_SOURCE') 31 | NO_EMAIL = os.environ.get('NO_EMAIL') 32 | -------------------------------------------------------------------------------- /app/static/scss/etc/_variables.scss: -------------------------------------------------------------------------------- 1 | $crimson: #a71930; 2 | $dark-grey: #555; 3 | $medium-dark-grey: #aaa; 4 | $medium-grey: #ddd; 5 | $light-grey: #ededed; 6 | 7 | $border-radius: 2px; 8 | 9 | $title-weight: 400; 10 | 11 | $h1-size: 2rem; 12 | $h1-size-sm: 2.3rem; 13 | $h1-size-xxl: 3rem; 14 | $h1-extra-padding: .33rem; 15 | $h1-extra-padding-sm: .67rem; 16 | $h1-extra-padding-xxl: 1rem; 17 | 18 | $h2-size: 1.25rem; 19 | $h2-size-sm: 1.4rem; 20 | $h2-size-xxl: 1.9rem; 21 | 22 | $std-bottom-margin: 1.7rem; 23 | $content-block-margin-bottom: 3rem; 24 | 25 | $index-svg-height: 4rem; 26 | 27 | $small-text-size: 0.8rem; 28 | $small-text-size-xxl: 0.9rem; 29 | 30 | $form-line-height: 1.5; 31 | $form-transition: 0.5s ease; 32 | $form-font-size: 1rem; 33 | $form-elt-bottom-margin: 2.25rem; 34 | $form-elt-margin: 0rem 0rem $form-elt-bottom-margin 0rem; 35 | $form-input-top-padding: .5rem; 36 | $form-input-padding: $form-input-top-padding 0.8rem; 37 | 38 | $form-font-size-desktop: 1rem; 39 | $form-input-padding-desktop: $form-input-top-padding 0.9rem; 40 | 41 | $form-font-size-xxl: 1.25rem; 42 | $form-input-padding-xxl: $form-input-top-padding 1.1rem; 43 | 44 | $form-invalid-input-color: rgba(238, 95, 91, .25); 45 | $form-invalid-msg-color: rgb(238, 95, 91); 46 | $form-valid-input-color: rgba(98, 196, 98, .25); 47 | $form-valid-msg-color: rgb(98, 196, 98); 48 | 49 | $form-label-bottom-margin: 0.35rem; 50 | $form-label-font-weight: 500; 51 | 52 | $modal-drawer-width: 16rem; 53 | $modal-drawer-header-height: 4.25rem; 54 | $modal-drawer-footer-height: 4.25rem; 55 | 56 | $info-svg-dim: 0.9rem; -------------------------------------------------------------------------------- /app/static/es/apiKeyForm.js: -------------------------------------------------------------------------------- 1 | const apiKeyForm = document.querySelector('#api-key-form'); 2 | 3 | /* Validate API Key */ 4 | const submitApiKey = async e => { 5 | e.preventDefault(); 6 | apiKeyForm.removeEventListener('submit', submitApiKey); 7 | if (!clientSideValidateForm(apiKeyForm)) { 8 | apiKeyForm.addEventListener('submit', submitApiKey); 9 | return; 10 | } 11 | const formElts = apiKeyForm.querySelectorAll('select,' + 12 | 'input:not([type="checkbox"]), .custom-control-label'); 13 | disable(formElts); 14 | const 15 | headers = new Headers({'X-CSRFToken': csrfToken}), 16 | formData = new FormData(apiKeyForm), 17 | payload = { 18 | method: 'POST', 19 | credentials: 'same-origin', 20 | headers: headers, 21 | body: formData 22 | }, 23 | request = new Request('/validate-api-key', payload); 24 | try { 25 | const response = await fetch(request); 26 | if (response.ok) 27 | window.location.href = '/select-list'; 28 | else { 29 | if (response.status == 422) { 30 | const invalidElts = apiKeyForm.querySelectorAll('.invalid'); 31 | for (let i = 0; i < invalidElts.length; ++i) 32 | invalidElts[i].classList.remove('invalid'); 33 | const errors = await response.json(); 34 | for (const [k, _] of Object.entries(errors)) 35 | tagField(apiKeyForm.querySelector('#' + k)); 36 | enable(formElts); 37 | apiKeyForm.addEventListener('submit', submitApiKey); 38 | } 39 | else 40 | throw new Error(response.statusText); 41 | } 42 | } 43 | catch(e) { 44 | console.error(e); 45 | } 46 | } 47 | 48 | if (apiKeyForm) 49 | apiKeyForm.addEventListener('submit', submitApiKey); 50 | -------------------------------------------------------------------------------- /app/static/scss/components/_nav.scss: -------------------------------------------------------------------------------- 1 | .navbar-light { 2 | background-color: white; 3 | min-height: calc(55px + 0.313rem + 0.5rem); 4 | @include media-breakpoint-up(sm) { 5 | min-height: calc(80px + 0.313rem + 0.5rem); 6 | } 7 | 8 | .navbar-toggler { 9 | cursor: pointer; 10 | border: none; 11 | 12 | &:focus { 13 | outline: none; 14 | } 15 | } 16 | 17 | .navbar-toggler-icon { 18 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgb(85,85,85)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); 19 | } 20 | } 21 | 22 | .navbar-brand { 23 | padding-top: 0.5rem; 24 | 25 | svg { 26 | height: 35px; 27 | 28 | @include media-breakpoint-up(sm) { 29 | height: 60px; 30 | } 31 | } 32 | } 33 | 34 | .link-divider { 35 | padding-top: 0.5rem; 36 | padding-bottom: 0.5rem; 37 | color: $medium-dark-grey; 38 | } 39 | 40 | .navbar-collapse { 41 | justify-content: flex-end; 42 | padding-right: 0.75rem; 43 | 44 | &.collapse { 45 | display: none; 46 | } 47 | } 48 | 49 | .close { 50 | position: absolute; 51 | top: 17.5px; 52 | left: -40px; 53 | padding: 0; 54 | background: transparent; 55 | border: 0; 56 | -webkit-appearance: none; 57 | 58 | span { 59 | font-size: 3.25rem; 60 | font-weight: 400; 61 | line-height: 1; 62 | text-shadow: none; 63 | cursor: pointer; 64 | color: white; 65 | } 66 | 67 | &:focus, span:focus { 68 | outline: none; 69 | } 70 | } 71 | 72 | #nav-drawer .modal-content > a:nth-child(2) { 73 | margin-top: 22px; 74 | } -------------------------------------------------------------------------------- /app/templates/user-form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Request access to the email benchmarking tool

8 |
9 | 10 |
11 | {{ user_form.name() }} 12 | 13 |
14 | 15 |
16 | {{ user_form.email(custom_type="email") }} 17 | 18 |
19 | 20 |
21 | {{ user_form.news_org() }} 22 | 23 |
24 |
25 | {{ user_form.submit() }} 26 |
27 |
28 |
29 |
30 |
31 |

What happens next?

32 |

It depends whether your organization is already in our system.

33 |

If it is, we will send you a unique access link for the tool within 1-2 business days. Otherwise, we will collect some additional information from you on the next page in order to verify that you represent a valid news organization.

34 |

By requesting access, you agree to our Terms of Service. 35 |

36 |
37 |
38 |
39 | {% endblock %} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiodns==1.1.1 2 | aiohttp==3.5.4 3 | alembic==1.0.4 4 | amqp==2.3.2 5 | astroid==2.1.0 6 | async-timeout==3.0.1 7 | asyncio==3.4.3 8 | asynctest==0.12.2 9 | atomicwrites==1.2.1 10 | attrs==18.2.0 11 | billiard==3.5.0.4 12 | boto3==1.9.93 13 | botocore==1.12.93 14 | cchardet==2.1.4 15 | celery==4.2.1 16 | certifi==2018.10.15 17 | chardet==3.0.4 18 | Click==7.0 19 | codecov==2.0.15 20 | coverage==4.5.2 21 | cycler==0.10.0 22 | decorator==4.3.0 23 | docutils==0.14 24 | Flask==1.0.2 25 | Flask-Migrate==2.3.0 26 | Flask-SQLAlchemy==2.3.2 27 | flask-talisman==0.6.0 28 | Flask-WTF==0.14.2 29 | gevent==1.4.0 30 | greenlet==0.4.15 31 | gunicorn==19.9.0 32 | idna==2.7 33 | idna-ssl==1.1.0 34 | ipython-genutils==0.2.0 35 | iso8601==0.1.12 36 | isort==4.3.4 37 | itsdangerous==1.1.0 38 | Jinja2==2.10 39 | jmespath==0.9.3 40 | jsonschema==2.6.0 41 | jupyter-core==4.4.0 42 | kiwisolver==1.0.1 43 | kombu==4.2.1 44 | lazy-object-proxy==1.3.1 45 | lxml==4.2.5 46 | mailchimp3==3.0.6 47 | Mako==1.0.7 48 | MarkupSafe==1.1.0 49 | matplotlib==3.0.2 50 | mccabe==0.6.1 51 | more-itertools==4.3.0 52 | multidict==4.5.1 53 | nbformat==4.4.0 54 | numpy==1.16.1 55 | packaging==18.0 56 | pandas==0.24.1 57 | pip-autoremove==0.9.1 58 | plotly==3.6.1 59 | pluggy==0.8.0 60 | psutil==5.4.8 61 | psycopg2-binary==2.7.6.1 62 | py==1.7.0 63 | pycares==2.3.0 64 | pylint==2.2.2 65 | pyparsing==2.3.0 66 | pytest==4.2.1 67 | pytest-asyncio==0.10.0 68 | pytest-cov==2.6.1 69 | pytest-mock==1.10.1 70 | pytest-sugar==0.9.2 71 | python-dateutil==2.7.5 72 | python-editor==1.0.3 73 | pytz==2018.7 74 | requests==2.21.0 75 | retrying==1.3.3 76 | s3transfer==0.2.0 77 | six==1.11.0 78 | SQLAlchemy==1.2.17 79 | termcolor==1.1.0 80 | titlecase==0.12.0 81 | traitlets==4.3.2 82 | typed-ast==1.1.1 83 | typing==3.6.6 84 | typing-extensions==3.7.2 85 | urllib3==1.24.1 86 | vine==1.1.4 87 | Werkzeug==0.14.1 88 | wrapt==1.10.11 89 | WTForms==2.2.1 90 | yarl==1.2.6 91 | -------------------------------------------------------------------------------- /app/static/scss/components/_switches.scss: -------------------------------------------------------------------------------- 1 | .switch { 2 | font-size: $font-size-base; 3 | position: relative; 4 | display: inline-block; 5 | height: $switch-height; 6 | 7 | input { 8 | position: absolute; 9 | height: 1px; 10 | width: 1px; 11 | background: none; 12 | border: 0; 13 | clip: rect(0 0 0 0); 14 | clip-path: inset(50%); 15 | overflow: hidden; 16 | padding: 0; 17 | 18 | + label { 19 | position: relative; 20 | min-width: calc(#{$switch-height} * 2); 21 | border-radius: $switch-border-radius; 22 | height: $switch-height; 23 | line-height: $switch-height; 24 | display: inline-block; 25 | cursor: pointer; 26 | outline: none; 27 | user-select: none; 28 | vertical-align: middle; 29 | text-indent: calc(calc(#{$switch-height} * 2) + .5rem); 30 | } 31 | 32 | + label::before, 33 | + label::after { 34 | content: ''; 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | width: calc(#{$switch-height} * 2); 39 | bottom: 0; 40 | display: block; 41 | } 42 | 43 | + label::before { 44 | right: 0; 45 | background-color: $switch-bg; 46 | border-radius: $switch-border-radius; 47 | transition: $switch-transition; 48 | } 49 | 50 | + label::after { 51 | top: $switch-thumb-padding; 52 | left: $switch-thumb-padding; 53 | width: calc(#{$switch-height} - calc(#{$switch-thumb-padding} * 2)); 54 | height: calc(#{$switch-height} - calc(#{$switch-thumb-padding} * 2)); 55 | border-radius: $switch-thumb-border-radius; 56 | background-color: $switch-thumb-bg; 57 | transition: $switch-transition; 58 | } 59 | 60 | &:checked + label::before { 61 | background-color: $switch-checked-bg; 62 | } 63 | 64 | &:checked + label::after { 65 | margin-left: $switch-height; 66 | } 67 | } 68 | 69 | + .switch { 70 | margin-left: 1rem; 71 | } 72 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const 2 | gulp = require('gulp'), 3 | sass = require('gulp-sass'), 4 | rename = require('gulp-rename'), 5 | terser = require('gulp-terser'), 6 | concat = require('gulp-concat'), 7 | gutil = require('gulp-util'), 8 | autoprefixer = require('gulp-autoprefixer'), 9 | eslint = require('gulp-eslint'); 10 | 11 | // static and templates folders 12 | const 13 | static = 'app/static/', 14 | templates = 'app/templates/' 15 | 16 | // boostrap scss source 17 | const bootstrap = { 18 | in: './node_modules/bootstrap/' 19 | }; 20 | 21 | // js 22 | const js = { 23 | in: [static + 'es/helpers.js', 24 | static + 'es/basicForm.js', 25 | static + 'es/orgForm.js', 26 | static + 'es/admin.js', 27 | static + 'es/apiKeyForm.js', 28 | static + 'es/listsTable.js', 29 | static + 'es/charts.js'], 30 | out: static + 'js/' 31 | }; 32 | 33 | // scss 34 | const scss = { 35 | in: static + 'scss/main.scss', 36 | out: static + 'css/', 37 | outName: 'styles.min.css', 38 | watch: static + 'scss/**/*', 39 | sassOpts: { 40 | outputStyle: 'compressed', 41 | precision: 3, 42 | includePaths: [bootstrap.in + 'scss'] 43 | } 44 | }; 45 | 46 | // compile scss 47 | gulp.task('scss', () => { 48 | return gulp.src(scss.in) 49 | .pipe(sass(scss.sassOpts)) 50 | .on('error', gutil.log) 51 | .pipe(autoprefixer()) 52 | .pipe(rename(scss.outName)) 53 | .pipe(gulp.dest(scss.out)); 54 | }); 55 | 56 | // lint es 57 | gulp.task('lint', () => { 58 | return gulp.src(js.in) 59 | .pipe(eslint()) 60 | .pipe(eslint.format()); 61 | }); 62 | 63 | // uglify es 64 | gulp.task('terser', async () => { 65 | return gulp.src(js.in) 66 | .pipe(concat('scripts.min.js')) 67 | .pipe(terser()) 68 | .on('error', gutil.log) 69 | .pipe(gulp.dest(js.out)); 70 | }); 71 | 72 | // default task 73 | gulp.task('default', 74 | gulp.series(gulp.parallel('scss', gulp.series('lint', 'terser')), 75 | () => { 76 | gulp.watch(scss.watch, gulp.series('scss')); 77 | gulp.watch(js.in, gulp.series('lint', 'terser')); 78 | } 79 | ) 80 | ); 81 | -------------------------------------------------------------------------------- /app/static/es/basicForm.js: -------------------------------------------------------------------------------- 1 | const basicInfoForm = document.querySelector('#basic-info-form'); 2 | 3 | /* Validate basic information submitted via form */ 4 | const submitBasicInfo = async e => { 5 | e.preventDefault(); 6 | basicInfoForm.removeEventListener('submit', submitBasicInfo); 7 | if (!clientSideValidateForm(basicInfoForm)) { 8 | basicInfoForm.addEventListener('submit', submitBasicInfo); 9 | return; 10 | } 11 | const formElts = basicInfoForm.querySelectorAll('input'); 12 | disable(formElts); 13 | const 14 | headers = new Headers({'X-CSRFToken': csrfToken}), 15 | formData = new FormData(basicInfoForm), 16 | payload = { 17 | method: 'POST', 18 | credentials: 'same-origin', 19 | headers: headers, 20 | body: formData 21 | }, 22 | request = new Request('/validate-basic-info', payload); 23 | try { 24 | const response = await fetch(request); 25 | if (response.ok) { 26 | const body = await response.json(); 27 | if (body.org == 'existing') { 28 | const 29 | title = 'Thanks!', 30 | pageBody = (body.user == 'approved') ? 'You\'re all set! ' + 31 | 'We\'ve emailed you a unique access link.' : 'We\'ve ' + 32 | 'received your details. Once our team has ' + 33 | 'reviewed your submission, we\'ll email you with ' + 34 | 'instructions for accessing our benchmarking tool.', 35 | url = '/confirmation?title=' + title + '&body=' + pageBody; 36 | window.location.href = url; 37 | } 38 | else 39 | window.location.href = '/org-info'; 40 | } 41 | else { 42 | if (response.status == 422) { 43 | const invalidElts = basicInfoForm.querySelectorAll('.invalid'); 44 | for (let i = 0; i < invalidElts.length; ++i) 45 | invalidElts[i].classList.remove('invalid'); 46 | const errors = await response.json(); 47 | for (const [k, _] of Object.entries(errors)) 48 | tagField(basicInfoForm.querySelector('#' + k)); 49 | enable(formElts); 50 | basicInfoForm.addEventListener('submit', submitBasicInfo); 51 | } 52 | else 53 | throw new Error(response.statusText); 54 | } 55 | } 56 | catch(e) { 57 | console.error(e); 58 | } 59 | } 60 | 61 | if (basicInfoForm) 62 | basicInfoForm.addEventListener('submit', submitBasicInfo); -------------------------------------------------------------------------------- /app/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

About

8 |

The Shorenstein Center at the Harvard Kennedy School produces original research on sustainable business models for news organizations in the digital age. The center works closely with legacy and emerging news organizations to put theories into practice—creating a cycle of research, implementation, and learning.

9 |

This email benchmarking tool grew out of the Single Subject News Research Project at the Shorenstein Center, funded by the Knight Foundation. The Single Subject News research project seeks to answer the question – how can nonprofit, single-subject news sites grow and engage their online audiences? The email newsletter is one tactic we are continuously testing as an audience growth and monetization tool with our cohort of participating newsrooms.

10 |

This benchmarking tool leverages our research into using data science to analyze email newsletters and allows news organizations to analyze their own MailChimp data without requiring any advanced technical knowledge. The goal of the tool is to encourage newsrooms to focus on the newsletter metrics that matter, including ones that focus on list composition, activity and quality of users.

11 |

Once you've signed up, our staff will vet your organization (to ensure that you are, in fact, representing a news organization) and send you a unique access link. Then, simply enter your API key, select the data you'd like analyzed, and we'll send you a comprehensive email report containing visualizations, recommendations, and comparisons to the other participating media organizations included in our secure database.

12 |
13 |
14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /app/emails.py: -------------------------------------------------------------------------------- 1 | """This module contains functions associated with sending email.""" 2 | import logging 3 | import boto3 4 | from flask import render_template 5 | from app import app 6 | 7 | def send_email(subject, recipients, template_name, template_context, # pylint: disable=too-many-arguments 8 | sender=None, configuration_set_name=None, error=False): 9 | """Sends an email using Amazon SES according to the args provided. 10 | 11 | Args: 12 | subject: the email subject line. 13 | recipients: list of recipient email addresses. 14 | template_name: the name of the template to render as the html body. 15 | template_context: the context to be passed to the html template. 16 | sender: sender's email address. Optional. 17 | error: boolean representing whether the email is an error message. 18 | """ 19 | ses = boto3.client( 20 | 'ses', 21 | region_name=app.config['SES_REGION_NAME'], 22 | aws_access_key_id=app.config['AWS_ACCESS_KEY_ID'], 23 | aws_secret_access_key=app.config['AWS_SECRET_ACCESS_KEY'] 24 | ) 25 | if not sender: 26 | sender = app.config['SES_DEFAULT_EMAIL_SOURCE'] 27 | 28 | # Set up tracking for sends, opens, clicks, etc. 29 | # if an SES configuration set for tracking such metrics was included 30 | message_tags = [] 31 | if configuration_set_name: 32 | message_tags.append({'Name': configuration_set_name, 33 | 'Value': configuration_set_name}) 34 | 35 | with app.app_context(): 36 | html = render_template(template_name, **template_context) 37 | if app.config['NO_EMAIL'] and not error: 38 | logger = logging.getLogger(__name__) 39 | logger.warning('NO_EMAIL environment variable set. ' 40 | 'Suppressing an email with the following params: ' 41 | 'Sender: %s. Recipients: %s. Subject: %s.', 42 | sender, recipients, subject) 43 | return 44 | ses.send_email( 45 | Source=sender, 46 | Destination={'ToAddresses': recipients}, 47 | Message={ 48 | 'Subject': {'Data': subject}, 49 | 'Body': { 50 | 'Html': {'Data': html} 51 | } 52 | }, 53 | ConfigurationSetName=configuration_set_name or '', 54 | Tags=message_tags 55 | ) 56 | -------------------------------------------------------------------------------- /app/templates/privacy.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Privacy Policy

8 |

We store three kinds of data in our secure database: information about you (the user of this tool), information about the organization you represent and information about any MailChimp list(s) you ask us to analyze.

9 |

We store the information that you provide about yourself (your name, email address, etc.) in order to ensure that you are indeed affiliated with the organization you registered with. We will not publically report this information.

10 |

We store information about your organization (organization size, budget, coverage scope, etc.) in order to provide you with more personalized metrics, such as benchmarks of organizations "like yours." We also reserve the right to publish anonymized aggregate data on what types of organizations are using our service. We will not publically report your organization's information. 11 |

Finally, if you choose not to opt-out, we also store information about the MailChimp list(s) that you ask us to analyze. This information consists of summary statistics we generate through a number of API calls and calculations (the same statistics we use to generate the charts in the report email we send you), as well as the API key you provide to us and the unique MailChimp ID of the list you ask us to analyze. We will also use these summary statistics to help calculate a aggregate statistics for other users of this tool. We do not store any raw data about your list, nor do we access any personally-identifying information about your list members. When you enter your API key, you may choose to uncheck both checkboxes containing "Store this API key." If you do, we will not store any information at all about your MailChimp list. This does, however, prevent us from caching and updating your data in the background (allowing you to instantly receive an up-to-date report on your list, or a scheduled monthly report). It also means you will not be contributing your data to an aggregate pool which helps other tool users as well as our research team.

12 |

If you would like us to delete your data, including summary statistics about your MailChimp lists, please contact us. Removal requests will be processed within 14 days.

13 |
14 |
15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /app/dbops.py: -------------------------------------------------------------------------------- 1 | """This module contains database operations, e.g. insert, update, etc.""" 2 | from sqlalchemy.exc import IntegrityError 3 | from app import db 4 | from app.models import AppUser, Organization 5 | 6 | def update_user(user_info, org): 7 | """Updates a user in the database. 8 | 9 | Args: 10 | user_info: a dictionary of user information. 11 | org: see store_user(). 12 | """ 13 | user = AppUser.query.filter_by(email=user_info['email']).first() 14 | try: 15 | user.name = user_info['name'] 16 | user.orgs.append(org) 17 | db.session.commit() 18 | except: 19 | db.session.rollback() 20 | raise 21 | return user 22 | 23 | def store_user(name, email, email_hash, org): 24 | """Inserts a new user into the database. 25 | 26 | Args: 27 | name: name of the user. 28 | email: user's email address. 29 | email_hash: md5-hash of the email address. 30 | org: SQLAlchemy Organization object representing the 31 | news organization the user belongs to. 32 | """ 33 | user_info = {'name': name, 34 | 'email': email, 35 | 'email_hash': email_hash} 36 | 37 | # The new user isn't approved for access by default 38 | user = AppUser(**user_info, approved=False, orgs=[org]) 39 | 40 | # Do a bootleg upsert (due to lack of ORM support) 41 | db.session.add(user) 42 | try: 43 | db.session.commit() 44 | except IntegrityError: 45 | db.session.rollback() 46 | return update_user(user_info, org) 47 | except: 48 | db.session.rollback() 49 | raise 50 | return user 51 | 52 | def store_org(org_info): 53 | """Inserts a new orgaization into the database. 54 | 55 | Args: 56 | org_info: a dictionary containing information about the organization. 57 | Each element corresponds to an Organization attribute defined in 58 | the database model. 59 | 60 | Returns: 61 | The inserted Organization object. 62 | """ 63 | organization = Organization(**org_info) 64 | db.session.add(organization) 65 | try: 66 | db.session.commit() 67 | except: 68 | db.session.rollback() 69 | raise 70 | return organization 71 | 72 | def associate_user_with_list(user_id, list_object): 73 | """Associates a user in the database with a list object. 74 | 75 | Args: 76 | user_id: the unique id of the user to be updated. 77 | list_object: the list_stats object to associate. 78 | """ 79 | user = AppUser.query.filter_by(id=user_id).first() 80 | user.lists.append(list_object) 81 | try: 82 | db.session.commit() 83 | except: 84 | db.session.rollback() 85 | raise 86 | -------------------------------------------------------------------------------- /app/static/es/orgForm.js: -------------------------------------------------------------------------------- 1 | const orgForm = document.querySelector('#org-form'); 2 | 3 | /* Validate and submit organization info */ 4 | const submitOrg = async e => { 5 | e.preventDefault(); 6 | orgForm.removeEventListener('submit', submitOrg); 7 | if (!clientSideValidateForm(orgForm)) { 8 | orgForm.addEventListener('submit', submitOrg); 9 | return; 10 | } 11 | const formElts = orgForm.querySelectorAll( 12 | 'label, .custom-control-label'); 13 | disable(formElts); 14 | const 15 | headers = new Headers({'X-CSRFToken': csrfToken}), 16 | formData = new FormData(orgForm), 17 | payload = { 18 | method: 'POST', 19 | credentials: 'same-origin', 20 | headers: headers, 21 | body: formData 22 | }, 23 | request = new Request('/validate-org-info', payload); 24 | try { 25 | const response = await fetch(request); 26 | if (response.ok) { 27 | const body = await response.json(); 28 | const 29 | title = 'Thanks!', 30 | pageBody = (body.user == 'approved') ? 'You\'re all set! ' + 31 | 'We\'ve emailed you a unique access link.' : 'We\'ve ' + 32 | 'received your details. Once our team has reviewed your ' + 33 | 'submission, we\'ll email you with instructions for ' + 34 | 'accessing our benchmarking tool.', 35 | url = '/confirmation?title=' + title + '&body=' + pageBody; 36 | window.location.href = url; 37 | } 38 | else { 39 | if (response.status == 422) { 40 | const invalidElts = orgForm.querySelectorAll('.invalid'); 41 | for (let i = 0; i < invalidElts.length; ++i) 42 | invalidElts[i].classList.remove('invalid'); 43 | const errors = await response.json(); 44 | for (const [k, _] of Object.entries(errors)) { 45 | tagField(orgForm.querySelector( 46 | '#' + k.replace('_', '-') + '-wrapper')); 47 | } 48 | enable(formElts); 49 | orgForm.addEventListener('submit', submitOrg); 50 | } 51 | else 52 | throw new Error(response.statusText); 53 | } 54 | } 55 | catch(e) { 56 | console.error(e); 57 | } 58 | } 59 | 60 | /* Disable/enable the "Other" affiliation field based on whether the 61 | "Other" checkbox is checked */ 62 | const toggleOtherAffField = (otherInput, otherInputWrapper) => { 63 | const classes = ['valid', 'invalid']; 64 | otherInput.classList.remove(...classes); 65 | otherInput.classList.toggle('disabled-elt'); 66 | otherInputWrapper.classList.remove(...classes); 67 | otherInput.value = ""; 68 | } 69 | 70 | if (orgForm) { 71 | orgForm.addEventListener('submit', submitOrg); 72 | const 73 | otherCheckbox = orgForm.querySelector('#other_affiliation'), 74 | otherInput = orgForm.querySelector('#other_affiliation_name'), 75 | otherInputWrapper = orgForm.querySelector( 76 | '#other-affiliation-name-wrapper'); 77 | otherCheckbox.addEventListener('change', () => 78 | toggleOtherAffField(otherInput, otherInputWrapper)); 79 | } 80 | -------------------------------------------------------------------------------- /tests/unit/test_emails.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.emails import send_email 3 | 4 | @pytest.mark.parametrize('sender, config_set_arg, config_set, tags', [ 5 | (None, None, '', []), 6 | ('foo@bar.com', 'foo', 'foo', [{'Name': 'foo', 'Value': 'foo'}]) 7 | ]) 8 | def test_send_email(test_app, mocker, sender, config_set_arg, 9 | config_set, tags): 10 | """Tests the send_email function.""" 11 | mocked_boto3_client = mocker.patch('app.emails.boto3.client') 12 | mocked_boto3_client_instance = mocked_boto3_client.return_value 13 | mocked_render_template = mocker.patch('app.emails.render_template') 14 | mocked_template_html = mocked_render_template.return_value 15 | test_app.config['NO_EMAIL'] = False 16 | with test_app.app_context(): 17 | test_app.config['SES_REGION_NAME'] = 'foo' 18 | test_app.config['AWS_ACCESS_KEY_ID'] = 'bar' 19 | test_app.config['AWS_SECRET_ACCESS_KEY'] = 'baz' 20 | test_app.config['SES_DEFAULT_EMAIL_SOURCE'] = 'foo@bar.com' 21 | send_email('foo', ['bar'], 'foo.html', {'baz': 'qux'}, 22 | sender=sender, 23 | configuration_set_name=config_set_arg) 24 | mocked_boto3_client.assert_called_with( 25 | 'ses', 26 | region_name='foo', 27 | aws_access_key_id='bar', 28 | aws_secret_access_key='baz') 29 | mocked_render_template.assert_called_with('foo.html', baz='qux') 30 | mocked_boto3_client_instance.send_email.assert_called_with( 31 | Source='foo@bar.com', 32 | Destination={'ToAddresses': ['bar']}, 33 | Message={ 34 | 'Subject': {'Data': 'foo'}, 35 | 'Body': { 36 | 'Html': {'Data': mocked_template_html} 37 | } 38 | }, 39 | ConfigurationSetName=config_set, 40 | Tags=tags 41 | ) 42 | 43 | def test_send_error_email_or_email_disabled(test_app, mocker, caplog): 44 | """Tests the send_email function for an error email or if NO_EMAIL is set.""" 45 | mocked_boto3_client = mocker.patch('app.emails.boto3.client') 46 | mocker.patch('app.emails.render_template') 47 | test_app.config['NO_EMAIL'] = True 48 | with test_app.app_context(): 49 | test_app.config['SES_REGION_NAME'] = 'foo' 50 | test_app.config['AWS_ACCESS_KEY_ID'] = 'bar' 51 | test_app.config['AWS_SECRET_ACCESS_KEY'] = 'baz' 52 | test_app.config['SES_DEFAULT_EMAIL_SOURCE'] = 'foo@bar.com' 53 | send_email('foo', ['bar'], 'foo.html', {}) 54 | log_text = ('NO_EMAIL environment variable set. ' 55 | 'Suppressing an email with the following params: ' 56 | 'Sender: {}. Recipients: {}. Subject: {}.'.format( 57 | 'foo@bar.com', ['bar'], 'foo')) 58 | assert log_text in caplog.text 59 | mocked_boto3_client.return_value.send_email.assert_not_called() 60 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /app/logs.py: -------------------------------------------------------------------------------- 1 | """This module sets up logging.""" 2 | import os 3 | import logging 4 | from logging.handlers import RotatingFileHandler, SMTPHandler 5 | import boto3 6 | from app import app 7 | 8 | class SESHandler(SMTPHandler): 9 | """An SMTP handler for logging which uses Amazon SES""" 10 | def __init__(self, mailhost, fromaddr, toaddrs, subject, aws_config): # pylint: disable=too-many-arguments 11 | self.aws_config = aws_config 12 | super().__init__(mailhost, fromaddr, toaddrs, subject) 13 | 14 | def emit(self, record): 15 | """Emits a record.""" 16 | try: 17 | ses = boto3.client( 18 | 'ses', 19 | region_name=self.aws_config['ses_region_name'], 20 | aws_access_key_id=self.aws_config['aws_access_key_id'], 21 | aws_secret_access_key=self.aws_config['aws_secret_access_key'] 22 | ) 23 | ses.send_email( 24 | Source=self.fromaddr, 25 | Destination={'ToAddresses': self.toaddrs}, 26 | Message={ 27 | 'Subject': {'Data': self.subject}, 28 | 'Body': { 29 | 'Text': {'Data': self.format(record)} 30 | } 31 | } 32 | ) 33 | except (KeyboardInterrupt, SystemExit): # pylint: disable=try-except-raise 34 | raise 35 | except: # pylint: disable=bare-except 36 | self.handleError(record) 37 | 38 | def setup_logging(): 39 | """Sets up logging for the Flask application.""" 40 | 41 | # Create file handler for error/warning/info/debug logs 42 | file_handler = RotatingFileHandler( 43 | 'benchmarks-log.log', maxBytes=10000000, backupCount=5) 44 | 45 | # Apply format to the log messages 46 | formatter = logging.Formatter( 47 | '[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s') 48 | file_handler.setFormatter(formatter) 49 | 50 | # Set the level according to whether we're debugging or not 51 | if app.debug: 52 | file_handler.setLevel(logging.DEBUG) 53 | else: 54 | file_handler.setLevel(logging.WARN) 55 | 56 | # Create equivalent mail handler 57 | mail_handler = SESHandler( 58 | mailhost="", 59 | fromaddr=app.config['SES_DEFAULT_EMAIL_SOURCE'], 60 | toaddrs=[os.environ.get('ADMIN_EMAIL')], 61 | subject='Application Error', 62 | aws_config={ 63 | 'ses_region_name': app.config['SES_REGION_NAME'], 64 | 'aws_access_key_id': app.config['AWS_ACCESS_KEY_ID'], 65 | 'aws_secret_access_key': app.config['AWS_SECRET_ACCESS_KEY']}) 66 | 67 | # Set the email format 68 | mail_handler.setFormatter(logging.Formatter(''' 69 | Message type: %(levelname)s 70 | Location: %(pathname)s:%(lineno)d 71 | Module: %(module)s 72 | Function: %(funcName)s 73 | Time: %(asctime)s 74 | 75 | Message: 76 | 77 | %(message)s 78 | ''')) 79 | 80 | # Only email errors, not warnings 81 | mail_handler.setLevel(logging.ERROR) 82 | 83 | # Add the handlers 84 | loggers = [app.logger, logging.getLogger('sqlalchemy'), 85 | logging.getLogger('werkzeug')] 86 | for logger in loggers: 87 | logger.addHandler(file_handler) 88 | logger.addHandler(mail_handler) 89 | -------------------------------------------------------------------------------- /app/templates/faq.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Frequently Asked Questions

8 |

Whom am I being benchmarked against?

9 |

The following is a breakdown of organizations in our database. All data is self-reported. 10 |

    11 |
  • {{ financial_classifications['Non-Profit'] }} of organizations are non-profits, {{ financial_classifications['For-Profit'] }} are for-profits and {{ financial_classifications['B Corp'] }} are B Corps.
  • 12 |
  • {{ coverage_scopes['Hyperlocal'] }} of organizations are hyperlocal in their coverage. {{ coverage_scopes['City'] }} cover a single city. {{ coverage_scopes['State'] }}, {{ coverage_scopes['National'] }} and {{ coverage_scopes['International'] }} provide statewide, national and international coverage respectively.
  • 13 |
  • {{ coverage_focuses['Single Subject'] }} of organizations focus on a single subject, while {{ coverage_focuses['Multiple Subjects'] }} cover multiple subjects. {{ coverage_focuses['Investigative'] }} of organizations are investigative in nature.
  • 14 |
  • {{ platforms['Digital Only'] }} of organizations are digital only and {{ platforms['Newsletter Only'] }} are print only. {{ platforms['Digital and Print'] }} publish in both print and digital form.
  • 15 |
  • {{ employees['5 or fewer'] }} of organizations have 5 or fewer employees. {{ employees['6-10'] }} have 6-10 employees, {{ employees['11-20'] }} have 11-20 employees, {{ employees['21-50'] }} have 21-50 employees and {{ employees['More than 50'] }} have more than 50 employees.
  • 16 |
  • {{ budgets['Less than $500k'] }} of organizations have an annual budget of less than $500k. {{ budgets['$500k-$2m'] }} have a budget of between $500k and $2m, {{ budgets['$2m-$10m'] }} have a budget of between $2m and $10m, {{ budgets['$10m-$30m'] }} have a budget of between $10m and $30 and {{ budgets['Greater than $30m'] }} have a budget of greater than $30m.
  • 17 |
18 |

What can you tell me about the lists in your database?

19 |

The following statistics derive from those users who contributed their anonymized list data to our aggregate statistics (see our Privacy Policy for more information).

20 |
    21 |
  • These statistics cover {{ sample_size }} lists. 22 |
  • The mean number of subscribers for an email list in our database is {{ subscribers['mean'] }} with a standard deviation of {{ subscribers['std'] }}. The largest list has {{ subscribers['max'] }} subscribers, the smallest list has {{ subscribers['min'] }} subscribers and the median list has {{ subscribers['med'] }} subscribers.
  • 23 |
  • The mean list open rate across our dataset is {{ open_rate['mean'] }} with a standard deviation of {{ open_rate['std'] }}. The highest open rate is {{ open_rate['max'] }}, the lowest open rate is {{ open_rate['min'] }} and the median open rate is {{ open_rate['med'] }}.
  • 24 |
25 |

Want us to answer another question for you?

26 |

Please fill out this form. Be sure to let us know whether you'd like an answer emailed directly to you or appended to this page.

27 |
28 |
29 |
30 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/terms.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Terms of Service ("Terms")

8 |

Last updated: August 15, 2018

9 |

Please read these Terms of Service ("Terms", "Terms of Service") carefully before using the emailbenchmarking.com website (the "Service") operated by the Shorenstein Center at the Harvard Kennedy School ("the Shorenstein Center", also "us", "we", or "our").

10 |

Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These Terms apply to all visitors, users and others who access or use the Service.

11 |

By accessing or using the Service you agree to be bound by these Terms. If you disagree with any part of the terms then you may not access the Service.

12 |

Eligibility

13 |

In order to use our Service, you must (a) be at least eighteen years of age, and legally able to enter into contracts; (b) complete the registration process; and (c) agree to our Privacy Policy. 14 |

Links To Other Web Sites

15 |

Our Service may contain links to third-party web sites or services that are not owned or controlled by the Shorenstein Center.

16 |

The Shorenstein Center has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that the Shorenstein Center shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such content, goods or services available on or through any such web sites or services.

17 |

We strongly advise you to read the terms and conditions and privacy policies of any third-party web sites or services that you visit.

18 |

Governing Law

19 |

These Terms shall be governed and construed in accordance with the laws of Massachusetts, United States, without regard to its conflict of law provisions.

20 |

Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us regarding our Service, and supersede and replace any prior agreements we might have between us regarding the Service.

21 |

Changes

22 |

We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material we will try to provide at least 30 days notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion.

23 |

By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, please stop using the Service.

24 |

Contact Us

25 |

If you have any questions about these Terms, please contact us.

26 |
27 |
28 |
29 | {% endblock %} -------------------------------------------------------------------------------- /tests/integration/test_store_users_and_orgs.py: -------------------------------------------------------------------------------- 1 | from werkzeug import ImmutableMultiDict 2 | from app import db 3 | from app.models import Organization, AppUser 4 | 5 | def test_store_new_user(client): 6 | """End-to-end test of submitting the /validate-basic-info route with a 7 | new user and an existing organization.""" 8 | existing_org = Organization( 9 | name='Foo Bar', 10 | financial_classification='', 11 | coverage_scope='', 12 | coverage_focus='', 13 | platform='', 14 | employee_range='', 15 | budget='', 16 | affiliations='') 17 | db.session.add(existing_org) 18 | db.session.commit() 19 | userform = ImmutableMultiDict([ 20 | ('name', 'foo'), ('email', 'foo@bar.com'), ('news_org', 'foo bar')]) 21 | response = client.post('/validate-basic-info', data=userform) 22 | user = AppUser.query.filter_by(email='foo@bar.com').first() 23 | assert user.name == 'Foo' 24 | assert len(user.orgs) == 1 25 | assert existing_org in user.orgs 26 | assert response.get_json()['user'] == 'other' 27 | 28 | def test_store_existing_user(client, caplog): 29 | """End-to-end test of submitting the /validate-basic-info route with an 30 | existing user and an existing organization.""" 31 | existing_org = Organization( 32 | name='Foo Bar', 33 | financial_classification='', 34 | coverage_scope='', 35 | coverage_focus='', 36 | platform='', 37 | employee_range='', 38 | budget='', 39 | affiliations='') 40 | db.session.add(existing_org) 41 | existing_user = AppUser( 42 | name='Foo', 43 | email='foo@bar.com', 44 | email_hash='f3ada405ce890b6f8204094deb12d8a8', 45 | approved=True, 46 | orgs=[existing_org]) 47 | db.session.add(existing_user) 48 | db.session.commit() 49 | userform = ImmutableMultiDict([ 50 | ('name', 'bar'), ('email', 'foo@bar.com'), ('news_org', 'foo bar')]) 51 | response = client.post('/validate-basic-info', data=userform) 52 | user = AppUser.query.filter_by(email='foo@bar.com').first() 53 | assert user.name == 'Bar' 54 | assert len(user.orgs) == 1 55 | assert user.orgs[0].name == 'Foo Bar' 56 | assert response.get_json()['user'] == 'approved' 57 | assert ('Suppressing an email with the following params: ' 58 | 'Sender: testing@testing.com. Recipients: [\'foo@bar.com\']. ' 59 | 'Subject: You\'re all set to access our benchmarks!' 60 | in caplog.text) 61 | 62 | def test_store_organization(client): 63 | """End-to-end tests of submitting the /validate-org-infon route with a new 64 | user and new organization.""" 65 | orgform = ImmutableMultiDict([ 66 | ('financial_classification', 'For-Profit'), 67 | ('coverage_scope', 'City'), 68 | ('coverage_focus', 'Investigative'), 69 | ('platform', 'Digital Only'), 70 | ('employee_range', '5 or fewer'), 71 | ('budget', '$500k-$2m'), 72 | ('news_revenue_hub', True), 73 | ('other_affiliation', True), 74 | ('other_affiliation_name', 'Baz')]) 75 | with client as c: 76 | with c.session_transaction() as sess: 77 | sess['user_name'] = 'Foo' 78 | sess['email'] = 'foo@bar.com' 79 | sess['email_hash'] = 'f3ada405ce890b6f8204094deb12d8a8' 80 | sess['org'] = 'Bar' 81 | response = c.post('/validate-org-info', data=orgform) 82 | assert response.get_json()['user'] == 'other' 83 | user = AppUser.query.filter_by(email='foo@bar.com').first() 84 | assert user.name == 'Foo' 85 | assert len(user.orgs) == 1 86 | org = user.orgs[0] 87 | assert org.name == 'Bar' 88 | assert org.budget == '$500k-$2m' 89 | assert org.affiliations == '["News Revenue Hub", "Baz"]' 90 | -------------------------------------------------------------------------------- /app/static/es/helpers.js: -------------------------------------------------------------------------------- 1 | /* Tag a form field as valid or invalid */ 2 | const tagField = (elt, valid) => { 3 | if (valid) { 4 | elt.classList.remove('invalid'); 5 | elt.parentElement.classList.remove('invalid'); 6 | elt.classList.add('valid'); 7 | elt.parentElement.classList.add('valid'); 8 | } 9 | else { 10 | elt.classList.remove('valid'); 11 | elt.parentElement.classList.remove('valid'); 12 | elt.classList.add('invalid'); 13 | elt.parentElement.classList.add('invalid'); 14 | } 15 | } 16 | 17 | /* Validate a form element on the client side 18 | Has the side-effect of applying valid/invalid classes */ 19 | const clientSideValidateField = elt => { 20 | const 21 | type = elt.getAttribute('custom_type'), 22 | value = elt.value; 23 | let valid = true; 24 | if (type == "key") 25 | valid = (value.length !== 0 && value.indexOf('-us') !== -1); 26 | else if (type == "email") { 27 | valid = (value.length !== 0 && 28 | value.indexOf('@') !== -1 && value.indexOf('.') !== -1); 29 | } 30 | else 31 | valid = value.length !== 0; 32 | tagField(elt, valid); 33 | return valid; 34 | } 35 | 36 | /* Validates a form elements on the client side 37 | slightly inefficiently written so that the whole loop will execute 38 | and tag each input as valid or invalid as a side effect. 39 | Client-side validation is not performed for radio buttons and 40 | checkboxes due to limitations around Bootstrap's custom styling 41 | implementation. */ 42 | const clientSideValidateForm = form => { 43 | const elts = form.querySelectorAll( 44 | 'input:not(.disabled-elt), select:not(.disabled-elt)'); 45 | let valid = true; 46 | for (let i = 0; i < elts.length; ++i) { 47 | const validity = clientSideValidateField(elts[i]) 48 | if (!validity) 49 | valid = false; 50 | } 51 | return valid; 52 | } 53 | 54 | /* Monitors form elements and automatically performs client-side validation 55 | whenever a user stops typing */ 56 | const inputs = document.querySelectorAll( 57 | '.form-input-wrapper:not(.enter-stats) input, .form-input-wrapper select'); 58 | for (let i = 0; i < inputs.length; ++i) { 59 | const input = inputs[i]; 60 | if (input.tagName == 'INPUT') { 61 | input.addEventListener( 62 | 'blur', e => clientSideValidateField(e.currentTarget)); 63 | } 64 | else { 65 | input.addEventListener( 66 | 'change', e => clientSideValidateField(e.currentTarget)) 67 | } 68 | } 69 | 70 | /* Disables an elt or a nodelist of elts */ 71 | const disable = elts => { 72 | if (NodeList.prototype.isPrototypeOf(elts)) { 73 | for (let i = 0; i < elts.length; ++i) 74 | elts[i].classList.add('disabled-elt'); 75 | } 76 | else 77 | elts.classList.add('disabled-elt'); 78 | } 79 | 80 | /* Enables an elt or a nodelist of elts */ 81 | const enable = elts => { 82 | if (NodeList.prototype.isPrototypeOf(elts)) { 83 | for (let i = 0; i < elts.length; ++i) 84 | elts[i].classList.remove('disabled-elt'); 85 | } 86 | else 87 | elts.classList.remove('disabled-elt'); 88 | } 89 | /* Debouncing helper function for event listeners. 90 | See codeburst.io/throttling-and-debouncing-in-javascript-646d076d0a44 */ 91 | const debounced = (delay, fn) => { 92 | let timerId; 93 | return (...args) => { 94 | if (timerId) { 95 | clearTimeout(timerId); 96 | } 97 | timerId = setTimeout(() => { 98 | fn(...args); 99 | timerId = null; 100 | }, delay); 101 | } 102 | } 103 | 104 | /* Value of csrf token to protect against cross-site forgery attacks */ 105 | const csrfToken = document.querySelector('meta[name=csrf-token]').content; -------------------------------------------------------------------------------- /tests/integration/test_analysis.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import json 4 | import requests 5 | from app import db 6 | from app.models import AppUser, Organization, EmailList 7 | 8 | def test_analysis(client, caplog): 9 | """End-to-end test of analyzing a list.""" 10 | existing_org = Organization( 11 | name='Foo Bar', 12 | financial_classification='', 13 | coverage_scope='', 14 | coverage_focus='', 15 | platform='', 16 | employee_range='', 17 | budget='', 18 | affiliations='') 19 | db.session.add(existing_org) 20 | existing_user = AppUser( 21 | name='Foo', 22 | email='foo@bar.com', 23 | email_hash='f3ada405ce890b6f8204094deb12d8a8', 24 | approved=True, 25 | orgs=[existing_org]) 26 | db.session.add(existing_user) 27 | db.session.commit() 28 | existing_org_id = existing_org.id 29 | existing_user_id = existing_user.id 30 | list_id = os.environ.get('TESTING_LIST_ID') 31 | api_key = os.environ.get('TESTING_API_KEY') 32 | data_center = api_key.rsplit('-', 1)[1] 33 | chart_files = glob.glob('app/static/charts/*.png') 34 | for file in chart_files: 35 | if list_id in file: 36 | os.remove(file) 37 | request_uri = 'https://{}.api.mailchimp.com/3.0/lists/{}'.format( 38 | data_center, list_id) 39 | params = ( 40 | ('fields', 'name,' 41 | 'stats.member_count,' 42 | 'stats.unsubscribe_count,' 43 | 'stats.cleaned_count,' 44 | 'stats.open_rate,' 45 | 'date_created,' 46 | 'stats.campaign_count'), 47 | ) 48 | response = requests.get(request_uri, params=params, 49 | auth=('email-benchmarks-testing', api_key)) 50 | data = response.json() 51 | request_data = { 52 | 'list_id': list_id, 53 | 'list_name': data['name'], 54 | 'total_count': (data['stats']['member_count'] + 55 | data['stats']['unsubscribe_count'] + 56 | data['stats']['cleaned_count']), 57 | 'open_rate': data['stats']['open_rate'], 58 | 'date_created': None, 59 | 'campaign_count': data['stats']['campaign_count'] 60 | } 61 | print(request_data['date_created']) 62 | print(type(request_data['date_created'])) 63 | with client as c: 64 | with c.session_transaction() as sess: 65 | sess['user_id'] = existing_user_id 66 | sess['email'] = 'foo@bar.com' 67 | sess['key'] = api_key 68 | sess['data_center'] = data_center 69 | sess['monthly_updates'] = True 70 | sess['store_aggregates'] = True 71 | sess['org_id'] = existing_org_id 72 | response = c.post('/analyze-list', data=json.dumps(request_data), 73 | content_type='application/json') 74 | assert response.status == '200 OK' 75 | chart_files = glob.glob('app/static/charts/*.png') 76 | assert any(list_id + '_size_' in file for file in chart_files) 77 | assert any(list_id + '_breakdown_' in file for file in chart_files) 78 | assert any(list_id + '_open_rate_' in file for file in chart_files) 79 | assert any(list_id + '_open_rate_histogram_' in file for file in chart_files) 80 | assert any(list_id + '_high_open_rt_pct_' in file for file in chart_files) 81 | assert any(list_id + '_cur_yr_inactive_pct_' in file for file in chart_files) 82 | assert ('Suppressing an email with the following params: ' 83 | 'Sender: testing@testing.com. Recipients: [\'foo@bar.com\']. ' 84 | 'Subject: Your Email Benchmarking Report is Ready!' 85 | in caplog.text) 86 | email_list = EmailList.query.filter_by(list_id=list_id).first() 87 | assert email_list 88 | assert email_list.org.id == existing_org_id 89 | assert email_list.monthly_update_users[0].id == existing_user_id 90 | assert email_list.analyses[0] 91 | assert email_list.analyses[0].open_rate == data['stats']['open_rate'] 92 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | """This module containes SQLAlchemy models.""" 2 | from datetime import datetime 3 | from app import db 4 | 5 | # Association table for many-to-many relationship between orgs and users 6 | users = db.Table( # pylint: disable=invalid-name 7 | 'users', 8 | db.Column('org_id', db.Integer, db.ForeignKey('organization.id'), 9 | primary_key=True), 10 | db.Column('user_id', db.Integer, db.ForeignKey('app_user.id'), 11 | primary_key=True)) 12 | 13 | # Association table for many-to-many relationship between lists and users 14 | list_users = db.Table( # pylint: disable=invalid-name 15 | 'list_users', 16 | db.Column('list_id', db.String(64), db.ForeignKey('email_list.list_id'), 17 | primary_key=True), 18 | db.Column('user_id', db.Integer, db.ForeignKey('app_user.id'), 19 | primary_key=True)) 20 | 21 | class AppUser(db.Model): # pylint: disable=too-few-public-methods 22 | """Stores users.""" 23 | id = db.Column(db.Integer, primary_key=True) 24 | signup_timestamp = db.Column(db.DateTime, default=datetime.utcnow) 25 | name = db.Column(db.String(64)) 26 | email = db.Column(db.String(64), index=True, unique=True) 27 | email_hash = db.Column(db.String(64), index=True, unique=True) 28 | approved = db.Column(db.Boolean) 29 | 30 | def __repr__(self): 31 | return ''.format(self.id) 32 | 33 | class ListStats(db.Model): # pylint: disable=too-few-public-methods 34 | """Stores stats associated with a MailChimp list.""" 35 | id = db.Column(db.Integer, primary_key=True) 36 | analysis_timestamp = db.Column(db.DateTime, default=datetime.utcnow) 37 | frequency = db.Column(db.Float) 38 | subscribers = db.Column(db.Integer) 39 | open_rate = db.Column(db.Float) 40 | hist_bin_counts = db.Column(db.String(512)) 41 | subscribed_pct = db.Column(db.Float) 42 | unsubscribed_pct = db.Column(db.Float) 43 | cleaned_pct = db.Column(db.Float) 44 | pending_pct = db.Column(db.Float) 45 | high_open_rt_pct = db.Column(db.Float) 46 | cur_yr_inactive_pct = db.Column(db.Float) 47 | list_id = db.Column(db.String(64), db.ForeignKey('email_list.list_id', 48 | name='fk_list_id')) 49 | 50 | def __repr__(self): 51 | return ''.format(self.id) 52 | 53 | class EmailList(db.Model): # pylint: disable=too-few-public-methods 54 | """Stores individual MailChimp lists.""" 55 | list_id = db.Column(db.String(64), primary_key=True) 56 | creation_timestamp = db.Column(db.DateTime) 57 | list_name = db.Column(db.String(128)) 58 | api_key = db.Column(db.String(64)) 59 | data_center = db.Column(db.String(64)) 60 | store_aggregates = db.Column(db.Boolean) 61 | monthly_updates = db.Column(db.Boolean) 62 | monthly_update_users = db.relationship( 63 | AppUser, secondary=list_users, backref='lists', lazy='subquery') 64 | org_id = db.Column(db.Integer, db.ForeignKey('organization.id', 65 | name='fk_org_id')) 66 | analyses = db.relationship(ListStats, backref='list') 67 | 68 | def __repr__(self): 69 | return ''.format(self.list_id) 70 | 71 | class Organization(db.Model): # pylint: disable=too-few-public-methods 72 | """Stores a media or journalism organization.""" 73 | id = db.Column(db.Integer, primary_key=True) 74 | name = db.Column(db.String(128), index=True, unique=True) 75 | financial_classification = db.Column(db.String(32)) 76 | coverage_scope = db.Column(db.String(32)) 77 | coverage_focus = db.Column(db.String(64)) 78 | platform = db.Column(db.String(64)) 79 | employee_range = db.Column(db.String(32)) 80 | budget = db.Column(db.String(64)) 81 | affiliations = db.Column(db.String(512)) 82 | lists = db.relationship(EmailList, backref='org') 83 | users = db.relationship(AppUser, secondary=users, backref='orgs') 84 | 85 | def __repr__(self): 86 | return ''.format(self.id) 87 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build_and_test: 4 | docker: 5 | - image: circleci/python:3.6-node-browsers 6 | environment: 7 | - SECRET_KEY: 'circleci' 8 | - SERVER_NAME: 'localhost' 9 | - ENV: ci 10 | - NO_PROXY: true 11 | - NO_EMAIL: true 12 | - FLASK_DEBUG: 1 13 | - image: rabbitmq:latest 14 | environment: 15 | - POSTGRES_USER: root 16 | - POSTGRES_PASS: test 17 | - POSTGRES_DB: circle-test 18 | working_directory: ~/repo 19 | steps: 20 | - checkout: 21 | path: ~/repo 22 | - restore_cache: 23 | key: pip-packages-{{ .Branch }}-{{ checksum "requirements.txt" }} 24 | - run: 25 | name: Install python dependencies 26 | command: | 27 | python3 -m venv venv 28 | . venv/bin/activate 29 | pip install -r requirements.txt 30 | - save_cache: 31 | key: pip-packages-{{ .Branch }}-{{ checksum "requirements.txt" }} 32 | paths: 33 | - "venv" 34 | - restore_cache: 35 | key: node-packages-{{ .Branch }}-{{ checksum "package-lock.json" }} 36 | - run: 37 | name: Install node dependencies 38 | command: | 39 | npm install --unsafe-perm 40 | - save_cache: 41 | key: node-packages-{{ .Branch }}-{{ checksum "package-lock.json" }} 42 | paths: 43 | - "node-modules" 44 | - run: 45 | name: Wait for RabbitMQ to start 46 | command: | 47 | for i in `seq 1 10`; 48 | do 49 | nc -z localhost 5672 && echo Success && exit 0 50 | echo -n . 51 | sleep 2 52 | done 53 | echo Failed waiting for RabbitMQ && exit 1 54 | - run: 55 | name: Run unit tests 56 | command: | 57 | . venv/bin/activate 58 | python -m pytest --cov=app --cov-report term-missing tests/unit 59 | - run: 60 | name: Run integration tests 61 | command: | 62 | CWD=$('pwd') 63 | echo 'export PATH=$CWD/node_modules/.bin:$PATH' >> $BASH_ENV 64 | source $BASH_ENV 65 | . venv/bin/activate 66 | python -m pytest tests/integration 67 | - store_artifacts: 68 | path: test-reports 69 | destination: test-reports 70 | - run: 71 | name: Send reports to codecov.io 72 | command: | 73 | . venv/bin/activate 74 | codecov 75 | 76 | deploy_master: 77 | docker: 78 | - image: circleci/node:latest 79 | environment: 80 | - AWS_CODE_DEPLOY_REGION: us-west-2 81 | - AWS_CODE_DEPLOY_APPLICATION_NAME: "benchmarks-project" 82 | - AWS_CODE_DEPLOY_DEPLOYMENT_CONFIG_NAME: CodeDeployDefault.AllAtOnce 83 | - AWS_CODE_DEPLOY_DEPLOYMENT_GROUP_NAME: "staging" 84 | - AWS_CODE_DEPLOY_SERVICE_ROLE_ARN: "arn:aws:iam::273834392857:role/CodeDeployServiceRole" 85 | - AWS_CODE_DEPLOY_EC2_TAG_FILTERS: "Key=Name,Value=emailbenchmarking.com,Type=KEY_AND_VALUE" 86 | - AWS_CODE_DEPLOY_APP_SOURCE: /home/circleci/repo 87 | - AWS_CODE_DEPLOY_S3_FILENAME: "${CIRCLE_BUILD_NUM}#${CIRCLE_SHA1:0:7}.zip" 88 | - AWS_CODE_DEPLOY_S3_BUCKET: circleci-codedeploy-bucket 89 | - AWS_CODE_DEPLOY_S3_LIMIT_BUCKET_FILES: 10 90 | - AWS_CODE_DEPLOY_S3_SSE: true 91 | - AWS_CODE_DEPLOY_REVISION_DESCRIPTION: "${CIRCLE_BRANCH} (#${CIRCLE_SHA1:0:7})" 92 | - AWS_CODE_DEPLOY_DEPLOYMENT_DESCRIPTION: "Deployed via CircleCI on $(date)" 93 | - AWS_CODE_DEPLOY_DEPLOYMENT_FILE_EXISTS_BEHAVIOR: "OVERWRITE" 94 | working_directory: ~/repo 95 | steps: 96 | - checkout: 97 | path: ~/repo 98 | - run: 99 | name: Deploy via AWS CodeDeploy 100 | command: | 101 | sudo npm install -g aws-code-deploy 102 | aws-code-deploy 103 | 104 | workflows: 105 | version: 2 106 | build_test_deploy: 107 | jobs: 108 | - build_and_test 109 | - deploy_master: 110 | requires: 111 | - build_and_test 112 | filters: 113 | branches: 114 | only: master 115 | -------------------------------------------------------------------------------- /tests/unit/test_dbops.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | from app.dbops import update_user, store_user, store_org, associate_user_with_list 4 | 5 | def test_update_user(mocker): 6 | """Tests the update_user function.""" 7 | mocked_user_obj = mocker.patch('app.dbops.AppUser') 8 | mocked_user = mocked_user_obj.query.filter_by.return_value.first.return_value 9 | mocked_user.orgs = [] 10 | mocked_db = mocker.patch('app.dbops.db') 11 | user = update_user({'name': 'foo', 'email': 'foo@bar.com'}, 'bar') 12 | mocked_user_obj.query.filter_by.assert_called_with(email='foo@bar.com') 13 | assert mocked_user.name == 'foo' 14 | assert mocked_user.orgs == ['bar'] 15 | mocked_db.session.commit.assert_called() 16 | assert user == mocked_user 17 | 18 | def test_update_user_db_exception(mocker): 19 | """Tests the update_user function when the database throws an exception.""" 20 | mocker.patch('app.dbops.AppUser') 21 | mocked_db = mocker.patch('app.dbops.db') 22 | mocked_db.session.commit.side_effect = Exception() 23 | with pytest.raises(Exception): 24 | update_user({'name': 'foo', 'email': 'foo@bar.com'}, 'bar') 25 | mocked_db.session.rollback.assert_called() 26 | 27 | def test_store_user(mocker): 28 | """Tests the store_user function.""" 29 | mocked_user_obj = mocker.patch('app.dbops.AppUser') 30 | mocked_user = mocked_user_obj.return_value 31 | mocked_db = mocker.patch('app.dbops.db') 32 | user = store_user('foo', 'foo@bar.com', 'bar', 'baz') 33 | mocked_user_obj.assert_called_with( 34 | name='foo', email='foo@bar.com', email_hash='bar', approved=False, 35 | orgs=['baz']) 36 | mocked_db.session.add.assert_called_with(mocked_user) 37 | mocked_db.session.commit.assert_called() 38 | assert user == mocked_user 39 | 40 | def test_store_user_integrityerror(mocker): 41 | """Tests the store_user function. Database commit raises an IntegrityError.""" 42 | mocker.patch('app.dbops.AppUser') 43 | mocked_db = mocker.patch('app.dbops.db') 44 | mocked_db.session.commit.side_effect = IntegrityError('foo', 'bar', 'baz') 45 | mocked_update_user = mocker.patch('app.dbops.update_user') 46 | store_user('foo', 'foo@bar.com', 'bar', 'baz') 47 | mocked_db.session.rollback.assert_called() 48 | mocked_update_user.assert_called_with( 49 | {'name': 'foo', 'email': 'foo@bar.com', 'email_hash': 'bar'}, 50 | 'baz') 51 | 52 | def test_store_user_othererror(mocker): 53 | """Tests the store user function Database commit raises a differerent error.""" 54 | mocker.patch('app.dbops.AppUser') 55 | mocked_db = mocker.patch('app.dbops.db') 56 | mocked_db.session.commit.side_effect = Exception() 57 | with pytest.raises(Exception): 58 | store_user('foo', 'foo@bar.com', 'bar', 'baz') 59 | mocked_db.session.rollback.assert_called() 60 | 61 | def test_store_org(mocker): 62 | """Tests the store_org function.""" 63 | mocked_org_obj = mocker.patch('app.dbops.Organization') 64 | mocked_org = mocked_org_obj.return_value 65 | mocked_db = mocker.patch('app.dbops.db') 66 | organization = store_org({'foo': 'bar'}) 67 | mocked_org_obj.assert_called_with(foo='bar') 68 | mocked_db.session.add.assert_called_with(mocked_org) 69 | mocked_db.session.commit.assert_called() 70 | assert organization == mocked_org 71 | 72 | def test_store_org_db_exception(mocker): 73 | """Tests the store_org function when the database throws an exception.""" 74 | mocker.patch('app.dbops.Organization') 75 | mocked_db = mocker.patch('app.dbops.db') 76 | mocked_db.session.commit.side_effect = Exception() 77 | with pytest.raises(Exception): 78 | store_org({'foo': 'bar'}) 79 | mocked_db.session.rollback.assert_called() 80 | 81 | def test_associate_user_with_list(mocker): 82 | """Tests the associate_user_with_list function.""" 83 | mocked_user_obj = mocker.patch('app.dbops.AppUser') 84 | mocked_user = mocked_user_obj.query.filter_by.return_value.first.return_value 85 | mocked_user.lists = [] 86 | mocked_db = mocker.patch('app.dbops.db') 87 | associate_user_with_list('foo', 'bar') 88 | mocked_user_obj.query.filter_by.assert_called_with(id='foo') 89 | assert mocked_user.lists == ['bar'] 90 | mocked_db.session.commit.assert_called() 91 | 92 | def test_associate_user_with_list_db_exception(mocker): 93 | """Tests the associate_user_with_List function when the database throws 94 | an exception.""" 95 | mocker.patch('app.dbops.AppUser') 96 | mocked_db = mocker.patch('app.dbops.db') 97 | mocked_db.session.commit.side_effect = Exception() 98 | with pytest.raises(Exception): 99 | associate_user_with_list('foo', 'bar') 100 | mocked_db.session.rollback.assert_called() 101 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | import pytest 3 | import pandas as pd 4 | from wtforms import BooleanField 5 | from app import app 6 | from app.lists import MailChimpList 7 | 8 | @pytest.fixture 9 | def test_app(): 10 | """Sets up a test app.""" 11 | app.config['TESTING'] = True 12 | app.config['WTF_CSRF_ENABLED'] = False 13 | yield app 14 | 15 | @pytest.fixture 16 | def client(test_app): 17 | """Sets up a test client.""" 18 | with test_app.test_client() as client: 19 | yield client 20 | 21 | @pytest.fixture 22 | def mocked_userform(mocker): 23 | """Mocks the UserForm. For use in testing application routes.""" 24 | mocked_userform = mocker.patch('app.routes.UserForm') 25 | mocked_userform.return_value.news_org.data = 'foo and a bar' 26 | mocked_userform.return_value.name.data = 'foo bar' 27 | mocked_userform.return_value.email.data = 'foo@bar.com' 28 | mocked_userform.return_value.validate_on_submit.return_value = True 29 | yield mocked_userform 30 | 31 | @pytest.fixture 32 | def mocked_orgform(mocker): 33 | """Mocks the OrgForm. For use in testing application routes.""" 34 | mocked_orgform = mocker.patch('app.routes.OrgForm') 35 | mocked_orgform.return_value.financial_classification.data = 'foo' 36 | mocked_orgform.return_value.coverage_scope.data = 'bar' 37 | mocked_orgform.return_value.coverage_focus.data = 'baz' 38 | mocked_orgform.return_value.platform.data = 'qux' 39 | mocked_orgform.return_value.employee_range.data = 'quux' 40 | mocked_orgform.return_value.budget.data = 'quuz' 41 | mocked_corge_booleanfield = MagicMock( 42 | spec=BooleanField, data=True, label=MagicMock(text='corge')) 43 | mocked_other_booleanfield = MagicMock( 44 | spec=BooleanField, data=True, label=MagicMock(text='Other')) 45 | mocked_orgform.return_value.__iter__.return_value = [ 46 | mocked_corge_booleanfield, mocked_other_booleanfield] 47 | mocked_orgform.return_value.other_affiliation_name.data = 'garply' 48 | mocked_orgform.return_value.validate_on_submit.return_value = True 49 | yield mocked_orgform 50 | 51 | @pytest.fixture 52 | def fake_list_data(): 53 | """Provides a dictionary containing fake data for a MailChimp list.""" 54 | data = { 55 | 'list_id': 'foo', 56 | 'list_name': 'bar', 57 | 'org_id': 1, 58 | 'key': 'foo-bar1', 59 | 'data_center': 'bar1', 60 | 'monthly_updates': False, 61 | 'store_aggregates': False, 62 | 'total_count': 'baz', 63 | 'open_rate': 'qux', 64 | 'creation_timestamp': 'quux', 65 | 'campaign_count': 'quuz' 66 | } 67 | yield data 68 | 69 | @pytest.fixture 70 | def fake_calculation_results(): 71 | """Provides a dictionary containing fake calculation results for a 72 | MailChimp list.""" 73 | calculation_results = { 74 | 'frequency': 0.1, 75 | 'subscribers': 2, 76 | 'open_rate': 0.5, 77 | 'hist_bin_counts': [0.1, 0.2, 0.3], 78 | 'subscribed_pct': 0.2, 79 | 'unsubscribed_pct': 0.2, 80 | 'cleaned_pct': 0.2, 81 | 'pending_pct': 0.1, 82 | 'high_open_rt_pct': 0.1, 83 | 'cur_yr_inactive_pct': 0.1 84 | } 85 | yield calculation_results 86 | 87 | @pytest.fixture 88 | def fake_list_stats_query_result_as_df(): 89 | """Provides a Pandas DataFrame containing fake stats as could be extracted 90 | from the database.""" 91 | yield pd.DataFrame({ 92 | 'subscribers': [3, 4, 6], 93 | 'subscribed_pct': [1, 1, 4], 94 | 'unsubscribed_pct': [1, 1, 1], 95 | 'cleaned_pct': [1, 1, 1], 96 | 'pending_pct': [1, 1, 1], 97 | 'open_rate': [0.5, 1, 1.5], 98 | 'high_open_rt_pct': [1, 1, 1], 99 | 'cur_yr_inactive_pct': [1, 1, 1] 100 | }) 101 | 102 | @pytest.fixture 103 | def fake_list_stats_query_result_means(): 104 | """Provides a dictionary containing the mean values for the 105 | fake_list_stats_query_result_as_df() fixture.""" 106 | yield { 107 | 'subscribers': [4], 108 | 'subscribed_pct': [2], 109 | 'unsubscribed_pct': [1], 110 | 'cleaned_pct': [1], 111 | 'pending_pct': [1], 112 | 'open_rate': [1], 113 | 'high_open_rt_pct': [1], 114 | 'cur_yr_inactive_pct': [1] 115 | } 116 | 117 | @pytest.fixture 118 | def mocked_mailchimp_list(mocker, fake_calculation_results): 119 | """Mocks the MailChimp list class from app/lists.py and attaches fake calculation 120 | results to the mock attributes.""" 121 | mocked_mailchimp_list = mocker.patch('app.tasks.MailChimpList') 122 | mocked_mailchimp_list.return_value = MagicMock(**fake_calculation_results) 123 | yield mocked_mailchimp_list 124 | 125 | @pytest.fixture 126 | def mailchimp_list(): 127 | """Creates a MailChimpList. Used for testing class/instance methiods.""" 128 | yield MailChimpList(1, 2, 'foo-bar1', 'bar1') 129 | -------------------------------------------------------------------------------- /migrations/versions/e1150a91b8d1_release_version_3_0.py: -------------------------------------------------------------------------------- 1 | """release version 3.0 2 | 3 | Revision ID: e1150a91b8d1 4 | Revises: 5 | Create Date: 2019-01-09 15:14:30.861585 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e1150a91b8d1' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('app_user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('signup_timestamp', sa.DateTime(), nullable=True), 24 | sa.Column('name', sa.String(length=64), nullable=True), 25 | sa.Column('email', sa.String(length=64), nullable=True), 26 | sa.Column('email_hash', sa.String(length=64), nullable=True), 27 | sa.Column('approved', sa.Boolean(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | with op.batch_alter_table('app_user', schema=None) as batch_op: 31 | batch_op.create_index(batch_op.f('ix_app_user_email'), ['email'], unique=True) 32 | batch_op.create_index(batch_op.f('ix_app_user_email_hash'), ['email_hash'], unique=True) 33 | 34 | op.create_table('organization', 35 | sa.Column('id', sa.Integer(), nullable=False), 36 | sa.Column('name', sa.String(length=128), nullable=True), 37 | sa.Column('financial_classification', sa.String(length=32), nullable=True), 38 | sa.Column('coverage_scope', sa.String(length=32), nullable=True), 39 | sa.Column('coverage_focus', sa.String(length=64), nullable=True), 40 | sa.Column('platform', sa.String(length=64), nullable=True), 41 | sa.Column('employee_range', sa.String(length=32), nullable=True), 42 | sa.Column('budget', sa.String(length=64), nullable=True), 43 | sa.Column('affiliations', sa.String(length=512), nullable=True), 44 | sa.PrimaryKeyConstraint('id') 45 | ) 46 | with op.batch_alter_table('organization', schema=None) as batch_op: 47 | batch_op.create_index(batch_op.f('ix_organization_name'), ['name'], unique=True) 48 | 49 | op.create_table('email_list', 50 | sa.Column('list_id', sa.String(length=64), nullable=False), 51 | sa.Column('list_name', sa.String(length=128), nullable=True), 52 | sa.Column('api_key', sa.String(length=64), nullable=True), 53 | sa.Column('data_center', sa.String(length=64), nullable=True), 54 | sa.Column('store_aggregates', sa.Boolean(), nullable=True), 55 | sa.Column('monthly_updates', sa.Boolean(), nullable=True), 56 | sa.Column('org_id', sa.Integer(), nullable=True), 57 | sa.ForeignKeyConstraint(['org_id'], ['organization.id'], name='fk_org_id'), 58 | sa.PrimaryKeyConstraint('list_id') 59 | ) 60 | op.create_table('users', 61 | sa.Column('org_id', sa.Integer(), nullable=False), 62 | sa.Column('user_id', sa.Integer(), nullable=False), 63 | sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ), 64 | sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ), 65 | sa.PrimaryKeyConstraint('org_id', 'user_id') 66 | ) 67 | op.create_table('list_stats', 68 | sa.Column('id', sa.Integer(), nullable=False), 69 | sa.Column('analysis_timestamp', sa.DateTime(), nullable=True), 70 | sa.Column('frequency', sa.Float(), nullable=True), 71 | sa.Column('subscribers', sa.Integer(), nullable=True), 72 | sa.Column('open_rate', sa.Float(), nullable=True), 73 | sa.Column('hist_bin_counts', sa.String(length=512), nullable=True), 74 | sa.Column('subscribed_pct', sa.Float(), nullable=True), 75 | sa.Column('unsubscribed_pct', sa.Float(), nullable=True), 76 | sa.Column('cleaned_pct', sa.Float(), nullable=True), 77 | sa.Column('pending_pct', sa.Float(), nullable=True), 78 | sa.Column('high_open_rt_pct', sa.Float(), nullable=True), 79 | sa.Column('cur_yr_inactive_pct', sa.Float(), nullable=True), 80 | sa.Column('list_id', sa.String(length=64), nullable=True), 81 | sa.ForeignKeyConstraint(['list_id'], ['email_list.list_id'], name='fk_list_id'), 82 | sa.PrimaryKeyConstraint('id') 83 | ) 84 | op.create_table('list_users', 85 | sa.Column('list_id', sa.String(length=64), nullable=False), 86 | sa.Column('user_id', sa.Integer(), nullable=False), 87 | sa.ForeignKeyConstraint(['list_id'], ['email_list.list_id'], ), 88 | sa.ForeignKeyConstraint(['user_id'], ['app_user.id'], ), 89 | sa.PrimaryKeyConstraint('list_id', 'user_id') 90 | ) 91 | # ### end Alembic commands ### 92 | 93 | 94 | def downgrade(): 95 | # ### commands auto generated by Alembic - please adjust! ### 96 | op.drop_table('list_users') 97 | op.drop_table('list_stats') 98 | op.drop_table('users') 99 | op.drop_table('email_list') 100 | with op.batch_alter_table('organization', schema=None) as batch_op: 101 | batch_op.drop_index(batch_op.f('ix_organization_name')) 102 | 103 | op.drop_table('organization') 104 | with op.batch_alter_table('app_user', schema=None) as batch_op: 105 | batch_op.drop_index(batch_op.f('ix_app_user_email_hash')) 106 | batch_op.drop_index(batch_op.f('ix_app_user_email')) 107 | 108 | op.drop_table('app_user') 109 | # ### end Alembic commands ### 110 | -------------------------------------------------------------------------------- /app/templates/org-form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Tell us about your organization

8 |
9 | 10 |
11 | 12 |
13 | 14 |
15 | {% for subfield in org_form.financial_classification %} 16 |
17 | {{ subfield(class="custom-control-input") }} 18 | {{ subfield.label(class="custom-control-label") }} 19 |
20 | {% endfor %} 21 |
22 | 23 |
24 | {% for subfield in org_form.coverage_scope %} 25 |
26 | {{ subfield(class="custom-control-input") }} 27 | {{ subfield.label(class="custom-control-label") }} 28 |
29 | {% endfor %} 30 |
31 | 32 |
33 | {% for subfield in org_form.coverage_focus %} 34 |
35 | {{ subfield(class="custom-control-input") }} 36 | {{ subfield.label(class="custom-control-label") }} 37 |
38 | {% endfor %} 39 |
40 | 41 |
42 | {% for subfield in org_form.platform %} 43 |
44 | {{ subfield(class="custom-control-input") }} 45 | {{ subfield.label(class="custom-control-label") }} 46 |
47 | {% endfor %} 48 |
49 | 50 |
51 | {% for subfield in org_form.employee_range %} 52 |
53 | {{ subfield(class="custom-control-input") }} 54 | {{ subfield.label(class="custom-control-label") }} 55 |
56 | {% endfor %} 57 |
58 | 59 |
60 | {% for subfield in org_form.budget %} 61 |
62 | {{ subfield(class="custom-control-input") }} 63 | {{ subfield.label(class="custom-control-label") }} 64 |
65 | {% endfor %} 66 |
67 | 68 |
69 |
70 | {{ org_form.news_revenue_hub(type="checkbox", class="custom-control-input") }} 71 | 72 |
73 |
74 | {{ org_form.institute_for_nonprofit_news(type="checkbox", class="custom-control-input") }} 75 | 76 |
77 |
78 | {{ org_form.lion_publishers(type="checkbox", class="custom-control-input") }} 79 | 80 |
81 |
82 | {{ org_form.other_affiliation(type="checkbox", class="custom-control-input") }} 83 | 84 |
85 |
86 | {{ org_form.other_affiliation_name(class="disabled-elt") }} 87 | 88 |
89 |
90 |
91 | {{ org_form.submit() }} 92 |
93 |
94 |
95 |
96 |
97 |

Why do we collect this data?

98 |

We collect this data in order to offer a benchmark tailed to your organization. As more users of the benchmarking tool tag these categories, we'll continue to build features that allow users to sort the email benchmarking database by those fields.

99 |
100 |
101 |
102 |
103 | {% endblock %} -------------------------------------------------------------------------------- /app/static/es/listsTable.js: -------------------------------------------------------------------------------- 1 | const listsTable = document.querySelector('.lists-table'); 2 | 3 | /* References to event listeners attached using closures */ 4 | let listeners = []; 5 | 6 | /* Approx. how long (in seconds) it takes to analyze a list member */ 7 | const analysisTime = 0.24; 8 | 9 | /* Function for converting seconds to times */ 10 | const secondsToHm = d => { 11 | if (d == 0) 12 | return "N/A"; 13 | d = Number(d); 14 | 15 | const 16 | h = Math.floor(d / 3600), 17 | m = Math.floor(d % 3600 / 60), 18 | hDisplay = h > 0 ? h + (m == 0 ? 19 | (h == 1 ? " hour" : " hours") : 20 | (h == 1 ? " hour, " : " hours, ")) : "", 21 | mDisplay = m > 0 ? m + (m == 1 ? " minute" : " minutes") : "", 22 | hm = hDisplay + mDisplay; 23 | 24 | return hm ? "~" + hm : "<1 minute"; 25 | } 26 | 27 | /* Fill lists table with details */ 28 | const setupListsTable = data => { 29 | let listsTableBody = ""; 30 | 31 | for (let i = 0; i < data.length; ++i) { 32 | listsTableBody += ""; 33 | listsTableBody += "" + data[i].name + ""; 34 | listsTableBody += "" + 35 | data[i].stats.member_count.toLocaleString() + ""; 36 | const calcTime = secondsToHm(data[i].stats.member_count * analysisTime); 37 | listsTableBody += "" + 38 | calcTime + ""; 39 | if (data[i].stats.member_count > 0) { 40 | listsTableBody += "" + 41 | "" + 50 | "" + 51 | "" + 55 | ""; 56 | } 57 | else 58 | listsTableBody += "" 59 | listsTableBody += ""; 60 | } 61 | listsTableBody += ""; 62 | document.querySelector('thead') 63 | .insertAdjacentHTML('afterend', listsTableBody); 64 | 65 | const analyzeLinks = document.querySelectorAll('.analyze-link'); 66 | for (let i = 0; i < analyzeLinks.length; ++i) { 67 | const 68 | listId = analyzeLinks[i].getAttribute('list-id'), 69 | listName = analyzeLinks[i].getAttribute('list-name'), 70 | totalCount = analyzeLinks[i].getAttribute('total-count'), 71 | openRate = analyzeLinks[i].getAttribute('open-rate'), 72 | dateCreated = analyzeLinks[i].getAttribute('date-created'), 73 | campaignCount = analyzeLinks[i].getAttribute('campaign-count'), 74 | listener = analyzeList(listId, listName, 75 | totalCount, openRate, dateCreated, campaignCount); 76 | analyzeLinks[i].addEventListener('click', listener); 77 | listeners[i] = listener; 78 | } 79 | } 80 | 81 | /* Submit a list for processing */ 82 | const analyzeList = 83 | (listId, listName, totalCount, openRate, dateCreated, campaignCount) => { 84 | 85 | return async e => { 86 | e.preventDefault(); 87 | const analyzeLinks = document.querySelectorAll('.analyze-link'); 88 | for (let i = 0; i < analyzeLinks.length; ++i) 89 | analyzeLinks[i].removeEventListener('click', listeners[i]); 90 | disable(document.querySelectorAll('.lists-table')); 91 | const 92 | headers = new Headers({ 93 | "X-CSRFToken": csrfToken, 94 | "content-type": "application/json" 95 | }), 96 | requestBody = { 97 | "list_id": listId, 98 | "list_name": listName, 99 | "total_count": totalCount, 100 | "open_rate": openRate, 101 | "date_created": dateCreated, 102 | "campaign_count": campaignCount 103 | }, 104 | payload = { 105 | method: 'POST', 106 | credentials: 'same-origin', 107 | headers: headers, 108 | body: JSON.stringify(requestBody) 109 | }, 110 | request = new Request('/analyze-list', payload); 111 | try { 112 | const response = await fetch(request); 113 | if (response.ok) { 114 | const 115 | title = 'Sit Tight!', 116 | body = 'We\'re currently analyzing your MailChimp ' + 117 | 'list. Once we\'ve finished, we\'ll email you ' + 118 | 'your report!'; 119 | window.location.href = '/confirmation?title=' + title + 120 | '&body=' + body; 121 | } 122 | else { 123 | enable(document.querySelectorAll('.lists-table')); 124 | for (let i = 0; i < analyzeLinks.length; ++i) 125 | analyzeLinks[i].addEventListener('click', listeners[i]); 126 | throw new Error(e.statusText); 127 | } 128 | } 129 | catch(e) { 130 | console.error(e) 131 | } 132 | } 133 | } 134 | 135 | 136 | /* Get data about lists from the server */ 137 | const getListData = async () => { 138 | const 139 | payload = { 140 | credentials: 'same-origin' 141 | }, 142 | request = new Request('/get-list-data', payload); 143 | try { 144 | const response = await fetch(request); 145 | if (response.ok) { 146 | const responseData = await response.json(); 147 | setupListsTable(responseData); 148 | } 149 | else 150 | throw new Error(response.statusText); 151 | } 152 | catch(e) { 153 | console.error(e); 154 | } 155 | 156 | } 157 | 158 | if (listsTable) 159 | getListData(); 160 | -------------------------------------------------------------------------------- /app/templates/enter-api-key.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

Enter your MailChimp API key

8 |
9 | 10 |
11 | {% if num_orgs == 1 %} 12 | {{ api_key_form.organization(class="custom-select disabled-elt") }} 13 | {% else %} 14 | {{ api_key_form.organization(class="custom-select") }} 15 | {% endif %} 16 | 17 |
18 | 19 | 20 |
21 | {{ api_key_form.key(custom_type="key") }} 22 | 23 |
24 |
25 | {{ api_key_form.store_aggregates(type="checkbox", class="custom-control-input", checked="true") }} 26 | 27 |
28 |
29 | {{ api_key_form.monthly_updates(type="checkbox", class="custom-control-input", checked="true") }} 30 | 31 |
32 |
33 | {{ api_key_form.submit() }} 34 |
35 |
36 |
37 |
38 |
39 |

How do we use your data?

40 |

We will use your API key to access MailChimp and request list data. We perform a number of calculations on this raw data, and use the calculation results to generate the email report we send to you. We do not store any raw data, nor do we request any personally-idenitifying information about your list members. You may choose how we store your API key and the calculation results: 41 |

If you check either box, we will securely store your API key. You will be able to generate new, up-to-date reports instantly.

42 |

You can choose to contribute your data to a "Database Average" or to receive an up-to-date email report every month.

43 |

If you uncheck both boxes, we will send you a report and then discard your list data. New reports will not be instantly generated.

44 |

For more information about how we use your data, see our Privacy Policy.

45 |
46 |
47 |
48 |
49 | {% endblock %} -------------------------------------------------------------------------------- /app/static/scss/bootstrap/_custom-modal.scss: -------------------------------------------------------------------------------- 1 | // .modal-open - body class for killing the scroll 2 | // .modal - container to scroll within 3 | // .modal-dialog - positioning shell for the actual modal 4 | // .modal-content - actual modal w/ bg and corners and stuff 5 | 6 | 7 | // Kill the scroll on the body 8 | .modal-open { 9 | overflow: hidden; 10 | } 11 | 12 | // Container that the modal scrolls within 13 | .modal { 14 | position: fixed; 15 | top: 0; 16 | right: 0; 17 | bottom: 0; 18 | left: 0; 19 | z-index: $zindex-modal; 20 | display: none; 21 | overflow: hidden; 22 | // Prevent Chrome on Windows from adding a focus outline. For details, see 23 | // https://github.com/twbs/bootstrap/pull/10951. 24 | outline: 0; 25 | // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a 26 | // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342 27 | // See also https://github.com/twbs/bootstrap/issues/17695 28 | 29 | // When fading in the modal, animate it to slide down 30 | &.fade .modal-dialog { 31 | @include transition($modal-transition); 32 | transform: translate(0, -25%); 33 | } 34 | &.show .modal-dialog { transform: translate(0, 0); } 35 | &.drawer { 36 | &.show { 37 | .modal-dialog { 38 | transform: translateX(0); 39 | } 40 | } 41 | &.left { 42 | .modal-dialog { 43 | left: 0; 44 | transform: translateX(-100%); 45 | } 46 | &.show { 47 | .modal-dialog { 48 | transform: translateX(0); 49 | } 50 | } 51 | } 52 | .modal-dialog { 53 | position: absolute; 54 | right: 0; 55 | width: $modal-drawer-width; 56 | height: 100%; 57 | margin: 0; 58 | transform: translateX(100%); 59 | .modal-content { 60 | height: 100%; 61 | border: 0; 62 | // Just in case the modal-body is longer than 100vh 63 | .modal-body { 64 | max-height: calc(100vh - #{$modal-drawer-header-height}); 65 | padding-bottom: $modal-drawer-header-height; 66 | overflow-y: auto; 67 | } 68 | } 69 | } 70 | .modal-content { 71 | border-radius: 0; 72 | } 73 | .modal-footer { 74 | position: absolute; 75 | bottom: 0; 76 | width: 100%; 77 | border: 0; 78 | border-radius: 0; 79 | } 80 | } 81 | } 82 | .modal-open .modal { 83 | overflow-x: hidden; 84 | overflow-y: auto; 85 | &.drawer { 86 | overflow-y: hidden; 87 | display: block; 88 | } 89 | } 90 | 91 | // Shell div to position the modal with bottom padding 92 | .modal-dialog { 93 | position: relative; 94 | width: auto; 95 | margin: $modal-dialog-margin; 96 | } 97 | 98 | // Actual modal 99 | .modal-content { 100 | position: relative; 101 | display: flex; 102 | flex-direction: column; 103 | background-color: $modal-content-bg; 104 | background-clip: padding-box; 105 | border: $modal-content-border-width solid $modal-content-border-color; 106 | @include border-radius($border-radius-lg); 107 | @include box-shadow($modal-content-box-shadow-xs); 108 | // Remove focus outline from opened modal 109 | outline: 0; 110 | } 111 | 112 | // Modal background 113 | .modal-backdrop { 114 | position: fixed; 115 | top: 0; 116 | right: 0; 117 | bottom: 0; 118 | left: 0; 119 | z-index: $zindex-modal-backdrop; 120 | background-color: $modal-backdrop-bg; 121 | 122 | // Fade for backdrop 123 | &.fade { opacity: 0; } 124 | &.show { opacity: $modal-backdrop-opacity; } 125 | } 126 | 127 | // Modal header 128 | // Top section of the modal w/ title and dismiss 129 | .modal-header { 130 | display: flex; 131 | align-items: center; // vertically center it 132 | justify-content: space-between; // Put modal header elements (title and dismiss) on opposite ends 133 | padding: $modal-header-padding; 134 | border-bottom: $modal-header-border-width solid $modal-header-border-color; 135 | } 136 | 137 | // Title text within header 138 | .modal-title { 139 | margin-bottom: 0; 140 | line-height: $modal-title-line-height; 141 | } 142 | 143 | // Modal body 144 | // Where all modal content resides (sibling of .modal-header and .modal-footer) 145 | .modal-body { 146 | position: relative; 147 | // Enable `flex-grow: 1` so that the body take up as much space as possible 148 | // when should there be a fixed height on `.modal-dialog`. 149 | flex: 1 1 auto; 150 | padding: $modal-inner-padding; 151 | } 152 | 153 | // Footer (for actions) 154 | .modal-footer { 155 | display: flex; 156 | align-items: center; // vertically center 157 | justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items 158 | padding: $modal-inner-padding; 159 | border-top: $modal-footer-border-width solid $modal-footer-border-color; 160 | 161 | // Easily place margin between footer elements 162 | > :not(:first-child) { margin-left: .25rem; } 163 | > :not(:last-child) { margin-right: .25rem; } 164 | } 165 | 166 | // Measure scrollbar width for padding body during modal show/hide 167 | .modal-scrollbar-measure { 168 | position: absolute; 169 | top: -9999px; 170 | width: 50px; 171 | height: 50px; 172 | overflow: scroll; 173 | } 174 | 175 | // Scale up the modal 176 | @include media-breakpoint-up(sm) { 177 | // Automatically set modal's width for larger viewports 178 | .modal-dialog { 179 | max-width: $modal-md; 180 | margin: $modal-dialog-margin-y-sm-up auto; 181 | } 182 | 183 | .modal-content { 184 | @include box-shadow($modal-content-box-shadow-sm-up); 185 | } 186 | 187 | .modal-sm { max-width: $modal-sm; } 188 | } 189 | 190 | @include media-breakpoint-up(lg) { 191 | .modal-lg { max-width: $modal-lg; } 192 | } -------------------------------------------------------------------------------- /app/forms.py: -------------------------------------------------------------------------------- 1 | """This module declares forms for the HTML templates. 2 | 3 | The forms are built using Flask-WTF. 4 | The templates are rendered using Jinja2. 5 | """ 6 | import requests 7 | from flask import session 8 | from flask_wtf import FlaskForm 9 | from wtforms import (StringField, SubmitField, 10 | BooleanField, RadioField, SelectField) 11 | from wtforms.validators import DataRequired, Email 12 | 13 | class UserForm(FlaskForm): 14 | """A form allowing the user to submit their basic information. 15 | 16 | Args: 17 | Flaskform: the base Flask-WTF form class. 18 | """ 19 | name = StringField('Name', validators=[DataRequired()]) 20 | email = StringField('Email Address', validators=[DataRequired(), Email()]) 21 | news_org = StringField('News Organization', validators=[DataRequired()]) 22 | submit = SubmitField('Submit') 23 | 24 | class OrgForm(FlaskForm): 25 | """A form allowing the user to submit information about their organization. 26 | 27 | Args: 28 | Flaskform: the base Flask-WTF form class. 29 | """ 30 | financial_classification = RadioField( 31 | 'Financial Classification', validators=[DataRequired()], 32 | choices=[('For-Profit', 'For-Profit'), 33 | ('Non-Profit', 'Non-Profit'), 34 | ('B Corp', 'B Corp')]) 35 | coverage_scope = RadioField( 36 | 'Coverage Scope', validators=[DataRequired()], 37 | choices=[('Hyperlocal', 'Hyperlocal'), 38 | ('City', 'City'), 39 | ('State', 'State'), 40 | ('National', 'National'), 41 | ('International', 'International')]) 42 | coverage_focus = RadioField( 43 | 'Coverage Focus', validators=[DataRequired()], 44 | choices=[('Single Subject', 'Single Subject'), 45 | ('Investigative', 'Investigative'), 46 | ('Multiple Subjects', 'Multiple Subjects')]) 47 | platform = RadioField( 48 | 'Publishing Platform', validators=[DataRequired()], 49 | choices=[('Digital Only', 'Digital Only'), 50 | ('Digital and Print', 'Digital and Print'), 51 | ('Newsletter Only', 'Newsletter Only')]) 52 | employee_range = RadioField( 53 | 'Number of Full-Time Employees', validators=[DataRequired()], 54 | choices=[('5 or fewer', '5 or fewer'), ('6-10', '6-10'), 55 | ('11-20', '11-20'), ('21-50', '21-50'), 56 | ('More than 50', 'More than 50')]) 57 | budget = RadioField( 58 | 'Annual Budget', validators=[DataRequired()], 59 | choices=[('Less than $500k', 'Less than $500k'), 60 | ('$500k-$2m', '$500k-$2m'), 61 | ('$2m-$10m', '$2m-$10m'), 62 | ('$10m-$30m', '$10m-$30m'), 63 | ('Greater than $30m', 'Greater than $30m')]) 64 | news_revenue_hub = BooleanField('News Revenue Hub') 65 | institute_for_nonprofit_news = BooleanField( 66 | 'Institute for Nonprofit News') 67 | lion_publishers = BooleanField('LION Publishers') 68 | other_affiliation = BooleanField('Other') 69 | other_affiliation_name = StringField('Other') 70 | submit = SubmitField('Submit') 71 | 72 | class ApiKeyForm(FlaskForm): 73 | """A form allowing the user to submit their MailChimp API key 74 | and data storage options. 75 | 76 | Args: 77 | Flaskform: the base Flask-WTF form class. 78 | """ 79 | key = StringField('API Key', validators=[DataRequired()]) 80 | organization = SelectField('Organization', choices=[]) 81 | store_aggregates = BooleanField('Use my aggregate MailChimp data' 82 | 'for benchmarking') 83 | monthly_updates = BooleanField('I would like to receive monthly' 84 | 'benchmarking updates') 85 | submit = SubmitField('Submit') 86 | 87 | def validate(self): 88 | """A custom validation function. 89 | 90 | Submits the API key to MailChimp to make sure it validates. 91 | If so, uses MailChimp API to discern how many lists the user has 92 | Finally, stores the information in the session. 93 | """ 94 | 95 | # Default validation (if any), e.g. required fields 96 | validated = FlaskForm.validate(self) 97 | if not validated: 98 | return False 99 | 100 | key = self.key.data 101 | 102 | # Check key contains a data center (i.e. ends with '-usX') 103 | if '-' not in key: 104 | self.key.errors.append('Key missing data center') 105 | return False 106 | 107 | data_center = key.rsplit('-', 1)[1] 108 | 109 | # Get total number of lists 110 | # If connection refused by server or request fails, bad API key 111 | request_uri = 'https://{}.api.mailchimp.com/3.0/'.format(data_center) 112 | params = ( 113 | ('fields', 'total_items'), 114 | ) 115 | try: 116 | response = (requests.get(request_uri + 117 | 'lists', params=params, 118 | auth=('shorenstein', key))) 119 | except requests.exceptions.ConnectionError: 120 | self.key.errors.append('Connection to MailChimp servers ' 121 | 'refused') 122 | return False 123 | if response.status_code != 200: 124 | self.key.errors.append('MailChimp responded with error ' 125 | 'code {}'.format(str(response.status_code))) 126 | return False 127 | 128 | # Store API key, data center, and number of lists in session 129 | session['key'] = key 130 | session['data_center'] = data_center 131 | session['num_lists'] = response.json().get('total_items') 132 | session['store_aggregates'] = self.store_aggregates.data 133 | session['monthly_updates'] = self.monthly_updates.data 134 | 135 | return True 136 | -------------------------------------------------------------------------------- /app/static/scss/components/_forms.scss: -------------------------------------------------------------------------------- 1 | /* Regular inputs */ 2 | 3 | %form-elt { 4 | padding: $form-input-padding; 5 | font-size: $form-font-size; 6 | line-height: $form-line-height; 7 | transition: color $form-transition, background-color $form-transition; 8 | border-radius: $border-radius; 9 | border: 1px solid $medium-grey; 10 | background-color: transparent; 11 | color: $body-color; 12 | height: auto; 13 | 14 | @include media-breakpoint-up(sm) { 15 | padding: $form-input-padding-desktop; 16 | font-size: $form-font-size-desktop; 17 | } 18 | 19 | @include media-breakpoint-up(xxl) { 20 | padding: $form-input-padding-xxl; 21 | font-size: $form-font-size-xxl; 22 | } 23 | } 24 | 25 | label { 26 | font-weight: $form-label-font-weight; 27 | margin-bottom: $form-label-bottom-margin; 28 | } 29 | 30 | .form-input-wrapper { 31 | position: relative; 32 | padding: 0; 33 | margin: $form-elt-margin; 34 | } 35 | 36 | .form-input-wrapper:after, .multi-custom-control-wrapper:after { 37 | position: absolute; 38 | font-size: $small-text-size; 39 | left: 0rem; 40 | transition: $form-transition; 41 | 42 | @include media-breakpoint-up(xxl) { 43 | font-size: $small-text-size-xxl; 44 | } 45 | } 46 | 47 | .form-input-wrapper:after, { 48 | top: 2 * $form-input-top-padding + 49 | $form-font-size * $form-line-height + 0.25rem; 50 | 51 | @include media-breakpoint-up(sm) { 52 | top: 2 * $form-input-top-padding + 53 | $form-font-size-desktop * $form-line-height + 0.25rem; 54 | } 55 | 56 | @include media-breakpoint-up(xxl) { 57 | top: 2 * $form-input-top-padding + 58 | $form-font-size-xxl * $form-line-height + 0.25rem; 59 | } 60 | } 61 | 62 | .multi-custom-control-wrapper:after { 63 | bottom: -1.4rem; 64 | } 65 | 66 | .form-input-wrapper.invalid:after, 67 | .multi-custom-control-wrapper.invalid:after { 68 | color: $form-invalid-msg-color; 69 | } 70 | 71 | .form-input-wrapper.valid:after, 72 | .multi-custom-control-wrapper.valid:after { 73 | color: $form-valid-msg-color; 74 | } 75 | 76 | .form-input-wrapper input, 77 | .form-input-wrapper select { 78 | @extend %form-elt; 79 | position: relative; 80 | width: 100%; 81 | 82 | &:focus { 83 | outline: 0; 84 | } 85 | 86 | &:focus ~ .focus-bg { 87 | opacity: 1; 88 | } 89 | } 90 | 91 | .form-input-wrapper input ~ .focus-bg, 92 | .form-input-wrapper select ~ .focus-bg { 93 | position: absolute; 94 | left: 0; 95 | top: 0; 96 | width: 100%; 97 | height: 100%; 98 | background-color: $light-grey; 99 | opacity: 0; 100 | transition: $form-transition; 101 | z-index: -1; 102 | } 103 | 104 | .form-input-wrapper input.invalid ~ .focus-bg, 105 | .form-input-wrapper select.invalid ~ .focus-bg { 106 | background-color: $form-invalid-input-color; 107 | opacity: 1; 108 | } 109 | 110 | .form-input-wrapper input.valid ~ .focus-bg, 111 | .form-input-wrapper select.valid ~ .focus-bg { 112 | background-color: $form-valid-input-color; 113 | opacity: 1; 114 | } 115 | 116 | @-webkit-keyframes autofill { 117 | to { 118 | background: transparent; 119 | } 120 | } 121 | 122 | input:-webkit-autofill { 123 | -webkit-animation-name: autofill; 124 | -webkit-animation-fill-mode: both; 125 | } 126 | 127 | #submit, .submit-btn { 128 | @extend %form-elt; 129 | cursor: pointer; 130 | font-weight: 500; 131 | display: inline-block; 132 | 133 | &:hover { 134 | background-color: $light-grey; 135 | color: inherit; 136 | } 137 | 138 | &:focus { 139 | outline: 0; 140 | } 141 | } 142 | 143 | /* Select boxes */ 144 | 145 | .custom-select:focus { 146 | border: 1px solid #ddd; 147 | box-shadow: none; 148 | } 149 | 150 | /* Checkboxes and Radios */ 151 | 152 | .custom-control { 153 | padding-left: 1.8rem; 154 | } 155 | 156 | .custom-control:not(.multi-custom-control) { 157 | margin: $form-elt-margin; 158 | } 159 | 160 | .multi-custom-control { 161 | margin-bottom: 0.35rem; 162 | } 163 | 164 | .multi-custom-control-wrapper { 165 | margin-top: 0.5rem; 166 | margin-bottom: $form-elt-bottom-margin; 167 | position: relative; 168 | } 169 | 170 | .custom-control-label { 171 | display: inline; 172 | } 173 | 174 | .custom-control-label:before { 175 | top: .05rem; 176 | border: 1px solid $medium-dark-grey; 177 | left: -1.8rem; 178 | } 179 | 180 | .custom-control-label:after { 181 | top: .05rem; 182 | left: -1.8rem; 183 | } 184 | 185 | /* Datepicker */ 186 | 187 | .form-control[readonly] { 188 | background-color: inherit; 189 | } 190 | 191 | .form-control:focus { 192 | color: inherit; 193 | background-color: transparent; 194 | border: 1px solid $medium-grey; 195 | outline: none; 196 | box-shadow: none; 197 | } 198 | 199 | .flatpickr-months { 200 | margin-bottom: 0.75rem; 201 | } 202 | 203 | .flatpickr-day.selected, 204 | .flatpickr-day.startRange, 205 | .flatpickr-day.endRange, .flatpickr-day.selected.inRange, 206 | .flatpickr-day.startRange.inRange, .flatpickr-day.endRange.inRange, 207 | .flatpickr-day.selected:focus, .flatpickr-day.startRange:focus, 208 | .flatpickr-day.endRange:focus, .flatpickr-day.selected:hover, 209 | .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover, 210 | .flatpickr-day.selected.prevMonthDay, 211 | .flatpickr-day.startRange.prevMonthDay, 212 | .flatpickr-day.endRange.prevMonthDay, 213 | .flatpickr-day.selected.nextMonthDay, 214 | .flatpickr-day.startRange.nextMonthDay, 215 | .flatpickr-day.endRange.nextMonthDay { 216 | border-color: $crimson !important; 217 | background-color: $crimson !important; 218 | } 219 | 220 | .flatpickr-months .flatpickr-prev-month:hover svg, 221 | .flatpickr-months .flatpickr-next-month:hover svg { 222 | fill: $crimson !important; 223 | } 224 | 225 | .flatpickr-current-month span.cur-month:hover { 226 | background-color: transparent !important; 227 | } 228 | 229 | .flatpickr-calendar { 230 | border-radius: $border-radius !important; 231 | box-shadow: none !important; 232 | border: 1px solid $medium-grey !important; 233 | } 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shorenstein Center Email Benchmarks 2 | 3 | This is a tool developed by the Shorenstein Center at the Harvard Kennedy School to import MailChimp email list data, analyze it, and output the resulting metrics in an email report. 4 | 5 | ## Status 6 | 7 | | Branch | Tests | Code Coverage | Comments | 8 | | ------ | ----- | ------------- | -------- | 9 | | `master` | [![CircleCI](https://circleci.com/gh/ShorensteinCenter/Benchmarks-Program.svg?style=svg)](https://circleci.com/gh/ShorensteinCenter/Benchmarks-Program) | [![codecov](https://codecov.io/gh/ShorensteinCenter/Benchmarks-Program/branch/master/graph/badge.svg)](https://codecov.io/gh/ShorensteinCenter/Benchmarks-Program) | Latest official release | 10 | 11 | ## Getting Started 12 | 13 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 14 | 15 | ### Prerequisites 16 | 17 | * [Python](https://www.python.org), version 3.5+ (3.6+ recommended). 18 | * [RabbitMQ](https://www.rabbitmq.com/) or another AMQP broker. 19 | * A relational database, e.g. [SQLite](https://www.sqlite.org) or [PostgreSQL](https://www.postgresql.org/). 20 | * [NodeJS](https://nodejs.org). We're currently using version 11.2, but any recent version should work. (We use [NVM](https://github.com/creationix/nvm) to manage Node versions.) 21 | * [Amazon SES](https://aws.amazon.com/ses/) (optional, see below). 22 | 23 | ### Local Development 24 | 25 | ##### Create a new virtual environment 26 | 27 | virtualenv venv 28 | source venv/bin/activate 29 | 30 | ##### Install Python dependencies 31 | 32 | pip install -r requirements.txt 33 | 34 | ##### Set environment variables 35 | 36 | * `SECRET_KEY` - Flask secret key. 37 | * `CELERY_BROKER_URI` - The URI of the Celery broker. Default `'amqp://guest:guest@localhost:5672/'` (a broker running locally on port `5672`). 38 | * `SQLALCHEMY_DATABASE_URI` - The URI of the database. Default is a `sqlite` database named `app.db` located at the application root. 39 | * `SERVER_NAME` - the URL for the app. Default `127.0.0.1:5000` (suitable for running locally). Note that the URLs for assets sent via email (images, etc.) are generated using Flask's `url_for()` function. If `SERVER_NAME` is not externally accessible these assets will not send succesfully. 40 | * `NO_PROXY` - We use proxies to distribute our MailChimp requests across IP addresses. Set this variable to `True` in order to disable proxying, or modify the `enable_proxy` method in `app/lists.py` according to your proxy configuration. 41 | * `NO_EMAIL` - If set, suppresses sending of email reports (as well as error emails, etc.). 42 | 43 | If `NO_EMAIL` is not set, Amazon SES is required along with the following variables: 44 | 45 | * `AWS_ACCESS_KEY_ID` - AWS Access Key ID for the API. 46 | * `AWS_SECRET_ACCESS_KEY` - AWS Secret Access Key for the API. 47 | * `SES_REGION_NAME` - AWS Simple Email Service region. Default `us-west-2`. 48 | * `SES_DEFAULT_EMAIL_SOURCE` - The default email address to send from. This email needs to be verified by SES and active outside the SES sandbox. 49 | * `ADMIN_EMAIL` - Email address to send error emails to. Optional. 50 | * `SES_CONFIGURATION_SET` - SES Configuration Set for tracking opens/clicks/etc. Optional. 51 | 52 | The following variables are only required to run integration tests: 53 | 54 | * `TESTING_API_KEY` - MailChimp API key to use in integration tests. 55 | * `TESTING_LIST_ID` - MailChimp list ID to run integration tests against. 56 | 57 | ##### Upgrade the database 58 | 59 | export FLASK_APP=app.py 60 | flask db upgrade 61 | 62 | ##### Install Node dependencies 63 | 64 | npm install 65 | 66 | You may need to add the installed binaries to your system path (or install with the `-g` flag), as the application expects to find certain executables (such as `orca`). 67 | 68 | ##### Compile front-end 69 | 70 | npm run gulp 71 | 72 | ##### Run the application 73 | 74 | flask run 75 | 76 | ##### Run Celery 77 | 78 | celery worker -A app.celery --loglevel=INFO 79 | 80 | Finally, open a web browser and navigate to the `SERVER_NAME` URI. 81 | 82 | ## Testing 83 | 84 | Run unit and integration tests with `pytest`: 85 | 86 | python -m pytest tests/unit 87 | python -m pytest tests/integration 88 | 89 | To generate a coverage report as well: 90 | 91 | python -m pytest --cov=app --cov-report term-missing tests/unit 92 | 93 | ## Linting 94 | 95 | Lint the backend with `pylint`: 96 | 97 | pylint app 98 | 99 | Lint the frontend: 100 | 101 | npm run lint 102 | 103 | Python and Javascript rules are defined in `pylintrc` and `.eslintrc`, respectively. 104 | 105 | ## Deployment 106 | 107 | This app is environment-agnostic. We deployed it on Ubuntu using `gunicorn` and `nginx`, and daemonized `Celery` and `Celery Beat`. Here are a few pointers on what we did. 108 | 109 | A sample init script for gunicorn: 110 | 111 | [Unit] 112 | Description=Gunicorn instance to serve app 113 | After=network.target 114 | 115 | [Service] 116 | User=app_user 117 | Group=www-data 118 | WorkingDirectory=/path/to/app 119 | Environment="PATH=/path/to/app/venv/bin" 120 | ExecStart=/path/to/app/venv/bin/gunicorn --workers 5 --bind unix:email-benchmarks.sock -m 007 app:app 121 | 122 | [Install] 123 | WantedBy=multi-user.target 124 | 125 | A sample init script for nginx: 126 | 127 | server { 128 | listen 80; 129 | server_name SERVER_NAME; 130 | 131 | location / { 132 | include proxy_params; 133 | proxy_pass http://unix:/path/to/app/email-benchmarks.sock; 134 | } 135 | } 136 | 137 | Sample init scripts for `Celery` can be found in the [Celery repo](https://github.com/celery/celery/tree/master/extra/generic-init.d/). 138 | 139 | Setting up [Orca](https://github.com/plotly/orca) (required for exporting visualizations from Plotly) can be tricky on headless machines. We got it to work by installing the standalone binaries and additional dependencies (such as `google-chrome-stable`) as per the `readme`, then using Xvfb with the `-a` flag, i.e. `xvfb-run -a ...`. Additionally, restarting a daemonized Celery will create a new xvfb instance rather than re-using the one that is already running. We added the following function to our Celery init script, which kills running xvfb processes: 140 | 141 | kill_xvfb () { 142 | local xvfb_pids=`ps aux | grep tmp/xvfb-run | grep -v grep | awk '{print $2}'` 143 | if [ "$xvfb_pids" != "" ]; then 144 | echo "Killing the following xvfb processes: $xvfb_pids" 145 | sudo kill $xvfb_pids 146 | else 147 | echo "No xvfb processes to kill" 148 | fi 149 | } 150 | 151 | ## Authors 152 | 153 | * **William Hakim** - [William Hakim](https://github.com/williamhakim10) 154 | 155 | ## Acknowledgements 156 | 157 | This project is generously supported by the [Knight Foundation](https://knightfoundation.org/). 158 | 159 | We use [Browserstack](https://www.browserstack.com/) to help ensure our projects work across platforms and devices. 160 | 161 | ## License 162 | 163 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 164 | -------------------------------------------------------------------------------- /app/static/es/charts.js: -------------------------------------------------------------------------------- 1 | const indexBubbleChart = document.getElementById('index-bubble-chart'); 2 | 3 | /* Calculates the difference, in months, between two Date objects */ 4 | const dateDiff = pastDate => Math.floor((new Date() - pastDate) / 2592000000); 5 | 6 | /* Updates the index page bubble chart with the current values of the list size, 7 | open rate, and date created field */ 8 | const updateChart = speed => { 9 | const 10 | userSubscribers = parseInt(document.getElementById('enter-list-size') 11 | .value.replace(/,/g, '')), 12 | userOpenRate = +(document.getElementById('enter-open-rate') 13 | .value.replace('%', '')), 14 | userListCreated = document.getElementById('enter-list-age').value; 15 | if (isNaN(userSubscribers) || userSubscribers < 0 || 16 | isNaN(userOpenRate) || userOpenRate < 0 || userOpenRate > 100) 17 | return; 18 | const 19 | userListAge = dateDiff( 20 | new Date(new Date(userListCreated).toUTCString())), 21 | openRateFormatted = userOpenRate.toFixed(1), 22 | animation = Plotly.animate(indexBubbleChart, { 23 | data: [ 24 | {x: [userListAge], 25 | y: [openRateFormatted], 26 | text: ['Age: ' + userListAge + ' months
' + 27 | 'Open Rate: ' + openRateFormatted + '%
' + 28 | 'Subscribers: ' + userSubscribers.toLocaleString()], 29 | marker: { 30 | size: [userSubscribers], 31 | } 32 | } 33 | ], 34 | traces: [1] 35 | }, { 36 | transition: { 37 | duration: speed, 38 | easing: 'ease', 39 | }, 40 | frame: { 41 | duration: speed 42 | } 43 | }); 44 | return animation; 45 | } 46 | 47 | if (indexBubbleChart) { 48 | const 49 | subscribers = JSON.parse(indexBubbleChart.getAttribute('data-subscribers')), 50 | openRates = JSON.parse( 51 | indexBubbleChart.getAttribute('data-open-rates')) 52 | .map(val => Math.round(1000 * val) / 10), 53 | listAges = JSON.parse(indexBubbleChart.getAttribute('data-ages')), 54 | janFirstDate = new Date( 55 | new Date(new Date().getFullYear() - 5, 0, 1).toUTCString()), 56 | mobile = window.innerWidth < 576; 57 | 58 | // Bubble chart data from the database 59 | const dbData = { 60 | x: listAges, 61 | y: openRates, 62 | text: Array.from( 63 | {length: listAges.length}, 64 | (v, i) => 65 | 'Age: ' + listAges[i] + ' months
' + 66 | 'Open Rate: ' + openRates[i] + '%
' + 67 | 'Subscribers: ' + subscribers[i].toLocaleString() 68 | ), 69 | hoverinfo: 'text', 70 | hoverlabel: { 71 | bgcolor: 'rgba(167, 25, 48, .85)', 72 | font: { 73 | family: 'Montserrat, sans-serif', 74 | size: 12 75 | }, 76 | 77 | }, 78 | mode: 'markers', 79 | marker: { 80 | size: subscribers, 81 | sizeref: 2.0 * Math.max(...subscribers) / (60**2), 82 | sizemode: 'area', 83 | color: new Array(subscribers.length).fill('rgba(167, 25, 48, .85)') 84 | } 85 | }; 86 | 87 | // Prepopulated dummy 'user' data 88 | const userData = { 89 | x: [dateDiff(janFirstDate)], 90 | y: [7.5], 91 | text: ['Age: ' + dateDiff(janFirstDate) + 92 | ' months
Open Rate: 7.5%
Subscribers: 5,000'], 93 | hoverinfo: 'text', 94 | hoverlabel: { 95 | bgcolor: 'rgba(215, 164, 45, 0.85)', 96 | font: { 97 | color: 'white', 98 | family: 'Montserrat, sans-serif', 99 | size: 12 100 | }, 101 | bordercolor: 'white' 102 | }, 103 | mode: 'markers', 104 | marker: { 105 | size: [5000], 106 | sizeref: 2.0 * Math.max(...subscribers) / (60**2), 107 | sizemode: 'area', 108 | color: ['rgba(215, 164, 45, 0.85)'] 109 | } 110 | }; 111 | 112 | const data = [dbData, userData]; 113 | 114 | // Bubble chart visual appearance 115 | const layout = { 116 | font: { 117 | family: 'Montserrat, sans-serif', 118 | size: mobile ? 12 : 16, 119 | }, 120 | yaxis: { 121 | range: [0, (1.25 * Math.max(...openRates) > 100) ? 100 : 122 | 1.25 * Math.max(...openRates)], 123 | color: '#aaa', 124 | tickfont: { 125 | color: '#555' 126 | }, 127 | tickprefix: mobile ? ' ' : ' ', 128 | ticksuffix: '% ', 129 | title: 'List Open Rate', 130 | titlefont: { 131 | color: '#555' 132 | }, 133 | automargin: true, 134 | fixedrange: true 135 | }, 136 | xaxis: { 137 | range: [0, 1.15 * Math.max(...listAges)], 138 | color: '#aaa', 139 | tickfont: { 140 | color: '#555' 141 | }, 142 | tickformat: ',', 143 | title: 'List Age (Months)', 144 | titlefont: { 145 | color: '#555' 146 | }, 147 | fixedrange: true 148 | }, 149 | showlegend: false, 150 | height: 525, 151 | margin: { 152 | l: mobile ? 65 : 75, 153 | t: 5, 154 | b: 105, 155 | r: mobile ? 45 : 75, 156 | autoexpand: true 157 | }, 158 | hovermode: 'closest' 159 | }; 160 | 161 | const config = { 162 | responsive: true, 163 | displayModeBar: false 164 | }; 165 | 166 | Plotly.newPlot(indexBubbleChart, data, layout, config); 167 | 168 | // Instantiate a flatpickr date picker widget on the list age field 169 | flatpickr('#enter-list-age', { 170 | defaultDate: janFirstDate, 171 | maxDate: 'today', 172 | dateFormat: 'm/d/Y' 173 | }); 174 | 175 | const enterStatsFields = document.querySelectorAll('.enter-stats input'); 176 | for (let i = 0; i < enterStatsFields.length; ++i) { 177 | const elt = enterStatsFields[i]; 178 | elt.addEventListener('change', () => updateChart(450)); 179 | } 180 | 181 | /* Event listener which triggers an animation when the chart comes into view */ 182 | const chartVisibleHandler = () => { 183 | const 184 | rect = indexBubbleChart.getBoundingClientRect(), 185 | top = rect.top, 186 | bottom = rect.bottom - 45; 187 | if (top >= 0 && bottom <= window.innerHeight) { 188 | const 189 | listSizeField = document.getElementById('enter-list-size'), 190 | openRateField = document.getElementById('enter-open-rate'); 191 | listSizeField.value = '25,000'; 192 | openRateField.value = '30%'; 193 | updateChart(1500); 194 | document.removeEventListener('scroll', debouncedChartHandler); 195 | } 196 | } 197 | 198 | const debouncedChartHandler = debounced(50, chartVisibleHandler); 199 | 200 | document.addEventListener('scroll', debouncedChartHandler); 201 | } -------------------------------------------------------------------------------- /tests/unit/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import flask 3 | import requests 4 | from app.forms import UserForm, OrgForm, ApiKeyForm 5 | 6 | def test_user_form(test_app): 7 | """Tests the UserForm.""" 8 | with test_app.app_context(): 9 | user_form = UserForm() 10 | assert isinstance(user_form, UserForm) 11 | 12 | @pytest.mark.parametrize('name, email, news_org, expect', [ 13 | ('', 'foo@bar.com', 'foo', False), 14 | ('foo', '', 'bar', False), 15 | ('foo', 'foo@', 'bar', False), 16 | ('foo', 'foo@bar.com', '', False), 17 | ('foo', 'foo@bar.com', 'bar', True) 18 | ]) 19 | def test_user_form_validation(test_app, name, email, news_org, expect): 20 | """Tests the UserForm with different types of parameters.""" 21 | with test_app.app_context(): 22 | user_form = UserForm() 23 | user_form.name.data = name 24 | user_form.email.data = email 25 | user_form.news_org.data = news_org 26 | assert user_form.validate() == expect 27 | 28 | def test_org_form(test_app): 29 | """Tests the OrgForm.""" 30 | with test_app.app_context(): 31 | org_form = OrgForm() 32 | assert isinstance(org_form, OrgForm) 33 | 34 | @pytest.mark.parametrize( 35 | 'financial_classification, coverage_scope, coverage_focus, platform, ' 36 | 'employee_range, budget, expect', [ 37 | ('', '', '', '', '', '', False), 38 | ('', 'City', 'Investigative', 'Digital Only', '6-10', 39 | '$2m-$10m', False), 40 | ('foo', 'City', 'Investigative', 'Digital Only', '6-10', 41 | '$2m-$10m', False), 42 | ('B Corp', '', 'Investigative', 'Digital Only', '6-10', 43 | '$2m-$10m', False), 44 | ('B Corp', 'City', '', 'Digital Only', '6-10', 45 | '$2m-$10m', False), 46 | ('B Corp', 'City', 'Investigative', '', '6-10', 47 | '$2m-$10m', False), 48 | ('B Corp', 'City', 'Investigative', 'Digital Only', '', 49 | '$2m-$10m', False), 50 | ('B Corp', 'City', 'Investigative', 'Digital Only', '6-10', 51 | '', False), 52 | ('B Corp', 'City', 'Investigative', 'Digital Only', '6-10', 53 | '$2m-$10m', True)]) 54 | def test_org_form_validation(test_app, financial_classification, 55 | coverage_scope, coverage_focus, platform, 56 | employee_range, budget, expect): 57 | """Tests the OrgForm with different types of parameters.""" 58 | with test_app.app_context(): 59 | org_form = OrgForm() 60 | org_form.financial_classification.data = financial_classification 61 | org_form.coverage_scope.data = coverage_scope 62 | org_form.coverage_focus.data = coverage_focus 63 | org_form.platform.data = platform 64 | org_form.employee_range.data = employee_range 65 | org_form.budget.data = budget 66 | assert org_form.validate() == expect 67 | 68 | def test_api_key_form(test_app): 69 | """Tests the API Key Form.""" 70 | with test_app.app_context(): 71 | api_key_form = ApiKeyForm() 72 | assert isinstance(api_key_form, ApiKeyForm) 73 | 74 | @pytest.mark.parametrize('key, org_choices, org, expect', [ 75 | ('', [('0', 'foo'), ('1', 'bar')], '0', False), 76 | ('foo', [], 'bar', False) 77 | ]) 78 | def test_api_key_form_basic_validation(test_app, key, org_choices, org, expect): 79 | """Tests the basic validation of the ApiKeyForm.""" 80 | with test_app.app_context(): 81 | api_key_form = ApiKeyForm() 82 | api_key_form.key.data = key 83 | api_key_form.organization.choices = org_choices 84 | api_key_form.organization.data = org 85 | assert api_key_form.validate() == expect 86 | 87 | def test_api_key_form_bad_data_center(test_app): 88 | """Tests that the ApiKeyForm flags a key without a data center.""" 89 | with test_app.app_context(): 90 | api_key_form = ApiKeyForm() 91 | api_key_form.key.data = 'foo' 92 | api_key_form.organization.choices = [('0', 'bar')] 93 | api_key_form.organization.data = '0' 94 | assert not api_key_form.validate() 95 | assert ['Key missing data center'] == list( 96 | api_key_form.errors.values())[0] 97 | 98 | def test_api_key_form_wellformed_request(test_app, mocker): 99 | """Tests that the request to MailChimp is properly formed.""" 100 | with test_app.app_context(): 101 | api_key_form = ApiKeyForm() 102 | api_key_form.key.data = 'foo-bar1' 103 | api_key_form.key.errors = [] 104 | mocked_validate = mocker.patch('app.forms.FlaskForm.validate') 105 | mocked_validate.return_value = True 106 | mocked_request = mocker.patch('app.forms.requests') 107 | api_key_form.validate() 108 | mocked_request.get.assert_called_with( 109 | 'https://bar1.api.mailchimp.com/3.0/lists', 110 | params=(('fields', 'total_items'),), 111 | auth=('shorenstein', 'foo-bar1')) 112 | 113 | def test_api_key_form_connection_error(test_app, mocker): 114 | """Tests that a ConnectionError from MailChimp is handled correctly.""" 115 | with test_app.app_context(): 116 | api_key_form = ApiKeyForm() 117 | api_key_form.key.data = 'foo-bar1' 118 | api_key_form.key.errors = [] 119 | mocked_validate = mocker.patch('app.forms.FlaskForm.validate') 120 | mocked_validate.return_value = True 121 | mocked_get_request = mocker.patch('app.forms.requests.get') 122 | mocked_get_request.side_effect = requests.exceptions.ConnectionError() 123 | assert not api_key_form.validate() 124 | assert ['Connection to MailChimp servers refused'] == list( 125 | api_key_form.errors.values())[0] 126 | 127 | def test_api_key_form_bad_request(test_app, mocker): 128 | """Tests that a bad request to MailChimp is handled correctly.""" 129 | with test_app.app_context(): 130 | api_key_form = ApiKeyForm() 131 | api_key_form.key.data = 'foo-bar1' 132 | api_key_form.key.errors = [] 133 | mocked_validate = mocker.patch('app.forms.FlaskForm.validate') 134 | mocked_validate.return_value = True 135 | mocked_request = mocker.patch('app.forms.requests') 136 | mocked_request.get.return_value.status_code = 404 137 | assert not api_key_form.validate() 138 | assert ['MailChimp responded with error code 404'] == list( 139 | api_key_form.errors.values())[0] 140 | 141 | def test_api_key_form_validates(test_app, mocker): 142 | """Tests succesful validation of the ApiKeyForm.""" 143 | with test_app.test_request_context(): 144 | api_key_form = ApiKeyForm() 145 | api_key_form.key.data = 'foo-bar1' 146 | api_key_form.store_aggregates.data = True 147 | api_key_form.monthly_updates.data = True 148 | mocked_validate = mocker.patch('app.forms.FlaskForm.validate') 149 | mocked_validate.return_value = True 150 | mocked_request = mocker.patch('app.forms.requests') 151 | mocked_request.get.return_value.status_code = 200 152 | mocked_request.get.return_value.json.return_value.get.return_value = 2 153 | assert api_key_form.validate() 154 | assert flask.session['key'] == 'foo-bar1' 155 | assert flask.session['data_center'] == 'bar1' 156 | assert flask.session['num_lists'] == 2 157 | assert flask.session['store_aggregates'] 158 | assert flask.session['monthly_updates'] 159 | -------------------------------------------------------------------------------- /app/static/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 113 | 118 | 125 | 129 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /app/visualizations.py: -------------------------------------------------------------------------------- 1 | """This module contains plotly visualizations.""" 2 | import plotly.graph_objs as go 3 | import plotly.io as pio 4 | 5 | OPACITY = 0.7 6 | COLORS = ['rgba(0,0,51,{})', 'rgba(94,12,35,{})', 'rgba(4,103,103,{})', 7 | 'rgba(128,128,128,{})'] 8 | FILL_COLORS = [color.format(OPACITY) for color in COLORS] 9 | HISTOGRAM_COLORS = ['rgba(94,12,35,{})', 'rgba(84,22,43,{})', 10 | 'rgba(74,32,50,{})', 'rgba(64,42,58,{})', 11 | 'rgba(54,52,65,{})', 'rgba(44,63,73,{})', 12 | 'rgba(34,73,80,{})', 'rgba(24,83,88,{})', 13 | 'rgba(14,93,95,{})', 'rgba(4,103,103,{})'] 14 | HISTOGRAM_FILL_COLORS = [color.format(OPACITY) for color in HISTOGRAM_COLORS] 15 | CHART_MARGIN = 55 16 | 17 | def write_png(data, layout, filename): 18 | """Writes out a visualization with the given data and layout to png.""" 19 | fig = go.Figure(data=data, layout=layout) 20 | pio.write_image( 21 | fig, 'app/static/charts/{}.png'.format(filename), scale=2) 22 | 23 | def draw_bar(x_vals, y_vals, diff_vals, title, filename, # pylint: disable=too-many-arguments 24 | percentage_values=False): 25 | """Creates a simple bar chart. See plot.ly/python/bar-charts. 26 | 27 | Args: 28 | x_vals: a list containing the bar x-values. 29 | y_vals: a list containing the bar y-values 30 | diff_vals difference between monthly values (for labels), if the 31 | previous month's data is included. 32 | title: the chart title. 33 | filename: the filename of the exported png. 34 | percentage_values: if true, formats y-values as percentages. 35 | """ 36 | label_text = [ 37 | '{:.1%}'.format(y_val) if percentage_values 38 | else '{:,d}'.format(int(y_val)) 39 | for y_val in y_vals] 40 | if diff_vals: 41 | label_text[1] += ('
(' + diff_vals[0] + ')') 42 | label_text[3] += ('
(' + diff_vals[1] + ')') 43 | 44 | trace = go.Bar( 45 | x=x_vals, 46 | y=y_vals, 47 | width=[0.6 for x_val in x_vals], 48 | text=label_text, 49 | textposition='outside', 50 | cliponaxis=False, 51 | marker={'color': ( 52 | [FILL_COLORS[0], FILL_COLORS[0], FILL_COLORS[1], FILL_COLORS[1]] 53 | if diff_vals 54 | else [FILL_COLORS[0], FILL_COLORS[1]])} 55 | ) 56 | data = [trace] 57 | layout = go.Layout( 58 | title=title, 59 | autosize=False, 60 | width=600, 61 | height=500, 62 | margin={'pad': 0, 'b': CHART_MARGIN - 10, 't': CHART_MARGIN + 5}, 63 | font={'size': 9}, 64 | titlefont={'size': 13}) 65 | if percentage_values: 66 | layout.yaxis = go.layout.YAxis(tickformat=',.0%') 67 | write_png(data, layout, filename) 68 | 69 | def draw_stacked_horizontal_bar(y_vals, x_series, diff_vals, title, filename): 70 | """Creates a horizontal stacked bar chart. 71 | 72 | See plot.ly/python/bar-charts/#stacked-bar-chart and 73 | https://plot.ly/python/horizontal-bar-charts. 74 | 75 | Args: 76 | y_vals: a list containing the bar y-values. 77 | x_series: a list of tuples. Each tuple represents a data series. 78 | The tuple's first element is the series name; the second element 79 | is a list containing the series data. 80 | diff_vals difference between monthly values (for labels), if the 81 | previous month's data is included. 82 | title: see draw_bar(). 83 | filename: see draw_bar(). 84 | """ 85 | data = [] 86 | for series_num, series_data in enumerate(x_series): 87 | 88 | text = [] 89 | for series_datum_num, series_datum in enumerate(series_data[1]): 90 | diff_val = ( 91 | diff_vals.pop(0) 92 | if diff_vals and series_datum_num % 2 != 0 93 | else None) 94 | 95 | if series_datum < .02 and series_data[0] != 'Pending %': 96 | text.append('') 97 | elif diff_val: 98 | text.append('{:.1%}'.format(series_datum) + 99 | '
(' + diff_val + ')') 100 | else: 101 | text.append('{:.1%}'.format(series_datum)) 102 | 103 | trace = go.Bar( 104 | y=y_vals, 105 | x=series_data[1], 106 | name=series_data[0], 107 | text=text, 108 | textposition='auto', 109 | textfont={'color': '#444' 110 | if series_data[0] == 'Pending %' 111 | else '#fff', 112 | 'size': 10.5}, 113 | cliponaxis=False, 114 | marker={'color': FILL_COLORS[series_num]}, 115 | orientation='h') 116 | data.append(trace) 117 | layout = go.Layout( 118 | title=title, 119 | barmode='stack', 120 | autosize=False, 121 | width=1000, 122 | height=450, 123 | margin={'pad': 0, 'b': CHART_MARGIN, 't': CHART_MARGIN}, 124 | legend={'traceorder': 'normal'}, 125 | xaxis=go.layout.XAxis(tickformat=',.0%'), 126 | yaxis=go.layout.YAxis(automargin=True)) 127 | write_png(data, layout, filename) 128 | 129 | def draw_histogram(x_data, y_data, title, legend_img_uri, filename): 130 | """Creates a histogram. 131 | 132 | Does not use plotly's histogram functionality 133 | (https://plot.ly/python/histograms) as the data is already binned 134 | in pandas (see calc_histogram() in lists.py). Instead uses a bar 135 | chart with no spacing and x-axis ticks between bars. 136 | 137 | Args: 138 | x_data: a dictionary containing the x-axis title and x-data. 139 | y_vals: a dictionary containing the y-axis title and y-data. 140 | title: see draw_bar(). 141 | legend_img_uri: the URI of the legend image. 142 | filename: see draw_bar(). 143 | """ 144 | trace = go.Bar( 145 | x=x_data['vals'], 146 | y=y_data['vals'], 147 | text=y_data['vals'], 148 | textposition='outside', 149 | marker={'color': HISTOGRAM_FILL_COLORS}) 150 | data = [trace] 151 | layout = go.Layout( 152 | title=title, 153 | annotations=[{ 154 | 'text': 'Lower Open Rates', 155 | 'font': { 156 | 'size': 12 157 | }, 158 | 'showarrow': False, 159 | 'xref': 'paper', 160 | 'yref': 'paper', 161 | 'x': .12, 162 | 'y': -0.175, 163 | 'xanchor': 'right', 164 | 'yanchor': 'bottom' 165 | }, { 166 | 'text': 'Higher Open Rates', 167 | 'font': { 168 | 'size': 12 169 | }, 170 | 'showarrow': False, 171 | 'xref': 'paper', 172 | 'yref': 'paper', 173 | 'x': .88, 174 | 'y': -0.175, 175 | 'xanchor': 'left', 176 | 'yanchor': 'bottom' 177 | }, { 178 | 'text': x_data['title'], 179 | 'font': { 180 | 'size': 13 181 | }, 182 | 'showarrow': False, 183 | 'xref': 'paper', 184 | 'yref': 'paper', 185 | 'x': .5, 186 | 'y': -0.275, 187 | 'align': 'center' 188 | }], 189 | autosize=False, 190 | width=1000, 191 | margin={'t': CHART_MARGIN, 'b': 115}, 192 | bargap=0, 193 | xaxis=go.layout.XAxis( 194 | tickmode='linear', 195 | tickformat=',.0%', 196 | tick0=0, 197 | dtick=0.1,), 198 | yaxis=go.layout.YAxis( 199 | title=y_data['title'], 200 | automargin=True, 201 | ticksuffix=' ', 202 | tickprefix=' '), 203 | images=[{ 204 | 'source': legend_img_uri, 205 | 'xref': 'paper', 206 | 'yref': 'paper', 207 | 'x': .5, 208 | 'y': -0.175, 209 | 'layer': 'above', 210 | 'sizex': .75, 211 | 'sizey': 1, 212 | 'xanchor': 'center', 213 | 'yanchor': 'bottom' 214 | }]) 215 | write_png(data, layout, filename) 216 | 217 | def draw_donuts(series_names, donuts, diff_vals, title, filename): 218 | """Creates two side-by-side donut charts. See plot.ly/python/pie-charts/. 219 | 220 | Args: 221 | series_names: a list containing the series names. 222 | donuts: a list of tuples, each containing the data for a chart. 223 | The first element of the tuple is the chart name; the second 224 | is a list of data corresponding to each series. 225 | diff_vals difference between monthly values (for labels), if the 226 | previous month's data is included. 227 | title: see draw_bar(). 228 | filename: see draw_bar(). 229 | """ 230 | data = [] 231 | 232 | donut_domains = ( 233 | [[0, .19], [.27, .46], [.54, .73], [.81, 1]] 234 | if len(donuts) == 4 235 | else [[.27, .46], [.54, .73]] 236 | ) 237 | 238 | donut_title_x = [.095, .365, .635, .905] if len(donuts) == 4 else [.365, .635] 239 | 240 | for donut_num, donut in enumerate(donuts): 241 | 242 | text = ['{:.1%}'.format(donut_val) for donut_val in donut[1]] 243 | if donut_num % 2 != 0 and diff_vals: 244 | text[0] += ('
(' + diff_vals.pop(0) + ')') 245 | 246 | trace = go.Pie( 247 | values=donut[1], 248 | labels=series_names, 249 | name=donut[0], 250 | text=text, 251 | hole=.45, 252 | domain={'x': donut_domains[donut_num]}, 253 | marker={'colors': FILL_COLORS, 254 | 'line': {'width': 0}}, 255 | textfont={'color': '#fff', 'size': 8.5}, 256 | textinfo='text') 257 | data.append(trace) 258 | layout = go.Layout( 259 | title=title, 260 | autosize=False, 261 | width=1000, 262 | height=500, 263 | margin={'pad': 0, 'b': 0, 't': CHART_MARGIN}, 264 | annotations=[{ 265 | 'text': donut[0], 266 | 'font': { 267 | 'size': 12.5, 268 | }, 269 | 'showarrow': False, 270 | 'align': 'center', 271 | 'x': donut_title_x[donut_num], 272 | 'y': .83, 273 | 'xanchor': 'center', 274 | 'yanchor': 'top'} for donut_num, donut in enumerate(donuts)], 275 | legend={'orientation': 'h', 276 | 'xanchor': 'center', 277 | 'yanchor': 'bottom', 278 | 'y': .15, 279 | 'x': .5}) 280 | write_png(data, layout, filename) 281 | -------------------------------------------------------------------------------- /app/static/js/scripts.min.js: -------------------------------------------------------------------------------- 1 | const tagField=(e,t)=>{t?(e.classList.remove("invalid"),e.parentElement.classList.remove("invalid"),e.classList.add("valid"),e.parentElement.classList.add("valid")):(e.classList.remove("valid"),e.parentElement.classList.remove("valid"),e.classList.add("invalid"),e.parentElement.classList.add("invalid"))},clientSideValidateField=e=>{const t=e.getAttribute("custom_type"),r=e.value;let o=!0;return o="key"==t?0!==r.length&&-1!==r.indexOf("-us"):"email"==t?0!==r.length&&-1!==r.indexOf("@")&&-1!==r.indexOf("."):0!==r.length,tagField(e,o),o},clientSideValidateForm=e=>{const t=e.querySelectorAll("input:not(.disabled-elt), select:not(.disabled-elt)");let r=!0;for(let e=0;eclientSideValidateField(e.currentTarget)):t.addEventListener("change",e=>clientSideValidateField(e.currentTarget))}const disable=e=>{if(NodeList.prototype.isPrototypeOf(e))for(let t=0;t{if(NodeList.prototype.isPrototypeOf(e))for(let t=0;t{let r;return(...o)=>{r&&clearTimeout(r),r=setTimeout(()=>{t(...o),r=null},e)}},csrfToken=document.querySelector("meta[name=csrf-token]").content,basicInfoForm=document.querySelector("#basic-info-form"),submitBasicInfo=async e=>{if(e.preventDefault(),basicInfoForm.removeEventListener("submit",submitBasicInfo),!clientSideValidateForm(basicInfoForm))return void basicInfoForm.addEventListener("submit",submitBasicInfo);const t=basicInfoForm.querySelectorAll("input");disable(t);const r=new Headers({"X-CSRFToken":csrfToken}),o=new FormData(basicInfoForm),a=new Request("/validate-basic-info",{method:"POST",credentials:"same-origin",headers:r,body:o});try{const r=await fetch(a);if(r.ok){const e=await r.json();if("existing"==e.org){const t="/confirmation?title="+"Thanks!"+"&body="+("approved"==e.user?"You're all set! We've emailed you a unique access link.":"We've received your details. Once our team has reviewed your submission, we'll email you with instructions for accessing our benchmarking tool.");window.location.href=t}else window.location.href="/org-info"}else{if(422!=r.status)throw new Error(r.statusText);{const e=basicInfoForm.querySelectorAll(".invalid");for(let t=0;t{if(e.preventDefault(),orgForm.removeEventListener("submit",submitOrg),!clientSideValidateForm(orgForm))return void orgForm.addEventListener("submit",submitOrg);const t=orgForm.querySelectorAll("label, .custom-control-label");disable(t);const r=new Headers({"X-CSRFToken":csrfToken}),o=new FormData(orgForm),a=new Request("/validate-org-info",{method:"POST",credentials:"same-origin",headers:r,body:o});try{const r=await fetch(a);if(r.ok){const e="/confirmation?title="+"Thanks!"+"&body="+("approved"==(await r.json()).user?"You're all set! We've emailed you a unique access link.":"We've received your details. Once our team has reviewed your submission, we'll email you with instructions for accessing our benchmarking tool.");window.location.href=e}else{if(422!=r.status)throw new Error(r.statusText);{const e=orgForm.querySelectorAll(".invalid");for(let t=0;t{const r=["valid","invalid"];e.classList.remove(...r),e.classList.toggle("disabled-elt"),t.classList.remove(...r),e.value=""};if(orgForm){orgForm.addEventListener("submit",submitOrg);const e=orgForm.querySelector("#other_affiliation"),t=orgForm.querySelector("#other_affiliation_name"),r=orgForm.querySelector("#other-affiliation-name-wrapper");e.addEventListener("change",()=>toggleOtherAffField(t,r))}const toggles=document.querySelectorAll("span.switch"),changeActivationStatus=async e=>{const t=e.currentTarget;t.removeEventListener("change",changeActivationStatus),disable(t);const r=t.getAttribute("switch-id"),o=new Headers({"X-CSRFToken":csrfToken}),a=new Request("/activate-user?user="+r,{method:"GET",credentials:"same-origin",headers:o});try{const r=await fetch(a);if(!r.ok)throw new Error(r.statusText);enable(t),t.addEventListener("change",changeActivationStatus)}catch(e){console.error(e)}};if(toggles)for(let e=0;e{if(e.preventDefault(),apiKeyForm.removeEventListener("submit",submitApiKey),!clientSideValidateForm(apiKeyForm))return void apiKeyForm.addEventListener("submit",submitApiKey);const t=apiKeyForm.querySelectorAll('select,input:not([type="checkbox"]), .custom-control-label');disable(t);const r=new Headers({"X-CSRFToken":csrfToken}),o=new FormData(apiKeyForm),a=new Request("/validate-api-key",{method:"POST",credentials:"same-origin",headers:r,body:o});try{const r=await fetch(a);if(r.ok)window.location.href="/select-list";else{if(422!=r.status)throw new Error(r.statusText);{const e=apiKeyForm.querySelectorAll(".invalid");for(let t=0;t{if(0==e)return"N/A";e=Number(e);const t=Math.floor(e/3600),r=Math.floor(e%3600/60),o=(t>0?t+(0==r?1==t?" hour":" hours":1==t?" hour, ":" hours, "):"")+(r>0?r+(1==r?" minute":" minutes"):"");return o?"~"+o:"<1 minute"},setupListsTable=e=>{let t="";for(let r=0;r",t+=""+e[r].name+"",t+=""+e[r].stats.member_count.toLocaleString()+"",t+=""+secondsToHm(.24*e[r].stats.member_count)+"",e[r].stats.member_count>0?t+="":t+="",t+=""}t+="",document.querySelector("thead").insertAdjacentHTML("afterend",t);const r=document.querySelectorAll(".analyze-link");for(let e=0;easync i=>{i.preventDefault();const s=document.querySelectorAll(".analyze-link");for(let e=0;eMath.floor((new Date-e)/2592e6),updateChart=e=>{const t=parseInt(document.getElementById("enter-list-size").value.replace(/,/g,"")),r=+document.getElementById("enter-open-rate").value.replace("%",""),o=document.getElementById("enter-list-age").value;if(isNaN(t)||t<0||isNaN(r)||r<0||r>100)return;const a=dateDiff(new Date(new Date(o).toUTCString())),n=r.toFixed(1);return Plotly.animate(indexBubbleChart,{data:[{x:[a],y:[n],text:["Age: "+a+" months
Open Rate: "+n+"%
Subscribers: "+t.toLocaleString()],marker:{size:[t]}}],traces:[1]},{transition:{duration:e,easing:"ease"},frame:{duration:e}})};if(indexBubbleChart){const e=JSON.parse(indexBubbleChart.getAttribute("data-subscribers")),t=JSON.parse(indexBubbleChart.getAttribute("data-open-rates")).map(e=>Math.round(1e3*e)/10),r=JSON.parse(indexBubbleChart.getAttribute("data-ages")),o=new Date(new Date((new Date).getFullYear()-5,0,1).toUTCString()),a=window.innerWidth<576,n=[{x:r,y:t,text:Array.from({length:r.length},(o,a)=>"Age: "+r[a]+" months
Open Rate: "+t[a]+"%
Subscribers: "+e[a].toLocaleString()),hoverinfo:"text",hoverlabel:{bgcolor:"rgba(167, 25, 48, .85)",font:{family:"Montserrat, sans-serif",size:12}},mode:"markers",marker:{size:e,sizeref:2*Math.max(...e)/3600,sizemode:"area",color:new Array(e.length).fill("rgba(167, 25, 48, .85)")}},{x:[dateDiff(o)],y:[7.5],text:["Age: "+dateDiff(o)+" months
Open Rate: 7.5%
Subscribers: 5,000"],hoverinfo:"text",hoverlabel:{bgcolor:"rgba(215, 164, 45, 0.85)",font:{color:"white",family:"Montserrat, sans-serif",size:12},bordercolor:"white"},mode:"markers",marker:{size:[5e3],sizeref:2*Math.max(...e)/3600,sizemode:"area",color:["rgba(215, 164, 45, 0.85)"]}}],i={font:{family:"Montserrat, sans-serif",size:a?12:16},yaxis:{range:[0,1.25*Math.max(...t)>100?100:1.25*Math.max(...t)],color:"#aaa",tickfont:{color:"#555"},tickprefix:a?" ":" ",ticksuffix:"% ",title:"List Open Rate",titlefont:{color:"#555"},automargin:!0,fixedrange:!0},xaxis:{range:[0,1.15*Math.max(...r)],color:"#aaa",tickfont:{color:"#555"},tickformat:",",title:"List Age (Months)",titlefont:{color:"#555"},fixedrange:!0},showlegend:!1,height:525,margin:{l:a?65:75,t:5,b:105,r:a?45:75,autoexpand:!0},hovermode:"closest"},s={responsive:!0,displayModeBar:!1};Plotly.newPlot(indexBubbleChart,n,i,s),flatpickr("#enter-list-age",{defaultDate:o,maxDate:"today",dateFormat:"m/d/Y"});const l=document.querySelectorAll(".enter-stats input");for(let e=0;eupdateChart(450))}const c=debounced(50,()=>{const e=indexBubbleChart.getBoundingClientRect(),t=e.top,r=e.bottom-45;if(t>=0&&r<=window.innerHeight){const e=document.getElementById("enter-list-size"),t=document.getElementById("enter-open-rate");e.value="25,000",t.value="30%",updateChart(1500),document.removeEventListener("scroll",c)}});document.addEventListener("scroll",c)} -------------------------------------------------------------------------------- /app/templates/report-email.html: -------------------------------------------------------------------------------- 1 | {% extends "email-base.html" %} 2 | 3 | {% block content %} 4 | 5 | 6 | 7 | 8 | 9 |
Newletter Guide LogoCheck out our new email newsletters playbook!
10 |

11 | Chart A: List Size 12 |

13 |

Chart A compares the total number of current subscribers on your list to the mean number of current subscribers across all lists we're tracking in our database.

14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 |
Tips Icon 17 | Our Tips
The Shorenstein Center’s Email Newsletter Playbook contains a section on email acquisition tactics, such as signup field/signup lightbox placement and off-platform acquisition strategies. That being said, all subscribers are not created equal. We encourage you to challenge your team to think about your list in terms of the current status of every email address you’ve ever acquired (see Chart B, below).
23 | 24 | 25 | 26 | 27 |
Want to know whom you're being benchmarked against? Check out our FAQ!
28 |

29 | Chart B: List Composition 30 |

31 |

Chart B breaks down the total number of unique email addresses in the entire list into percentages. In this case, the entire list refers to all email addresses ever acquired, both currently and formerly subscribed. MailChimp has four possible values for list member status:

32 |
  • Subscribed: current subscribers
  • Unsubscribed: subscribers who removed themselves from list or whom the list owner removed
  • Cleaned: subscribers whom MailChimp automatically removed from your list after a number of email bounces
  • Pending: semi-subscribers stuck in the limbo of double opt in—or, someone who gave their email address but did not hit the confirmation button in their email inbox
33 |

For more information about how MailChimp breaks down your list, refer to the following support material: MailChimp: About Your Contacts.

34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 |
Tips Icon 37 | Our Tips
You should pay particular attention this chart if you see large percentages of "pending" or "unsubscribed" email addresses. Pendings can be caused by a number of factors, including confusing language around confirmation for double opt-in, bot traffic, or deliverability. Consider using a service like NeverBounce to test which of your pending users are valid and active email addresses, and consider reaching out to your unsubscribers with a survey request to learn more about why they didn’t love your email product.
43 |

44 |

45 | Chart C: List Open Rate 46 |

47 |

Chart C shows your List Open Rate. MailChimp calculates List Open Rate by taking the mean of your past Campaign Open Rates over the life of your list. Each Campaign Open Rate is calculated by dividing the number of recipients who opened the campaign email by the number of emails delivered. While List and Campaign Open Rates are the traditional way of looking at your email performance, these metrics lose a large part of the story. As with list size (Chart A, above), a better way to look at your List Open Rate is through a distribution of your subscribers' individual unique open rates (see Chart D, below).

48 |

49 | Chart D: Distribution of Subscribers by Open Rate 50 |

51 |

Chart D shows the distribution of open rates among current subscribers on your list. (MailChimp calculates each user's open rate by dividing the total number of emails a user has opened by the total number of emails successfully delivered to him or her.) This histogram is created through binning, which groups together consecutive continuous numbers into discrete bins. The x axis shows the range that each bin contains. As an example, the leftmost bin contains subscribers with an open rate between 0% and 10%. The rightmost bin contains subscribers with an open rate between 90% and 100%. The y axis shows the number of current subscribers who fall into each bin. Open rates generally trend downward before upticking between 80-100%. For a more comprehensive look at typical distributions, refer to Section 3.4 of our Research Guide.

52 |

53 | Chart E: Percentage of Subscribers with User Unique Open Rate >80% 54 |

55 |

Chart E shows your most engaged subscribers: those who open between 80% and 100% of your emails.

56 | 57 | 58 | 60 | 61 | 62 | 63 | 64 |
Tips Icon 59 | Our Tips
We recommend surveying these users. Who are they? What are their habits? What else do they like to read? What do they want out of an email product? What might compel them to support or contribute to your work? Then, develop a few user personas. You should use this data to target your acquisition efforts towards subscribers who are likely to be be highly engaged. Alternatively, try to figure out what separates users in this category from semi-engaged subscribers (those who open ~30-80% of your emails). Might they be better served with a different newsletter or product from your newsroom?
65 |

66 | Chart F: Percentage of Subscribers who did not Open in last 365 Days 67 |

68 |

Chart F shows your current subscribers who haven't opened one of your emails within the past 365 days. Inactive subscribers can make it harder to understand your list dynamics as well as affect your email deliverability (i.e. increase the probability that your emails are relegated to spam).

69 | 70 | 71 | 73 | 74 | 75 | 76 | 77 |
Tips Icon 72 | Our Tips
We recommend that newsrooms routinely and manually remove inactive users from their list after sending them a re-engagement campaign. This is also called "cleaning" your list. If you have never cleaned your list, we strongly recommend that you set aside some time to do so. Then, monitor to see if and when the inactive or pending segment grows. We generally recommend a list clean twice a year (around every six months). To learn more, read the leaky bucket analogy.
78 |

Dataset average represents the mean of all lists in our dataset. · We update cached data at least once every 30 days. · We acknowledge the frequency with which emails are sent can affect metrics, with daily sends having lower engagement than weekly or monthly sends. We do not take this into account at the time of this analysis.

To learn more about analyzing the health of your email list using data science tools, please see our Research Guide as well as our comprehensive Playbook for email newsletters.

For more information on how we use and store your data, check out our Privacy Policy.

If you would no longer like to hear from us, please email us at contact@emailbenchmarking.com. 79 |

80 | {% endblock %} --------------------------------------------------------------------------------