├── .gitignore ├── README.md ├── app_config.py ├── carebot ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py ├── confs ├── app.ini ├── nginx.conf └── uwsgi.conf ├── crontab ├── data ├── .placeholder ├── projects.csv ├── queries │ ├── amazon-link-clicked.yaml │ ├── email-link-clicked.yaml │ ├── facebook-share-link-clicked.yaml │ ├── itunes-link-clicked.yaml │ ├── performance.yaml │ ├── rdio-link-clicked.yaml │ ├── sessions-by-browser.yaml │ ├── sessions-by-device-category.yaml │ ├── sessions-by-referring-domain.yaml │ ├── sessions-by-referring-social-network.yaml │ ├── share-discuss-panel-opened.yaml │ ├── songs-skipped.yaml │ ├── spotify-link-clicked.yaml │ ├── summary-copied.yaml │ ├── tags-finished.yaml │ ├── tags-selected.yaml │ ├── time-spent-on-page-by-device-category.yaml │ ├── totals.yaml │ └── tweet-link-clicked.yaml └── test_user.json ├── fabfile ├── __init__.py ├── cron_jobs.py ├── data.py ├── servers.py └── utils.py ├── manage.py ├── render_utils.py ├── reports ├── __init__.py ├── admin.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150106_1134.py │ ├── 0003_auto_20150107_1033.py │ └── __init__.py ├── models.py ├── static │ └── reports │ │ ├── bootstrap.min.css │ │ ├── bootstrap.min.js │ │ ├── jquery-1.11.2.min.js │ │ ├── report.css │ │ └── tablesort.min.js ├── templates │ ├── _base.html │ ├── _compare_dimension.html │ ├── _report_dimension.html │ ├── _tags.html │ ├── compare_query.html │ ├── email.txt │ ├── index.html │ ├── project.html │ └── report.html ├── templatetags │ ├── __init__.py │ └── social.py ├── tests.py └── views.py ├── requirements.txt ├── run_on_server.sh └── utils.py /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /carebot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nprapps/carebot/fc9b87869137144c11ef021e9889b3d55c837ae3/carebot/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /confs/nginx.conf: -------------------------------------------------------------------------------- 1 | location ^~ /{{ PROJECT_SLUG }}/ { 2 | uwsgi_pass unix:///tmp/{{ PROJECT_FILENAME }}.uwsgi.sock; 3 | include /etc/nginx/uwsgi_params; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nprapps/carebot/fc9b87869137144c11ef021e9889b3d55c837ae3/data/.placeholder -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /data/queries/amazon-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Amazon link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==amazon-click" -------------------------------------------------------------------------------- /data/queries/email-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Email link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==email" -------------------------------------------------------------------------------- /data/queries/facebook-share-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Facebook share link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==facebook" -------------------------------------------------------------------------------- /data/queries/itunes-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "iTunes link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==itunes-click" -------------------------------------------------------------------------------- /data/queries/performance.yaml: -------------------------------------------------------------------------------- 1 | name: "Performance" 2 | metrics: 3 | - "ga:avgPageLoadTime" 4 | - "ga:avgPageDownloadTime" 5 | - "ga:avgDomInteractiveTime" 6 | - "ga:avgDomContentLoadedTime" -------------------------------------------------------------------------------- /data/queries/rdio-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Rdio link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==rdio-click" -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /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/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/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/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/spotify-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Spotify link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==spotify-click" -------------------------------------------------------------------------------- /data/queries/summary-copied.yaml: -------------------------------------------------------------------------------- 1 | name: "Summary Copied" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==summary-copied" -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/tweet-link-clicked.yaml: -------------------------------------------------------------------------------- 1 | name: "Tweet link clicked" 2 | metrics: 3 | - "ga:totalEvents" 4 | filter: "ga:eventAction==tweet" -------------------------------------------------------------------------------- /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/__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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nprapps/carebot/fc9b87869137144c11ef021e9889b3d55c837ae3/reports/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nprapps/carebot/fc9b87869137144c11ef021e9889b3d55c837ae3/reports/migrations/__init__.py -------------------------------------------------------------------------------- /reports/models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from collections import OrderedDict 4 | from copy import copy 5 | from datetime import date, datetime, timedelta 6 | from itertools import izip 7 | import json 8 | import subprocess 9 | 10 | from clan import utils as clan_utils 11 | from django.core.urlresolvers import reverse 12 | from django.db import models 13 | from django.dispatch import receiver 14 | from django.utils import timezone 15 | import requests 16 | import yaml 17 | 18 | import app_config 19 | import utils 20 | 21 | FIELD_DEFINITIONS = clan_utils.load_field_definitions() 22 | 23 | class Query(models.Model): 24 | """ 25 | A clan query. 26 | """ 27 | slug = models.SlugField(max_length=128, primary_key=True) 28 | name = models.CharField(max_length=128) 29 | description = models.CharField(max_length=256, default='', blank=True) 30 | is_comparable = models.BooleanField(default=True, 31 | help_text='Should this query be available for cross-project comparison.') 32 | clan_yaml = models.TextField() 33 | 34 | class Meta: 35 | ordering = ('name',) 36 | verbose_name_plural = 'queries' 37 | 38 | def __unicode__(self): 39 | return self.name 40 | 41 | @property 42 | def config(self): 43 | data = yaml.load(self.clan_yaml) 44 | 45 | data['name'] = self.name 46 | data['description'] = self.description 47 | 48 | return data 49 | 50 | class Tag(models.Model): 51 | """ 52 | A tag describing a project. 53 | """ 54 | slug = models.CharField(max_length=32, primary_key=True) 55 | 56 | class Meta: 57 | ordering = ('slug',) 58 | 59 | def __unicode__(self): 60 | return self.slug 61 | 62 | class Project(models.Model): 63 | """ 64 | A project (app/site). 65 | """ 66 | slug = models.SlugField(max_length=128, primary_key=True) 67 | title = models.CharField(max_length=128) 68 | property_id = models.CharField(max_length=10, default='53470309') 69 | domain = models.CharField(max_length=128, default='apps.npr.org', blank=True) 70 | prefix = models.CharField(max_length=128, blank=True) 71 | start_date = models.DateField() 72 | queries = models.ManyToManyField(Query, through='ProjectQuery') 73 | tags = models.ManyToManyField(Tag) 74 | 75 | class Meta: 76 | ordering = ('-start_date',) 77 | 78 | def __unicode__(self): 79 | return self.title 80 | 81 | def get_absolute_url(self): 82 | return reverse('reports.views.project', args=[self.slug]) 83 | 84 | def tag_list(self): 85 | return ','.join([tag.slug for tag in self.tags.all()]) 86 | 87 | @property 88 | def all_time_report(self): 89 | return self.reports.get(ndays__isnull=True) 90 | 91 | def run_reports(self, overwrite=False): 92 | """ 93 | Runs all reports, optionally overwriting existing results. 94 | """ 95 | print 'Running reports for %s' % self.title 96 | 97 | updated_reports = [] 98 | 99 | for report in self.reports.all(): 100 | if overwrite or not report.last_run or not report.ndays: 101 | updated = report.run() 102 | 103 | if updated and report.ndays: 104 | updated_reports.append(report) 105 | else: 106 | print 'Skipping %s report for %s (already run).' % (report.timespan, self.title) 107 | 108 | return updated_reports 109 | 110 | def get_clan_config(self): 111 | return { 112 | 'title': self.title, 113 | 'property-id': self.property_id, 114 | 'domain': self.domain or None, 115 | 'prefix': self.prefix or None, 116 | 'start-date': datetime.strftime(self.start_date, '%Y-%m-%d') 117 | } 118 | 119 | @receiver(models.signals.post_save, sender=Project) 120 | def on_project_post_save(sender, instance, created, *args, **kwargs): 121 | """ 122 | Create default reports for a new project. 123 | """ 124 | if created: 125 | default_queries = copy(app_config.DEFAULT_QUERIES) 126 | 127 | if instance.start_date > date(2014, 6, 1): 128 | default_queries.extend(app_config.DEFAULT_EVENT_QUERIES) 129 | 130 | for i, query_slug in enumerate(default_queries): 131 | ProjectQuery.objects.create( 132 | project=instance, 133 | query=Query.objects.get(slug=query_slug), 134 | order=i 135 | ) 136 | 137 | Report.objects.create( 138 | project=instance, 139 | ndays=None 140 | ) 141 | 142 | for ndays in app_config.DEFAULT_REPORT_NDAYS: 143 | Report.objects.create( 144 | project=instance, 145 | ndays=ndays 146 | ) 147 | 148 | Social.objects.create(project=instance) 149 | 150 | class ProjectQuery(models.Model): 151 | """ 152 | M2M relationship between Projects and Queries. 153 | """ 154 | project = models.ForeignKey(Project, related_name='project_queries') 155 | query = models.ForeignKey(Query, related_name='project_queries') 156 | order = models.PositiveIntegerField() 157 | 158 | class Meta: 159 | ordering = ('order',) 160 | 161 | class Report(models.Model): 162 | """ 163 | A report for a given project over some number of days. 164 | """ 165 | project = models.ForeignKey(Project, related_name='reports') 166 | ndays = models.PositiveIntegerField(null=True) 167 | results_json = models.TextField() 168 | last_run = models.DateTimeField(null=True) 169 | 170 | pageviews = models.PositiveIntegerField(null=True) 171 | unique_pageviews = models.PositiveIntegerField(null=True) 172 | users = models.PositiveIntegerField(null=True) 173 | sessions = models.PositiveIntegerField(null=True) 174 | 175 | class Meta: 176 | ordering = ('project__start_date', 'ndays',) 177 | 178 | def __unicode__(self): 179 | return '%s (%s)' % (self.project.title, self.timespan) 180 | 181 | def get_absolute_url(self): 182 | return reverse( 183 | 'reports.views.report', 184 | args=[ 185 | self.project.slug, 186 | self.ndays or 'all-time' 187 | ] 188 | ) 189 | 190 | @property 191 | def timespan(self): 192 | if self.ndays: 193 | return '%i-day%s' % (self.ndays, 's' if self.ndays > 1 else '') 194 | 195 | return 'all-time' 196 | 197 | def is_timely(self): 198 | """ 199 | Checks if it has been long enough to have data for this report. 200 | """ 201 | if not self.ndays: 202 | return True 203 | 204 | return date.today() >= self.project.start_date + timedelta(days=self.ndays) 205 | 206 | def build_clan_yaml(self): 207 | """ 208 | Build YAML configuration for this report. 209 | """ 210 | data = self.project.get_clan_config() 211 | 212 | if self.ndays: 213 | data['ndays'] = self.ndays 214 | 215 | data['queries'] = [] 216 | 217 | for project_query in ProjectQuery.objects.filter(project=self.project): 218 | data['queries'].append(project_query.query.config) 219 | 220 | return yaml.safe_dump(data, encoding='utf-8', allow_unicode=True) 221 | 222 | def run(self): 223 | """ 224 | Run this report, stash it's results and render it out to S3. 225 | """ 226 | if not self.is_timely(): 227 | print 'Skipping %s report for %s (not timely).' % (self.timespan, self.project.title) 228 | return False 229 | 230 | print 'Running %s report for %s' % (self.timespan, self.project.title) 231 | 232 | with open('/tmp/clan.yaml', 'w') as f: 233 | y = self.build_clan_yaml() 234 | f.write(y) 235 | 236 | subprocess.call(['clan', 'report', '/tmp/clan.yaml', '/tmp/clan.json']) 237 | 238 | with open('/tmp/clan.json') as f: 239 | self.results_json = f.read() 240 | self.last_run = timezone.now() 241 | 242 | # Delete existing results 243 | self.query_results.all().delete() 244 | 245 | data = json.loads(self.results_json, object_pairs_hook=OrderedDict) 246 | i = 0 247 | 248 | # Query results 249 | for project_query, result in izip(self.project.project_queries.all(), data['queries']): 250 | project_title = self.project.title 251 | query = project_query.query 252 | query_name = query.name 253 | metrics = result['config']['metrics'] 254 | data_types = result['data_types'] 255 | 256 | qr = QueryResult( 257 | report=self, 258 | query=query, 259 | order=i, 260 | sampled=result['sampled'], 261 | project_title=project_title, 262 | report_ndays=self.ndays, 263 | query_name=query_name 264 | ) 265 | 266 | if result['sampled']: 267 | qr.sample_size = result['sampleSize'] 268 | qr.sample_space = result['sampleSpace'] 269 | qr.sample_percent = float(result['sampleSize']) / result['sampleSpace'] * 100 270 | 271 | qr.save() 272 | 273 | j = 0 274 | 275 | # Metrics 276 | for metric_name in metrics: 277 | self._make_metric( 278 | qr, 279 | metric_name, 280 | j, 281 | data_types[metric_name], 282 | result['data'][metric_name] 283 | ) 284 | 285 | j += 1 286 | 287 | i += 1 288 | 289 | qr = self.query_results.get(query__slug='totals') 290 | 291 | for metric in qr.metrics.all(): 292 | if metric.name == 'ga:pageviews': 293 | self.pageviews = metric.total_dimension.value 294 | elif metric.name == 'ga:uniquePageviews': 295 | self.unique_pageviews = metric.total_dimension.value 296 | elif metric.name == 'ga:users': 297 | self.users = metric.total_dimension.value 298 | elif metric.name == 'ga:sessions': 299 | self.sessions = metric.total_dimension.value 300 | 301 | self.save() 302 | 303 | return True 304 | 305 | def _make_metric(self, query_result, metric_name, order, data_type, dimensions): 306 | """ 307 | Create a Metric and related Dimensions. 308 | """ 309 | total_value = dimensions['total'] 310 | 311 | metric = MetricResult( 312 | query_result=query_result, 313 | order=order, 314 | name=metric_name, 315 | data_type=data_type, 316 | project_title=query_result.project_title, 317 | report_ndays=query_result.report_ndays, 318 | query_name=query_result.query_name 319 | ) 320 | 321 | metric.save() 322 | 323 | i = 0 324 | 325 | # Dimensions 326 | for dimension_name, value in dimensions.items(): 327 | self._make_dimension( 328 | metric, 329 | dimension_name, 330 | i, 331 | data_type, 332 | value, 333 | total_value 334 | ) 335 | 336 | i += 1 337 | 338 | def _make_dimension(self, metric, dimension_name, order, data_type, value, total_value): 339 | """ 340 | Create a new Dimension. 341 | """ 342 | dimension = DimensionResult( 343 | order=order, 344 | name=dimension_name, 345 | _value=value, 346 | project_title=metric.project_title, 347 | report_ndays=metric.report_ndays, 348 | query_name=metric.query_name, 349 | metric_name=metric.name, 350 | metric_data_type=metric.data_type 351 | ) 352 | 353 | if dimension_name != 'total': 354 | if data_type in 'INTEGER' and total_value != 0: 355 | dimension.percent_of_total = float(value) / int(total_value) * 100 356 | 357 | dimension.metric = metric 358 | dimension.save() 359 | 360 | return dimension 361 | 362 | class QueryResult(models.Model): 363 | """ 364 | The results of a query for a certain report. 365 | """ 366 | report = models.ForeignKey(Report, related_name='query_results') 367 | query = models.ForeignKey(Query, related_name='query_results') 368 | order = models.PositiveIntegerField() 369 | 370 | sampled = models.BooleanField(default=False) 371 | sample_size = models.PositiveIntegerField(default=0) 372 | sample_space = models.PositiveIntegerField(default=0) 373 | sample_percent = models.FloatField(default=100) 374 | 375 | # Denormalized fields 376 | project_title = models.CharField(max_length=128) 377 | report_ndays = models.PositiveIntegerField(null=True) 378 | query_name = models.CharField(max_length=128) 379 | 380 | class Meta: 381 | ordering = ('report', 'order') 382 | 383 | class MetricResult(models.Model): 384 | """ 385 | The results for a specific metric. 386 | """ 387 | query_result = models.ForeignKey(QueryResult, related_name='metrics') 388 | order = models.PositiveIntegerField() 389 | 390 | name = models.CharField(max_length=128) 391 | data_type = models.CharField(max_length=30) 392 | 393 | # Denormalized fields 394 | project_title = models.CharField(max_length=128) 395 | report_ndays = models.PositiveIntegerField(null=True) 396 | query_name = models.CharField(max_length=128) 397 | 398 | class Meta: 399 | ordering = ('query_result', 'order') 400 | 401 | def __unicode__(self): 402 | return self.name 403 | 404 | @property 405 | def display_name(self): 406 | return FIELD_DEFINITIONS[self.name]['uiName'] 407 | 408 | @property 409 | def total_dimension(self): 410 | return self.dimensions.get(name='total') 411 | 412 | class DimensionResult(models.Model): 413 | """ 414 | Results for one dimension of a metric. 415 | """ 416 | metric = models.ForeignKey(MetricResult, related_name='dimensions', null=True) 417 | order = models.PositiveIntegerField() 418 | 419 | name = models.CharField(max_length=256) 420 | _value = models.CharField(max_length=128) 421 | percent_of_total = models.FloatField(null=True) 422 | 423 | # Denormalized fields 424 | project_title = models.CharField(max_length=128) 425 | report_ndays = models.PositiveIntegerField(null=True) 426 | query_name = models.CharField(max_length=128) 427 | metric_name = models.CharField(max_length=128) 428 | metric_data_type = models.CharField(max_length=30) 429 | 430 | class Meta: 431 | ordering = ('metric', 'order') 432 | 433 | @property 434 | def value(self): 435 | if self.metric_data_type == 'INTEGER': 436 | return int(self._value) 437 | elif self.metric_data_type == 'STRING': 438 | return self._value 439 | elif self.metric_data_type in ['FLOAT', 'PERCENT', 'TIME', 'CURRENCY']: 440 | return float(self._value) 441 | 442 | return None 443 | 444 | @property 445 | def value_formatted(self): 446 | if self.metric_data_type == 'INTEGER': 447 | return utils.format_comma(int(self._value)) 448 | elif self.metric_data_type == 'STRING': 449 | return self._value 450 | elif self.metric_data_type in ['FLOAT', 'PERCENT', 'CURRENCY']: 451 | return '%.1f' % float(self._value) 452 | elif self.metric_data_type == 'TIME': 453 | return clan_utils.format_duration(float(self._value)) 454 | 455 | return None 456 | 457 | @property 458 | def per_1000_sessions(self): 459 | if self.metric_data_type != 'INTEGER': 460 | return None 461 | 462 | try: 463 | return float(self.value) / (self.metric.query_result.report.sessions / 1000) 464 | except ZeroDivisionError: 465 | return 'undefined' 466 | 467 | class Social(models.Model): 468 | """ 469 | Social count data for a project. NOT timeboxed. 470 | """ 471 | project = models.OneToOneField(Project, primary_key=True) 472 | 473 | facebook_likes = models.PositiveIntegerField(default=0) 474 | facebook_shares = models.PositiveIntegerField(default=0) 475 | facebook_comments = models.PositiveIntegerField(default=0) 476 | twitter = models.PositiveIntegerField(default=0) 477 | google = models.PositiveIntegerField(default=0) 478 | pinterest = models.PositiveIntegerField(default=0) 479 | linkedin = models.PositiveIntegerField(default=0) 480 | stumbleupon = models.PositiveIntegerField(default=0) 481 | 482 | last_update = models.DateTimeField(null=True) 483 | 484 | class Meta: 485 | ordering = ('-project__start_date',) 486 | verbose_name = 'social count' 487 | verbose_name_plural = 'social counts' 488 | 489 | def __unicode__(self): 490 | return 'Social counts for %s' % self.project.title 491 | 492 | def total(self): 493 | return sum([ 494 | self.facebook_shares, 495 | self.twitter, 496 | self.google, 497 | self.pinterest, 498 | self.linkedin, 499 | self.stumbleupon 500 | ]) 501 | 502 | def refresh(self): 503 | secrets = app_config.get_secrets() 504 | 505 | if not self.project.domain: 506 | return 507 | 508 | url = 'http://%s%s' % (self.project.domain, self.project.prefix) 509 | response = requests.get('https://free.sharedcount.com/url?apikey=%s&url=%s' % (secrets['SHAREDCOUNT_API_KEY'], url)) 510 | 511 | if response.status_code != 200: 512 | print 'Failed to refresh social data from SharedCount: %i.' % response.status_code 513 | return 514 | 515 | print 'Updating social counts from SharedCount' 516 | 517 | data = response.json() 518 | 519 | self.facebook_likes = data['Facebook']['like_count'] or 0 520 | self.facebook_shares = data['Facebook']['share_count'] or 0 521 | self.facebook_comments = data['Facebook']['comment_count'] or 0 522 | self.twitter = data['Twitter'] or 0 523 | self.google = data['GooglePlusOne'] or 0 524 | self.pinterest = data['Pinterest'] or 0 525 | self.linkedin = data['LinkedIn'] or 0 526 | self.stumbleupon = data['StumbleUpon'] or 0 527 | 528 | self.last_update = timezone.now() 529 | 530 | self.save() 531 | 532 | -------------------------------------------------------------------------------- /reports/static/reports/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.4 (http://getbootstrap.com) 3 | * Copyright 2011-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.4",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.4",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active"));a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.4",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.4",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.4",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(this.options.viewport.selector||this.options.viewport),this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c&&c.$tip&&c.$tip.is(":visible")?void(c.hoverState="in"):(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.options.container?a(this.options.container):this.$element.parent(),p=this.getPosition(o);h="bottom"==h&&k.bottom+m>p.bottom?"top":"top"==h&&k.top-mp.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.width&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){return this.$tip=this.$tip||a(this.options.template)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type)})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;(e||!/destroy|hide/.test(b))&&(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.4",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.4",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.4",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return c>e?"top":!1;if("bottom"==this.affixed)return null!=c?e+this.unpin<=f.top?!1:"bottom":a-d>=e+g?!1:"bottom";var h=null==this.affixed,i=h?e:f.top,j=h?g:b;return null!=c&&c>=e?"top":null!=d&&i+j>=a-d?"bottom":!1},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=a(document.body).height();"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); -------------------------------------------------------------------------------- /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/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) === '