├── data ├── .placeholder ├── queries │ ├── email-link-clicked.yaml │ ├── tweet-link-clicked.yaml │ ├── rdio-link-clicked.yaml │ ├── summary-copied.yaml │ ├── amazon-link-clicked.yaml │ ├── itunes-link-clicked.yaml │ ├── spotify-link-clicked.yaml │ ├── facebook-share-link-clicked.yaml │ ├── share-discuss-panel-opened.yaml │ ├── sessions-by-browser.yaml │ ├── sessions-by-device-category.yaml │ ├── sessions-by-referring-domain.yaml │ ├── performance.yaml │ ├── sessions-by-referring-social-network.yaml │ ├── totals.yaml │ ├── songs-skipped.yaml │ ├── tags-finished.yaml │ ├── tags-selected.yaml │ └── time-spent-on-page-by-device-category.yaml ├── projects.csv └── test_user.json ├── carebot ├── __init__.py ├── wsgi.py ├── urls.py └── settings.py ├── reports ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20150106_1134.py │ ├── 0003_auto_20150107_1033.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── social.py ├── tests.py ├── templates │ ├── _tags.html │ ├── email.txt │ ├── _report_dimension.html │ ├── _base.html │ ├── _compare_dimension.html │ ├── index.html │ ├── report.html │ ├── compare_query.html │ └── project.html ├── static │ └── reports │ │ ├── report.css │ │ ├── tablesort.min.js │ │ └── bootstrap.min.js ├── admin.py ├── views.py └── models.py ├── run_on_server.sh ├── confs ├── nginx.conf ├── uwsgi.conf └── app.ini ├── crontab ├── manage.py ├── fabfile ├── utils.py ├── data.py ├── __init__.py ├── cron_jobs.py └── servers.py ├── requirements.txt ├── utils.py ├── .gitignore ├── render_utils.py ├── README.md └── app_config.py /data/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /carebot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reports/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reports/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reports/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /run_on_server.sh: -------------------------------------------------------------------------------- 1 | source /etc/environment 2 | cd `dirname "$0"` 3 | source ../virtualenv/bin/activate 4 | eval $@ -------------------------------------------------------------------------------- /data/queries/email-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Email link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==email" -------------------------------------------------------------------------------- /data/queries/tweet-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Tweet link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==tweet" -------------------------------------------------------------------------------- /data/queries/rdio-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Rdio link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==rdio-click" -------------------------------------------------------------------------------- /data/queries/summary-copied.yaml: -------------------------------------------------------------------------------- 1 | name: "Summary Copied" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==summary-copied" -------------------------------------------------------------------------------- /data/queries/amazon-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Amazon link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==amazon-click" -------------------------------------------------------------------------------- /data/queries/itunes-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "iTunes link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==itunes-click" -------------------------------------------------------------------------------- /data/queries/spotify-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Spotify link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==spotify-click" -------------------------------------------------------------------------------- /data/queries/facebook-share-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Facebook share link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==facebook" -------------------------------------------------------------------------------- /confs/nginx.conf: -------------------------------------------------------------------------------- 1 | location ^~ /{{ PROJECT_SLUG }}/ { 2 | uwsgi_pass unix:///tmp/{{ PROJECT_FILENAME }}.uwsgi.sock; 3 | include /etc/nginx/uwsgi_params; 4 | } 5 | -------------------------------------------------------------------------------- /data/queries/share-discuss-panel-opened.yaml: -------------------------------------------------------------------------------- 1 | name: "Share/Discuss panel opened" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==open-share-discuss" -------------------------------------------------------------------------------- /data/queries/sessions-by-browser.yaml: -------------------------------------------------------------------------------- 1 | name: "Sessions by browser" 2 | metrics: 3 | - "ga:sessions" 4 | dimensions: 5 | - "ga:browser" 6 | sort: 7 | - "-ga:sessions" -------------------------------------------------------------------------------- /reports/templates/_tags.html: -------------------------------------------------------------------------------- 1 | {% for tag in project.tags.all %}{{ tag }}{% if not forloop.last %}, {% endif %}{% endfor %} 2 | -------------------------------------------------------------------------------- /data/queries/sessions-by-device-category.yaml: -------------------------------------------------------------------------------- 1 | name: "Sessions by device category" 2 | metrics: 3 | - "ga:sessions" 4 | dimensions: 5 | - "ga:deviceCategory" 6 | sort: 7 | - "-ga:sessions" 8 | -------------------------------------------------------------------------------- /data/queries/sessions-by-referring-domain.yaml: -------------------------------------------------------------------------------- 1 | name: "Sessions by referring domain" 2 | metrics: 3 | - "ga:sessions" 4 | dimensions: 5 | - "ga:source" 6 | sort: 7 | - "-ga:sessions" 8 | -------------------------------------------------------------------------------- /data/queries/performance.yaml: -------------------------------------------------------------------------------- 1 | name: "Performance" 2 | metrics: 3 | - "ga:avgPageLoadTime" 4 | - "ga:avgPageDownloadTime" 5 | - "ga:avgDomInteractiveTime" 6 | - "ga:avgDomContentLoadedTime" -------------------------------------------------------------------------------- /crontab: -------------------------------------------------------------------------------- 1 | 0 */2 * * * ubuntu /bin/bash /home/ubuntu/apps/carebot/repository/run_on_server.sh fab $DEPLOYMENT_TARGET cron_jobs.run_reports cron_jobs.update_geckoboard >> /var/log/carebot/crontab.log 2>&1 2 | -------------------------------------------------------------------------------- /data/queries/sessions-by-referring-social-network.yaml: -------------------------------------------------------------------------------- 1 | name: "Sessions by referring social network" 2 | metrics: 3 | - "ga:sessions" 4 | dimensions: 5 | - "ga:socialNetwork" 6 | sort: 7 | - "-ga:sessions" -------------------------------------------------------------------------------- /data/queries/totals.yaml: -------------------------------------------------------------------------------- 1 | name: "Totals" 2 | description: "Note: Sessions is the most important number for comparison." 3 | metrics: 4 | - "ga:pageviews" 5 | - "ga:uniquePageviews" 6 | - "ga:users" 7 | - "ga:sessions" -------------------------------------------------------------------------------- /data/queries/songs-skipped.yaml: -------------------------------------------------------------------------------- 1 | name: "Songs skipped" 2 | description: "Sorted by songs skipped most often." 3 | metrics: 4 | - "ga:totalEvents" 5 | dimensions: 6 | - "ga:eventLabel" 7 | filter: "ga:eventAction==song-skip" 8 | sort: 9 | - "-ga:totalEvents" 10 | max-results: 20 -------------------------------------------------------------------------------- /data/queries/tags-finished.yaml: -------------------------------------------------------------------------------- 1 | name: "Tags finished" 2 | description: "Sorted by tags finished most often." 3 | metrics: 4 | - "ga:totalEvents" 5 | dimensions: 6 | - "ga:eventLabel" 7 | filter: "ga:eventAction==tag-finish" 8 | sort: 9 | - "-ga:totalEvents" 10 | max-results: 20 -------------------------------------------------------------------------------- /data/queries/tags-selected.yaml: -------------------------------------------------------------------------------- 1 | name: "Tags selected" 2 | description: "Sorted by tags selected most often." 3 | metrics: 4 | - "ga:totalEvents" 5 | dimensions: 6 | - "ga:eventLabel" 7 | filter: "ga:eventAction==switch-tag" 8 | sort: 9 | - "-ga:totalEvents" 10 | max-results: 20 -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "carebot.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /confs/uwsgi.conf: -------------------------------------------------------------------------------- 1 | # description "uWSGI server for {{ PROJECT_SLUG }}" 2 | 3 | start on runlevel [2345] 4 | stop on runlevel [!2345] 5 | 6 | respawn 7 | 8 | script 9 | . /etc/environment 10 | /usr/local/bin/uwsgi --ini {{ SERVER_REPOSITORY_PATH }}/{{ PROJECT_FILENAME }}.app.ini 11 | end script 12 | -------------------------------------------------------------------------------- /reports/templates/email.txt: -------------------------------------------------------------------------------- 1 | Carebot has created new reports for you! 2 | 3 | {% for report in reports %}* {{ report.project.title }} ({{ report.ndays }}-day{{ report.ndays|pluralize }}): http://{{ app_config.SERVERS.0 }}{{ report.get_absolute_url }} 4 | {% endfor %} 5 | 6 | Index of all reports: {{ app_config.SERVER_BASE_URL }}/ 7 | -------------------------------------------------------------------------------- /data/queries/time-spent-on-page-by-device-category.yaml: -------------------------------------------------------------------------------- 1 | name: "Time spent on page by device category" 2 | description: "Note: Time on page is an unreliable metric and not reliable except for comparison to other projects." 3 | metrics: 4 | - "ga:avgTimeOnPage" 5 | dimensions: 6 | - "ga:deviceCategory" 7 | sort: 8 | - "-ga:avgTimeOnPage" 9 | -------------------------------------------------------------------------------- /reports/templates/_report_dimension.html: -------------------------------------------------------------------------------- 1 | 2 | {{ dimension.name }} 3 | 4 | {{ dimension.value_formatted }} 5 | {% if dimension.percent_of_total %}{{ dimension.percent_of_total|floatformat:"1" }}%{% else %}-{% endif %} 6 | 7 | -------------------------------------------------------------------------------- /fabfile/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Utilities used by multiple commands. 5 | """ 6 | 7 | from fabric.api import prompt 8 | 9 | def confirm(message): 10 | """ 11 | Verify a users intentions. 12 | """ 13 | answer = prompt(message, default="Not at all") 14 | 15 | if answer.lower() not in ('y', 'yes', 'buzz off', 'screw you'): 16 | exit() 17 | 18 | -------------------------------------------------------------------------------- /carebot/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for carebot project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "carebot.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /confs/app.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | virtualenv = {{ SERVER_VIRTUALENV_PATH }} 3 | chdir = {{ SERVER_REPOSITORY_PATH }} 4 | module = carebot.wsgi:application 5 | callable = app 6 | touch-reload = {{ SERVER_REPOSITORY_PATH }}/carebot/wsgi.py 7 | socket = {{ UWSGI_SOCKET_PATH }} 8 | chmod-socket = 644 9 | chown-socket = www-data:www-data 10 | logto = {{ SERVER_LOG_PATH }}/uwsgi.log 11 | uid = ubuntu 12 | gid = ubuntu 13 | die-on-term 14 | catch-exceptions 15 | workers = 1 16 | harakiri = 120 17 | max-requests = 50 18 | master 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.7.1 2 | Fabric==1.4.3 3 | MarkupSafe==0.23 4 | PyYAML==3.11 5 | bcdoc==0.12.0 6 | boto==2.34.0 7 | botocore==0.31.0 8 | clan==0.2.3 9 | colorama==0.2.5 10 | cssmin==0.1.4 11 | django-grappelli==2.6.3 12 | django-sortedm2m==0.8.1 13 | docutils==0.11 14 | google-api-python-client==1.3.1 15 | gunicorn==19.1.1 16 | httplib2==0.9 17 | jmespath==0.2.1 18 | nose==1.2.1 19 | odict==1.5.1 20 | openpyxl==1.8.5 21 | ply==3.4 22 | psycopg2==2.5.4 23 | pyasn1==0.1.7 24 | pycrypto==2.6 25 | python-dateutil==2.2 26 | requests==2.5.0 27 | rsa==3.1.4 28 | six==1.6.1 29 | slimit==0.7.4 30 | smartypants==1.8.6 31 | ssh==1.7.14 32 | termcolor==1.1.0 33 | wheel==0.23.0 34 | wsgiref==0.1.2 35 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | def format_comma(d): 4 | """ 5 | Format a comma separated number. 6 | """ 7 | return '{:,d}'.format(int(d)) 8 | 9 | def format_duration(secs): 10 | """ 11 | Format a duration in seconds as minutes and seconds. 12 | """ 13 | secs = int(secs) 14 | 15 | if abs(secs) > 60: 16 | mins = abs(secs) / 60 17 | secs = abs(secs) - (mins * 60) 18 | 19 | return '%s%im %02is' % ('-' if secs < 0 else '', mins, secs) 20 | 21 | return '%is' % secs 22 | 23 | def format_percent(d, t): 24 | """ 25 | Format a value as a percent of a total. 26 | """ 27 | return '{:.1%}'.format(float(d) / t) 28 | 29 | -------------------------------------------------------------------------------- /carebot/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | 5 | from carebot import settings 6 | from reports import views 7 | 8 | urlpatterns = patterns('', 9 | url(r'^carebot/$', views.index), 10 | url(r'^carebot/compare-query/$', views.compare_query), 11 | url(r'^carebot/project/(?P[\w-]+)/$', views.project), 12 | url(r'^carebot/report/(?P[\w-]+)/(?P[\w-]+)/$', views.report), 13 | url(r'^carebot/grappelli/', include('grappelli.urls')), 14 | url(r'^carebot/admin/', include(admin.site.urls)), 15 | ) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 16 | -------------------------------------------------------------------------------- /reports/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block body %}{% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.sw[op] 3 | 4 | # Packages 5 | *.egg 6 | *.egg-info 7 | dist 8 | build 9 | eggs 10 | parts 11 | bin 12 | var 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | node_modules 17 | 18 | # Installer logs 19 | pip-log.txt 20 | 21 | # Unit test / coverage reports 22 | .coverage 23 | .tox 24 | 25 | #Translations 26 | *.mo 27 | 28 | .DS_store 29 | 30 | # Rendered files 31 | www/*.html 32 | www/css/*.min.*.css 33 | www/css/*.min.css 34 | www/css/*.less.css 35 | www/js/*.min.*.js 36 | www/js/*.min.js 37 | www/js/templates.js 38 | www/js/app_config.js 39 | www/js/copy.js 40 | www/test/test.html 41 | www/comments/index.html 42 | confs/rendered/* 43 | 44 | # Local data 45 | data/gdoc*.csv 46 | data/copy.xls 47 | data/copy.xlsx 48 | www/assets/* 49 | !www/assets/assetsignore 50 | /static 51 | -------------------------------------------------------------------------------- /render_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import codecs 4 | 5 | from django.template import Context, loader 6 | 7 | import app_config 8 | 9 | def render_to_file(template_name, data, filename): 10 | """ 11 | Render a Django template directly to a file. 12 | """ 13 | data['app_config'] = app_config.__dict__ 14 | template = loader.get_template(template_name) 15 | ctx = Context(data) 16 | 17 | with codecs.open(filename, 'w', 'utf-8') as f: 18 | f.write(template.render(ctx)) 19 | 20 | def render_to_string(template_name, data, filename): 21 | """ 22 | Render a Django template directly to a file. 23 | """ 24 | data['app_config'] = app_config.__dict__ 25 | template = loader.get_template(template_name) 26 | ctx = Context(data) 27 | 28 | return template.render(ctx) 29 | 30 | -------------------------------------------------------------------------------- /reports/templatetags/social.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | @register.simple_tag 8 | def social_per_1000_sessions(project, metric): 9 | if metric == 'total': 10 | value = project.social.total() 11 | else: 12 | value = getattr(project.social, metric) 13 | 14 | sessions = project.all_time_report.sessions 15 | fb_shares = project.social.facebook_shares 16 | 17 | try: 18 | if metric == 'facebook_likes' or metric == 'facebook_comments': 19 | if not fb_shares: 20 | return 0 21 | 22 | return '%.2f' % (float(value) / fb_shares) 23 | else: 24 | if not sessions: 25 | return 0 26 | 27 | return '%.2f' % (float(value) / (sessions / 1000)) 28 | except (TypeError, ZeroDivisionError): 29 | return 0 30 | -------------------------------------------------------------------------------- /reports/migrations/0002_auto_20150106_1134.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('reports', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='tag', 16 | options={'ordering': ('slug',)}, 17 | ), 18 | migrations.AddField( 19 | model_name='query', 20 | name='is_comparable', 21 | field=models.BooleanField(default=True, help_text=b'Should this query be available for cross-project comparison.'), 22 | preserve_default=True, 23 | ), 24 | migrations.AlterField( 25 | model_name='query', 26 | name='description', 27 | field=models.CharField(default=b'', max_length=256, blank=True), 28 | preserve_default=True, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /reports/migrations/0003_auto_20150107_1033.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('reports', '0002_auto_20150106_1134'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='dimensionresult', 16 | name='name', 17 | field=models.CharField(max_length=256), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name='project', 22 | name='domain', 23 | field=models.CharField(default=b'apps.npr.org', max_length=128, blank=True), 24 | preserve_default=True, 25 | ), 26 | migrations.AlterField( 27 | model_name='project', 28 | name='prefix', 29 | field=models.CharField(max_length=128, blank=True), 30 | preserve_default=True, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /reports/templates/_compare_dimension.html: -------------------------------------------------------------------------------- 1 | {% if dimension_name in project_dimensions %} 2 | {% with dimension=project_dimensions|keyvalue:dimension_name %} 3 | {% if unit == "percent" %} 4 | {% if dimension.percent_of_total %}{{ dimension.percent_of_total|floatformat:"1" }}%{% else %}-{% endif %} 5 | {% elif unit == "per-1000-sessions" %} 6 | {% with per_1000_sessions=dimension.per_1000_sessions %} 7 | {% if per_1000_sessions %}{{ per_1000_sessions|floatformat:"1" }}{% else %}-{% endif %} 8 | {% endwith %} 9 | {% else %} 10 | {{ dimension.value_formatted }} 11 | {% endif %} 12 | {% endwith %} 13 | {% else %} 14 | - 15 | {% endif %} 16 | 17 | -------------------------------------------------------------------------------- /reports/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load humanize %} 3 | {% load staticfiles %} 4 | {% load social %} 5 | 6 | {% block title %}carebot{% endblock %} 7 | 8 | {% block body %} 9 |
10 |

Carebot

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% for project in projects %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% endfor %} 34 | 35 |
ProjectLaunch dateTagsSessionsSharesPer 1,000 Sessions
{{ project.title }}{{ project.start_date }}{% include "_tags.html" %}{{ project.all_time_report.sessions|intcomma }}{{ project.social.total|intcomma }}{% social_per_1000_sessions project "total" %}
36 | 37 | 40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /reports/static/reports/report.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 16px/1.5em Helvetica, Arial, sans-serif; 3 | color: #111; 4 | -webkit-font-smoothing: antialiased; 5 | } 6 | 7 | .container-fluid { 8 | max-width: 1024px; 9 | margin-top: 20px; 10 | } 11 | 12 | h1,h2,h3,h4,h5,h6 { 13 | font-weight: bold; 14 | } 15 | 16 | h2 { 17 | font-weight: normal; 18 | font-size: 24px; 19 | margin-top: 0; 20 | } 21 | 22 | h3 { 23 | margin-bottom: 20px; 24 | } 25 | 26 | .description { 27 | margin: 0 0 20px; 28 | } 29 | 30 | .value, .percent, .points { 31 | text-align: right; 32 | white-space: nowrap; 33 | } 34 | 35 | th, td { 36 | width: 33.3333%; 37 | } 38 | 39 | .diff th, td { 40 | width: 25%; 41 | } 42 | 43 | th { 44 | font-weight: normal; 45 | color: #666; 46 | } 47 | 48 | .table-striped tbody tr.total { 49 | background-color: rgba(91, 192, 222, 0.15); 50 | } 51 | 52 | .table-striped tbody tr td.total, 53 | .table-striped thead tr th.total { 54 | font-weight: bold; 55 | } 56 | 57 | .ga-name { 58 | color: #666; 59 | font-size: 13px; 60 | font-weight: normal; 61 | margin-left: 10px; 62 | } 63 | 64 | .sample-size,.last-update { 65 | color: #666; 66 | font-size: 13px; 67 | font-weight: normal; 68 | } 69 | 70 | .query { 71 | margin-bottom: 50px; 72 | } 73 | 74 | footer p { 75 | font-size: 13px; 76 | color: #666; 77 | } 78 | 79 | /* Bar charts */ 80 | .bar rect { 81 | fill: steelblue; 82 | shape-rendering: crispEdges; 83 | } 84 | 85 | .bar.highlight rect { 86 | fill: red; 87 | } 88 | 89 | .bar text { 90 | font: 10px sans-serif; 91 | fill: #fff; 92 | } 93 | 94 | .axis path, .axis line { 95 | fill: none; 96 | stroke: #000; 97 | shape-rendering: crispEdges; 98 | } 99 | 100 | -------------------------------------------------------------------------------- /reports/templates/report.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load tz %} 3 | 4 | {% block title %}{{ report.project.title }} :: carebot{% endblock %} 5 | 6 | {% block body %} 7 |
8 |

{{ report.project.title }}

9 | 10 |

{{ report.timespan }} report

11 | 12 |

This report was last run on {{ report.last_run|localtime }}

13 | 14 |

« Back to project

15 | 16 | {% for query_result in report.query_results.all %} 17 |
18 |

{{ query_result.query_name }}{% if query_result.query.is_comparable %} (compare){% endif %}

19 | 20 |

{{ query_result.query.description }}

21 | 22 | {% for metric in query_result.metrics.all %} 23 |
24 |

25 | {{ metric.display_name }} 26 | {{ metric.name }} 27 |

28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% for dimension in metric.dimensions.all %} 38 | {% include '_report_dimension.html' with dimension=dimension %} 39 | {% endfor %} 40 | 41 |
DimensionValuePercent of total
42 |
43 | {% endfor %} 44 | 45 | {% if query_result.sampled %} 46 | Query results based on a sample of {{ query_result.sample_percent|floatformat:"1" }}% of sessions. 47 | {% endif %} 48 |
49 | 50 | {% endfor %} 51 |
52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /carebot/settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import app_config 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | import os 7 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 8 | 9 | SECRET_KEY = '=_1*x-_9+d_5xn#$cx%yap+@y-#13%1=1$lay5@c#^f%-u2nj-' 10 | DEBUG = app_config.DEBUG 11 | TEMPLATE_DEBUG = app_config.DEBUG 12 | ALLOWED_HOSTS = [] 13 | 14 | INSTALLED_APPS = ( 15 | 'grappelli', 16 | 'django.contrib.admin', 17 | 'django.contrib.auth', 18 | 'django.contrib.contenttypes', 19 | 'django.contrib.sessions', 20 | 'django.contrib.messages', 21 | 'django.contrib.staticfiles', 22 | 'django.contrib.humanize', 23 | 'reports' 24 | ) 25 | 26 | MIDDLEWARE_CLASSES = ( 27 | 'django.contrib.sessions.middleware.SessionMiddleware', 28 | 'django.middleware.common.CommonMiddleware', 29 | 'django.middleware.csrf.CsrfViewMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 32 | 'django.contrib.messages.middleware.MessageMiddleware', 33 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 34 | ) 35 | 36 | TEMPLATE_CONTEXT_PROCESSORS = ( 37 | 'django.core.context_processors.request', 38 | 'django.contrib.auth.context_processors.auth' 39 | ) 40 | 41 | ROOT_URLCONF = 'carebot.urls' 42 | WSGI_APPLICATION = 'carebot.wsgi.application' 43 | 44 | secrets = app_config.get_secrets() 45 | 46 | DATABASES = { 47 | 'default': { 48 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 49 | 'NAME': app_config.PROJECT_SLUG, 50 | 'USER': secrets.get('POSTGRES_USER') or app_config.PROJECT_SLUG, 51 | 'PASSWORD': secrets.get('POSTGRES_PASSWORD') or None, 52 | 'HOST': secrets.get('POSTGRES_HOST') or 'localhost', 53 | 'PORT': secrets.get('POSTGRES_PORT') or '5432' 54 | } 55 | } 56 | 57 | LANGUAGE_CODE = 'en-us' 58 | TIME_ZONE = 'America/New_York' 59 | USE_I18N = True 60 | USE_L10N = True 61 | USE_TZ = True 62 | 63 | STATIC_URL = '/%s/static/' % app_config.PROJECT_SLUG 64 | STATIC_ROOT = 'static' 65 | 66 | GRAPPELLI_ADMIN_TITLE = 'carebot' 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Copyright 2014 NPR. All rights reserved. No part of these materials may be reproduced, modified, stored in a retrieval system, or retransmitted, in any form or by any means, electronic, mechanical or otherwise, without prior written permission from NPR. 2 | 3 | (Want to use this code? Send an email to nprapps@npr.org!) 4 | 5 | 6 | carebot 7 | ======================== 8 | 9 | * [What is this?](#what-is-this) 10 | * [Assumptions](#assumptions) 11 | * [Bootstrap the project](#bootstrap-the-project) 12 | * [Hide project secrets](#hide-project-secrets) 13 | * [Run the project](#run-the-project) 14 | * [Deploy to EC2](#deploy-to-ec2) 15 | * [Run a remote fab command](#run-a-remote-fab-command) 16 | 17 | What is this? 18 | ------------- 19 | 20 | Carebot cares about us so much it automatically reports out, summarizes and sends us our analytics. 21 | 22 | For documentation of the metrics and queries used see the [reports](https://github.com/nprapps/reports#google-metrics-we-care-about) repo. 23 | 24 | Assumptions 25 | ----------- 26 | 27 | The following things are assumed to be true in this documentation. 28 | 29 | * You are running OSX. 30 | * You are using Python 2.7. (Probably the version that came OSX.) 31 | * You have [virtualenv](https://pypi.python.org/pypi/virtualenv) and [virtualenvwrapper](https://pypi.python.org/pypi/virtualenvwrapper) installed and working. 32 | * You have NPR's AWS credentials stored as environment variables locally. 33 | 34 | For more details on the technology stack used with the app-template, see our [development environment blog post](http://blog.apps.npr.org/2013/06/06/how-to-setup-a-developers-environment.html). 35 | 36 | Bootstrap the project 37 | --------------------- 38 | 39 | ``` 40 | cd carebot 41 | mkvirtualenv carebot 42 | pip install -r requirements.txt 43 | fab data.local_reset_db data.bootstrap_db 44 | python manage.py collectstatic 45 | ``` 46 | 47 | **Problems installing requirements?** You may need to run the pip command as ``ARCHFLAGS=-Wno-error=unused-command-line-argument-hard-error-in-future pip install -r requirements.txt`` to work around an issue with OSX. 48 | 49 | Hide project secrets 50 | -------------------- 51 | 52 | Project secrets should **never** be stored in ``app_config.py`` or anywhere else in the repository. They will be leaked to the client if you do. Instead, always store passwords, keys, etc. in environment variables and document that they are needed here in the README. 53 | 54 | Run the project 55 | --------------- 56 | 57 | ``` 58 | workon $PROJECT_SLUG 59 | fab public_app 60 | ``` 61 | 62 | Visit [localhost:8000](http://localhost:8000) in your browser. 63 | 64 | Deploy to EC2 65 | ------------- 66 | 67 | One time setup: 68 | 69 | ``` 70 | fab staging master servers.setup 71 | fab staging master data.server_reset_db 72 | fab staging master servers.fabcast:data.bootstrap_db 73 | ``` 74 | 75 | Routine deployment: 76 | 77 | ``` 78 | fab staging master deploy 79 | ``` 80 | 81 | Run a remote fab command 82 | ------------------------- 83 | 84 | Sometimes it makes sense to run a fabric command on the server, for instance, when you need to render using a production database. You can do this with the `fabcast` fabric command. For example: 85 | 86 | ``` 87 | fab staging master servers.fabcast:cron_jobs.run_reports 88 | ``` 89 | 90 | If any of the commands you run themselves require executing on the server, the server will SSH into itself to run them. 91 | -------------------------------------------------------------------------------- /fabfile/data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Commands that update or process the application data. 5 | """ 6 | import csv 7 | from datetime import datetime 8 | from glob import glob 9 | import os 10 | import yaml 11 | 12 | from django.utils.text import slugify 13 | from fabric.api import local, settings, run, sudo, task 14 | 15 | import app_config 16 | import servers 17 | from reports.models import Project, Query, Tag 18 | 19 | SERVER_POSTGRES_CMD = 'export PGPASSWORD=$carebot_POSTGRES_PASSWORD && %s --username=$carebot_POSTGRES_USER --host=$carebot_POSTGRES_HOST --port=$carebot_POSTGRES_PORT' 20 | 21 | @task 22 | def server_reset_db(): 23 | """ 24 | Reset the database on a server. 25 | """ 26 | with settings(warn_only=True): 27 | services = ['uwsgi'] 28 | for service in services: 29 | service_name = servers._get_installed_service_name(service) 30 | sudo('service %s stop' % service_name) 31 | 32 | run(SERVER_POSTGRES_CMD % ('dropdb %s' % app_config.PROJECT_SLUG)) 33 | run(SERVER_POSTGRES_CMD % ('createdb %s' % app_config.PROJECT_SLUG)) 34 | 35 | for service in services: 36 | service_name = servers._get_installed_service_name(service) 37 | sudo('service %s start' % service_name) 38 | 39 | @task 40 | def migrate_db(): 41 | local('python manage.py migrate') 42 | 43 | @task 44 | def local_reset_db(): 45 | secrets = app_config.get_secrets() 46 | 47 | with settings(warn_only=True): 48 | local('dropdb %s' % app_config.PROJECT_SLUG) 49 | local('echo "CREATE USER %s WITH PASSWORD \'%s\';" | psql' % (app_config.PROJECT_SLUG, secrets['POSTGRES_PASSWORD'])) 50 | 51 | local('createdb -O %s %s' % (app_config.PROJECT_SLUG, app_config.PROJECT_SLUG)) 52 | 53 | @task 54 | def bootstrap_db(): 55 | local('python manage.py migrate') 56 | local('python manage.py loaddata data/test_user.json') 57 | 58 | for yaml_path in glob('data/queries/*.yaml'): 59 | path, filename = os.path.split(yaml_path) 60 | slug, ext = os.path.splitext(filename) 61 | 62 | with open(yaml_path, 'r') as f: 63 | data = yaml.load(f) 64 | 65 | q = Query( 66 | name=data['name'], 67 | description=data.get('description', ''), 68 | slug=slug, 69 | ) 70 | 71 | del data['name'] 72 | 73 | if 'description' in data: 74 | q.description = data['description'] 75 | del data['description'] 76 | 77 | q.clan_yaml = yaml.dump(data, indent=4) 78 | 79 | q.save() 80 | 81 | with open('data/projects.csv') as f: 82 | rows = csv.DictReader(f) 83 | 84 | for row in rows: 85 | p = Project.objects.create( 86 | title=row['title'], 87 | slug=slugify(unicode(row['title'])), 88 | property_id=row['property_id'], 89 | domain=row['domain'], 90 | prefix=row['prefix'], 91 | start_date=datetime.strptime(row['start_date'], '%Y-%m-%d').date() 92 | ) 93 | 94 | for tag in row['tags'].split(','): 95 | obj, created = Tag.objects.get_or_create(slug=tag) 96 | p.tags.add(obj) 97 | 98 | @task 99 | def rerun(slug): 100 | """ 101 | Force a project to rerun all its reports. 102 | """ 103 | project = Project.objects.get(slug=slug) 104 | 105 | project.run_reports(overwrite=True) 106 | project.social.refresh() 107 | -------------------------------------------------------------------------------- /fabfile/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from fabric.api import local, require, settings, task 4 | from fabric.state import env 5 | from termcolor import colored 6 | 7 | from fabric.contrib import django 8 | django.project('carebot') 9 | 10 | import django 11 | django.setup() 12 | 13 | import app_config 14 | 15 | # Other fabfiles 16 | import data 17 | import utils 18 | 19 | if app_config.DEPLOY_TO_SERVERS: 20 | import servers 21 | 22 | if app_config.DEPLOY_CRONTAB: 23 | import cron_jobs 24 | 25 | """ 26 | Base configuration 27 | """ 28 | env.user = app_config.SERVER_USER 29 | env.forward_agent = True 30 | 31 | env.hosts = [] 32 | env.settings = None 33 | 34 | """ 35 | Environments 36 | 37 | Changing environment requires a full-stack test. 38 | """ 39 | @task 40 | def production(): 41 | """ 42 | Run as though on production. 43 | """ 44 | env.settings = 'production' 45 | app_config.configure_targets(env.settings) 46 | env.hosts = app_config.SERVERS 47 | 48 | @task 49 | def staging(): 50 | """ 51 | Run as though on staging. 52 | """ 53 | env.settings = 'staging' 54 | app_config.configure_targets(env.settings) 55 | env.hosts = app_config.SERVERS 56 | 57 | """ 58 | Branches 59 | 60 | Changing branches requires deploying that branch to a host. 61 | """ 62 | @task 63 | def stable(): 64 | """ 65 | Work on stable branch. 66 | """ 67 | env.branch = 'stable' 68 | 69 | @task 70 | def master(): 71 | """ 72 | Work on development branch. 73 | """ 74 | env.branch = 'master' 75 | 76 | @task 77 | def branch(branch_name): 78 | """ 79 | Work on any specified branch. 80 | """ 81 | env.branch = branch_name 82 | 83 | """ 84 | Running the app 85 | """ 86 | @task 87 | def public_app(port='8000'): 88 | """ 89 | Serve public_app.py. 90 | """ 91 | local('gunicorn -b 0.0.0.0:%s --timeout 3600 --debug --reload --error-logfile - carebot.wsgi:application' % port) 92 | 93 | """ 94 | Deployment 95 | 96 | Changes to deployment requires a full-stack test. 97 | """ 98 | @task 99 | def deploy(remote='origin'): 100 | """ 101 | Deploy the latest app to our servers. 102 | """ 103 | require('settings', provided_by=[production, staging]) 104 | 105 | if app_config.DEPLOY_TO_SERVERS: 106 | require('branch', provided_by=[stable, master, branch]) 107 | 108 | if (app_config.DEPLOYMENT_TARGET == 'production' and env.branch != 'stable'): 109 | utils.confirm( 110 | colored("You are trying to deploy the '%s' branch to production.\nYou should really only deploy a stable branch.\nDo you know what you're doing?" % env.branch, "red") 111 | ) 112 | 113 | servers.checkout_latest(remote) 114 | 115 | if app_config.DEPLOY_CRONTAB: 116 | servers.install_crontab() 117 | 118 | if app_config.DEPLOY_SERVICES: 119 | servers.deploy_confs() 120 | 121 | """ 122 | Destruction 123 | 124 | Changes to destruction require setup/deploy to a test host in order to test. 125 | """ 126 | @task 127 | def shiva_the_destroyer(): 128 | """ 129 | Deletes the app. 130 | """ 131 | require('settings', provided_by=[production, staging]) 132 | 133 | utils.confirm( 134 | colored("You are about to destroy everything deployed to %s for this project.\nDo you know what you're doing?')" % app_config.DEPLOYMENT_TARGET, "red") 135 | ) 136 | 137 | with settings(warn_only=True): 138 | if app_config.DEPLOY_TO_SERVERS: 139 | servers.delete_project() 140 | 141 | if app_config.DEPLOY_CRONTAB: 142 | servers.uninstall_crontab() 143 | 144 | if app_config.DEPLOY_SERVICES: 145 | servers.nuke_confs() 146 | 147 | -------------------------------------------------------------------------------- /reports/admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from django.contrib import admin 4 | from grappelli.forms import GrappelliSortableHiddenMixin 5 | 6 | from reports import models 7 | 8 | class QueryAdmin(admin.ModelAdmin): 9 | """ 10 | Admin for the Query model. 11 | """ 12 | list_display = ('name', 'is_comparable', 'description') 13 | 14 | fieldsets = ( 15 | (None, { 16 | 'fields': ('name', 'slug', 'description', 'is_comparable') 17 | }), 18 | (None, { 19 | 'fields': ('clan_yaml',), 20 | 'classes': ('monospace',) 21 | }) 22 | ) 23 | 24 | prepopulated_fields = { 'slug': ('name',) } 25 | 26 | class TagInline(admin.TabularInline): 27 | model = models.Project.tags.through 28 | extra = 2 29 | 30 | class ProjectQueryInline(GrappelliSortableHiddenMixin, admin.TabularInline): 31 | """ 32 | Admin for the ProjectQuery M2M inline. 33 | """ 34 | model = models.ProjectQuery 35 | extra = 3 36 | sortable_field_name = 'order' 37 | 38 | class ProjectAdmin(admin.ModelAdmin): 39 | """ 40 | Admin for the Project model. 41 | """ 42 | fields = ('title', 'slug', 'property_id', 'domain', 'prefix', 'start_date') 43 | prepopulated_fields = { 'slug': ('title',) } 44 | 45 | list_display = ('title', 'tag_list', 'property_id', 'domain', 'prefix', 'start_date', 'view_reports') 46 | list_display_links = ('title',) 47 | list_filter = ('property_id', 'domain') 48 | search_fields = ('title',) 49 | 50 | def change_view(self, *args, **kwargs): 51 | """ 52 | Change view, with inlines. 53 | """ 54 | self.inlines = (TagInline, ProjectQueryInline,) 55 | 56 | return super(ProjectAdmin, self).change_view(*args, **kwargs) 57 | 58 | def add_view(self, *args, **kwargs): 59 | """ 60 | Add view, without inlines. 61 | """ 62 | self.inlines = (TagInline,) 63 | 64 | return super(ProjectAdmin, self).add_view(*args, **kwargs) 65 | 66 | def tag_list(self, model): 67 | return model.tag_list() 68 | 69 | tag_list.short_description = 'Tags' 70 | 71 | def view_reports(self, model): 72 | return 'View' % model.get_absolute_url() 73 | 74 | view_reports.allow_tags = True 75 | view_reports.short_description = 'Reports' 76 | 77 | class ReportAdmin(admin.ModelAdmin): 78 | readonly_fields = ('results_json', 'last_run') 79 | list_display = ('project', 'ndays', 'last_run', 'view_report') 80 | list_display_links = ('ndays',) 81 | 82 | def view_report(self, model): 83 | if not model.last_run: 84 | return None 85 | else: 86 | return 'View' % model.get_absolute_url() 87 | 88 | view_report.allow_tags = True 89 | view_report.short_description = 'View' 90 | 91 | class QueryResultAdmin(admin.ModelAdmin): 92 | list_display = ('project_title', 'report_ndays', 'query_name') 93 | list_display_links = ('query_name',) 94 | 95 | class MetricResultAdmin(admin.ModelAdmin): 96 | list_display = ('project_title', 'report_ndays', 'query_name', 'name') 97 | list_display_links = ('name',) 98 | 99 | class DimensionResultAdmin(admin.ModelAdmin): 100 | list_display = ('project_title', 'report_ndays', 'query_name', 'metric_name', 'name', 'value', 'percent_of_total') 101 | list_display_links = ('name',) 102 | 103 | class SocialAdmin(admin.ModelAdmin): 104 | list_display = ('project', 'facebook_likes', 'facebook_shares', 'facebook_comments', 'twitter', 'google', 'pinterest', 'linkedin', 'stumbleupon') 105 | list_display_links = ('project',) 106 | 107 | class TagAdmin(admin.ModelAdmin): 108 | list_display = ('slug',) 109 | list_display_links = ('slug',) 110 | 111 | admin.site.register(models.Query, QueryAdmin) 112 | admin.site.register(models.Project, ProjectAdmin) 113 | admin.site.register(models.Report, ReportAdmin) 114 | admin.site.register(models.Social, SocialAdmin) 115 | admin.site.register(models.Tag, TagAdmin) 116 | #admin.site.register(models.QueryResult, QueryResultAdmin) 117 | #admin.site.register(models.MetricResult, MetricResultAdmin) 118 | #admin.site.register(models.DimensionResult, DimensionResultAdmin) 119 | -------------------------------------------------------------------------------- /reports/templates/compare_query.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block title %}{% if query %}{{ query.name }} :: {% endif %}carebot{% endblock %} 4 | 5 | {% block body %} 6 |
7 |

Query Comparison

8 | 9 |

« Back to project index

10 | 11 |
12 |
13 | 14 | 19 |
20 | 21 |
22 | 28 |
29 | 30 |
31 | 36 |
37 | 38 |
39 | 40 | 46 |
47 | 48 |
49 | 50 |
51 |
52 | 53 | {% if query %} 54 |

Configuration

55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
Query{{ query.name }}
Unit{{ unit }}
Timespan{% if ndays %}{{ ndays }}-day{{ ndays|pluralize:"s"}}{% else %}all-time{% endif %}
Tagged{% if tag %}{{ tag.slug }}{% else %}(any){% endif %}
76 | 77 |

{{ query.description }}

78 | 79 | {% for metric_names, projects in results.items %} 80 |

81 | {{ metric_names.1 }} 82 | {{ metric_names.0 }} 83 |

84 | 85 | 86 | 87 | 88 | 89 | {% for dimension_name in metric_dimensions|keyvalue:metric_names.0 %} 90 | 91 | {% endfor %} 92 | 93 | 94 | 95 | {% for project_name, project_dimensions in projects.items %} 96 | 97 | 98 | {% for dimension_name in metric_dimensions|keyvalue:metric_names.0 %} 99 | {% include '_compare_dimension.html' %} 100 | {% endfor %} 101 | 102 | {% endfor %} 103 | 104 |
Project{{ dimension_name }}
{{ project_name }}
105 | {% endfor %} 106 | {% endif %} 107 |
108 | 109 | 112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /data/projects.csv: -------------------------------------------------------------------------------- 1 | title,tags,property_id,domain,prefix,start_date,default_queries 2 | Demolished: The End of Chicago's Public Housing,"lookatthis,slides",53470309,apps.npr.org,/lookatthis/posts/publichousing/,2014-12-23,complex 3 | Naughty or Nice,"app,audio",53470309,apps.npr.org,/naughty-or-nice/,2014-12-21,complex 4 | Songs We Love 2014,"app,music,audio",53470309,apps.npr.org,/best-songs-2014/,2014-12-10,complex 5 | Book Concierge 2014,"app,books",53470309,apps.npr.org,/best-books-2014/,2014-12-03,complex 6 | A Photo I Love: Erin Mystkowski,"lookatthis,audio",53470309,apps.npr.org,/lookatthis/posts/mystkowski-loves/,2014-11-18,complex 7 | Election Party 2014,"app,politics,live,tv",53470309,elections.npr.org,/,2014-11-04,complex 8 | Talking While Female,"seamus,video",53470309,www.npr.org,/blogs/health/2014/10/24/357584372/video-what-women-get-flak-for-when-they-talk,2014-10-24,complex 9 | This Is Color,"lookatthis,slides",53470309,apps.npr.org,/lookatthis/posts/colors/,2014-10-23,complex 10 | Plastic Rebirth,"lookatthis,slides",53470309,apps.npr.org,/lookatthis/posts/plastic/,2014-10-09,complex 11 | What Do Homeless Veteran's Look Like?,"lookatthis,slides",53470309,apps.npr.org,/lookatthis/posts/veterans/,2014-09-17,complex 12 | The End of Neighborhood Schools,"app,scroll",53470309,apps.npr.org,/the-end-of-neighborhood-schools/,2014-09-02,complex 13 | Behind the Civil Rights Act,app,53470309,apps.npr.org,/behind-the-civil-rights-act/,2014-07-02,complex 14 | "The Best Commencement Speeches, Ever",app,53470309,apps.npr.org,/commencement/,2014-05-19,simple 15 | 12 Weeks To A 6-Figure Job,"seamus,graphic",53470309,www.npr.org,/blogs/ed/2014/12/20/370954988/twelve-weeks-to-a-six-figure-job,2014-12-20,simple 16 | Buzkashi,"app,scroll",53470309,apps.npr.org,/buzkashi/,2014-05-04,simple 17 | Borderland,"app,slides",53470309,apps.npr.org,/borderland/,2014-04-03,simple 18 | Grave Science,"app,scroll",53470309,apps.npr.org,/grave-science/,2014-03-05,simple 19 | Wolves At The Door,"app,scroll",53470309,apps.npr.org,/wolves/,2014-02-03,simple 20 | Oil Boom,"seamus,gif",53470309,www.npr.org,/2014/01/29/266757131/welcome-to-oil-country-a-modern-day-gold-rush-in-north-dakota,2014-01-29,simple 21 | In Memoriam 2013,"app,music",53470309,apps.npr.org,/music-memoriam-2013/,2013-12-19,simple 22 | Your Questions About the Affordable Care Act,"app,faq",53470309,apps.npr.org,/affordable-care-act-questions/,2013-12-18,simple 23 | Book Concierge 2013,"app,books",53470309,apps.npr.org,/best-books-2013/,2013-12-04,simple 24 | Planet Money Makes A T-Shirt,"app,video,ugc",53470309,apps.npr.org,/tshirt/,2013-11-25,simple 25 | Lobbying Missuori,"app,station,politics,evergreen",53470309,www.lobbyingmissouri.org,/,2013-11-04,simple 26 | Playgrounds For Everyone,"app,evergreen",53470309,apps.npr.org,/playgrounds/,2013-08-27,simple 27 | Okkervil River,"app,music,audio",53470309,apps.npr.org,/okkervil-river/,2013-07-15,simple 28 | Zoom In On Oklahoma Tornado Damage,"app,breaking",53470309,apps.npr.org,/moore-oklahoma-tornado-damage/,2013-05-23,simple 29 | Deals For Developers,"app,politics,station",53470309,apps.npr.org,/deals-for-developers-wamu/,2013-05-20,simple 30 | Previously On Arrested Development,app,53470309,apps.npr.org,/arrested-development/,2013-05-17,simple 31 | Teenage Diaries,app,53470309,apps.npr.org,/teenage-diaries/,2013-05-10,simple 32 | She Works,"app,tumblr,ugc",53470309,she-works.tumblr.com,/,2013-05-06,simple 33 | Cook Your Cupboard,"app,tumblr,ugc",53470309,cookyourcupboard.tumblr.com,/,2013-04-02,simple 34 | Buried In Grain,"app,scroll",53470309,apps.npr.org,/buried-in-grain/,2013-03-24,simple 35 | Unfit For Work,"app,scroll",53470309,apps.npr.org,/unfit-for-work/,2013-03-22,simple 36 | Dinnertime Confessional,"app,tumblr",53470309,dinnertimeconfessional.tumblr.com,/,2013-02-26,simple 37 | Oscar Night Live Coverage,"app,live",53470309,apps.npr.org,/oscars-2013/,2013-02-22,simple 38 | Inauguration Live,"app,live",53470309,apps.npr.org,/inauguration/,2013-01-18,simple 39 | Sotomayor: From The Bronx to The Bench,"app,audio,slides",53470309,apps.npr.org,/sotomayor-family-photos/,2013-01-12,simple 40 | Dear Mr. President,"app,tumblr,ugc",53470309,inauguration2013.tumblr.com,/,2013-01-09,simple 41 | Bob Boilen's Wristbands,"app,music",53470309,apps.npr.org,/bob-boilens-wristbands-2012/,2012-12-31,simple 42 | In Memoriam 2012,"app,audio,slides",53470309,apps.npr.org,/music-memoriam-2012/,2012-12-24,simple 43 | Election Night 2012,"app,politics,live",53470309,elections2012.npr.org,/,2012-11-06,simple 44 | Election 2012: Early Voting,"app,politics",53470309,apps.npr.org,/early-voting-2012/,2012-09-25,simple 45 | Democratic National Convention,"app,live",53470309,apps.npr.org,/2012-democratic-national-convention/,2012-09-02,simple 46 | Fire Forecast,"app,evergreen",53470309,apps.npr.org,/fire-forecast/,2012-08-23,simple 47 | -------------------------------------------------------------------------------- /reports/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from collections import OrderedDict 4 | 5 | from django.shortcuts import render 6 | from django.template.defaulttags import register 7 | 8 | import app_config 9 | from reports import models 10 | 11 | @register.filter 12 | def keyvalue(dict, key): 13 | return dict[key] 14 | 15 | def index(request): 16 | """ 17 | Project index. 18 | """ 19 | projects = models.Project.objects.all() 20 | 21 | context = { 22 | 'projects': projects 23 | } 24 | 25 | return render(request, 'index.html', context) 26 | 27 | def project(request, slug): 28 | """ 29 | Project report index. 30 | """ 31 | obj = models.Project.objects.get(slug=slug) 32 | 33 | all_shares = [] 34 | all_shares_per_session = [] 35 | socials = models.Social.objects.all() 36 | 37 | for social in socials: 38 | total = social.total() 39 | 40 | if total: 41 | all_shares.append(total) 42 | try: 43 | all_shares_per_session.append(float(total) / (float(social.project.all_time_report.sessions) / 1000)) 44 | except ZeroDivisionError: 45 | all_shares_per_session.append('undefined') 46 | except TypeError: 47 | all_shares_per_session.append('undefined') 48 | 49 | try: 50 | shares_per_session = float(obj.social.total()) / (float(obj.all_time_report.sessions) / 1000) 51 | except ZeroDivisionError: 52 | shares_per_session = 'undefined' 53 | except TypeError: 54 | shares_per_session = 'undefined' 55 | 56 | context = { 57 | 'project': obj, 58 | 'reports': obj.reports.exclude(last_run__isnull=True), 59 | 'all_shares': all_shares, 60 | 'all_shares_per_session': all_shares_per_session, 61 | 'shares_per_session': shares_per_session 62 | } 63 | 64 | return render(request, 'project.html', context) 65 | 66 | def report(request, slug, ndays=None): 67 | """ 68 | Generate a project report. 69 | """ 70 | if ndays == 'all-time': 71 | ndays = None 72 | 73 | obj = models.Report.objects.get( 74 | project__slug=slug, 75 | ndays=ndays 76 | ) 77 | 78 | context = { 79 | 'report': obj 80 | } 81 | 82 | return render(request, 'report.html', context) 83 | 84 | def compare_query(request): 85 | """ 86 | Compare results of a query. 87 | """ 88 | context= { 89 | 'queries': models.Query.objects.filter(is_comparable=True), 90 | 'report_ndays': app_config.DEFAULT_REPORT_NDAYS, 91 | 'tags': models.Tag.objects.all() 92 | } 93 | 94 | query_slug = request.GET.get('query', 'totals') 95 | ndays = request.GET.get('ndays', None) 96 | context['unit'] = request.GET.get('unit', 'count') 97 | tag_slug = request.GET.get('tag', None) 98 | 99 | if ndays == 'None': 100 | ndays = None 101 | 102 | context['query'] = models.Query.objects.get(slug=query_slug) 103 | 104 | query_results = models.QueryResult.objects.filter( 105 | query=context['query'], 106 | ) 107 | 108 | if ndays: 109 | context['ndays'] = int(ndays) 110 | query_results = query_results.filter(report_ndays=context['ndays']) 111 | else: 112 | context['ndays'] = ndays 113 | query_results = query_results.filter(report_ndays__isnull=True) 114 | 115 | if tag_slug: 116 | context['tag'] = models.Tag.objects.get(slug=tag_slug) 117 | query_results = query_results.filter( 118 | report__project__tags=context['tag'] 119 | ) 120 | 121 | metric_dimensions = OrderedDict() 122 | results = OrderedDict() 123 | 124 | # Build comparison table 125 | for qr in query_results: 126 | project_title = qr.project_title 127 | 128 | for metric in qr.metrics.all(): 129 | m = (metric.name, metric.display_name) 130 | 131 | if m not in results: 132 | results[m] = OrderedDict() 133 | 134 | if metric.name not in metric_dimensions: 135 | if metric.name != 'total': 136 | metric_dimensions[metric.name] = [] 137 | 138 | if project_title not in results[m]: 139 | results[m][project_title] = {} 140 | 141 | for dimension in metric.dimensions.all(): 142 | if dimension.name not in metric_dimensions[metric.name]: 143 | if dimension.name != 'total': 144 | metric_dimensions[metric.name].append(dimension.name) 145 | 146 | if dimension.name not in results[m][project_title]: 147 | results[m][project_title][dimension.name] = dimension 148 | 149 | for metric_name in metric_dimensions: 150 | metric_dimensions[metric_name].append('total') 151 | 152 | context.update({ 153 | 'metric_dimensions': metric_dimensions, 154 | 'results': results 155 | }) 156 | 157 | return render(request, 'compare_query.html', context) 158 | -------------------------------------------------------------------------------- /app_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Project-wide application configuration. 5 | 6 | DO NOT STORE SECRETS, PASSWORDS, ETC. IN THIS FILE. 7 | They will be exposed to users. Use environment variables instead. 8 | See get_secrets() below for a fast way to access them. 9 | """ 10 | 11 | import os 12 | 13 | """ 14 | NAMES 15 | """ 16 | # Project name to be used in urls 17 | # Use dashes, not underscores! 18 | PROJECT_SLUG = 'carebot' 19 | 20 | # Project name to be used in file paths 21 | PROJECT_FILENAME = 'carebot' 22 | 23 | # The name of the repository containing the source 24 | REPOSITORY_NAME = 'carebot' 25 | GITHUB_USERNAME = 'nprapps' 26 | REPOSITORY_URL = 'git@github.com:%s/%s.git' % (GITHUB_USERNAME, REPOSITORY_NAME) 27 | REPOSITORY_ALT_URL = None # 'git@bitbucket.org:nprapps/%s.git' % REPOSITORY_NAME' 28 | 29 | # Project name used for assets rig 30 | # Should stay the same, even if PROJECT_SLUG changes 31 | ASSETS_SLUG = 'carebot' 32 | 33 | """ 34 | DEPLOYMENT 35 | """ 36 | PRODUCTION_SERVERS = ['cron.nprapps.org'] 37 | STAGING_SERVERS = ['cron-staging.nprapps.org'] 38 | 39 | # Should code be deployed to the web/cron servers? 40 | DEPLOY_TO_SERVERS = True 41 | 42 | SERVER_USER = 'ubuntu' 43 | SERVER_PYTHON = 'python2.7' 44 | SERVER_PROJECT_PATH = '/home/%s/apps/%s' % (SERVER_USER, PROJECT_FILENAME) 45 | SERVER_REPOSITORY_PATH = '%s/repository' % SERVER_PROJECT_PATH 46 | SERVER_VIRTUALENV_PATH = '%s/virtualenv' % SERVER_PROJECT_PATH 47 | 48 | # Should the crontab file be installed on the servers? 49 | # If True, DEPLOY_TO_SERVERS must also be True 50 | DEPLOY_CRONTAB = True 51 | 52 | # Should the service configurations be installed on the servers? 53 | # If True, DEPLOY_TO_SERVERS must also be True 54 | DEPLOY_SERVICES = True 55 | 56 | UWSGI_SOCKET_PATH = '/tmp/%s.uwsgi.sock' % PROJECT_FILENAME 57 | 58 | # Services are the server-side services we want to enable and configure. 59 | # A three-tuple following this format: 60 | # (service name, service deployment path, service config file extension) 61 | SERVER_SERVICES = [ 62 | ('app', SERVER_REPOSITORY_PATH, 'ini'), 63 | ('uwsgi', '/etc/init', 'conf'), 64 | ('nginx', '/etc/nginx/locations-enabled', 'conf'), 65 | ] 66 | 67 | # These variables will be set at runtime. See configure_targets() below 68 | SERVERS = [] 69 | SERVER_BASE_URL = None 70 | SERVER_LOG_PATH = None 71 | DEBUG = True 72 | 73 | SES_REGION = 'us-east-1' 74 | 75 | PROJECT_TYPES = [ 76 | ('app', 'App'), 77 | ('seamus-graphic', 'Seamus Graphic'), 78 | ('lookatthis-post', 'Look At This Post') 79 | ] 80 | 81 | DEFAULT_QUERIES = [ 82 | 'totals', 83 | 'sessions-by-device-category', 84 | 'sessions-by-browser', 85 | 'sessions-by-referring-domain', 86 | 'sessions-by-referring-social-network', 87 | 'performance', 88 | 'time-spent-on-page-by-device-category' 89 | ] 90 | 91 | DEFAULT_EVENT_QUERIES = [ 92 | 'share-discuss-panel-opened', 93 | 'tweet-link-clicked', 94 | 'facebook-share-link-clicked', 95 | 'email-link-clicked', 96 | 'summary-copied' 97 | ] 98 | 99 | DEFAULT_REPORT_NDAYS = [ 100 | 1, 101 | 7, 102 | 30 103 | ] 104 | 105 | EMAIL_SEND_ADDRESS = 'nprapps@npr.org' 106 | EMAIL_NOTIFY_ADDRESS = 'nprapps@npr.org' 107 | 108 | """ 109 | Utilities 110 | """ 111 | def get_secrets(): 112 | """ 113 | A method for accessing our secrets. 114 | """ 115 | secrets = [ 116 | 'POSTGRES_USER', 117 | 'POSTGRES_PASSWORD', 118 | 'POSTGRES_HOST', 119 | 'POSTGRES_PORT', 120 | 'SHAREDCOUNT_API_KEY', 121 | 'GECKOBOARD_API_KEY' 122 | ] 123 | 124 | secrets_dict = {} 125 | 126 | for secret in secrets: 127 | name = '%s_%s' % (PROJECT_FILENAME, secret) 128 | secrets_dict[secret] = os.environ.get(name, None) 129 | 130 | return secrets_dict 131 | 132 | def configure_targets(deployment_target): 133 | """ 134 | Configure deployment targets. Abstracted so this can be 135 | overriden for rendering before deployment. 136 | """ 137 | global SERVERS 138 | global SERVER_BASE_URL 139 | global SERVER_LOG_PATH 140 | global DEBUG 141 | global DEPLOYMENT_TARGET 142 | global DISQUS_SHORTNAME 143 | 144 | if deployment_target == 'production': 145 | SERVERS = PRODUCTION_SERVERS 146 | SERVER_BASE_URL = 'http://%s/%s' % (SERVERS[0], PROJECT_SLUG) 147 | SERVER_LOG_PATH = '/var/log/%s' % PROJECT_FILENAME 148 | DEBUG = False 149 | elif deployment_target == 'staging': 150 | SERVERS = STAGING_SERVERS 151 | SERVER_BASE_URL = 'http://%s/%s' % (SERVERS[0], PROJECT_SLUG) 152 | SERVER_LOG_PATH = '/var/log/%s' % PROJECT_FILENAME 153 | DEBUG = True 154 | else: 155 | SERVERS = [] 156 | SERVER_BASE_URL = 'http://127.0.0.1:8001/%s' % PROJECT_SLUG 157 | SERVER_LOG_PATH = '/tmp' 158 | DEBUG = True 159 | 160 | DEPLOYMENT_TARGET = deployment_target 161 | 162 | """ 163 | Run automated configuration 164 | """ 165 | DEPLOYMENT_TARGET = os.environ.get('DEPLOYMENT_TARGET', None) 166 | 167 | configure_targets(DEPLOYMENT_TARGET) 168 | -------------------------------------------------------------------------------- /data/test_user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "codename": "add_logentry", 5 | "name": "Can add log entry", 6 | "content_type": 1 7 | }, 8 | "model": "auth.permission", 9 | "pk": 1 10 | }, 11 | { 12 | "fields": { 13 | "codename": "change_logentry", 14 | "name": "Can change log entry", 15 | "content_type": 1 16 | }, 17 | "model": "auth.permission", 18 | "pk": 2 19 | }, 20 | { 21 | "fields": { 22 | "codename": "delete_logentry", 23 | "name": "Can delete log entry", 24 | "content_type": 1 25 | }, 26 | "model": "auth.permission", 27 | "pk": 3 28 | }, 29 | { 30 | "fields": { 31 | "codename": "add_permission", 32 | "name": "Can add permission", 33 | "content_type": 2 34 | }, 35 | "model": "auth.permission", 36 | "pk": 4 37 | }, 38 | { 39 | "fields": { 40 | "codename": "change_permission", 41 | "name": "Can change permission", 42 | "content_type": 2 43 | }, 44 | "model": "auth.permission", 45 | "pk": 5 46 | }, 47 | { 48 | "fields": { 49 | "codename": "delete_permission", 50 | "name": "Can delete permission", 51 | "content_type": 2 52 | }, 53 | "model": "auth.permission", 54 | "pk": 6 55 | }, 56 | { 57 | "fields": { 58 | "codename": "add_group", 59 | "name": "Can add group", 60 | "content_type": 3 61 | }, 62 | "model": "auth.permission", 63 | "pk": 7 64 | }, 65 | { 66 | "fields": { 67 | "codename": "change_group", 68 | "name": "Can change group", 69 | "content_type": 3 70 | }, 71 | "model": "auth.permission", 72 | "pk": 8 73 | }, 74 | { 75 | "fields": { 76 | "codename": "delete_group", 77 | "name": "Can delete group", 78 | "content_type": 3 79 | }, 80 | "model": "auth.permission", 81 | "pk": 9 82 | }, 83 | { 84 | "fields": { 85 | "codename": "add_user", 86 | "name": "Can add user", 87 | "content_type": 4 88 | }, 89 | "model": "auth.permission", 90 | "pk": 10 91 | }, 92 | { 93 | "fields": { 94 | "codename": "change_user", 95 | "name": "Can change user", 96 | "content_type": 4 97 | }, 98 | "model": "auth.permission", 99 | "pk": 11 100 | }, 101 | { 102 | "fields": { 103 | "codename": "delete_user", 104 | "name": "Can delete user", 105 | "content_type": 4 106 | }, 107 | "model": "auth.permission", 108 | "pk": 12 109 | }, 110 | { 111 | "fields": { 112 | "codename": "add_contenttype", 113 | "name": "Can add content type", 114 | "content_type": 5 115 | }, 116 | "model": "auth.permission", 117 | "pk": 13 118 | }, 119 | { 120 | "fields": { 121 | "codename": "change_contenttype", 122 | "name": "Can change content type", 123 | "content_type": 5 124 | }, 125 | "model": "auth.permission", 126 | "pk": 14 127 | }, 128 | { 129 | "fields": { 130 | "codename": "delete_contenttype", 131 | "name": "Can delete content type", 132 | "content_type": 5 133 | }, 134 | "model": "auth.permission", 135 | "pk": 15 136 | }, 137 | { 138 | "fields": { 139 | "codename": "add_session", 140 | "name": "Can add session", 141 | "content_type": 6 142 | }, 143 | "model": "auth.permission", 144 | "pk": 16 145 | }, 146 | { 147 | "fields": { 148 | "codename": "change_session", 149 | "name": "Can change session", 150 | "content_type": 6 151 | }, 152 | "model": "auth.permission", 153 | "pk": 17 154 | }, 155 | { 156 | "fields": { 157 | "codename": "delete_session", 158 | "name": "Can delete session", 159 | "content_type": 6 160 | }, 161 | "model": "auth.permission", 162 | "pk": 18 163 | }, 164 | { 165 | "fields": { 166 | "codename": "add_query", 167 | "name": "Can add query", 168 | "content_type": 7 169 | }, 170 | "model": "auth.permission", 171 | "pk": 19 172 | }, 173 | { 174 | "fields": { 175 | "codename": "change_query", 176 | "name": "Can change query", 177 | "content_type": 7 178 | }, 179 | "model": "auth.permission", 180 | "pk": 20 181 | }, 182 | { 183 | "fields": { 184 | "codename": "delete_query", 185 | "name": "Can delete query", 186 | "content_type": 7 187 | }, 188 | "model": "auth.permission", 189 | "pk": 21 190 | }, 191 | { 192 | "fields": { 193 | "codename": "add_project", 194 | "name": "Can add project", 195 | "content_type": 8 196 | }, 197 | "model": "auth.permission", 198 | "pk": 22 199 | }, 200 | { 201 | "fields": { 202 | "codename": "change_project", 203 | "name": "Can change project", 204 | "content_type": 8 205 | }, 206 | "model": "auth.permission", 207 | "pk": 23 208 | }, 209 | { 210 | "fields": { 211 | "codename": "delete_project", 212 | "name": "Can delete project", 213 | "content_type": 8 214 | }, 215 | "model": "auth.permission", 216 | "pk": 24 217 | }, 218 | { 219 | "fields": { 220 | "username": "test", 221 | "first_name": "", 222 | "last_name": "", 223 | "is_active": true, 224 | "is_superuser": true, 225 | "is_staff": true, 226 | "last_login": "2014-12-19T19:29:06.150Z", 227 | "groups": [], 228 | "user_permissions": [], 229 | "password": "pbkdf2_sha256$12000$Bz0EdooRXhBp$gys9qlRjqkkaM8k6EeBJcJ0JMh1OiDY6eVdlMsrPvXU=", 230 | "email": "test@test.com", 231 | "date_joined": "2014-12-19T19:29:06.150Z" 232 | }, 233 | "model": "auth.user", 234 | "pk": 1 235 | }, 236 | { 237 | "fields": { 238 | "expire_date": "2015-01-02T19:27:45.865Z", 239 | "session_data": "YTMyNWNjNjNhY2NiYTQ2YTNiYWE3NzYxNzI0MTVlOTA4Yjc1NDAzNzp7fQ==" 240 | }, 241 | "model": "sessions.session", 242 | "pk": "wffjvq6bl0o9n2fikjighdigqosoa8g8" 243 | } 244 | ] 245 | -------------------------------------------------------------------------------- /fabfile/cron_jobs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Cron jobs 5 | """ 6 | from datetime import date 7 | import json 8 | 9 | import boto 10 | import boto.ses 11 | from django.utils import timezone 12 | from fabric.api import local, require, task 13 | import requests 14 | 15 | import app_config 16 | from reports.models import Project, Report 17 | from render_utils import render_to_string 18 | 19 | @task 20 | def test(): 21 | """ 22 | Example cron task. Note we use "local" instead of "run" 23 | because this will run on the server. 24 | """ 25 | require('settings', provided_by=['production', 'staging']) 26 | 27 | local('echo $DEPLOYMENT_TARGET > /tmp/cron_test.txt') 28 | 29 | @task 30 | def run_reports(overwrite='false'): 31 | """ 32 | Run project reports. 33 | """ 34 | overwrite = (overwrite == 'true') 35 | 36 | print 'Starting at %s' % timezone.now() 37 | 38 | updated_reports = [] 39 | 40 | for project in Project.objects.all(): 41 | updated_reports.extend(project.run_reports(overwrite=overwrite)) 42 | project.social.refresh() 43 | 44 | if updated_reports: 45 | print 'Sending notification email' 46 | 47 | email_body = render_to_string( 48 | 'email.txt', 49 | { 50 | 'reports': updated_reports 51 | }, 52 | '/tmp/email.txt' 53 | ) 54 | 55 | if app_config.DEPLOYMENT_TARGET: 56 | ses = boto.ses.connect_to_region( 57 | app_config.SES_REGION 58 | ) 59 | 60 | ses.send_email( 61 | app_config.EMAIL_SEND_ADDRESS, 62 | 'Carebot cares!', 63 | email_body, 64 | [app_config.EMAIL_NOTIFY_ADDRESS] 65 | ) 66 | 67 | GECKOBOARD_WIDGETS = { 68 | 'projects': [{ 69 | 'title': '123621-8996005e-6ad7-4c99-8d71-326e14377926', 70 | 'date': '77517-ffadabe0-7363-0132-9f06-22000b490a2f', 71 | 'sessions': '123621-96934a20-e4d1-4241-b4f2-eb194397b799', 72 | 'social': '77517-6d790ab0-7333-0132-9ead-22000b490a2f', 73 | 'devices': '123621-7004cb03-40fc-4391-8792-d84a5c020043' 74 | }, { 75 | 'title': '77517-9da9ad80-7332-0132-9eab-22000b490a2f', 76 | 'date': '77517-3e2547e0-7364-0132-ef2d-22000b5e86d6', 77 | 'sessions': '77517-d0351800-7332-0132-9eac-22000b490a2f', 78 | 'social': '77517-a547fda0-7333-0132-df92-22000b51936c', 79 | 'devices': '77517-457cf730-7338-0132-eefa-22000b5e86d6' 80 | }, { 81 | 'title': '77517-1c6c2d80-7336-0132-9ec6-22000b490a2f', 82 | 'date': '77517-41d93a50-7364-0132-dfd5-22000b51936c', 83 | 'sessions': '77517-1e591bc0-7336-0132-eef8-22000b5e86d6', 84 | 'social': '77517-2020a130-7336-0132-7329-22000b5391df', 85 | 'devices': '77517-4862e280-7338-0132-df9d-22000b51936c' 86 | }], 87 | 'sessions_leaderboard': '123621-0528aa9d-a700-43d0-ae59-f6ce5cf42984' 88 | } 89 | 90 | @task 91 | def update_geckoboard(): 92 | top = Project.objects.all()[:3] 93 | 94 | for i, project in enumerate(top): 95 | widgets = GECKOBOARD_WIDGETS['projects'][i] 96 | all_time_report = project.all_time_report 97 | 98 | _geckoboard_text( 99 | widgets['title'], 100 | '%s' % ( 101 | app_config.STAGING_SERVERS[0], 102 | project.get_absolute_url(), 103 | project.title 104 | ) 105 | ) 106 | 107 | days_ago = (date.today() - project.start_date).days 108 | 109 | _geckoboard_text( 110 | widgets['date'], 111 | '%s
(%s)
' % ( 112 | project.domain, 113 | project.prefix, 114 | project.start_date.strftime('%b. %d'), 115 | '%i day%s ago' % (days_ago, '' if days_ago == 1 else 's') 116 | ) 117 | ) 118 | 119 | _geckoboard_number( 120 | widgets['sessions'], 121 | all_time_report.sessions, 122 | 'sessions' 123 | ) 124 | 125 | _geckoboard_number( 126 | widgets['social'], 127 | float(project.social.total()) / (all_time_report.sessions / 1000), 128 | 'social interactions per 1,000 sessions' 129 | ) 130 | 131 | query_result = all_time_report.query_results.get(query__slug='sessions-by-device-category') 132 | sessions_metric = query_result.metrics.all()[0] 133 | desktop_dimension = sessions_metric.dimensions.get(name='desktop') 134 | mobile_dimension = sessions_metric.dimensions.get(name='mobile') 135 | tablet_dimension = sessions_metric.dimensions.get(name='tablet') 136 | 137 | _geckoboard_rag( 138 | widgets['devices'], 139 | ('Mobile', '%.1f%%' % mobile_dimension.percent_of_total), 140 | ('Tablet', '%.1f' % tablet_dimension.percent_of_total), 141 | ('Desktop', '%.1f' % desktop_dimension.percent_of_total) 142 | ) 143 | 144 | top_sessions = [] 145 | all_time_reports = Report.objects.filter(ndays__isnull=True).order_by('-sessions')[:10] 146 | 147 | for report in all_time_reports: 148 | top_sessions.append((report.project.title, report.sessions)) 149 | 150 | _geckoboard_leaderboard(GECKOBOARD_WIDGETS['sessions_leaderboard'], top_sessions) 151 | 152 | def _geckoboard_push(widget_key, data): 153 | payload = { 154 | 'api_key': app_config.get_secrets()['GECKOBOARD_API_KEY'], 155 | 'data': data 156 | } 157 | 158 | response = requests.post( 159 | 'https://push.geckoboard.com/v1/send/%s' % widget_key, 160 | json.dumps(payload) 161 | ) 162 | 163 | if response.status_code != 200: 164 | print 'Failed update update Geckoboard widget %s' % widget_key 165 | print response.content 166 | 167 | def _geckoboard_text(widget_key, text): 168 | data = { 169 | 'item': [{ 170 | 'text': text 171 | }] 172 | } 173 | 174 | _geckoboard_push(widget_key, data) 175 | 176 | def _geckoboard_number(widget_key, value, label): 177 | data = { 178 | 'item': [{ 179 | 'value': value, 180 | 'text': label 181 | }] 182 | } 183 | 184 | _geckoboard_push(widget_key, data) 185 | 186 | def _geckoboard_meter(widget_key, value, min_value, max_value): 187 | data = { 188 | 'item': value, 189 | 'min': { 190 | 'value': min_value 191 | }, 192 | 'max': { 193 | 'value': max_value 194 | } 195 | } 196 | 197 | _geckoboard_push(widget_key, data) 198 | 199 | def _geckoboard_leaderboard(widget_key, pairs): 200 | data = { 201 | 'items': [] 202 | } 203 | 204 | for label, value in pairs: 205 | data['items'].append({ 206 | 'label': label, 207 | 'value': value 208 | }) 209 | 210 | _geckoboard_push(widget_key, data) 211 | 212 | def _geckoboard_rag(widget_key, red_pair, orange_pair, green_pair): 213 | data = { 214 | 'item': [] 215 | } 216 | 217 | for label, value in [red_pair, orange_pair, green_pair]: 218 | data['item'].append({ 219 | 'value': value, 220 | 'text': label 221 | }) 222 | 223 | _geckoboard_push(widget_key, data) 224 | 225 | -------------------------------------------------------------------------------- /reports/templates/project.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load humanize %} 3 | {% load tz %} 4 | {% load admin_urls %} 5 | {% load social %} 6 | 7 | {% block title %}{{ project.title }} :: carebot{% endblock %} 8 | 9 | {% block body %} 10 |
11 |

{{ project.title }}

12 | 13 |

« Back to project list

14 | 15 |

Configuration (edit)

16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
Google Analytics Property ID{{ project.property_id }}
Domain{{ project.domain }}
Prefix{{ project.prefix }}
Launch date{{ project.start_date }}
Tags{% include "_tags.html" %}
41 | 42 |

All-time sharing activity

43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
InteractionCountPer 1,000 Sessions
Facebook shares{{ project.social.facebook_shares|intcomma }}{% social_per_1000_sessions project "facebook_shares" %}
Twitter{{ project.social.twitter|intcomma }}{% social_per_1000_sessions project "twitter" %}
Google+{{ project.social.google|intcomma }}{% social_per_1000_sessions project "google" %}
Pinterest{{ project.social.pinterest|intcomma }}{% social_per_1000_sessions project "pinterest" %}
LinkedIn{{ project.social.linkedin|intcomma }}{% social_per_1000_sessions project "linkedin" %}
StumbleUpon{{ project.social.stumbleupon|intcomma }}{% social_per_1000_sessions project "stumbleupon" %}
Total{{ project.social.total|intcomma }}{% social_per_1000_sessions project "total" %}
96 | 97 |
98 |

Shares across all projects

99 | 100 |
101 | 102 |
103 |

Shares per 1,000 sessions across all projects

104 | 105 |
106 | 107 |

All-time Facebook likes/comments

108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 |
InteractionCountPer Facebook Share
Facebook likes{{ project.social.facebook_likes|intcomma }}{% social_per_1000_sessions project "facebook_likes" %}
Facebook comments{{ project.social.facebook_comments|intcomma }}{% social_per_1000_sessions project "facebook_comments" %}
129 | 130 | Last updated: {{ project.social.last_update|localtime }} 131 | 132 |

Reports

133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | {% for report in reports %} 144 | 145 | 146 | 147 | 148 | 149 | {% endfor %} 150 | 151 |
Report timespanRun dateSessions
{{ report.timespan }}{{ report.last_run|localtime }}{{ report.sessions|intcomma }}
152 |
153 | 154 | 155 | 240 | {% endblock %} 241 | -------------------------------------------------------------------------------- /reports/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='DimensionResult', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('order', models.PositiveIntegerField()), 18 | ('name', models.CharField(max_length=128)), 19 | ('_value', models.CharField(max_length=128)), 20 | ('percent_of_total', models.FloatField(null=True)), 21 | ('project_title', models.CharField(max_length=128)), 22 | ('report_ndays', models.PositiveIntegerField(null=True)), 23 | ('query_name', models.CharField(max_length=128)), 24 | ('metric_name', models.CharField(max_length=128)), 25 | ('metric_data_type', models.CharField(max_length=30)), 26 | ], 27 | options={ 28 | 'ordering': ('metric', 'order'), 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | migrations.CreateModel( 33 | name='MetricResult', 34 | fields=[ 35 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 36 | ('order', models.PositiveIntegerField()), 37 | ('name', models.CharField(max_length=128)), 38 | ('data_type', models.CharField(max_length=30)), 39 | ('project_title', models.CharField(max_length=128)), 40 | ('report_ndays', models.PositiveIntegerField(null=True)), 41 | ('query_name', models.CharField(max_length=128)), 42 | ], 43 | options={ 44 | 'ordering': ('query_result', 'order'), 45 | }, 46 | bases=(models.Model,), 47 | ), 48 | migrations.CreateModel( 49 | name='Project', 50 | fields=[ 51 | ('slug', models.SlugField(max_length=128, serialize=False, primary_key=True)), 52 | ('title', models.CharField(max_length=128)), 53 | ('property_id', models.CharField(default=b'53470309', max_length=10)), 54 | ('domain', models.CharField(default=b'apps.npr.org', max_length=128)), 55 | ('prefix', models.CharField(max_length=128)), 56 | ('start_date', models.DateField()), 57 | ], 58 | options={ 59 | 'ordering': ('-start_date',), 60 | }, 61 | bases=(models.Model,), 62 | ), 63 | migrations.CreateModel( 64 | name='ProjectQuery', 65 | fields=[ 66 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 67 | ('order', models.PositiveIntegerField()), 68 | ], 69 | options={ 70 | 'ordering': ('order',), 71 | }, 72 | bases=(models.Model,), 73 | ), 74 | migrations.CreateModel( 75 | name='Query', 76 | fields=[ 77 | ('slug', models.SlugField(max_length=128, serialize=False, primary_key=True)), 78 | ('name', models.CharField(max_length=128)), 79 | ('description', models.CharField(default=b'', max_length=256)), 80 | ('clan_yaml', models.TextField()), 81 | ], 82 | options={ 83 | 'ordering': ('name',), 84 | 'verbose_name_plural': 'queries', 85 | }, 86 | bases=(models.Model,), 87 | ), 88 | migrations.CreateModel( 89 | name='QueryResult', 90 | fields=[ 91 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 92 | ('order', models.PositiveIntegerField()), 93 | ('sampled', models.BooleanField(default=False)), 94 | ('sample_size', models.PositiveIntegerField(default=0)), 95 | ('sample_space', models.PositiveIntegerField(default=0)), 96 | ('sample_percent', models.FloatField(default=100)), 97 | ('project_title', models.CharField(max_length=128)), 98 | ('report_ndays', models.PositiveIntegerField(null=True)), 99 | ('query_name', models.CharField(max_length=128)), 100 | ('query', models.ForeignKey(related_name='query_results', to='reports.Query')), 101 | ], 102 | options={ 103 | 'ordering': ('report', 'order'), 104 | }, 105 | bases=(models.Model,), 106 | ), 107 | migrations.CreateModel( 108 | name='Report', 109 | fields=[ 110 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 111 | ('ndays', models.PositiveIntegerField(null=True)), 112 | ('results_json', models.TextField()), 113 | ('last_run', models.DateTimeField(null=True)), 114 | ('pageviews', models.PositiveIntegerField(null=True)), 115 | ('unique_pageviews', models.PositiveIntegerField(null=True)), 116 | ('users', models.PositiveIntegerField(null=True)), 117 | ('sessions', models.PositiveIntegerField(null=True)), 118 | ], 119 | options={ 120 | 'ordering': ('project__start_date', 'ndays'), 121 | }, 122 | bases=(models.Model,), 123 | ), 124 | migrations.CreateModel( 125 | name='Social', 126 | fields=[ 127 | ('project', models.OneToOneField(primary_key=True, serialize=False, to='reports.Project')), 128 | ('facebook_likes', models.PositiveIntegerField(default=0)), 129 | ('facebook_shares', models.PositiveIntegerField(default=0)), 130 | ('facebook_comments', models.PositiveIntegerField(default=0)), 131 | ('twitter', models.PositiveIntegerField(default=0)), 132 | ('google', models.PositiveIntegerField(default=0)), 133 | ('pinterest', models.PositiveIntegerField(default=0)), 134 | ('linkedin', models.PositiveIntegerField(default=0)), 135 | ('stumbleupon', models.PositiveIntegerField(default=0)), 136 | ('last_update', models.DateTimeField(null=True)), 137 | ], 138 | options={ 139 | 'ordering': ('-project__start_date',), 140 | 'verbose_name': 'social count', 141 | 'verbose_name_plural': 'social counts', 142 | }, 143 | bases=(models.Model,), 144 | ), 145 | migrations.CreateModel( 146 | name='Tag', 147 | fields=[ 148 | ('slug', models.CharField(max_length=32, serialize=False, primary_key=True)), 149 | ], 150 | options={ 151 | }, 152 | bases=(models.Model,), 153 | ), 154 | migrations.AddField( 155 | model_name='report', 156 | name='project', 157 | field=models.ForeignKey(related_name='reports', to='reports.Project'), 158 | preserve_default=True, 159 | ), 160 | migrations.AddField( 161 | model_name='queryresult', 162 | name='report', 163 | field=models.ForeignKey(related_name='query_results', to='reports.Report'), 164 | preserve_default=True, 165 | ), 166 | migrations.AddField( 167 | model_name='projectquery', 168 | name='project', 169 | field=models.ForeignKey(related_name='project_queries', to='reports.Project'), 170 | preserve_default=True, 171 | ), 172 | migrations.AddField( 173 | model_name='projectquery', 174 | name='query', 175 | field=models.ForeignKey(related_name='project_queries', to='reports.Query'), 176 | preserve_default=True, 177 | ), 178 | migrations.AddField( 179 | model_name='project', 180 | name='queries', 181 | field=models.ManyToManyField(to='reports.Query', through='reports.ProjectQuery'), 182 | preserve_default=True, 183 | ), 184 | migrations.AddField( 185 | model_name='project', 186 | name='tags', 187 | field=models.ManyToManyField(to='reports.Tag'), 188 | preserve_default=True, 189 | ), 190 | migrations.AddField( 191 | model_name='metricresult', 192 | name='query_result', 193 | field=models.ForeignKey(related_name='metrics', to='reports.QueryResult'), 194 | preserve_default=True, 195 | ), 196 | migrations.AddField( 197 | model_name='dimensionresult', 198 | name='metric', 199 | field=models.ForeignKey(related_name='dimensions', to='reports.MetricResult', null=True), 200 | preserve_default=True, 201 | ), 202 | ] 203 | -------------------------------------------------------------------------------- /fabfile/servers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Commands work with servers. (Hiss, boo.) 5 | """ 6 | 7 | import copy 8 | 9 | from fabric.api import local, put, settings, require, run, sudo, task 10 | from fabric.state import env 11 | from jinja2 import Template 12 | 13 | import app_config 14 | 15 | """ 16 | Setup 17 | """ 18 | 19 | @task 20 | def setup(): 21 | """ 22 | Setup servers for deployment. 23 | 24 | This does not setup services or push to S3. Run deploy() next. 25 | """ 26 | require('settings', provided_by=['production', 'staging']) 27 | require('branch', provided_by=['stable', 'master', 'branch']) 28 | 29 | if not app_config.DEPLOY_TO_SERVERS: 30 | print 'You must set DEPLOY_TO_SERVERS = True in your app_config.py before setting up the servers.' 31 | 32 | return 33 | 34 | create_directories() 35 | create_virtualenv() 36 | clone_repo() 37 | checkout_latest() 38 | install_requirements() 39 | setup_logs() 40 | 41 | def create_directories(): 42 | """ 43 | Create server directories. 44 | """ 45 | require('settings', provided_by=['production', 'staging']) 46 | 47 | run('mkdir -p %(SERVER_PROJECT_PATH)s' % app_config.__dict__) 48 | run('mkdir -p /var/www/uploads/%(PROJECT_FILENAME)s' % app_config.__dict__) 49 | 50 | def create_virtualenv(): 51 | """ 52 | Setup a server virtualenv. 53 | """ 54 | require('settings', provided_by=['production', 'staging']) 55 | 56 | run('virtualenv -p %(SERVER_PYTHON)s %(SERVER_VIRTUALENV_PATH)s' % app_config.__dict__) 57 | run('source %(SERVER_VIRTUALENV_PATH)s/bin/activate' % app_config.__dict__) 58 | 59 | def clone_repo(): 60 | """ 61 | Clone the source repository. 62 | """ 63 | require('settings', provided_by=['production', 'staging']) 64 | 65 | run('git clone %(REPOSITORY_URL)s %(SERVER_REPOSITORY_PATH)s' % app_config.__dict__) 66 | 67 | if app_config.REPOSITORY_ALT_URL: 68 | run('git remote add bitbucket %(REPOSITORY_ALT_URL)s' % app_config.__dict__) 69 | 70 | @task 71 | def checkout_latest(remote='origin'): 72 | """ 73 | Checkout the latest source. 74 | """ 75 | require('settings', provided_by=['production', 'staging']) 76 | require('branch', provided_by=['stable', 'master', 'branch']) 77 | 78 | run('cd %s; git fetch %s' % (app_config.SERVER_REPOSITORY_PATH, remote)) 79 | run('cd %s; git checkout %s; git pull %s %s' % (app_config.SERVER_REPOSITORY_PATH, env.branch, remote, env.branch)) 80 | 81 | @task 82 | def install_requirements(): 83 | """ 84 | Install the latest requirements. 85 | """ 86 | require('settings', provided_by=['production', 'staging']) 87 | 88 | run('%(SERVER_VIRTUALENV_PATH)s/bin/pip install -U -r %(SERVER_REPOSITORY_PATH)s/requirements.txt' % app_config.__dict__) 89 | 90 | @task 91 | def setup_logs(): 92 | """ 93 | Create log directories. 94 | """ 95 | require('settings', provided_by=['production', 'staging']) 96 | 97 | sudo('mkdir %(SERVER_LOG_PATH)s' % app_config.__dict__) 98 | sudo('chown ubuntu:ubuntu %(SERVER_LOG_PATH)s' % app_config.__dict__) 99 | 100 | @task 101 | def install_crontab(): 102 | """ 103 | Install cron jobs script into cron.d. 104 | """ 105 | require('settings', provided_by=['production', 'staging']) 106 | 107 | sudo('cp %(SERVER_REPOSITORY_PATH)s/crontab /etc/cron.d/%(PROJECT_FILENAME)s' % app_config.__dict__) 108 | 109 | @task 110 | def uninstall_crontab(): 111 | """ 112 | Remove a previously install cron jobs script from cron.d 113 | """ 114 | require('settings', provided_by=['production', 'staging']) 115 | 116 | sudo('rm /etc/cron.d/%(PROJECT_FILENAME)s' % app_config.__dict__) 117 | 118 | def delete_project(): 119 | """ 120 | Remove the project directory. Invoked by shiva. 121 | """ 122 | run('rm -rf %(SERVER_PROJECT_PATH)s' % app_config.__dict__) 123 | 124 | """ 125 | Configuration 126 | """ 127 | 128 | def _get_template_conf_path(service, extension): 129 | """ 130 | Derive the path for a conf template file. 131 | """ 132 | return 'confs/%s.%s' % (service, extension) 133 | 134 | def _get_rendered_conf_path(service, extension): 135 | """ 136 | Derive the rendered path for a conf file. 137 | """ 138 | return 'confs/rendered/%s.%s.%s' % (app_config.PROJECT_FILENAME, service, extension) 139 | 140 | def _get_installed_conf_path(service, remote_path, extension): 141 | """ 142 | Derive the installed path for a conf file. 143 | """ 144 | return '%s/%s.%s.%s' % (remote_path, app_config.PROJECT_FILENAME, service, extension) 145 | 146 | def _get_installed_service_name(service): 147 | """ 148 | Derive the init service name for an installed service. 149 | """ 150 | return '%s.%s' % (app_config.PROJECT_FILENAME, service) 151 | 152 | @task 153 | def render_confs(): 154 | """ 155 | Renders server configurations. 156 | """ 157 | require('settings', provided_by=['production', 'staging']) 158 | 159 | with settings(warn_only=True): 160 | local('mkdir confs/rendered') 161 | 162 | # Copy the app_config so that when we load the secrets they don't 163 | # get exposed to other management commands 164 | context = copy.copy(app_config.__dict__) 165 | context.update(app_config.get_secrets()) 166 | 167 | for service, remote_path, extension in app_config.SERVER_SERVICES: 168 | template_path = _get_template_conf_path(service, extension) 169 | rendered_path = _get_rendered_conf_path(service, extension) 170 | 171 | with open(template_path, 'r') as read_template: 172 | 173 | with open(rendered_path, 'wb') as write_template: 174 | payload = Template(read_template.read()) 175 | write_template.write(payload.render(**context)) 176 | 177 | @task 178 | def deploy_confs(): 179 | """ 180 | Deploys rendered server configurations to the specified server. 181 | This will reload nginx and the appropriate uwsgi config. 182 | """ 183 | require('settings', provided_by=['production', 'staging']) 184 | 185 | render_confs() 186 | 187 | with settings(warn_only=True): 188 | for service, remote_path, extension in app_config.SERVER_SERVICES: 189 | rendered_path = _get_rendered_conf_path(service, extension) 190 | installed_path = _get_installed_conf_path(service, remote_path, extension) 191 | 192 | a = local('md5 -q %s' % rendered_path, capture=True) 193 | b = run('md5sum %s' % installed_path).split()[0] 194 | 195 | if a != b: 196 | print 'Updating %s' % installed_path 197 | put(rendered_path, installed_path, use_sudo=True) 198 | 199 | if service == 'nginx': 200 | sudo('service nginx reload') 201 | elif service == 'uwsgi': 202 | service_name = _get_installed_service_name(service) 203 | sudo('initctl reload-configuration') 204 | sudo('service %s restart' % service_name) 205 | elif service == 'app': 206 | run('touch %s' % app_config.UWSGI_SOCKET_PATH) 207 | sudo('chmod 644 %s' % app_config.UWSGI_SOCKET_PATH) 208 | sudo('chown www-data:www-data %s' % app_config.UWSGI_SOCKET_PATH) 209 | else: 210 | print '%s has not changed' % rendered_path 211 | 212 | @task 213 | def reload_service(service): 214 | require('settings', provided_by=['production', 'staging']) 215 | 216 | if service == 'nginx': 217 | sudo('service nginx reload') 218 | elif service == 'uwsgi': 219 | service_name = _get_installed_service_name(service) 220 | sudo('initctl reload-configuration') 221 | sudo('service %s restart' % service_name) 222 | elif service == 'app': 223 | run('touch %s' % app_config.UWSGI_SOCKET_PATH) 224 | sudo('chmod 644 %s' % app_config.UWSGI_SOCKET_PATH) 225 | sudo('chown www-data:www-data %s' % app_config.UWSGI_SOCKET_PATH) 226 | 227 | @task 228 | def nuke_confs(): 229 | """ 230 | DESTROYS rendered server configurations from the specified server. 231 | This will reload nginx and stop the uwsgi config. 232 | """ 233 | require('settings', provided_by=['production', 'staging']) 234 | 235 | for service, remote_path, extension in app_config.SERVER_SERVICES: 236 | with settings(warn_only=True): 237 | installed_path = _get_installed_conf_path(service, remote_path, extension) 238 | 239 | sudo('rm -f %s' % installed_path) 240 | 241 | if service == 'nginx': 242 | sudo('service nginx reload') 243 | elif service == 'uwsgi': 244 | service_name = _get_installed_service_name(service) 245 | sudo('service %s stop' % service_name) 246 | sudo('initctl reload-configuration') 247 | elif service == 'app': 248 | sudo('rm %s' % app_config.UWSGI_SOCKET_PATH) 249 | 250 | """ 251 | Django 252 | """ 253 | 254 | @task 255 | def collectstatic(): 256 | require('settings', provided_by=['production', 'staging']) 257 | 258 | run('cd %(SERVER_REPOSITORY_PATH)s; %(SERVER_VIRTUALENV_PATH)s/bin/python %(SERVER_REPOSITORY_PATH)s/manage.py collectstatic --noinput' % app_config.__dict__) 259 | 260 | """ 261 | Fabcasting 262 | """ 263 | 264 | @task 265 | def fabcast(command): 266 | """ 267 | Actually run specified commands on the server specified 268 | by staging() or production(). 269 | """ 270 | require('settings', provided_by=['production', 'staging']) 271 | 272 | if not app_config.DEPLOY_TO_SERVERS: 273 | print 'You must set DEPLOY_TO_SERVERS = True in your app_config.py and setup a server before fabcasting.' 274 | 275 | run('cd %s && bash run_on_server.sh fab branch:%s $DEPLOYMENT_TARGET %s' % (app_config.SERVER_REPOSITORY_PATH, env.branch, command)) 276 | 277 | -------------------------------------------------------------------------------- /reports/static/reports/tablesort.min.js: -------------------------------------------------------------------------------- 1 | ;(function() { 2 | function Tablesort(el, options) { 3 | if (!el) throw new Error('Element not found'); 4 | if (el.tagName !== 'TABLE') throw new Error('Element must be a table'); 5 | this.init(el, options || {}); 6 | } 7 | 8 | Tablesort.prototype = { 9 | 10 | init: function(el, options) { 11 | var that = this, 12 | firstRow; 13 | this.thead = false; 14 | this.options = options; 15 | 16 | if (el.rows && el.rows.length > 0) { 17 | if (el.tHead && el.tHead.rows.length > 0) { 18 | firstRow = el.tHead.rows[el.tHead.rows.length - 1]; 19 | that.thead = true; 20 | } else { 21 | firstRow = el.rows[0]; 22 | } 23 | } 24 | 25 | if (!firstRow) return; 26 | 27 | var onClick = function() { 28 | if (that.current && that.current !== this) { 29 | if (that.current.classList.contains(classSortUp)) { 30 | that.current.classList.remove(classSortUp); 31 | } 32 | else if (that.current.classList.contains(classSortDown)) { 33 | that.current.classList.remove(classSortDown); 34 | } 35 | } 36 | 37 | that.current = this; 38 | that.sortTable(this); 39 | }; 40 | 41 | var defaultSort; 42 | 43 | // Assume first row is the header and attach a click handler to each. 44 | for (var i = 0; i < firstRow.cells.length; i++) { 45 | var cell = firstRow.cells[i]; 46 | if (!cell.classList.contains('no-sort')) { 47 | cell.classList.add('sort-header'); 48 | cell.addEventListener('click', onClick, false); 49 | 50 | if (cell.classList.contains('sort-default')) { 51 | defaultSort = cell; 52 | } 53 | } 54 | } 55 | 56 | if (defaultSort) { 57 | that.current = defaultSort; 58 | that.sortTable(defaultSort, true); 59 | } 60 | }, 61 | 62 | getFirstDataRowIndex: function() { 63 | // If table does not have a , assume that first row is 64 | // a header and skip it. 65 | if (!this.thead) { 66 | return 1; 67 | } else { 68 | return 0; 69 | } 70 | }, 71 | 72 | sortTable: function(header, update) { 73 | var that = this, 74 | column = header.cellIndex, 75 | sortFunction, 76 | t = getParent(header, 'table'), 77 | item = '', 78 | i = that.getFirstDataRowIndex(); 79 | 80 | if (t.rows.length <= 1) return; 81 | 82 | while (item === '' && i < t.tBodies[0].rows.length) { 83 | item = getInnerText(t.tBodies[0].rows[i].cells[column]); 84 | item = item.trim(); 85 | // Exclude cell values where commented out HTML exists 86 | if (item.substr(0, 4) === '