├── home ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0012_merge_0011_intentionalwalk_walk_time_0011_leaderboard.py │ ├── 0002_remove_dailywalk_event_id.py │ ├── 0003_auto_20200426_0613.py │ ├── 0005_contest_start_promo.py │ ├── 0009_contest_start_baseline_date.py │ ├── 0011_intentionalwalk_walk_time.py │ ├── 0004_contest.py │ ├── 0011_leaderboard.py │ ├── 0013_weeklygoal.py │ ├── 0010_auto_20220321_0407.py │ ├── 0008_alter_gender_race_fields_setfield.py │ ├── 0007_new_demographic_cols.py │ └── 0001_initial.py ├── tests │ ├── unit │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ └── test_appuser.py │ │ ├── test_dates.py │ │ └── test_utils.py │ ├── __init__.py │ └── integration │ │ ├── contest │ │ └── __init__.py │ │ ├── views │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── test_export.py │ │ │ └── utils.py │ │ └── web │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── appuser │ │ ├── __init__.py │ │ └── test_delete.py │ │ ├── dailywalk │ │ ├── __init__.py │ │ └── test_update.py │ │ ├── weeklygoal │ │ ├── __init__.py │ │ └── test_get.py │ │ └── intentionalwalk │ │ ├── __init__.py │ │ └── test_create.py ├── templatetags │ ├── __init__.py │ └── format_helpers.py ├── views │ ├── api │ │ ├── histogram │ │ │ └── __init__.py │ │ ├── serializers │ │ │ ├── __init__.py │ │ │ ├── response_serializers.py │ │ │ └── request_serializers.py │ │ ├── __init__.py │ │ ├── contest.py │ │ ├── utils.py │ │ ├── leaderboard.py │ │ └── weeklygoal.py │ ├── web │ │ ├── __init__.py │ │ ├── home.py │ │ └── intentionalwalk.py │ └── __init__.py ├── apps.py ├── models │ ├── __init__.py │ ├── leaderboard.py │ ├── weeklygoal.py │ ├── device.py │ ├── intentionalwalk.py │ ├── dailywalk.py │ ├── contest.py │ └── account.py ├── utils │ ├── dates.py │ └── __init__.py ├── management │ └── commands │ │ ├── dedupe.py │ │ ├── resetdb.py │ │ └── backfill.py ├── admin.py ├── templates │ └── home │ │ └── base.html └── urls.py ├── server ├── __init__.py ├── asgi.py ├── wsgi.py ├── urls.py └── settings.py ├── static └── home │ ├── main.js │ ├── main.css │ ├── user_plots.js │ ├── home_plots.js │ └── iw_plots.js ├── .gitattributes ├── runtime.txt ├── Procfile ├── client ├── src │ ├── Navigation.scss │ ├── Home.scss │ ├── _common.scss │ ├── Users │ │ ├── UsersRoutes.jsx │ │ ├── UsersList.scss │ │ └── UsersExportModal.jsx │ ├── main.jsx │ ├── Components │ │ ├── Loader.jsx │ │ ├── OrderBy.scss │ │ ├── OrderBy.jsx │ │ ├── ErrorTryAgainLater.jsx │ │ ├── IntensityMap.jsx │ │ ├── BarChart.jsx │ │ ├── Histogram.jsx │ │ └── Pagination.jsx │ ├── index.scss │ ├── App.css │ ├── Login.jsx │ ├── App.jsx │ ├── AuthContext.jsx │ ├── _bootstrap.scss │ ├── Navigation.jsx │ ├── assets │ │ └── react.svg │ └── Api.js ├── public │ ├── favicon.ico │ └── vite.svg ├── eslint.config.js ├── .gitignore ├── vite.config.js ├── README.md ├── index.html └── package.json ├── .coveragerc ├── Procfile.dev ├── pytest.ini ├── .flake8 ├── bin ├── pg_restore_dump └── init ├── docker-compose.yml ├── Makefile ├── package.json ├── example.env ├── docker-compose.override.yml ├── manage.py ├── .github └── dependabot.yaml ├── pyproject.toml ├── Dockerfile ├── scripts └── coverage_html.py ├── .gitignore ├── .circleci └── config.yml └── docs └── schema.md /home/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/home/main.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | -------------------------------------------------------------------------------- /home/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/unit/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.13.1 2 | -------------------------------------------------------------------------------- /home/views/api/histogram/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/views/api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests 2 | -------------------------------------------------------------------------------- /home/tests/integration/contest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/integration/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/integration/views/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/tests/integration/views/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/views/api/__init__.py: -------------------------------------------------------------------------------- 1 | # home.views.api 2 | -------------------------------------------------------------------------------- /home/views/web/__init__.py: -------------------------------------------------------------------------------- 1 | # home.views.web 2 | -------------------------------------------------------------------------------- /home/tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests.integration 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate 2 | web: gunicorn server.wsgi 3 | -------------------------------------------------------------------------------- /home/tests/integration/appuser/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests.integration.appuser 2 | -------------------------------------------------------------------------------- /home/tests/integration/dailywalk/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests.integration.dailywalk 2 | -------------------------------------------------------------------------------- /client/src/Navigation.scss: -------------------------------------------------------------------------------- 1 | .navigation__links { 2 | justify-content: end; 3 | } 4 | -------------------------------------------------------------------------------- /home/tests/integration/weeklygoal/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests.integration.weeklygoal 2 | -------------------------------------------------------------------------------- /home/tests/integration/intentionalwalk/__init__.py: -------------------------------------------------------------------------------- 1 | # home.tests.integration.intentionalwalk 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = home 3 | omit = 4 | *migrations* 5 | *tests* 6 | *.cache/ 7 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | client: cd client && npm run dev -- --host --port 3000 2 | server: python manage.py runserver 0:8000 3 | -------------------------------------------------------------------------------- /home/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HomeConfig(AppConfig): 5 | name = "home" 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = server.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfbrigade/intentional-walk-server/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | home/views/__init__.py: F401 5 | home/models/__init__.py: F401 6 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import neostandard from 'neostandard'; 2 | 3 | export default neostandard({ 4 | ignores: ['dist/*'], 5 | semi: true, 6 | }); 7 | -------------------------------------------------------------------------------- /bin/pg_restore_dump: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | psql $DATABASE_URL -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 3 | pg_restore --clean --no-acl --no-owner -d $DATABASE_URL $1 4 | -------------------------------------------------------------------------------- /client/src/Home.scss: -------------------------------------------------------------------------------- 1 | .home__users { 2 | color: #e59866; 3 | } 4 | 5 | .home__steps { 6 | color: #2ecc71; 7 | } 8 | 9 | .home__distance { 10 | color: #1abc9c; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/_common.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/scss/functions"; 2 | @import "bootstrap/scss/variables"; 3 | @import "bootstrap/scss/maps"; 4 | @import "bootstrap/scss/mixins"; 5 | @import "bootstrap/scss/utilities"; 6 | -------------------------------------------------------------------------------- /home/templatetags/format_helpers.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | def m_to_mi(value): 7 | return value * 0.000621371 8 | 9 | 10 | register.filter("m_to_mi", m_to_mi) 11 | -------------------------------------------------------------------------------- /home/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .contest import Contest 3 | from .dailywalk import DailyWalk 4 | from .device import Device 5 | from .intentionalwalk import IntentionalWalk 6 | from .leaderboard import Leaderboard 7 | from .weeklygoal import WeeklyGoal 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:14 4 | environment: 5 | - POSTGRES_HOST_AUTH_METHOD=trust 6 | volumes: 7 | - postgres-data:/var/lib/postgresql/data 8 | ports: 9 | - 5432:5432 10 | 11 | volumes: 12 | postgres-data: {} 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test coverage 2 | 3 | test: 4 | @echo "Running tests" 5 | @poetry run pytest 6 | 7 | coverage: 8 | @echo "Running tests with coverage" 9 | @poetry run scripts/coverage_html.py 10 | 11 | lint: 12 | @echo "Running linter" 13 | @poetry run black . 14 | @poetry run flake8 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intentional-walk-server", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": "22", 7 | "npm": "10" 8 | }, 9 | "workspaces": [ 10 | "client" 11 | ], 12 | "scripts": { 13 | "build": "npm run build -w client" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | ALLOWED_HOSTS=localhost,127.0.0.1 2 | CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 3 | DATABASE_URL=postgresql://postgres@db/iwalk 4 | DEBUG=1 5 | SECRET_KEY=q!=ahgtraat*k4ytuniq)0892h7j5^2koqp55mh6p18$4344ks 6 | TIME_ZONE=America/Los_Angeles 7 | DEPLOY_ENV=development 8 | SENTRY_DSN= 9 | -------------------------------------------------------------------------------- /client/src/Users/UsersRoutes.jsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router'; 2 | 3 | import UsersList from './UsersList'; 4 | 5 | function UsersRoutes () { 6 | return ( 7 | 8 | } /> 9 | 10 | ); 11 | } 12 | 13 | export default UsersRoutes; 14 | -------------------------------------------------------------------------------- /home/utils/dates.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | DATE_FORMAT = "%Y-%m-%d" 4 | 5 | 6 | def get_start_of_week(dt: date) -> date: 7 | return dt - timedelta(days=dt.weekday()) 8 | 9 | 10 | def get_start_of_current_week() -> date: 11 | dt = date.today() 12 | return get_start_of_week(dt) 13 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /home/migrations/0012_merge_0011_intentionalwalk_walk_time_0011_leaderboard.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2023-03-16 02:39 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("home", "0011_intentionalwalk_walk_time"), 10 | ("home", "0011_leaderboard"), 11 | ] 12 | 13 | operations = [] 14 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { BrowserRouter as Router } from 'react-router'; 4 | 5 | import './index.scss'; 6 | import App from './App.jsx'; 7 | 8 | createRoot(document.getElementById('root')).render( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | proxy: { 9 | '/admin': 'http://localhost:8000', 10 | '/api': 'http://localhost:8000', 11 | '/static': 'http://localhost:8000' 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /client/src/Components/Loader.jsx: -------------------------------------------------------------------------------- 1 | function Loader ({ width, height }) { 2 | return ( 3 |
13 | ); 14 | } 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /home/migrations/0002_remove_dailywalk_event_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-25 23:41 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("home", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="dailywalk", 15 | name="event_id", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /client/src/Users/UsersList.scss: -------------------------------------------------------------------------------- 1 | .users-list__title { 2 | color: #cd6155; 3 | text-align: center; 4 | } 5 | 6 | .users-list__map-legend { 7 | dt, 8 | dd { 9 | display: inline; 10 | } 11 | dt { 12 | margin-right: 0.5rem; 13 | font-weight: normal; 14 | } 15 | dd { 16 | font-weight: bold; 17 | span { 18 | font-weight: normal; 19 | } 20 | } 21 | } 22 | 23 | .users-list__table { 24 | font-size: 1.2rem; 25 | } 26 | -------------------------------------------------------------------------------- /server/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for server project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /server/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for server 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/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /home/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | from django.utils import timezone 4 | 5 | 6 | # This function converts a date into a datetime based on the default time zone 7 | # (TIME_ZONE, which set in server/settings.py but overridden in .env). 8 | # This is particularly useful for comparing dates to datetimes in db queries. 9 | def localize(d: date) -> datetime: 10 | dt = datetime.combine(d, datetime.min.time()) 11 | tz = timezone.get_default_timezone() 12 | return dt.astimezone(tz) 13 | -------------------------------------------------------------------------------- /client/src/Components/OrderBy.scss: -------------------------------------------------------------------------------- 1 | .order-by { 2 | color: inherit; 3 | font-size: inherit; 4 | font-weight: inherit; 5 | margin: 0; 6 | padding: 0; 7 | text-decoration: none; 8 | 9 | &:hover { 10 | text-decoration: underline; 11 | } 12 | } 13 | 14 | .order-by--ascending, 15 | .order-by--descending { 16 | &::after { 17 | margin-left: 0.5rem; 18 | } 19 | } 20 | 21 | .order-by--ascending::after { 22 | content: "\2193"; 23 | } 24 | 25 | .order-by--descending::after { 26 | content: "\2191"; 27 | } 28 | -------------------------------------------------------------------------------- /home/migrations/0003_auto_20200426_0613.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-26 06:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("home", "0002_remove_dailywalk_event_id"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="intentionalwalk", 15 | name="event_id", 16 | field=models.CharField(max_length=250, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /home/migrations/0005_contest_start_promo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-30 00:53 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("home", "0004_contest"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="contest", 17 | name="start_promo", 18 | field=models.DateField(default=datetime.date(2020, 4, 30)), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: . 4 | command: bash -l -c "bin/init; nf start -j Procfile.dev" 5 | container_name: server 6 | depends_on: 7 | - db 8 | env_file: 9 | - .env 10 | ports: 11 | - "3000:3000" 12 | - "8000:8000" 13 | volumes: 14 | - .:/app 15 | - /app/client/node_modules 16 | - /app/node_modules 17 | coverage: 18 | image: python:3.13.1 19 | volumes: 20 | - .:/app 21 | working_dir: /app/htmlcov 22 | command: python -m http.server 8001 23 | ports: 24 | - "8001:8001" 25 | -------------------------------------------------------------------------------- /home/tests/unit/test_dates.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.test import TestCase 4 | 5 | from home.utils.dates import get_start_of_week, get_start_of_current_week 6 | 7 | 8 | class TestDates(TestCase): 9 | 10 | # Test get_start_of_week 11 | def test_get_start_of_week(self): 12 | dt = date(2023, 8, 23) 13 | monday = date(2023, 8, 21) 14 | start_of_week = get_start_of_week(dt) 15 | self.assertEqual(monday, start_of_week) 16 | 17 | def test_get_start_of_curent_week(self): 18 | dt = date.today() 19 | d = get_start_of_week(dt) 20 | self.assertAlmostEqual(d, get_start_of_current_week()) 21 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "common"; 2 | @import "bootstrap"; 3 | 4 | html, 5 | body { 6 | margin: 0; 7 | font-family: 8 | "Rajdhani", 9 | -apple-system, 10 | BlinkMacSystemFont, 11 | "Segoe UI", 12 | "Roboto", 13 | "Oxygen", 14 | "Ubuntu", 15 | "Cantarell", 16 | "Fira Sans", 17 | "Droid Sans", 18 | "Helvetica Neue", 19 | sans-serif; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | .navbar { 25 | font-family: "Exo"; 26 | font-size: 1.25em; 27 | } 28 | 29 | code { 30 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 31 | monospace; 32 | } 33 | -------------------------------------------------------------------------------- /home/migrations/0009_contest_start_baseline_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-02-03 06:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("home", "0008_alter_gender_race_fields_setfield"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="contest", 15 | name="start_baseline", 16 | field=models.DateField( 17 | blank=True, 18 | help_text="Start of baseline period (prior to contest start)", 19 | null=True, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | Intentional Walk 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /client/src/Components/OrderBy.jsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | import './OrderBy.scss'; 4 | 5 | function OrderBy ({ children, currentValue, onChange, value }) { 6 | const isAscending = currentValue === value; 7 | const isDescending = currentValue === `-${value}`; 8 | 9 | function onClick () { 10 | if (isAscending) { 11 | onChange(`-${value}`); 12 | } else { 13 | onChange(value); 14 | } 15 | } 16 | 17 | return ( 18 | 27 | ); 28 | } 29 | 30 | export default OrderBy; 31 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /home/migrations/0011_intentionalwalk_walk_time.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2023-03-05 22:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("home", "0010_auto_20220321_0407"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="intentionalwalk", 15 | name="walk_time", 16 | field=models.FloatField( 17 | default=0, 18 | help_text="Total time walked not including pause time", 19 | ), 20 | preserve_default=False, 21 | ), 22 | migrations.RunSQL( 23 | 'UPDATE home_intentionalwalk SET walk_time=EXTRACT(EPOCH FROM ("end" - "start")) - pause_time' 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /client/src/Login.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useLocation, useNavigate } from 'react-router'; 3 | 4 | import { useAuthContext } from './AuthContext'; 5 | 6 | function Login () { 7 | const authContext = useAuthContext(); 8 | const location = useLocation(); 9 | const navigate = useNavigate(); 10 | 11 | useEffect(() => { 12 | if (authContext.user) { 13 | navigate(location.state?.from || '/', { replace: true }); 14 | } 15 | }, [authContext.user, location, navigate]); 16 | 17 | return ( 18 |
19 |
20 |

21 | Please Login to view the page 22 |

23 |
24 |
25 | ); 26 | } 27 | export default Login; 28 | -------------------------------------------------------------------------------- /client/src/Components/ErrorTryAgainLater.jsx: -------------------------------------------------------------------------------- 1 | function ErrorTryAgainLater ({ error, width, height }) { 2 | let message = 'Oops! Something went wrong. Please try again later.'; 3 | if (error?.response?.status === 422) { 4 | // Likely a developer-facing error 5 | // As we should not be allowing free-form input 6 | // to our APIs. 7 | message = 'Oops! Invalid input.'; 8 | console.error(error.response.data); 9 | } 10 | return ( 11 |
12 |
20 | ⚠️ 21 | {message} 22 |
23 |
24 | ); 25 | } 26 | 27 | export default ErrorTryAgainLater; 28 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | groups: 8 | python-build-tools: 9 | patterns: 10 | - "pip" 11 | - "setuptools" 12 | - "wheel" 13 | - "poetry" 14 | python-dev-tools: 15 | patterns: 16 | - "pytest*" 17 | - "coverage" 18 | - "flake8" 19 | - "mypy*" 20 | - "black" 21 | - "autoflake" 22 | - package-ecosystem: npm 23 | directory: "/" 24 | schedule: 25 | interval: monthly 26 | groups: 27 | javascript-build-tools: 28 | patterns: 29 | - "yarn" 30 | - "npm" 31 | - "babel*" 32 | javascript-dev-tools: 33 | patterns: 34 | - "prettier" 35 | - "@testing-library/*" 36 | - "react-scripts" 37 | - "foreman" -------------------------------------------------------------------------------- /server/urls.py: -------------------------------------------------------------------------------- 1 | """server URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import include, path 19 | 20 | urlpatterns = [ 21 | path("", include("home.urls")), 22 | path("admin/doc/", include("django.contrib.admindocs.urls")), 23 | path("admin/", admin.site.urls), 24 | ] 25 | -------------------------------------------------------------------------------- /home/migrations/0004_contest.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-26 19:04 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("home", "0003_auto_20200426_0613"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Contest", 17 | fields=[ 18 | ( 19 | "contest_id", 20 | models.CharField( 21 | default=uuid.uuid4, 22 | max_length=250, 23 | primary_key=True, 24 | serialize=False, 25 | ), 26 | ), 27 | ("start", models.DateField()), 28 | ("end", models.DateField()), 29 | ], 30 | options={ 31 | "ordering": ("-start",), 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | package-mode = false 3 | 4 | [tool.poetry.dependencies] 5 | python = "^3.13.1" 6 | django = ">=5.2,<6.0" 7 | psycopg2 = "^2.9.1" 8 | dj-database-url = "^2.3.0" 9 | python-dotenv = "^1.0.1" 10 | gunicorn = "^23.0.0" 11 | python-dateutil = "^2.8.1" 12 | whitenoise = "^6.8.2" 13 | uuid = "^1.30" 14 | django-postgres-setfield = {git = "https://github.com/benperlman/django-postgres-setfield.git"} 15 | djangorestframework = "^3.14.0" 16 | sentry-sdk = {extras = ["django"], version = "^2.14.0"} 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | autoflake = "^2.3.1" 20 | black = "^25.1.0" 21 | coverage = "^5.0.3" 22 | coveralls = "^3.3.1" 23 | faker = "^35.2.0" 24 | flake8 = "^7.1.1" 25 | freezegun = "^1.5.1" 26 | pytest = "^8.3.4" 27 | pytest-django = "^4.9.0" 28 | pytest-libfaketime = "^0.1.3" 29 | libfaketime = "^2.1.0" 30 | 31 | [tool.black] 32 | line-length = 79 33 | 34 | [build-system] 35 | requires = ["poetry>=0.12"] 36 | build-backend = "poetry.masonry.api" 37 | -------------------------------------------------------------------------------- /home/models/leaderboard.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | # Event model 5 | class Leaderboard(models.Model): 6 | """ """ 7 | 8 | steps = models.IntegerField(help_text="Number of steps recorded") 9 | 10 | account = models.ForeignKey( 11 | "Account", 12 | on_delete=models.CASCADE, 13 | help_text="Account the data is linked to", 14 | ) 15 | device = models.ForeignKey( 16 | "Device", 17 | on_delete=models.CASCADE, 18 | help_text="Device the data is coming from", 19 | ) 20 | contest = models.ForeignKey( 21 | "Contest", 22 | on_delete=models.CASCADE, 23 | help_text="The contest the account is enrolled in", 24 | ) 25 | 26 | # Auto populate the account field from the device field 27 | def save(self, *args, **kwargs): 28 | self.account = self.device.account 29 | super().save(*args, **kwargs) 30 | 31 | def __str__(self): 32 | return f"{self.device.device_id} | {self.steps}" 33 | -------------------------------------------------------------------------------- /home/models/weeklygoal.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from home.utils.dates import get_start_of_current_week 4 | 5 | 6 | class WeeklyGoal(models.Model): 7 | """ 8 | Stores a weekly goal. 9 | An entry is created for the first weekly goal 10 | set by a user and when a user updates their weekly goal 11 | """ 12 | 13 | start_of_week = models.DateField( 14 | default=get_start_of_current_week, 15 | help_text="The start of the week for the goal", 16 | ) 17 | steps = models.IntegerField(help_text="Step goal for the week") 18 | days = models.IntegerField( 19 | help_text="Number of days per week to reach goal" 20 | ) 21 | account = models.ForeignKey( 22 | "Account", 23 | on_delete=models.CASCADE, 24 | help_text="Account the data is linked to", 25 | ) 26 | 27 | def __str__(self): 28 | return f"{self.account.email} | {self.start_of_week}" 29 | 30 | class Meta: 31 | ordering = ("-start_of_week",) 32 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router'; 2 | 3 | import { AuthContextProvider, AuthProtected } from './AuthContext'; 4 | import Navigation from './Navigation'; 5 | import Home from './Home'; 6 | import Login from './Login'; 7 | import UsersRoutes from './Users/UsersRoutes'; 8 | 9 | function App () { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | } /> 16 | 20 | 21 | 22 | } 23 | /> 24 | 28 | 29 | 30 | } 31 | /> 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | export default App; 39 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "intentional-walk-client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint --fix", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.7.9", 14 | "bootstrap": "^5.3.3", 15 | "classnames": "^2.5.1", 16 | "csv-parse": "^5.6.0", 17 | "d3": "^7.9.0", 18 | "file-saver": "^2.0.5", 19 | "luxon": "^3.5.0", 20 | "numeral": "^2.0.6", 21 | "react": "^18.3.1", 22 | "react-dom": "^18.3.1", 23 | "react-google-charts": "^5.2.1", 24 | "react-router": "^7.0.2" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.3.12", 28 | "@types/react-dom": "^18.3.1", 29 | "@vitejs/plugin-react-swc": "^3.7.2", 30 | "eslint": "^9.18.0", 31 | "foreman": "^3.0.1", 32 | "globals": "^15.14.0", 33 | "neostandard": "^0.12.0", 34 | "sass-embedded": "^1.83.0", 35 | "vite": "^6.0.11" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/AuthContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from 'react'; 2 | import { Navigate, useLocation } from 'react-router'; 3 | 4 | const authContext = createContext(); 5 | 6 | function useAuthContext () { 7 | return useContext(authContext); 8 | } 9 | 10 | function AuthContextValue () { 11 | const [user, setUser] = useState(null); 12 | return { 13 | user, 14 | setUser, 15 | }; 16 | } 17 | 18 | function AuthContextProvider ({ children }) { 19 | const value = AuthContextValue(); 20 | return {children}; 21 | } 22 | 23 | function AuthProtected ({ isAdminRequired, children }) { 24 | const location = useLocation(); 25 | const authContext = useAuthContext(); 26 | return authContext.user && (!isAdminRequired || authContext.user.isAdmin) 27 | ? ( 28 | children 29 | ) 30 | : ( 31 | 32 | ); 33 | } 34 | 35 | export { useAuthContext, AuthContextProvider, AuthProtected }; 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.1 2 | ENV PATH="/root/.local/bin:${PATH}" 3 | 4 | # Install postgres client 5 | RUN wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O - | apt-key add - && \ 6 | echo "deb http://apt.postgresql.org/pub/repos/apt/ bullseye-pgdg main" >> /etc/apt/sources.list.d/pgdg.list && \ 7 | apt-get update -y && \ 8 | apt-get install -y postgresql-client-12 tzdata && \ 9 | curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ 10 | apt-get install -y nodejs && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | # Install Heroku client 14 | RUN curl https://cli-assets.heroku.com/install.sh | sh 15 | 16 | # Install poetry 17 | RUN curl -sSL https://install.python-poetry.org | python3 - 18 | 19 | # Add files 20 | ADD . /app 21 | WORKDIR /app 22 | 23 | # Install client dependencies and update path to include poetry and node module executables 24 | RUN npm install && \ 25 | echo "export PATH=~/.local/bin:/app/node_modules/.bin:/app/client/node_modules/.bin:\$PATH\n" >> /root/.bashrc 26 | 27 | # Run poetry to install dependencies 28 | RUN poetry config virtualenvs.create false && \ 29 | poetry install 30 | -------------------------------------------------------------------------------- /scripts/coverage_html.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Persists the coverage report in htmlcov/ directory. 4 | 5 | This script is a workaround to explicitly save the coverage report 6 | and generate an html report. 7 | `coverage run` isn't computing a coverage report let alone generating the report. 8 | Correct mounts, permissions and pre-reqs are set up and the issue may be something else 9 | as suggested here: 10 | https://stackoverflow.com/a/53346768 11 | Manually importing, cleaning up and invoking the coverage + explicitly saving 12 | works as expected. 13 | """ 14 | 15 | from coverage import Coverage 16 | import pytest 17 | 18 | 19 | def build_coverage_report(): 20 | """Run tests and generate an html coverage report. 21 | 22 | Equivalent to `coverage run` && `coverage html`, 23 | using the default configuration files: 24 | `.coveragerc` and `pytest.ini`. 25 | """ 26 | cov = Coverage() 27 | cov.erase() 28 | cov.start() 29 | pytest.main() 30 | cov.stop() 31 | cov.save() 32 | cov.report() 33 | cov.html_report(directory="htmlcov") 34 | 35 | 36 | if __name__ == "__main__": 37 | build_coverage_report() 38 | -------------------------------------------------------------------------------- /bin/init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # check if .env file exists, if not copy from example 4 | if [ ! -f .env ]; then 5 | cp example.env .env 6 | fi 7 | 8 | # load the .env into variables 9 | export $(grep -v '^#' .env | xargs) 10 | 11 | # wait for postgres to accept connections... 12 | while !/dev/null 2>&1 18 | RESULT=$? 19 | if [ $RESULT -ne 0 ]; then 20 | DB_HOST=${DATABASE_URL%/*} 21 | DB_NAME=${DATABASE_URL##*/} 22 | psql ${DB_HOST} -c "CREATE DATABASE ${DB_NAME};" 23 | python manage.py migrate 24 | 25 | echo "Generating random SQL data..." 26 | sql="$(python3 /app/scripts/dummydata.py --accounts 100)" 27 | 28 | echo "Importing random SQL data..." 29 | echo $sql | psql --quiet "${DATABASE_URL}" 30 | 31 | echo "Done importing random SQL data to the new database." 32 | echo "To create a superuser, run a bash shell in the \ 33 | server docker container and create one:" 34 | echo ' `docker compose exec server bash -l` 35 | `/app# python3 manage.py createsuperuser` 36 | ' 37 | fi 38 | -------------------------------------------------------------------------------- /home/models/device.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Device(models.Model): 5 | """ 6 | Stores a single device registration. This entry is created when the app 7 | is installed and a registration call is made from the device. 8 | This is always linked to a user's account - :model: `home.Account`. 9 | All the daily and intentional walks link back to this 10 | """ 11 | 12 | device_id = models.CharField( 13 | max_length=250, 14 | primary_key=True, 15 | help_text=( 16 | "A unique id generated by the app when it is first installed" 17 | " on a device. Used for authentication on subsequent calls" 18 | ), 19 | ) 20 | account = models.ForeignKey( 21 | "Account", 22 | on_delete=models.CASCADE, 23 | help_text="User account associated with this registered device", 24 | ) 25 | created = models.DateTimeField( 26 | auto_now_add=True, 27 | help_text="When the record was created/device was registered", 28 | ) 29 | 30 | def __str__(self): 31 | return f"{self.device_id} | {self.account} " 32 | 33 | class Meta: 34 | ordering = ("-created",) 35 | -------------------------------------------------------------------------------- /home/views/api/contest.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.utils.decorators import method_decorator 3 | from django.views import View 4 | from django.views.decorators.csrf import csrf_exempt 5 | 6 | from home.models import Contest 7 | 8 | 9 | @method_decorator(csrf_exempt, name="dispatch") 10 | class ContestCurrentView(View): 11 | """View to retrieve current Contest""" 12 | 13 | model = Contest 14 | http_method_names = ["get"] 15 | 16 | def get(self, request, *args, **kwargs): 17 | # get the current/next Contest 18 | contest = Contest.active() 19 | if contest is None: 20 | return JsonResponse( 21 | { 22 | "status": "error", 23 | "message": "There are no contests", 24 | } 25 | ) 26 | return JsonResponse( 27 | { 28 | "status": "success", 29 | "payload": { 30 | "contest_id": contest.contest_id, 31 | "start_baseline": contest.start_baseline, 32 | "start_promo": contest.start_promo, 33 | "start": contest.start, 34 | "end": contest.end, 35 | }, 36 | } 37 | ) 38 | -------------------------------------------------------------------------------- /home/management/commands/dedupe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db import connection 5 | 6 | from home.models import Account 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Deletes duplicate account records" 13 | 14 | def handle(self, *args, **options): 15 | emails = [] 16 | with connection.cursor() as cursor: 17 | cursor.execute( 18 | """ 19 | SELECT LOWER("email") AS le, COUNT(*) 20 | FROM home_account 21 | GROUP BY le 22 | HAVING COUNT(*) > 1 23 | ORDER BY le 24 | """ 25 | ) 26 | results = cursor.fetchall() 27 | emails = [row[0] for row in results] 28 | 29 | for email in emails: 30 | logger.info(f"Deduping: {email}") 31 | accounts = Account.objects.filter(email__iexact=email).order_by( 32 | "created" 33 | ) 34 | # delete all but the newest account 35 | accounts = accounts[0 : len(accounts) - 1] # noqa E203 36 | for account in accounts: 37 | account.delete() 38 | 39 | logger.info("Done.") 40 | -------------------------------------------------------------------------------- /home/views/__init__.py: -------------------------------------------------------------------------------- 1 | # Import API views 2 | from .api.admin import ( 3 | AdminMeView, 4 | AdminHomeView, 5 | AdminHomeUsersDailyView, 6 | AdminHomeUsersCumulativeView, 7 | AdminHomeStepsDailyView, 8 | AdminHomeStepsCumulativeView, 9 | AdminHomeDistanceDailyView, 10 | AdminHomeDistanceCumulativeView, 11 | AdminContestsView, 12 | AdminUsersView, 13 | AdminUsersByZipView, 14 | AdminUsersActiveByZipView, 15 | AdminUsersByZipMedianStepsView, 16 | AdminHistogramView, 17 | ) 18 | from .api.appuser import AppUserCreateView, AppUserDeleteView 19 | from .api.dailywalk import DailyWalkCreateView, DailyWalkListView 20 | from .api.export import ExportUsersView 21 | from .api.intentionalwalk import IntentionalWalkView, IntentionalWalkListView 22 | from .api.contest import ContestCurrentView 23 | from .api.leaderboard import LeaderboardListView 24 | from .api.weeklygoal import WeeklyGoalCreateView, WeeklyGoalsListView 25 | 26 | # Import web views 27 | from .web.home import HomeView 28 | from .web.intentionalwalk import IntentionalWalkWebView 29 | from .web.user import UserListView 30 | from .web.data import user_agg_csv_view 31 | from .web.data import users_csv_view 32 | from .web.data import daily_walks_csv_view 33 | from .web.data import intentional_walks_csv_view 34 | -------------------------------------------------------------------------------- /home/management/commands/resetdb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.db import connection 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Reset database (truncate application tables)" 10 | 11 | def handle(self, *args, **options): 12 | if os.getenv("DEPLOY_ENV").lower().startswith("prod"): 13 | print("This command is not allowed on a production server.") 14 | sys.exit(1) 15 | 16 | question = ( 17 | "\nAre you sure you want to truncate all tables?" 18 | " (Type `yes` exactly): " 19 | ) 20 | answer = input(question) 21 | if answer == "yes": 22 | cursor = connection.cursor() 23 | 24 | # Truncating accounts (with cascade) will also truncate 25 | # devices, daily walks, and intentional walks 26 | cursor.execute( 27 | "TRUNCATE TABLE home_account RESTART IDENTITY CASCADE;" 28 | ) 29 | print("All accounts, devices, and walks have been reset.") 30 | 31 | # Also truncate contests 32 | cursor.execute("TRUNCATE TABLE home_contest CASCADE;") 33 | print("All contests have been reset.") 34 | 35 | else: 36 | print("Aborted resetdb.\n") 37 | -------------------------------------------------------------------------------- /client/src/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Layout & components 2 | @import "bootstrap/scss/root"; 3 | @import "bootstrap/scss/reboot"; 4 | @import "bootstrap/scss/type"; 5 | @import "bootstrap/scss/images"; 6 | @import "bootstrap/scss/containers"; 7 | @import "bootstrap/scss/grid"; 8 | @import "bootstrap/scss/tables"; 9 | @import "bootstrap/scss/forms"; 10 | @import "bootstrap/scss/buttons"; 11 | @import "bootstrap/scss/transitions"; 12 | @import "bootstrap/scss/dropdown"; 13 | @import "bootstrap/scss/button-group"; 14 | @import "bootstrap/scss/nav"; 15 | @import "bootstrap/scss/navbar"; 16 | @import "bootstrap/scss/card"; 17 | @import "bootstrap/scss/accordion"; 18 | @import "bootstrap/scss/breadcrumb"; 19 | @import "bootstrap/scss/pagination"; 20 | @import "bootstrap/scss/badge"; 21 | @import "bootstrap/scss/alert"; 22 | @import "bootstrap/scss/progress"; 23 | @import "bootstrap/scss/list-group"; 24 | @import "bootstrap/scss/close"; 25 | @import "bootstrap/scss/toasts"; 26 | @import "bootstrap/scss/modal"; 27 | @import "bootstrap/scss/tooltip"; 28 | @import "bootstrap/scss/popover"; 29 | @import "bootstrap/scss/carousel"; 30 | @import "bootstrap/scss/spinners"; 31 | @import "bootstrap/scss/offcanvas"; 32 | @import "bootstrap/scss/placeholders"; 33 | 34 | // Helpers 35 | @import "bootstrap/scss/helpers"; 36 | 37 | // Utilities 38 | @import "bootstrap/scss/utilities/api"; 39 | -------------------------------------------------------------------------------- /static/home/main.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Rajdhani'; 3 | height: 100% 4 | } 5 | .navbar { 6 | font-family: 'Exo'; 7 | font-size: 1.25em; 8 | } 9 | .rw-col { 10 | display: none; 11 | } 12 | .yesno { 13 | text-transform: capitalize; 14 | } 15 | .map-title { 16 | margin-top: 10px; 17 | font-size: 1.25em; 18 | font-weight: bold; 19 | text-align: center; 20 | } 21 | 22 | #contest-title { 23 | padding: 50px; 24 | color: #CD6155; 25 | text-align: center; 26 | } 27 | #geo-info { 28 | padding-top: 50px; 29 | padding-left: 75px; 30 | min-width: 400px; 31 | } 32 | #geo-info .row { 33 | margin-bottom: 0; 34 | } 35 | #geo-info-name { 36 | font-weight: bold; 37 | margin-bottom: 50px; 38 | } 39 | #contest-maps-container { 40 | margin-bottom: 50px; 41 | } 42 | #map-label-1 { 43 | color: #702B84; 44 | } 45 | #map-label-2 { 46 | color: #2B388F; 47 | } 48 | 49 | .graph-title { 50 | font-family: 'Rajdhani'; 51 | font-weight: bold; 52 | font-size: 1.25em; 53 | } 54 | 55 | .label { 56 | font-family: 'Rajdhani'; 57 | font-weight: bold; 58 | } 59 | 60 | .tooltip-text { 61 | padding-top: 5px; 62 | padding-bottom: 5px; 63 | padding-left: 10px; 64 | padding-right: 10px; 65 | background-color: #222; 66 | color: #fff !important; 67 | border-radius: 4px; 68 | } 69 | .tooltip, .tooltip-text { 70 | cursor: default; 71 | } 72 | -------------------------------------------------------------------------------- /home/tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | from home.tests.integration.views.api.utils import Login 2 | from home.views.api.utils import require_authn 3 | from django.contrib.auth.models import User, AnonymousUser 4 | 5 | from django.test import TestCase, RequestFactory 6 | from django.views import View 7 | from django.http import HttpResponse 8 | 9 | 10 | class TestAuthn(TestCase): 11 | def setUp(self): 12 | # Create a test view that uses the require_authn decorator 13 | class TestView(View): 14 | @require_authn 15 | def get(self, request): 16 | return HttpResponse("Hello, World!") 17 | 18 | self.view = TestView.as_view() 19 | self.factory = RequestFactory() 20 | 21 | def test_authenticated(self): 22 | Login() 23 | self.user = User.objects.get(username=Login.username) 24 | 25 | # Create a request and authenticate the user 26 | request = self.factory.get("/") 27 | request.user = self.user 28 | 29 | response = self.view(request) 30 | self.assertEqual(response.status_code, 200) 31 | self.assertEqual(response.content.decode(), "Hello, World!") 32 | 33 | def test_unauthenticated(self): 34 | # Create a request and don't authenticate the user 35 | request = self.factory.get("/") 36 | request.user = AnonymousUser() 37 | 38 | response = self.view(request) 39 | self.assertEqual(response.status_code, 401) 40 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/views/api/serializers/response_serializers.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains serializers that are used for formatting the response data. 3 | 4 | Each serializer in this module corresponds to a specific API endpoint. 5 | These serve to map internal Python data types 6 | to JSON-compatible data types that can be sent in the HTTP response, 7 | and to clearly document the structure of the response data. 8 | """ 9 | 10 | from rest_framework import serializers 11 | from home.models import Account 12 | 13 | 14 | class GetUsersRespSerializer(serializers.ModelSerializer): 15 | # Daily walk metrics. 16 | dw_count = serializers.IntegerField() 17 | dw_steps = serializers.IntegerField() 18 | dw_distance = serializers.FloatField() 19 | 20 | # Contest-id specific metrics. These only appear if contest_id 21 | # was specified in the query URL. 22 | iw_count = serializers.IntegerField(required=False) 23 | iw_steps = serializers.IntegerField(required=False) 24 | iw_distance = serializers.FloatField(required=False) 25 | iw_time = serializers.IntegerField(required=False) 26 | 27 | # True if the user's Account was created within the contest period. 28 | is_new = serializers.BooleanField( 29 | required=False, 30 | ) 31 | # True is the user has walked at least one step. 32 | is_active = serializers.BooleanField( 33 | required=False, 34 | ) 35 | 36 | class Meta: 37 | model = Account 38 | fields = "__all__" 39 | -------------------------------------------------------------------------------- /client/src/Components/IntensityMap.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import * as d3 from 'd3'; 3 | 4 | function IntensityMap ({ 5 | data, 6 | map, 7 | onMouseOver, 8 | minColor, 9 | maxColor, 10 | width, 11 | height, 12 | }) { 13 | const ref = useRef(); 14 | 15 | useEffect(() => { 16 | if (data && map && width && height && minColor && maxColor && onMouseOver) { 17 | // set up map projection 18 | const projection = d3 19 | .geoMercator() 20 | .scale(120000) 21 | .center([-122.44, 37.76]) 22 | .translate([width / 2, height / 2]); 23 | 24 | // The largest value could still be 0 ... 25 | const maxDataValue = Math.floor(Math.max(...Object.values(data))); 26 | 27 | // Ensure the upperLimit is at least greater than 0, 28 | // so that we don't have a scale of [0, 0] 29 | const upperLimit = Math.max(maxDataValue, 1); 30 | // set up color gradient 31 | const colorScale = d3 32 | .scaleLinear() 33 | .domain([0, upperLimit]) 34 | .range([minColor, maxColor]); 35 | 36 | // draw map 37 | const el = d3.select(ref.current); 38 | el.selectAll('*').remove(); 39 | el.append('g') 40 | .selectAll('path') 41 | .data(map) 42 | .enter() 43 | .append('path') 44 | // draw each neighborhood 45 | .attr('d', d3.geoPath().projection(projection)) 46 | // use a dark stroke so that even when the value for a region is empty (0), there 47 | // is still a visible outline of the neighborhood. 48 | .attr('stroke', '#000000') 49 | .attr('fill', (feature) => colorScale(data[feature.id] ?? 0)) 50 | .on('mouseover', (_, feature) => onMouseOver(feature)) 51 | .on('mouseout', () => onMouseOver()); 52 | } 53 | }, [data, map, width, height, minColor, maxColor, onMouseOver]); 54 | 55 | return ; 56 | } 57 | 58 | export default IntensityMap; 59 | -------------------------------------------------------------------------------- /home/migrations/0011_leaderboard.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2023-03-06 18:08 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("home", "0010_auto_20220321_0407"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Leaderboard", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "steps", 28 | models.IntegerField(help_text="Number of steps recorded"), 29 | ), 30 | ( 31 | "account", 32 | models.ForeignKey( 33 | help_text="Account the data is linked to", 34 | on_delete=django.db.models.deletion.CASCADE, 35 | to="home.account", 36 | ), 37 | ), 38 | ( 39 | "contest", 40 | models.ForeignKey( 41 | help_text="The contest the account is enrolled in", 42 | on_delete=django.db.models.deletion.CASCADE, 43 | to="home.contest", 44 | ), 45 | ), 46 | ( 47 | "device", 48 | models.ForeignKey( 49 | help_text="Device the data is coming from", 50 | on_delete=django.db.models.deletion.CASCADE, 51 | to="home.device", 52 | ), 53 | ), 54 | ], 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /home/migrations/0013_weeklygoal.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2023-08-22 01:39 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import home.utils.dates 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("home", "0012_merge_0011_intentionalwalk_walk_time_0011_leaderboard"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="WeeklyGoal", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ( 28 | "start_of_week", 29 | models.DateField( 30 | default=home.utils.dates.get_start_of_current_week, 31 | help_text="The start of the week for the goal", 32 | ), 33 | ), 34 | ( 35 | "steps", 36 | models.IntegerField(help_text="Step goal for the week"), 37 | ), 38 | ( 39 | "days", 40 | models.IntegerField( 41 | help_text="Number of days per week to reach goal" 42 | ), 43 | ), 44 | ( 45 | "account", 46 | models.ForeignKey( 47 | help_text="Account the data is linked to", 48 | on_delete=django.db.models.deletion.CASCADE, 49 | to="home.account", 50 | ), 51 | ), 52 | ], 53 | options={ 54 | "ordering": ("-start_of_week",), 55 | }, 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | .devcontainer 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # Eclipse files 108 | .project 109 | .pydevproject 110 | .idea 111 | 112 | *.dump 113 | 114 | .DS_Store 115 | 116 | # Local paths 117 | home/static 118 | 119 | # React SPA 120 | /client/dist 121 | /node_modules 122 | -------------------------------------------------------------------------------- /home/tests/unit/api/test_appuser.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from home.views.api.appuser import is_tester, validate_account_input 4 | 5 | 6 | class TestIsTester(TestCase): 7 | def test_is_tester(self): 8 | examples = [ 9 | ("Iwt A", True), 10 | ("Test B", False), 11 | ("iwt c", True), 12 | ("John Iwt", True), 13 | ("Iwterosa", False), 14 | ("iwt-d", False), 15 | ("Iwt_E", False), 16 | ("iwtrata", False), 17 | ("iwt", True), 18 | ] 19 | for example, expected in examples: 20 | self.assertEqual( 21 | expected, is_tester(example), f"failed '{example}'" 22 | ) 23 | 24 | 25 | class TestValidateAccountInput(TestCase): 26 | def test_valid_input(self): 27 | examples = [ 28 | dict( 29 | zip="12345", 30 | age=99, 31 | is_latino="YE", 32 | race=["BL"], 33 | gender="TF", 34 | sexual_orien="SG", 35 | ), 36 | dict( 37 | zip="12345", age=99, is_latino="DA", race=["DA"], gender="DA" 38 | ), 39 | dict(is_latino=None, gender=None, race=None), 40 | dict( 41 | gender="OT", 42 | gender_other="other gender", 43 | sexual_orien="OT", 44 | sexual_orien_other="pansexual", 45 | ), 46 | dict(race=["BL", "OT"], race_other="other race"), 47 | dict(), 48 | ] 49 | 50 | for example in examples: 51 | validate_account_input(example) 52 | 53 | def test_invalid_input(self): 54 | examples = [ 55 | dict(name=""), 56 | dict(zip="1234"), 57 | dict(age=0), 58 | dict(is_latino=True), 59 | dict(gender="", gender_other="other gender"), 60 | dict(sexual_orien="", sexual_orien_other="idk"), 61 | dict(race=None, race_other="other race"), 62 | dict(gender="NB", gender_other="nonbinary"), 63 | ] 64 | 65 | for example in examples: 66 | with self.assertRaises(AssertionError, msg=example): 67 | validate_account_input(example) 68 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | docker: circleci/docker@2.2.0 5 | 6 | jobs: 7 | build-and-test: 8 | machine: 9 | image: ubuntu-2004:current 10 | docker_layer_caching: true 11 | steps: 12 | - checkout 13 | - run: 14 | name: Copy example.env to .env 15 | command: | 16 | cp example.env .env 17 | - run: 18 | name: Build Docker image 19 | command: | 20 | docker-compose -p iwalk -f docker-compose.yml -f docker-compose.override.yml build --no-cache server 21 | - run: 22 | name: Run Docker image 23 | command: | 24 | docker-compose -p iwalk -f docker-compose.yml -f docker-compose.override.yml up -d 25 | - run: 26 | name: LOGS 27 | command: | 28 | echo "showing logs..." 29 | docker-compose -p iwalk logs 30 | - run: 31 | name: Check Poetry 32 | command: | 33 | docker-compose -p iwalk exec server bash -c "poetry check -vvv" 34 | - run: 35 | name: Check code formatting with Black 36 | command: | 37 | docker-compose -p iwalk exec server bash -c "black --check ." 38 | - run: 39 | name: Lint code with Flake8 40 | command: | 41 | docker-compose -p iwalk exec server bash -c "poetry run flake8 --exclude=node_modules --count ." 42 | - run: 43 | name: Run Pytest, report coverage 44 | # make test and make coverage are redundant, 45 | # but make coverage actually doesn't fail the build if it fails; it just reports a lower 46 | # coverage percentage for failing short, but "succeeds" 47 | # We are not at risk: 48 | # As we are 49 | # Building this in a container on a remote ci server, 50 | # we are not at risk of the following: 51 | # https://stackoverflow.com/a/73100228 52 | # git config --global --add safe.directory /app 53 | command: | 54 | docker-compose -p iwalk exec -e COVERALLS_REPO_TOKEN server bash -c " 55 | git config --global --add safe.directory /app 56 | make test && make coverage && poetry run coveralls 57 | " 58 | workflows: 59 | main: 60 | jobs: 61 | - build-and-test 62 | -------------------------------------------------------------------------------- /home/migrations/0010_auto_20220321_0407.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-03-21 11:07 2 | 3 | import setfield 4 | from django.db import migrations, models 5 | 6 | import home.models.account 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("home", "0009_contest_start_baseline_date"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="account", 18 | name="sexual_orien", 19 | field=models.CharField( 20 | blank=True, 21 | help_text="Self-identified sexual orientation of user", 22 | max_length=2, 23 | null=True, 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="account", 28 | name="sexual_orien_other", 29 | field=models.CharField( 30 | blank=True, 31 | help_text="Free-form text field for 'sexual_orien' value 'OT'", 32 | max_length=75, 33 | null=True, 34 | ), 35 | ), 36 | migrations.AlterField( 37 | model_name="account", 38 | name="is_latino", 39 | field=models.CharField( 40 | blank=True, 41 | help_text="Latino or Hispanic origin", 42 | max_length=2, 43 | null=True, 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name="account", 48 | name="race", 49 | field=setfield.SetField( 50 | base_field=models.CharField( 51 | choices=[ 52 | ("NA", home.models.account.RaceLabels["NA"]), 53 | ("BL", home.models.account.RaceLabels["BL"]), 54 | ("AS", home.models.account.RaceLabels["AS"]), 55 | ("PI", home.models.account.RaceLabels["PI"]), 56 | ("WH", home.models.account.RaceLabels["WH"]), 57 | ("OT", home.models.account.RaceLabels["OT"]), 58 | ("DA", home.models.account.RaceLabels["DA"]), 59 | ], 60 | max_length=2, 61 | ), 62 | blank=True, 63 | default=list, 64 | size=None, 65 | ), 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /client/src/Navigation.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | import Api from './Api'; 5 | import './Navigation.scss'; 6 | import { useAuthContext } from './AuthContext'; 7 | 8 | function Navigation () { 9 | const { user, setUser } = useAuthContext(); 10 | 11 | useEffect(() => { 12 | Api.admin 13 | .me() 14 | .then((response) => { 15 | if (response.status === 204) { 16 | setUser(null); 17 | } else { 18 | setUser(response.data); 19 | } 20 | }) 21 | .catch(() => setUser(null)); 22 | }, [setUser]); 23 | 24 | return ( 25 | 76 | ); 77 | } 78 | export default Navigation; 79 | -------------------------------------------------------------------------------- /home/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ( 4 | Account, 5 | Contest, 6 | DailyWalk, 7 | Device, 8 | IntentionalWalk, 9 | Leaderboard, 10 | WeeklyGoal, 11 | ) 12 | 13 | 14 | @admin.register(Device) 15 | class DeviceAdmin(admin.ModelAdmin): 16 | list_display = ["account", "device_id", "created"] 17 | list_display_links = ["account"] 18 | ordering = ["-created"] 19 | search_fields = ["account__name__icontains", "account__email__icontains"] 20 | 21 | 22 | @admin.register(Account) 23 | class AccountAdmin(admin.ModelAdmin): 24 | list_display = [ 25 | "name", 26 | "email", 27 | "is_tester", 28 | "is_sf_resident", 29 | "created", 30 | "updated", 31 | ] 32 | list_display_links = ["email", "name"] 33 | ordering = ["-created"] 34 | search_fields = ["email", "name", "zip"] 35 | 36 | 37 | @admin.register(DailyWalk) 38 | class DailyWalkAdmin(admin.ModelAdmin): 39 | list_display = ["account", "date", "steps", "distance"] 40 | list_display_links = ["account", "date", "steps", "distance"] 41 | readonly_fields = ["account", "created", "updated"] 42 | ordering = ["-date"] 43 | search_fields = ["account__name__icontains", "account__email__icontains"] 44 | 45 | 46 | @admin.register(IntentionalWalk) 47 | class IntentionalWalkAdmin(admin.ModelAdmin): 48 | list_display = ["account", "start", "end", "steps", "distance"] 49 | list_display_links = ["account", "start", "end", "steps", "distance"] 50 | readonly_fields = ["account", "created"] 51 | ordering = ["-start"] 52 | search_fields = ["account__name__icontains", "account__email__icontains"] 53 | 54 | 55 | @admin.register(Contest) 56 | class AppUserAdmin(admin.ModelAdmin): 57 | list_display = ["start_promo", "start", "end", "contest_id"] 58 | list_display_links = ["contest_id"] 59 | ordering = ["-start_promo"] 60 | search_fields = [] 61 | 62 | 63 | @admin.register(Leaderboard) 64 | class LeaderboardUserAdmin(admin.ModelAdmin): 65 | list_display = ["account", "device", "steps", "contest_id"] 66 | list_filter = ["contest"] 67 | ordering = ["contest", "-steps"] 68 | 69 | 70 | @admin.register(WeeklyGoal) 71 | class WeeklyGoalAdmin(admin.ModelAdmin): 72 | list_display = ["account", "start_of_week", "steps", "days"] 73 | list_filter = ["account"] 74 | ordering = ["-start_of_week"] 75 | -------------------------------------------------------------------------------- /client/src/Components/BarChart.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import * as d3 from 'd3'; 3 | 4 | function BarChart ({ data, width, height, minColor, maxColor }) { 5 | const ref = useRef(); 6 | 7 | useEffect(() => { 8 | if (data && width && height && minColor && maxColor) { 9 | const margin = { 10 | left: 40, 11 | top: 30, 12 | right: 0, 13 | bottom: 30, 14 | }; 15 | const content = { 16 | width: width - margin.left - margin.right, 17 | height: height - margin.top - margin.bottom, 18 | }; 19 | const groups_domain = data.map((e) => e.label); 20 | 21 | const el = d3.select(ref.current); 22 | el.selectAll('*').remove(); 23 | 24 | const chart = el 25 | .append('g') 26 | .attr('transform', `translate(${margin.left}, ${margin.top})`); 27 | 28 | // x-axis 29 | const x = d3 30 | .scaleBand() 31 | .domain(groups_domain) 32 | .range([0, content.width]) 33 | .padding(0.3); 34 | 35 | chart 36 | .append('g') 37 | .attr('transform', `translate(0, ${content.height})`) 38 | .call(d3.axisBottom(x)); 39 | 40 | // y-axis 41 | const y = d3 42 | .scaleLinear() 43 | .domain([0, data.reduce((max, e) => Math.max(max, e.value), 0)]) 44 | .range([content.height, 0]); 45 | 46 | chart.append('g').call(d3.axisLeft(y)); 47 | 48 | // data bars 49 | const color = d3 50 | .scaleOrdinal() 51 | .domain(groups_domain) 52 | .range([minColor, maxColor]); 53 | 54 | chart 55 | .selectAll() 56 | .data(data) 57 | .enter() 58 | .append('g') 59 | .append('rect') 60 | .attr('x', (e) => x(e.label)) 61 | .attr('y', (e) => y(e.value)) 62 | .attr('width', x.bandwidth()) 63 | .attr('height', (e) => content.height - y(e.value) + 'px') 64 | .attr('fill', (e) => color(e.label)); 65 | 66 | // labels under the bars 67 | for (const e of data) { 68 | chart 69 | .append('text') 70 | .attr('class', 'h6') 71 | .attr('x', x(e.label) + x.bandwidth() / 2) 72 | .attr('y', y(e.value) - 10) 73 | .attr('text-anchor', 'middle') 74 | .attr('color', '#444444') 75 | .text(e.value + ''); 76 | } 77 | } 78 | }, [data, width, height, minColor, maxColor]); 79 | 80 | return ; 81 | } 82 | 83 | export default BarChart; 84 | -------------------------------------------------------------------------------- /home/migrations/0008_alter_gender_race_fields_setfield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-28 02:13 2 | 3 | import setfield 4 | from django.db import migrations, models 5 | 6 | import home.models.account 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("home", "0007_new_demographic_cols"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="account", 18 | name="gender_other", 19 | field=models.CharField( 20 | blank=True, 21 | help_text="Free-form text field for 'gender' value 'OT'", 22 | max_length=75, 23 | null=True, 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="account", 28 | name="race_other", 29 | field=models.CharField( 30 | blank=True, 31 | help_text="Free-form text field for 'race' value 'OT'", 32 | max_length=75, 33 | null=True, 34 | ), 35 | ), 36 | migrations.AlterField( 37 | model_name="account", 38 | name="gender", 39 | field=models.CharField( 40 | blank=True, 41 | help_text="Self-identified gender identity of user", 42 | max_length=2, 43 | null=True, 44 | ), 45 | ), 46 | migrations.RemoveField( 47 | model_name="account", 48 | name="race", 49 | ), 50 | migrations.AddField( 51 | model_name="account", 52 | name="race", 53 | field=setfield.SetField( 54 | base_field=models.CharField( 55 | choices=[ 56 | ("NA", home.models.account.RaceLabels["NA"]), 57 | ("BL", home.models.account.RaceLabels["BL"]), 58 | ("AS", home.models.account.RaceLabels["AS"]), 59 | ("PI", home.models.account.RaceLabels["PI"]), 60 | ("WH", home.models.account.RaceLabels["WH"]), 61 | ("OT", home.models.account.RaceLabels["OT"]), 62 | ], 63 | max_length=2, 64 | ), 65 | blank=True, 66 | default=list, 67 | size=None, 68 | ), 69 | ), 70 | migrations.AlterField( 71 | model_name="dailywalk", 72 | name="date", 73 | field=models.DateField( 74 | help_text="The specific date for which the steps are recorded" 75 | ), 76 | ), 77 | migrations.DeleteModel( 78 | name="Race", 79 | ), 80 | ] 81 | -------------------------------------------------------------------------------- /home/models/intentionalwalk.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from dateutil import parser 4 | 5 | from django.db import models 6 | 7 | from home.templatetags.format_helpers import m_to_mi 8 | 9 | 10 | class IntentionalWalk(models.Model): 11 | """ 12 | Stores a single intentional/recorded walk from a user/account. It is always 13 | linked to the specific device identifier :model: `home.Device` 14 | """ 15 | 16 | # event id will be a v4 random uuid generated on the client 17 | event_id = models.CharField(max_length=250, unique=True) 18 | # Walk meta 19 | start = models.DateTimeField( 20 | help_text="Timestamp when the intentional walk started" 21 | ) 22 | end = models.DateTimeField( 23 | help_text="Timestamp when the intentional walk ended" 24 | ) 25 | steps = models.IntegerField(help_text="Number of steps recorded") 26 | pause_time = models.FloatField(help_text="Total time paused (in seconds)") 27 | walk_time = models.FloatField( 28 | help_text="Total time walked not including pause time" 29 | ) 30 | distance = models.FloatField(help_text="Total distance covered") 31 | device = models.ForeignKey( 32 | "Device", 33 | on_delete=models.CASCADE, 34 | help_text="Device the data is coming from", 35 | ) 36 | account = models.ForeignKey( 37 | "Account", 38 | on_delete=models.CASCADE, 39 | help_text="Account the data is linked to", 40 | ) 41 | created = models.DateTimeField( 42 | auto_now_add=True, help_text="Record creation timestamp" 43 | ) 44 | 45 | @property 46 | def walk_time_repr(self): 47 | return time.strftime( 48 | "%Hh %Mm %Ss", 49 | time.gmtime(int(self.walk_time)), 50 | ) 51 | 52 | @property 53 | def pause_time_repr(self): 54 | return time.strftime("%Hh %Mm %Ss", time.gmtime(int(self.pause_time))) 55 | 56 | @property 57 | def distance_in_miles(self): 58 | return m_to_mi(self.distance) 59 | 60 | @property 61 | def speed_mph(self): 62 | return (self.distance_in_miles / self.walk_time) * 3600 63 | 64 | def update_walk_time(self): 65 | end = self.end 66 | if type(end) is str: 67 | end = parser.parse(end) 68 | start = self.start 69 | if type(start) is str: 70 | start = parser.parse(start) 71 | self.walk_time = (end - start).total_seconds() - self.pause_time 72 | 73 | def save(self, *args, **kwargs): 74 | # Auto populate the account field from the device field 75 | self.account = self.device.account 76 | # Calculate the walk time 77 | self.update_walk_time() 78 | super().save(*args, **kwargs) 79 | 80 | def __str__(self): 81 | return f"{self.account.email} | {self.start} - {self.end}" 82 | 83 | class Meta: 84 | ordering = ("-start",) 85 | -------------------------------------------------------------------------------- /home/views/api/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from math import ceil 3 | from typing import Any, Dict, List, Callable 4 | from django.http import HttpResponse 5 | from django.views import View 6 | 7 | 8 | def paginate(request, results, page, per_page): 9 | count = results.count() 10 | pages_count = ceil(count / per_page) 11 | base_url = f"{request.scheme}://{request.get_host()}{request.path}" 12 | query = request.GET.copy() 13 | links = [] 14 | if page < pages_count: 15 | query["page"] = page + 1 16 | links.append(f'<{base_url}?{query.urlencode()}>; rel="next"') 17 | if page < pages_count - 1: 18 | query["page"] = pages_count 19 | links.append(f'<{base_url}?{query.urlencode()}>; rel="last"') 20 | if page > 2: 21 | query["page"] = 1 22 | links.append(f'<{base_url}?{query.urlencode()}>; rel="first"') 23 | if page > 1: 24 | query["page"] = page - 1 25 | links.append(f'<{base_url}?{query.urlencode()}>; rel="prev"') 26 | return ( 27 | results[(page - 1) * per_page : page * per_page], # noqa: E203 28 | ", ".join(links), 29 | ) 30 | 31 | 32 | def validate_request_json( 33 | json_data: Dict[str, Any], required_fields: List[str] 34 | ) -> Dict[str, str]: 35 | """Generic function to check the request json payload for required fields 36 | and create an error response if missing 37 | 38 | Parameters 39 | ---------- 40 | json_data 41 | Input request json converted to a python dict 42 | required_fields 43 | Fields required in the input json 44 | 45 | Returns 46 | ------- 47 | Dictionary with a boolean indicating if the input json is validated and 48 | an optional error message 49 | 50 | """ 51 | # Create a default success message 52 | response = {"status": "success"} 53 | for required_field in required_fields: 54 | if required_field not in json_data: 55 | # Set the error fields 56 | response["status"] = "error" 57 | response["message"] = ( 58 | f"Required input '{required_field}' missing in the request" 59 | ) 60 | # Fail on the first missing key 61 | break 62 | 63 | return response 64 | 65 | 66 | def require_authn(func: Callable[[View, Any, Any], HttpResponse]): 67 | """Decorator for Django View methods to require authn. 68 | 69 | Checks if the request's user is authenticated. If not, returns a 401 HttpResponse. 70 | Otherwise, calls the decorated method. 71 | 72 | Parameters 73 | ---------- 74 | func: 75 | The View method to decorate. 76 | 77 | Returns 78 | ------- 79 | The decorated method. 80 | 81 | """ 82 | 83 | @functools.wraps(func) 84 | def wrapper(self, *args, **kwargs): 85 | if not self.request.user.is_authenticated: 86 | return HttpResponse(status=401) 87 | return func(self, *args, **kwargs) 88 | 89 | return wrapper 90 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Database Schema/App models 2 | 3 | ## Account 4 | 5 | An Account uniquely identifies a user by their email address. Demographics about the user are collected in the sign-up flow and stored in the Account. 6 | 7 | id: integer primary key, sequentially generated by database 8 | email: a unique email address 9 | name: the user's full name 10 | zip: the user's home zip code 11 | age: the user's self reported age 12 | is_latino: enum of responses, see IsLatinoLabels in home/models/account.py 13 | is_sf_resident: true/false if the zip code is in the list of SF zip codes 14 | is_tester: true if the user is a tested, automatically set if the name includes IWT or iwt 15 | gender: enum of responses, see GenderLabels in home/models/account.py 16 | gender_other: freeform text response if gender is Other 17 | race: enum of responses, see RaceLabels in home/models/account.py 18 | race_other: freeform text response if race is Other 19 | sexual_orien: enum of responses, see SexualOrientationLabels in home/models/account.py 20 | sexual_orien_other: freeform text response if sexual_orien is Other 21 | created: timestamp of record creation 22 | updated: timestamp of record last update 23 | 24 | ## Contest 25 | 26 | A Contest represents a period of time in which steps are counted and recorded for the purposes of winning a prize. 27 | 28 | contest_id: uuid primary key 29 | start_promo: date from which to start promoting the Contest in app 30 | start_baseline: date from which to start collecting baseline step data in advance of the Contest 31 | start: start date of contest 32 | end: end date of contest, inclusive 33 | created: timestamp of record creation 34 | updated: timestamp of record last update 35 | 36 | ## DailyWalk 37 | 38 | A DailyWalk represents the total number of steps and total distance walked by a user on a given day. 39 | 40 | id 41 | account_id 42 | device_id 43 | date 44 | steps 45 | distance 46 | created: timestamp of record creation 47 | updated: timestamp of record last update 48 | 49 | ## Device 50 | 51 | A Device represents a unique installation of the app by a given user, based on a unique identifier (UUID) generated by the app at first launch after installation. It may or may not represent a completely different physical device- simply deleting and re-installing the app will generate a new identifier. 52 | 53 | device_id 54 | account_id 55 | created 56 | 57 | ## IntentionalWalk 58 | 59 | An IntentionalWalk represents an explicitly recorded walk in which the user marked the start and end times between which the total number of steps and total distance during that time period are recorded. 60 | 61 | id 62 | event_id 63 | account_id 64 | device_id 65 | start 66 | end 67 | pause_time 68 | steps 69 | distance 70 | created 71 | 72 | ## WeeklyGoal 73 | 74 | A WeeklyGoal represents a user's step and days goal for a week. Entries represent the creation or updates to a user's goal 75 | 76 | account_id: integer 77 | start_of_week: timestamp of record creation 78 | steps: integer 79 | days: integer -------------------------------------------------------------------------------- /home/models/dailywalk.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import models 4 | from home.models.leaderboard import Leaderboard 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.db.models import Sum 7 | 8 | 9 | from home.templatetags.format_helpers import m_to_mi 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | # Event model 15 | class DailyWalk(models.Model): 16 | """ 17 | Stores a single daily walk. This is unique for any given date and registered 18 | account. It is always linked to the specific device identifier 19 | :model: `home.Device` 20 | """ 21 | 22 | date = models.DateField( 23 | help_text="The specific date for which the steps are recorded" 24 | ) 25 | steps = models.IntegerField(help_text="Number of steps recorded") 26 | distance = models.FloatField(help_text="Total distance covered") 27 | device = models.ForeignKey( 28 | "Device", 29 | on_delete=models.CASCADE, 30 | help_text="Device the data is coming from", 31 | ) 32 | account = models.ForeignKey( 33 | "Account", 34 | on_delete=models.CASCADE, 35 | help_text="Account the data is linked to", 36 | ) 37 | created = models.DateTimeField( 38 | auto_now_add=True, help_text="Record creation timestamp" 39 | ) 40 | updated = models.DateTimeField( 41 | auto_now=True, help_text="Record updation timestamp" 42 | ) 43 | 44 | @property 45 | def distance_in_miles(self): 46 | return m_to_mi(self.distance) 47 | 48 | # Auto populate the account field from the device field 49 | def save(self, *args, **kwargs): 50 | self.account = self.device.account 51 | super().save(*args, **kwargs) 52 | 53 | def __str__(self): 54 | return f"{self.account.email} | {self.date}" 55 | 56 | def update_leaderboard(**kwargs): 57 | device = kwargs.get("device") 58 | contest = kwargs.get("contest") 59 | total_steps = ( 60 | DailyWalk.objects.filter(account=device.account) 61 | .filter(date__range=(contest.start, contest.end)) 62 | .aggregate(Sum("steps")) 63 | ) 64 | if total_steps["steps__sum"] is None: 65 | total_steps["steps__sum"] = 0 66 | try: 67 | # Update 68 | leaderboard = Leaderboard.objects.get( 69 | account=device.account, contest=contest 70 | ) 71 | leaderboard.steps = total_steps["steps__sum"] 72 | leaderboard.device = device 73 | leaderboard.save() 74 | except ObjectDoesNotExist: 75 | leaderboard = Leaderboard.objects.create( 76 | steps=total_steps["steps__sum"], 77 | device=device, 78 | contest=contest, 79 | ) 80 | 81 | class Meta: 82 | ordering = ("-date",) 83 | constraints = [ 84 | models.UniqueConstraint( 85 | fields=["account", "date"], name="account_date" 86 | ), 87 | ] 88 | -------------------------------------------------------------------------------- /home/tests/integration/views/api/test_export.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | import logging 4 | 5 | from datetime import date, timedelta 6 | 7 | from django.test import Client, TestCase 8 | 9 | from home.views.api.export import CSV_COLUMNS 10 | from .utils import Login, generate_test_data 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class TestExportViews(TestCase): 16 | contest0_id = None 17 | 18 | @classmethod 19 | def setUpTestData(cls): 20 | cls.contest0_id = generate_test_data() 21 | 22 | def test_export_users(self): 23 | c = Client() 24 | self.assertTrue(Login.login(c)) 25 | 26 | response = c.get(f"/api/export/users?contest_id={self.contest0_id}") 27 | self.assertEqual(200, response.status_code) 28 | self.assertEqual("text/csv", response["Content-Type"]) 29 | 30 | content = response.getvalue().decode("utf-8") 31 | reader = csv.DictReader(io.StringIO(content)) 32 | headers = reader.fieldnames 33 | self.assertEqual( 34 | headers, 35 | [col["name"] for col in CSV_COLUMNS] 36 | + [ 37 | str(date(3000, 2, 28) + timedelta(days=dt)) for dt in range(15) 38 | ], 39 | ) 40 | 41 | rows = list(reader) 42 | self.assertEqual(len(rows), 4) 43 | self.assertEqual(rows[0]["Participant Name"], "User 2") 44 | self.assertEqual(rows[0]["Is New Signup"], "False") 45 | self.assertEqual(rows[0]["Active During Contest"], "True") 46 | self.assertEqual(rows[0]["Total Daily Walks During Contest"], "7") 47 | self.assertEqual(rows[0]["Total Steps During Contest"], "70000") 48 | self.assertEqual(rows[0]["Total Recorded Walks During Contest"], "2") 49 | self.assertEqual( 50 | rows[0]["Total Recorded Steps During Contest"], "4000" 51 | ) 52 | 53 | self.assertEqual(rows[1]["Participant Name"], "User 3") 54 | self.assertEqual(rows[1]["Is New Signup"], "True") 55 | self.assertEqual(rows[1]["Active During Contest"], "True") 56 | self.assertEqual(rows[1]["Total Daily Walks During Contest"], "7") 57 | self.assertEqual(rows[1]["Total Steps During Contest"], "105000") 58 | self.assertEqual(rows[1]["Total Recorded Walks During Contest"], "0") 59 | self.assertEqual(rows[1]["Total Recorded Steps During Contest"], "") 60 | 61 | self.assertEqual(rows[2]["Participant Name"], "User 4") 62 | self.assertEqual(rows[2]["Is New Signup"], "True") 63 | self.assertEqual(rows[2]["Active During Contest"], "True") 64 | self.assertEqual(rows[2]["Total Daily Walks During Contest"], "0") 65 | self.assertEqual(rows[2]["Total Steps During Contest"], "") 66 | self.assertEqual(rows[2]["Total Recorded Walks During Contest"], "2") 67 | self.assertEqual( 68 | rows[2]["Total Recorded Steps During Contest"], "6000" 69 | ) 70 | 71 | self.assertEqual(rows[3]["Participant Name"], "User 5") 72 | self.assertEqual(rows[3]["Is New Signup"], "True") 73 | self.assertEqual(rows[3]["Active During Contest"], "False") 74 | self.assertEqual(rows[3]["Total Daily Walks During Contest"], "0") 75 | self.assertEqual(rows[3]["Total Steps During Contest"], "") 76 | self.assertEqual(rows[3]["Total Recorded Walks During Contest"], "0") 77 | self.assertEqual(rows[3]["Total Recorded Steps During Contest"], "") 78 | -------------------------------------------------------------------------------- /home/migrations/0007_new_demographic_cols.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | from home.models.account import SAN_FRANCISCO_ZIP_CODES 3 | 4 | """ 5 | This migration adds new demographic fields into Account: 6 | * is_latino: Latino/Hispanic origin 7 | * is_sf_resident: whether the user is a SF resident or not (based on zip) 8 | 9 | It also creates a new table of Race 10 | (many-to-many relationship of users and races) 11 | """ 12 | 13 | 14 | def populate_is_sf_resident(apps, schema_editor): 15 | Account = apps.get_model("home", "Account") 16 | db_alias = schema_editor.connection.alias 17 | 18 | for account in Account.objects.using(db_alias): 19 | account.is_sf_resident = account.zip in SAN_FRANCISCO_ZIP_CODES 20 | account.save() 21 | 22 | 23 | def depopulate_is_sf_resident(apps, schema_editor): 24 | Account = apps.get_model("home", "Account") 25 | db_alias = schema_editor.connection.alias 26 | 27 | for account in Account.objects.using(db_alias): 28 | account.is_sf_resident = None 29 | account.save() 30 | 31 | 32 | class Migration(migrations.Migration): 33 | 34 | dependencies = [ 35 | ("home", "0006_auto_20200623_2207"), 36 | ] 37 | 38 | operations = [ 39 | migrations.CreateModel( 40 | name="Race", 41 | fields=[ 42 | ( 43 | "id", 44 | models.AutoField( 45 | auto_created=True, 46 | primary_key=True, 47 | serialize=False, 48 | verbose_name="ID", 49 | ), 50 | ), 51 | ("label", models.CharField(max_length=75, unique=True)), 52 | ], 53 | ), 54 | migrations.AddField( 55 | model_name="account", 56 | name="is_sf_resident", 57 | field=models.BooleanField( 58 | help_text=( 59 | "Whether the user is a SF resident or not, based on zip" 60 | ), 61 | null=True, 62 | ), 63 | ), 64 | migrations.AddField( 65 | model_name="account", 66 | name="is_latino", 67 | field=models.BooleanField( 68 | blank=True, help_text="Latino or Hispanic origin", null=True 69 | ), 70 | ), 71 | migrations.AddField( 72 | model_name="account", 73 | name="race", 74 | field=models.ManyToManyField( 75 | blank=True, 76 | help_text="Self-identified race(s) of user", 77 | to="home.Race", 78 | ), 79 | ), 80 | migrations.AddField( 81 | model_name="account", 82 | name="gender", 83 | field=models.CharField( 84 | blank=True, 85 | help_text="Self-identified gender identity of user", 86 | max_length=25, 87 | ), 88 | ), 89 | migrations.AddField( 90 | model_name="account", 91 | name="is_tester", 92 | field=models.BooleanField( 93 | help_text="User is an app tester", default=False 94 | ), 95 | ), 96 | migrations.RunPython( 97 | populate_is_sf_resident, reverse_code=depopulate_is_sf_resident 98 | ), 99 | ] 100 | -------------------------------------------------------------------------------- /client/src/Users/UsersExportModal.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { parse } from 'csv-parse/browser/esm/sync'; 3 | import { saveAs } from 'file-saver'; 4 | 5 | import Api from '../Api'; 6 | 7 | function UsersExportModal ({ contest_id, is_tester, onClose }) { 8 | const [cols, setCols] = useState([]); 9 | const [emailCol, setEmailCol] = useState(); 10 | const [idCol, setIdCol] = useState(); 11 | const [file, setFile] = useState(); 12 | 13 | function onChangeFile (event) { 14 | const [newFile] = event.target.files; 15 | const reader = new window.FileReader(); 16 | reader.onload = (event) => { 17 | const csv = event.target.result; 18 | const data = parse(csv); 19 | setCols(data[0]); 20 | }; 21 | reader.readAsText(newFile); 22 | setFile(newFile); 23 | } 24 | 25 | async function onExport () { 26 | const data = new FormData(); 27 | data.append('contest_id', contest_id); 28 | data.append('is_tester', is_tester); 29 | data.append('email', emailCol); 30 | data.append('id', idCol); 31 | data.append('file', file); 32 | try { 33 | const response = await Api.export.users(data); 34 | saveAs(response.data, 'users_agg_with_ids.csv'); 35 | } catch (err) { 36 | console.error(err); 37 | } 38 | } 39 | 40 | return ( 41 | <> 42 |
43 |
44 |
45 |
46 |
47 |
Export as CSV with Survey IDs
48 |
50 |
51 |
52 | 53 | 54 |
55 | {cols.length > 0 && ( 56 | <> 57 |
58 | 59 | 62 |
63 |
64 | 65 | 68 |
69 | 70 | )} 71 |
72 |
73 | 74 | 75 |
76 |
77 |
78 |
79 | 80 | ); 81 | } 82 | 83 | export default UsersExportModal; 84 | -------------------------------------------------------------------------------- /home/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-04-05 01:06 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="AppUser", 16 | fields=[ 17 | ( 18 | "account_id", 19 | models.CharField( 20 | max_length=250, primary_key=True, serialize=False 21 | ), 22 | ), 23 | ("name", models.CharField(max_length=250)), 24 | ("email", models.EmailField(max_length=254)), 25 | ("zip", models.CharField(max_length=25)), 26 | ("age", models.IntegerField()), 27 | ("created", models.DateTimeField(auto_now_add=True)), 28 | ("updated", models.DateTimeField(auto_now=True)), 29 | ], 30 | options={ 31 | "ordering": ("-created",), 32 | }, 33 | ), 34 | migrations.CreateModel( 35 | name="IntentionalWalk", 36 | fields=[ 37 | ( 38 | "id", 39 | models.AutoField( 40 | auto_created=True, 41 | primary_key=True, 42 | serialize=False, 43 | verbose_name="ID", 44 | ), 45 | ), 46 | ("event_id", models.CharField(max_length=250)), 47 | ("start", models.DateTimeField()), 48 | ("end", models.DateTimeField()), 49 | ("steps", models.IntegerField()), 50 | ("pause_time", models.FloatField()), 51 | ("distance", models.FloatField()), 52 | ("created", models.DateTimeField(auto_now_add=True)), 53 | ( 54 | "appuser", 55 | models.ForeignKey( 56 | on_delete=django.db.models.deletion.CASCADE, 57 | to="home.AppUser", 58 | ), 59 | ), 60 | ], 61 | options={ 62 | "ordering": ("-start",), 63 | }, 64 | ), 65 | migrations.CreateModel( 66 | name="DailyWalk", 67 | fields=[ 68 | ( 69 | "id", 70 | models.AutoField( 71 | auto_created=True, 72 | primary_key=True, 73 | serialize=False, 74 | verbose_name="ID", 75 | ), 76 | ), 77 | ("event_id", models.CharField(max_length=250)), 78 | ("date", models.DateField()), 79 | ("steps", models.IntegerField()), 80 | ("distance", models.FloatField()), 81 | ("created", models.DateTimeField(auto_now_add=True)), 82 | ("updated", models.DateTimeField(auto_now=True)), 83 | ( 84 | "appuser", 85 | models.ForeignKey( 86 | on_delete=django.db.models.deletion.CASCADE, 87 | to="home.AppUser", 88 | ), 89 | ), 90 | ], 91 | options={ 92 | "ordering": ("-date",), 93 | }, 94 | ), 95 | migrations.AddConstraint( 96 | model_name="dailywalk", 97 | constraint=models.UniqueConstraint( 98 | fields=("appuser", "date"), name="appuser_date" 99 | ), 100 | ), 101 | ] 102 | -------------------------------------------------------------------------------- /client/src/Components/Histogram.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Chart } from 'react-google-charts'; 3 | import Api from '../Api'; 4 | import Loading from './Loader'; 5 | import ErrorTryAgainLater from './ErrorTryAgainLater'; 6 | 7 | /* eslint-disable camelcase */ 8 | 9 | function Histogram ({ 10 | // query 11 | contest_id, 12 | start_date, 13 | end_date, 14 | bin_size, 15 | bin_count, 16 | bin_custom, 17 | field, 18 | path, 19 | // props 20 | options, 21 | width, 22 | height, 23 | }) { 24 | const [resp, setResp] = useState({ 25 | data: [], 26 | unit: '', 27 | }); 28 | const [loading, setLoading] = useState(true); 29 | const [error, setError] = useState(); 30 | useEffect(() => { 31 | let cancelled = false; 32 | setLoading(true); 33 | setError(null); 34 | const fetchHistogram = async () => { 35 | try { 36 | const response = await Api.admin.histogram({ 37 | contest_id, 38 | start_date, 39 | end_date, 40 | field, 41 | path, 42 | bin_size, 43 | bin_count, 44 | bin_custom, 45 | }); 46 | const { data, unit } = response.data; 47 | if (!cancelled) { 48 | setResp({ 49 | data: transform(data, field), 50 | unit, 51 | bin_size, 52 | }); 53 | } 54 | } catch (error) { 55 | !cancelled && setError(error); 56 | } finally { 57 | !cancelled && setLoading(false); 58 | } 59 | }; 60 | fetchHistogram(); 61 | return () => void (cancelled = true); 62 | }, [ 63 | contest_id, 64 | start_date, 65 | end_date, 66 | bin_size, 67 | bin_count, 68 | bin_custom, 69 | field, 70 | path, 71 | ]); 72 | if (error) { 73 | return ; 74 | } 75 | if (loading) { 76 | return ; 77 | } 78 | // Header is the first entry, so it needs more than 1 length to have data. 79 | const hasData = resp.data.length > 1; 80 | return ( 81 | <> 82 |

{pathToTitle(path, field, resp.unit)}

83 | 84 | { 85 | // The header is the first entry, the rest are 86 | // the bins. This indicates that there is data. 87 | hasData ? ( 88 | 96 | ) : ( 97 |

No data available.

98 | ) 99 | } 100 | 101 | ); 102 | } 103 | 104 | const transform = (data, field) => { 105 | // Display a cutoff for the last bin, 106 | // since the last bin is the upper limit. 107 | const lastIdx = data.length - 1; 108 | return [ 109 | [capitalize(field), 'Count'], 110 | ...data.map(({ bin_start, bin_end, count }, i) => [ 111 | // Subtract by 1 at the end since the ranges are exclusive, and we want to 112 | // display them as inclusive. 113 | i !== lastIdx ? `${bin_start}-${bin_end - 1}` : `>${bin_start}`, 114 | count, 115 | ]), 116 | ]; 117 | }; 118 | 119 | const capitalize = (str) => { 120 | return str.charAt(0).toUpperCase() + str.slice(1); 121 | }; 122 | 123 | const pathToTitle = (path, field, unit) => { 124 | switch (path) { 125 | case 'intentionalwalk': 126 | return `Intentional Walk (${unit})`; 127 | case 'users': 128 | return `User ${field} (${unit})`; 129 | case 'dailywalk': 130 | return `Daily Walk (${unit})`; 131 | case 'leaderboard': 132 | return `Leaderboard (${unit})`; 133 | default: 134 | return `${capitalize(path)} ${field} (${unit})`; 135 | } 136 | }; 137 | 138 | export default Histogram; 139 | -------------------------------------------------------------------------------- /client/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/models/contest.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | from django.core.exceptions import ValidationError 5 | from django.db import models 6 | from django.db.models import Q 7 | 8 | 9 | class Contest(models.Model): 10 | """ 11 | Each entry defines a contest with a start and end date. Many to many links 12 | with :model: `home.Account` 13 | """ 14 | 15 | contest_id = models.CharField( 16 | default=uuid.uuid4, 17 | max_length=250, 18 | primary_key=True, 19 | help_text="Contest identifier", 20 | ) 21 | start_baseline = models.DateField( 22 | blank=True, 23 | null=True, 24 | help_text="Start of baseline period (prior to contest start)", 25 | ) 26 | start_promo = models.DateField(help_text="Start date of promotion") 27 | start = models.DateField(help_text="Contest start date") 28 | end = models.DateField(help_text="Contest end date") 29 | created = models.DateTimeField( 30 | auto_now_add=True, help_text="Contest creation timestamp" 31 | ) 32 | updated = models.DateTimeField( 33 | auto_now=True, help_text="Contest updation timestamp" 34 | ) 35 | 36 | def __str__(self): 37 | return f"{self.start} | {self.end}" 38 | 39 | class Meta: 40 | ordering = ("-start",) 41 | 42 | @staticmethod 43 | def active(for_date=None, strict=False): 44 | # Gets the "active" contest for a given date (generally "today") 45 | # 46 | # A contest is not considered active during the baseline period. 47 | # Therefore, the `for_date` must fall after the promo date. 48 | # 49 | # strict: `for_date` must fall strictly within the contest dates, 50 | # starting on promo date, ending on end date (inclusive) 51 | # 52 | # If strict is False, then find most recent contest (prior to for_date) 53 | # 54 | if isinstance(for_date, str): 55 | for_date = datetime.date.fromisoformat(for_date) 56 | today = datetime.date.today() if for_date is None else for_date 57 | contest = ( 58 | Contest.objects.filter(start_promo__lte=today, end__gte=today) 59 | .order_by("start_promo") 60 | .first() 61 | ) 62 | if contest is None and not strict: 63 | # get the last contest 64 | contest = ( 65 | Contest.objects.filter(end__lt=today).order_by("-end").first() 66 | ) 67 | return contest 68 | 69 | @staticmethod 70 | def for_baseline(for_date: datetime.date): 71 | return ( 72 | Contest.objects.filter( 73 | start_baseline__lte=for_date, start__gt=for_date 74 | ) 75 | .order_by("-start") 76 | .first() 77 | ) 78 | 79 | def save(self, *args, **kwargs): 80 | # ensure promotion begins before or at same time as contest start 81 | if self.start < self.start_promo: 82 | raise ValidationError( 83 | "Promotion must start before or at same time as Start" 84 | ) 85 | # ensure baseline begins before contest start 86 | if self.start_baseline and self.start_baseline >= self.start: 87 | raise ValidationError( 88 | "Baseline period must begin before contest start" 89 | ) 90 | # ensure end is greater than start 91 | if self.end <= self.start: 92 | raise ValidationError("End of contest must be after Start") 93 | # ensure that this does not overlap an existing contest period 94 | query = Contest.objects.filter( 95 | Q(start_promo__lte=self.start, end__gt=self.start) 96 | | Q(start_promo__lt=self.end, end__gte=self.end) 97 | | Q(start_promo__gte=self.start, end__lte=self.end) 98 | ) 99 | if self.pk: 100 | query = query.exclude(pk=self.pk) 101 | if query.exists(): 102 | raise ValidationError("Contest must not overlap another") 103 | super().save(*args, **kwargs) 104 | -------------------------------------------------------------------------------- /home/tests/integration/dailywalk/test_update.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | 3 | 4 | class ApiTestCase(TestCase): 5 | def setUp(self): 6 | # Test client 7 | self.client = Client() 8 | # Create a user 9 | response = self.client.post( 10 | path="/api/appuser/create", 11 | data={ 12 | "name": "Abhay Kashyap", 13 | "email": "abhay@blah.com", 14 | "zip": "72185", 15 | "age": 99, 16 | "account_id": "12345", 17 | }, 18 | content_type="application/json", 19 | ) 20 | 21 | # Check for a successful response by the server 22 | self.assertEqual(response.status_code, 200) 23 | # Parse the response 24 | response_data = response.json() 25 | fail_message = f"Server response - {response_data}" 26 | self.assertEqual(response_data["status"], "success", msg=fail_message) 27 | self.assertEqual( 28 | response_data["message"], 29 | "Device registered & account registered successfully", 30 | msg=fail_message, 31 | ) 32 | 33 | # Details for Daily walk even creation 34 | self.url = "/api/dailywalk/create" 35 | # Request parameters 36 | self.request_params = { 37 | "account_id": "12345", 38 | "daily_walks": [ 39 | {"date": "2020-02-22", "steps": 500, "distance": 1.3} 40 | ], 41 | } 42 | # Content type 43 | self.content_type = "application/json" 44 | 45 | # Create a daily walk 46 | # Send the request 47 | response = self.client.post( 48 | path=self.url, 49 | data=self.request_params, 50 | content_type=self.content_type, 51 | ) 52 | # Check for a successful response by the server 53 | self.assertEqual(response.status_code, 200) 54 | # Parse the response 55 | response_data = response.json() 56 | fail_message = f"Server response - {response_data}" 57 | self.assertEqual(response_data["status"], "success", msg=fail_message) 58 | self.assertEqual( 59 | response_data["message"], 60 | "Dailywalks recorded successfully", 61 | msg=fail_message, 62 | ) 63 | 64 | # Test creation of a daily walk for the same date twice 65 | def test_update_steps_dailywalk_success(self): 66 | 67 | # Send the second request but ensure its an update 68 | self.request_params["steps"] = 1000 69 | self.request_params["distance"] = 2.1 70 | 71 | response = self.client.post( 72 | path=self.url, 73 | data=self.request_params, 74 | content_type=self.content_type, 75 | ) 76 | # Check for a successful response by the server 77 | self.assertEqual(response.status_code, 200) 78 | # Parse the response 79 | response_data = response.json() 80 | fail_message = f"Server response - {response_data}" 81 | self.assertEqual(response_data["status"], "success", msg=fail_message) 82 | self.assertEqual( 83 | response_data["message"], 84 | "Dailywalks recorded successfully", 85 | msg=fail_message, 86 | ) 87 | self.assertEqual( 88 | response_data["payload"]["account_id"], 89 | self.request_params["account_id"], 90 | msg=fail_message, 91 | ) 92 | self.assertEqual( 93 | response_data["payload"]["daily_walks"][0]["date"], 94 | self.request_params["daily_walks"][0]["date"], 95 | msg=fail_message, 96 | ) 97 | self.assertEqual( 98 | response_data["payload"]["daily_walks"][0]["steps"], 99 | self.request_params["daily_walks"][0]["steps"], 100 | msg=fail_message, 101 | ) 102 | self.assertEqual( 103 | response_data["payload"]["daily_walks"][0]["distance"], 104 | self.request_params["daily_walks"][0]["distance"], 105 | msg=fail_message, 106 | ) 107 | -------------------------------------------------------------------------------- /home/views/api/leaderboard.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, JsonResponse 2 | from django.utils.decorators import method_decorator 3 | from django.views import View 4 | from django.views.decorators.csrf import csrf_exempt 5 | from django.db.models.functions import Rank 6 | from django.db.models.expressions import Window 7 | from django.db.models import F 8 | from django.core.exceptions import ObjectDoesNotExist 9 | 10 | 11 | from home.models import ( 12 | Contest, 13 | Device, 14 | Leaderboard, 15 | ) 16 | 17 | 18 | @method_decorator(csrf_exempt, name="dispatch") 19 | # Dispatch? 20 | class LeaderboardListView(View): 21 | """View to retrieve leaderboard""" 22 | 23 | # model = Leaderboard 24 | http_method_names = ["get"] 25 | 26 | def get(self, request, *args, **kwargs): 27 | 28 | contest_id = request.GET.get("contest_id") 29 | device_id = request.GET.get("device_id") 30 | 31 | # Parse params 32 | if contest_id is None: 33 | return HttpResponse("No contest specified") 34 | 35 | current_contest = Contest.objects.filter(contest_id=contest_id) 36 | if current_contest is None: 37 | return JsonResponse( 38 | { 39 | "status": "error", 40 | "message": "Contest not found", 41 | } 42 | ) 43 | 44 | # http://localhost:8000/api/leaderboard/ 45 | # get?contest_id=?device_id= 46 | 47 | # Validate request. If any field is missing, 48 | # send back the response message 49 | # Get the device if already registered 50 | device = None 51 | try: 52 | device = Device.objects.get(device_id=device_id) 53 | except ObjectDoesNotExist: 54 | return JsonResponse( 55 | { 56 | "status": "error", 57 | "message": ( 58 | "Unregistered device - " 59 | "Please register first!" 60 | "device_id:" 61 | f"{device_id}" 62 | ), 63 | } 64 | ) 65 | 66 | # Json response template 67 | json_response = { 68 | "status": "success", 69 | "message": "Leaderboard accessed successfully", 70 | "payload": { 71 | "leaderboard": [], 72 | }, 73 | } 74 | 75 | leaderboard_list = [] 76 | leaderboard_length = 10 77 | leaderboard = ( 78 | Leaderboard.objects.filter( 79 | contest_id=contest_id, 80 | account__is_tester=device.account.is_tester, 81 | ) 82 | .values("account_id", "steps") 83 | .annotate( 84 | rank=Window(expression=Rank(), order_by=F("steps").desc()) 85 | ) 86 | ) 87 | 88 | # get top 10 89 | leaderboard_list = list(leaderboard[0:leaderboard_length]) 90 | 91 | # Check if user should be added after top 10 displayed 92 | eleventh_place = True 93 | for user in leaderboard_list: 94 | if user["account_id"] == device.account.id: 95 | user["device_id"] = device_id 96 | eleventh_place = False 97 | break 98 | 99 | # If user not in top 10, add as 11th in list 100 | if eleventh_place: 101 | leaderboard = Leaderboard.objects.filter( 102 | contest_id=contest_id, account=device.account 103 | ).values("account_id", "steps") 104 | if len(leaderboard) > 0: 105 | user = leaderboard[0] 106 | user["device_id"] = device_id 107 | user["rank"] = Leaderboard.objects.filter( 108 | contest_id=contest_id, 109 | steps__gte=user["steps"], 110 | account__is_tester=device.account.is_tester, 111 | ).count() 112 | leaderboard_list.append(user) 113 | 114 | json_response["payload"]["leaderboard"] = leaderboard_list 115 | 116 | return JsonResponse(json_response) 117 | -------------------------------------------------------------------------------- /client/src/Api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /* eslint-disable camelcase */ 4 | 5 | const instance = axios.create({ 6 | headers: { 7 | Accept: 'application/json', 8 | }, 9 | }); 10 | 11 | instance.interceptors.response.use( 12 | (response) => response, 13 | (error) => { 14 | if (error.response.status === 401) { 15 | window.location = '/admin/login'; 16 | } 17 | return Promise.reject(error); 18 | } 19 | ); 20 | 21 | function parseLinkHeader (response) { 22 | const link = response.headers?.link; 23 | if (link) { 24 | const linkRe = /<([^>]+)>; rel="([^"]+)"/g; 25 | const urls = {}; 26 | let m; 27 | while ((m = linkRe.exec(link)) !== null) { 28 | const url = m[1]; 29 | urls[m[2]] = url; 30 | } 31 | return urls; 32 | } 33 | return null; 34 | } 35 | 36 | const Api = { 37 | parseLinkHeader, 38 | admin: { 39 | me () { 40 | return instance.get('/api/admin/me'); 41 | }, 42 | home () { 43 | return instance.get('/api/admin/home'); 44 | }, 45 | homeUsersDaily ({ contest_id, start_date, end_date }) { 46 | return instance.get('/api/admin/home/users/daily', { 47 | params: { contest_id, start_date, end_date }, 48 | }); 49 | }, 50 | homeUsersCumulative ({ contest_id, start_date, end_date }) { 51 | return instance.get('/api/admin/home/users/cumulative', { 52 | params: { contest_id, start_date, end_date }, 53 | }); 54 | }, 55 | homeStepsDaily ({ contest_id, start_date, end_date }) { 56 | return instance.get('/api/admin/home/steps/daily', { 57 | params: { contest_id, start_date, end_date }, 58 | }); 59 | }, 60 | homeStepsCumulative ({ contest_id, start_date, end_date }) { 61 | return instance.get('/api/admin/home/steps/cumulative', { 62 | params: { contest_id, start_date, end_date }, 63 | }); 64 | }, 65 | homeDistanceDaily ({ contest_id, start_date, end_date }) { 66 | return instance.get('/api/admin/home/distance/daily', { 67 | params: { contest_id, start_date, end_date }, 68 | }); 69 | }, 70 | homeDistanceCumulative ({ contest_id, start_date, end_date }) { 71 | return instance.get('/api/admin/home/distance/cumulative', { 72 | params: { contest_id, start_date, end_date }, 73 | }); 74 | }, 75 | contests () { 76 | return instance.get('/api/admin/contests'); 77 | }, 78 | users ({ contest_id, is_tester, order_by, query, page }) { 79 | return instance.get('/api/admin/users', { 80 | params: { contest_id, is_tester, order_by, query, page }, 81 | }); 82 | }, 83 | usersByZip ({ contest_id, is_tester }) { 84 | return instance.get('/api/admin/users/zip', { 85 | params: { contest_id, is_tester }, 86 | }); 87 | }, 88 | usersByZipActive ({ contest_id, is_tester }) { 89 | return instance.get('/api/admin/users/zip/active', { 90 | params: { contest_id, is_tester }, 91 | }); 92 | }, 93 | usersByZipMedianSteps ({ contest_id, is_tester }) { 94 | return instance.get('/api/admin/users/zip/steps', { 95 | params: { contest_id, is_tester }, 96 | }); 97 | }, 98 | histogram ({ 99 | path, 100 | field, 101 | contest_id, 102 | is_tester, 103 | start_date, 104 | end_date, 105 | bin_size, 106 | bin_count, 107 | bin_custom, 108 | }) { 109 | if (contest_id) { 110 | [start_date, end_date] = ''; 111 | } 112 | return instance.get(`/api/admin/${path}/histogram`, { 113 | params: { 114 | contest_id, 115 | is_tester, 116 | field, 117 | start_date, 118 | end_date, 119 | bin_size, 120 | bin_count, 121 | bin_custom, 122 | }, 123 | }); 124 | }, 125 | }, 126 | export: { 127 | users (formData) { 128 | return instance.post('/api/export/users', formData, { 129 | responseType: 'blob', 130 | }); 131 | } 132 | }, 133 | static: { 134 | map () { 135 | return instance.get('/static/home/SanFrancisco.Neighborhoods.json'); 136 | }, 137 | }, 138 | }; 139 | 140 | export default Api; 141 | -------------------------------------------------------------------------------- /client/src/Components/Pagination.jsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { Link } from 'react-router'; 3 | 4 | function Pagination ({ page, lastPage, otherParams = {} }) { 5 | function onClick () { 6 | window.scrollTo(0, 0); 7 | } 8 | return ( 9 | 127 | ); 128 | } 129 | export default Pagination; 130 | -------------------------------------------------------------------------------- /home/management/commands/backfill.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management.base import BaseCommand 4 | from home.models import Contest, DailyWalk 5 | 6 | 7 | class Command(BaseCommand): 8 | """ 9 | Example: 10 | python manage.py backfill account_contests --dry_run 11 | """ 12 | 13 | help = "Backfill data" 14 | choices = { 15 | "account_contests": ( 16 | "Use dailywalks to backfill account_contests" 17 | " relationship (many-to-many)" 18 | ), 19 | } 20 | 21 | def add_arguments(self, parser): 22 | subparsers = parser.add_subparsers() 23 | 24 | # Backfill table home_account_contests (contests by account) 25 | subparser_account_contests = subparsers.add_parser( 26 | "account_contests", 27 | help=( 28 | "Use dailywalks to backfill account_contests" 29 | " relationship (many-to-many)" 30 | ), 31 | ) 32 | subparser_account_contests.add_argument( 33 | "--dry_run", "-N", action="store_true", help="Dry run (no-op)" 34 | ) 35 | subparser_account_contests.set_defaults( 36 | func=self._backfill_account_contests 37 | ) 38 | 39 | subparser_account_contests_opts = ( 40 | subparser_account_contests.add_mutually_exclusive_group( 41 | required=True 42 | ) 43 | ) 44 | subparser_account_contests_opts.add_argument( 45 | "--all", action="store_true", help="Populate all contests" 46 | ) 47 | subparser_account_contests_opts.add_argument( 48 | "--contest_date", 49 | help=( 50 | "Select contest to populate by any date during the" 51 | " contest (or multiple if separated by commas)" 52 | ), 53 | ) 54 | subparser_account_contests_opts.add_argument( 55 | "--contest_id", 56 | help=( 57 | "Select contest to populate by contest_id." 58 | " (Separate multiple by commas.)" 59 | ), 60 | ) 61 | 62 | def handle(self, *args, **options): 63 | options["func"](**options) 64 | 65 | def _backfill_account_contests(self, dry_run=False, **options): 66 | walks_processed = 0 67 | contest_walks = 0 68 | entry_set = set() 69 | 70 | # Choose contest 71 | # TODO: allow start and end dates for filtering 72 | if options["contest_id"]: 73 | contests = set( 74 | [ 75 | Contest.objects.get(pk=_cid) 76 | for _cid in options["contest_id"].split(",") 77 | ] 78 | ) 79 | elif options["contest_date"]: 80 | contests = set( 81 | [ 82 | Contest.active(for_date=_date, strict=True) 83 | for _date in options["contest_date"].split(",") 84 | ] 85 | ) 86 | elif options["all"]: 87 | contests = Contest.objects.all() 88 | else: 89 | sys.exit(1) 90 | 91 | # Retrieve ALL daily walks and try to fit them into contests 92 | daily_walks = DailyWalk.objects.all().order_by("date") 93 | for walk in daily_walks: 94 | acct = walk.account 95 | walks_processed += 1 96 | 97 | # Retrieve the active contest for this walk 98 | active_contest = Contest.active(for_date=walk.date, strict=True) 99 | 100 | # Only process if the active contest was selected 101 | if active_contest in contests: 102 | contest_walks += 1 103 | 104 | if not acct.contests.filter(pk=active_contest.pk): 105 | # Keep track of rows that were (or would have been) added 106 | entry_set.add((acct, active_contest)) 107 | if not dry_run: 108 | walk.account.contests.add(active_contest) 109 | 110 | if walks_processed % 1000 == 0: 111 | print(f"Processed {walks_processed} walks...") 112 | 113 | if_dry_run = " (DRY RUN)" if dry_run else "" 114 | print(f"Contest walks processed: {contest_walks}") 115 | print(f"Rows added: {len(entry_set)}{if_dry_run}") 116 | -------------------------------------------------------------------------------- /home/models/account.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from django.db import models 4 | from setfield import SetField 5 | 6 | SAN_FRANCISCO_ZIP_CODES = set( 7 | [ 8 | "94102", 9 | "94103", 10 | "94104", 11 | "94105", 12 | "94107", 13 | "94108", 14 | "94109", 15 | "94110", 16 | "94111", 17 | "94112", 18 | "94114", 19 | "94115", 20 | "94116", 21 | "94117", 22 | "94118", 23 | "94121", 24 | "94122", 25 | "94123", 26 | "94124", 27 | "94127", 28 | "94129", 29 | "94130", 30 | "94131", 31 | "94132", 32 | "94133", 33 | "94134", 34 | "94158", 35 | ] 36 | ) 37 | 38 | 39 | class GenderLabels(Enum): 40 | CF = "Female" 41 | CM = "Male" 42 | TF = "Trans Female" 43 | TM = "Trans Male" 44 | NB = "Non-binary" 45 | OT = "Other" 46 | DA = "Decline to answer" 47 | 48 | 49 | class RaceLabels(Enum): 50 | NA = "American Indian or Alaska Native" 51 | BL = "Black" 52 | AS = "Asian" 53 | PI = "Native Hawaiian or other Pacific Islander" 54 | WH = "White" 55 | OT = "Other" 56 | DA = "Decline to answer" 57 | 58 | 59 | class SexualOrientationLabels(Enum): 60 | BS = "Bisexual" 61 | SG = "SameGenderLoving" 62 | US = "Unsure" 63 | HS = "Heterosexual" 64 | OT = "Other" 65 | DA = "Decline to answer" 66 | 67 | 68 | class IsLatinoLabels(Enum): 69 | YE = "Yes" 70 | NO = "No" 71 | DA = "Decline to answer" 72 | 73 | 74 | # Note: Maybe inherit from Django's User model? 75 | class Account(models.Model): 76 | """ 77 | Stores a single user account as identified by email. This is created when 78 | the app is installed and the user signs up for the first time and is has 79 | multiple devices - :model: `home.Device` associated with it 80 | """ 81 | 82 | email = models.EmailField( 83 | unique=True, help_text="Email which uniquely identifies an account" 84 | ) 85 | name = models.CharField(max_length=250, help_text="User's name") 86 | zip = models.CharField(max_length=25, help_text="User's zipcode") 87 | age = models.IntegerField(help_text="User's age") 88 | is_sf_resident = models.BooleanField( 89 | null=True, 90 | help_text="Whether the user is a SF resident or not, based on zip", 91 | ) 92 | is_latino = models.CharField( 93 | max_length=2, 94 | null=True, 95 | blank=True, 96 | help_text="Latino or Hispanic origin", 97 | ) 98 | race = SetField( 99 | models.CharField( 100 | max_length=2, choices=list(RaceLabels.__members__.items()) 101 | ), 102 | default=list, 103 | blank=True, 104 | ) 105 | race_other = models.CharField( 106 | max_length=75, 107 | null=True, 108 | blank=True, 109 | help_text="Free-form text field for 'race' value 'OT'", 110 | ) 111 | gender = models.CharField( 112 | max_length=2, 113 | null=True, 114 | blank=True, 115 | help_text="Self-identified gender identity of user", 116 | ) 117 | gender_other = models.CharField( 118 | max_length=75, 119 | null=True, 120 | blank=True, 121 | help_text="Free-form text field for 'gender' value 'OT'", 122 | ) 123 | sexual_orien = models.CharField( 124 | max_length=2, 125 | null=True, 126 | blank=True, 127 | help_text="Self-identified sexual orientation of user", 128 | ) 129 | sexual_orien_other = models.CharField( 130 | max_length=75, 131 | null=True, 132 | blank=True, 133 | help_text="Free-form text field for 'sexual_orien' value 'OT'", 134 | ) 135 | is_tester = models.BooleanField( 136 | default=False, help_text="User is an app tester" 137 | ) 138 | contests = models.ManyToManyField( 139 | "Contest", 140 | blank=True, 141 | help_text="All the contests the account has enrolled in", 142 | ) 143 | created = models.DateTimeField( 144 | auto_now_add=True, help_text="Accounts creation timestamp" 145 | ) 146 | updated = models.DateTimeField( 147 | auto_now=True, help_text="Accounts updation timestamp" 148 | ) 149 | 150 | def __str__(self): 151 | return f"{self.name} | {self.email}" 152 | 153 | class Meta: 154 | ordering = ("-created",) 155 | -------------------------------------------------------------------------------- /static/home/user_plots.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * The following Javascript call is meant to demonstrate passing 4 | * a Jinja2 context variables to bootstrapUserBar. 5 | * 6 | * bootstrapUserBar( 7 | * "#my-user-bar-div", 8 | * {{ cnt_users }}, 9 | * {{ cnt_signups }} 10 | * ); 11 | * 12 | **/ 13 | function bootstrapUserBar(id, title, cnt_users, cnt_signups) { 14 | // Configurable constants 15 | 16 | // The previous bar color is meant to be the Intentional Walk 17 | // primary color, at #702B84. 18 | const PREVIOUS_BAR_COLOR = "rgb(112, 43, 132)"; 19 | 20 | // The new user bar color is meant to be a 35% transparent 21 | // version of PREVIOUS_BAR_COLOR (100 - 35 = 65). 22 | const NEW_BAR_COLOR = "rgba(112, 43, 132, 0.65)"; 23 | 24 | const cnt_delta = cnt_users - cnt_signups; 25 | const data = [ 26 | {label: "Previous", value: cnt_delta}, 27 | {label: "New", value: cnt_signups} 28 | ]; 29 | 30 | const groups_domain = data.reduce(function(total, e) { 31 | total.push(e.label); 32 | return total; 33 | }, []); 34 | 35 | const domain = [0, cnt_users]; 36 | 37 | const margin = { 38 | left: 60, 39 | top: 80, 40 | right: 30, 41 | bottom: 70 42 | }; 43 | 44 | const width = 400 - margin.left - margin.right; 45 | const height = 400 - margin.top - margin.bottom; 46 | 47 | let svg = d3.select(id) 48 | .append("svg") 49 | .attr("width", 400) 50 | .attr("height", 400); 51 | 52 | let chart = svg.append("g") 53 | .attr( 54 | "transform", 55 | "translate(" + margin.left + ", " + margin.top + ")" 56 | ); 57 | 58 | let x = d3.scaleBand() 59 | .range([ 0, width ]) 60 | .domain(groups_domain) 61 | .padding(0.3); 62 | 63 | chart.append("g") 64 | .attr("transform", "translate(0, " + height + ")") 65 | .call(d3.axisBottom(x)); 66 | 67 | const cnt_max = Math.max(cnt_delta, cnt_signups); 68 | let y = d3.scaleLinear() 69 | .domain([0, cnt_max]) 70 | .range([height, 0]); 71 | 72 | chart.append("g") 73 | .call(d3.axisLeft(y)); 74 | 75 | let color = d3.scaleOrdinal() 76 | .domain(groups_domain) 77 | .range([PREVIOUS_BAR_COLOR, NEW_BAR_COLOR]); 78 | 79 | let i = 0; 80 | let groups = chart.selectAll() 81 | .data(data) 82 | .enter() 83 | .append("g"); 84 | 85 | groups.append("rect") 86 | .attr("x", function(e) { return x(e.label); }) 87 | .attr("y", function(e) { return y(e.value); }) 88 | .attr("width", x.bandwidth()) 89 | .attr("height", function(e) { return (height - y(e.value)) + "px"; }) 90 | .attr("fill", function(e) { return color(e.label); }); 91 | 92 | svg.append("text") 93 | .attr("class", "graph-title") 94 | .attr("x", width / 2 + margin.left) 95 | .attr("y", 40) 96 | .attr("text-anchor", "middle") 97 | .text(title); 98 | 99 | svg.append("g") 100 | .attr("class", "tooltip") 101 | .append("text") 102 | .text("Test!") 103 | .attr("class", "tooltip-text"); 104 | 105 | let tooltip = d3.select(id + " .tooltip") 106 | .style("opacity", 0); 107 | 108 | chart.append("text") 109 | .attr("class", "label") 110 | .attr("x", x(data[0].label) + 47) 111 | .attr("y", y(data[0].value) - 10) 112 | .attr("text-anchor", "middle") 113 | .attr("color", "#444444") 114 | .text(cnt_delta + ""); 115 | 116 | chart.append("text") 117 | .attr("class", "label") 118 | .attr("x", x(data[1].label) + 47) 119 | .attr("y", y(data[1].value) - 10) 120 | .attr("text-anchor", "middle") 121 | .attr("color", "#444444") 122 | .text(cnt_signups + ""); 123 | 124 | const mouseover = function(event, d) { 125 | tooltip.style("opacity", 1); 126 | }; 127 | 128 | const mouseleave = function(event, d) { 129 | tooltip.style("opacity", 0); 130 | }; 131 | 132 | const mousemove = function(event, d) { 133 | console.log(id + " .tooltip > .tooltip-text"); 134 | const text = d3.select(id + " .tooltip > .tooltip-text"); 135 | text.text(title + ": " + cnt_users); 136 | const [x, y] = d3.mouse(this); 137 | tooltip.attr("transform", "translate(" + x + ", " + y + ")"); 138 | }; 139 | 140 | svg.on("mouseover", mouseover) 141 | .on("mouseleave", mouseleave) 142 | .on("mousemove", mousemove); 143 | 144 | } 145 | -------------------------------------------------------------------------------- /home/views/web/home.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | 4 | from django.views import generic 5 | 6 | from home.models import Account, DailyWalk 7 | from home.templatetags.format_helpers import m_to_mi 8 | 9 | # Date range for data aggregation 10 | DEFAULT_START_DATE = datetime.date(2020, 4, 1) 11 | DEFAULT_END_DATE = datetime.datetime.today().date() 12 | 13 | 14 | # Home page view 15 | class HomeView(generic.TemplateView): 16 | template_name = "home/home.html" 17 | 18 | # Augment context data to 19 | def get_context_data(self, **kwargs): 20 | # Call the base implementation first to get a context 21 | context = super().get_context_data(**kwargs) 22 | 23 | start_date_str = self.request.GET.get("start_date") 24 | start_date = ( 25 | datetime.datetime.strptime(start_date_str, "%Y-%m-%d").date() 26 | if start_date_str 27 | else DEFAULT_START_DATE 28 | ) 29 | end_date_str = self.request.GET.get("end_date") 30 | end_date = ( 31 | datetime.datetime.strptime(end_date_str, "%Y-%m-%d").date() 32 | if end_date_str 33 | else DEFAULT_END_DATE 34 | ) 35 | 36 | # Get aggregate stats for all users 37 | all_accounts = Account.objects.all().order_by("created") 38 | 39 | # Save the total number of users 40 | context["accounts"] = all_accounts.values() 41 | 42 | # Get signups per day 43 | signup_dist = { 44 | date: len(list(group)) 45 | for date, group in itertools.groupby( 46 | all_accounts.values(), key=lambda x: x["created"].date() 47 | ) 48 | } 49 | 50 | # Fill the gaps cos google charts is annoying af 51 | current_date = start_date 52 | delta = datetime.timedelta(days=1) 53 | context["daily_signups"] = [] 54 | # Iterate over the entire date range 55 | while current_date <= end_date: 56 | context["daily_signups"].append( 57 | [current_date, signup_dist.get(current_date, 0)] 58 | ) 59 | current_date += delta 60 | # Get cumulative distribution 61 | context["cumu_signups"] = [] 62 | total = 0 63 | for date, count in context["daily_signups"]: 64 | total += count 65 | context["cumu_signups"].append([date, total]) 66 | 67 | # Save the total number of daily walks over time 68 | daily_walks = DailyWalk.objects.all().values() 69 | # Get walks per day 70 | step_dist = { 71 | date: sum([walk["steps"] for walk in group]) 72 | for date, group in itertools.groupby( 73 | daily_walks.values(), key=lambda x: x["date"] 74 | ) 75 | } 76 | # Fill the gaps cos google charts is annoying af 77 | current_date = start_date 78 | delta = datetime.timedelta(days=1) 79 | context["daily_steps"] = [] 80 | # Iterate over the entire date range 81 | while current_date <= end_date: 82 | context["daily_steps"].append( 83 | [current_date, step_dist.get(current_date, 0)] 84 | ) 85 | current_date += delta 86 | context["cumu_steps"] = [] 87 | total_steps = 0 88 | for date, steps in context["daily_steps"]: 89 | total_steps += steps 90 | context["cumu_steps"].append([date, total_steps]) 91 | context["total_steps"] = total_steps 92 | 93 | # Get growth for mile 94 | mile_dist = { 95 | date: sum([m_to_mi(walk["distance"]) for walk in group]) 96 | for date, group in itertools.groupby( 97 | daily_walks.values(), key=lambda x: x["date"] 98 | ) 99 | } 100 | # Fill the gaps cos google charts if annoying af 101 | current_date = start_date 102 | delta = datetime.timedelta(days=1) 103 | context["daily_miles"] = [] 104 | # Iterate over the entire date range 105 | while current_date <= end_date: 106 | context["daily_miles"].append( 107 | [current_date, mile_dist.get(current_date, 0)] 108 | ) 109 | current_date += delta 110 | context["cumu_miles"] = [] 111 | total_miles = 0 112 | for date, mile in context["daily_miles"]: 113 | total_miles += mile 114 | context["cumu_miles"].append([date, total_miles]) 115 | context["total_miles"] = total_miles 116 | 117 | context["start_date"] = start_date 118 | context["end_date"] = end_date 119 | context["min_date"] = DEFAULT_START_DATE 120 | context["max_date"] = DEFAULT_END_DATE 121 | 122 | return context 123 | -------------------------------------------------------------------------------- /home/tests/integration/views/api/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from datetime import date, datetime, timedelta 4 | from freezegun import freeze_time 5 | from pytz import utc 6 | 7 | from django.contrib.auth.models import User 8 | from django.test import Client 9 | from django.utils import timezone 10 | 11 | from home.utils.generators import ( 12 | AccountGenerator, 13 | ContestGenerator, 14 | DailyWalkGenerator, 15 | DeviceGenerator, 16 | IntentionalWalkGenerator, 17 | ) 18 | 19 | 20 | class Login: 21 | username = "testadmin" 22 | password = "test*PW" 23 | 24 | def __init__(self): 25 | User.objects.create_user( 26 | username=self.username, password=self.password 27 | ) 28 | 29 | @classmethod 30 | def login(cls, client: Client): 31 | return client.login(username=cls.username, password=cls.password) 32 | 33 | 34 | def generate_test_data(): 35 | # Create user login 36 | Login() 37 | 38 | # Accounts generated for testing 39 | # account0: 94102 signup before current contest, not part of current contest 40 | # account1: 94102 signup before current contest, tester, dailywalks 5k steps/day, intentionalwalks 41 | # account2: 94103 signup before current contest, dailywalks 10k steps/day, intentionalwalks 42 | # account3: 94103 signup during current contest, dailywalks 15k steps/day 43 | # account4: 94104 signup during current contest, intentionalwalks 44 | # account5: 94104 signup during current contest, inactive 45 | 46 | # Generate 3 accounts before the current contest 47 | accounts = list(AccountGenerator().generate(3)) 48 | # Make second account a tester 49 | accounts[1].is_tester = True 50 | accounts[1].save() 51 | # Generate 3 accounts during the contest 52 | with freeze_time("3000-03-02 12:00:00"): 53 | accounts = accounts + list(AccountGenerator().generate(3)) 54 | # Set names for testing ordering, zip codes for grouping 55 | for i, account in enumerate(accounts): 56 | account.name = f"User {i}" 57 | account.zip = f"{94102 + math.floor(i / 2)}" 58 | account.save() 59 | 60 | # generate devices for the active accounts 61 | device1 = list(DeviceGenerator(accounts[1:2]).generate(1)) 62 | device2 = list(DeviceGenerator(accounts[2:3]).generate(1)) 63 | device3 = list(DeviceGenerator(accounts[3:4]).generate(1)) 64 | device4 = list(DeviceGenerator(accounts[4:5]).generate(1)) 65 | 66 | # Generate the "current" Contest we want to test with 67 | params = { 68 | "start_baseline": date(3000, 2, 28), 69 | "start_promo": date(3000, 3, 1), 70 | "start": date(3000, 3, 7), 71 | "end": date(3000, 3, 14), 72 | } 73 | contest0 = next(ContestGenerator().generate(1, **params)) 74 | 75 | # Add the last five accounts to this contest 76 | for account in accounts[1:6]: 77 | account.contests.add(contest0) 78 | 79 | # Generate daily walks (10 per device) 80 | dwalks1 = DailyWalkGenerator(device1) 81 | dwalks2 = DailyWalkGenerator(device2) 82 | dwalks3 = DailyWalkGenerator(device3) 83 | tz = timezone.get_default_timezone() 84 | for dt in range(14): 85 | # Set dates on walks to 3000-02-28 to 3000-03-14 86 | t = datetime(3000, 2, 28, 10, 0).astimezone(tz) + timedelta(days=dt) 87 | next(dwalks1.generate(1, date=t, steps=5000, distance=4000)) 88 | next(dwalks2.generate(1, date=t, steps=10000, distance=8000)) 89 | next(dwalks3.generate(1, date=t, steps=15000, distance=12000)) 90 | 91 | # Generate intentional walks (5, every other day) 92 | iwalks1 = IntentionalWalkGenerator(device1) 93 | iwalks2 = IntentionalWalkGenerator(device2) 94 | iwalks4 = IntentionalWalkGenerator(device4) 95 | for dt in range(5): 96 | # Set dates on walks to [2, 4, 6, 8, 10] (3000-03) 97 | t = utc.localize(datetime(3000, 3, 2, 10, 0)) + timedelta( 98 | days=(dt * 2) 99 | ) 100 | next( 101 | iwalks1.generate( 102 | 1, start=t, end=(t + timedelta(hours=2)), steps=1000 103 | ) 104 | ) 105 | next( 106 | iwalks2.generate( 107 | 1, start=t, end=(t + timedelta(hours=2)), steps=2000 108 | ) 109 | ) 110 | next( 111 | iwalks4.generate( 112 | 1, start=t, end=(t + timedelta(hours=2)), steps=3000 113 | ) 114 | ) 115 | 116 | # create an "old" Contest, make first acccount part of it 117 | params = { 118 | "start_baseline": date(2999, 2, 28), 119 | "start_promo": date(2999, 3, 1), 120 | "start": date(2999, 3, 7), 121 | "end": date(2999, 3, 14), 122 | } 123 | contest1 = next(ContestGenerator().generate(1, **params)) 124 | accounts[0].contests.add(contest1) 125 | 126 | # return the "current" contest id 127 | return str(contest0.pk) 128 | -------------------------------------------------------------------------------- /home/templates/home/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Intentional Walk - Admin console 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 81 | 82 | 83 | {% if user.is_authenticated %} 84 | {% block content %} 85 | {% endblock %} 86 | {% else %} 87 |
88 |
89 |

Please Login to view the page

90 |
91 |
92 | {% endif %} 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | {% block post_scripts %} 107 | {% endblock %} 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /home/views/web/intentionalwalk.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import itertools 3 | 4 | from django.db.models import Sum 5 | from django.views import generic 6 | 7 | from home.models import Account, DailyWalk, IntentionalWalk 8 | from home.templatetags.format_helpers import m_to_mi 9 | 10 | # Date range for data aggregation 11 | DEFAULT_START_DATE = datetime.date(2020, 4, 1) 12 | DEFAULT_END_DATE = datetime.datetime.today().date() 13 | 14 | 15 | class IntentionalWalkWebView(generic.ListView): 16 | template_name = "home/iw_list.html" 17 | model = IntentionalWalk 18 | 19 | # Augment context data to 20 | def get_context_data(self, **kwargs): 21 | # Call the base implementation first to get a context 22 | context = super().get_context_data(**kwargs) 23 | 24 | start_date_str = self.request.GET.get("start_date") 25 | start_date = ( 26 | datetime.datetime.strptime(start_date_str, "%Y-%m-%d").date() 27 | if start_date_str 28 | else DEFAULT_START_DATE 29 | ) 30 | end_date_str = self.request.GET.get("end_date") 31 | end_date = ( 32 | datetime.datetime.strptime(end_date_str, "%Y-%m-%d").date() 33 | if end_date_str 34 | else DEFAULT_END_DATE 35 | ) 36 | 37 | # Get aggregate stats for all users 38 | all_accounts = Account.objects.all().order_by("created") 39 | 40 | # Get recorded walks per day 41 | recorded_walks = IntentionalWalk.objects.all().values() 42 | recorded_walks_stats = {} 43 | for date, group in itertools.groupby( 44 | recorded_walks, key=lambda x: x["start"].date() 45 | ): 46 | recorded_walks_stats[date] = { 47 | "count": 0, 48 | "steps": 0, 49 | "time": 0, 50 | "miles": 0, 51 | } 52 | for obj in group: 53 | recorded_walks_stats[date]["count"] += 1 # Update count 54 | recorded_walks_stats[date]["steps"] += obj[ 55 | "steps" 56 | ] # Update count 57 | recorded_walks_stats[date]["time"] += ( 58 | obj["end"] - obj["start"] 59 | ).total_seconds() - obj["pause_time"] 60 | recorded_walks_stats[date]["miles"] += m_to_mi( 61 | obj["distance"] 62 | ) # Update count 63 | 64 | # Fill the gaps cos google charts if annoying af 65 | current_date = start_date 66 | delta = datetime.timedelta(days=1) 67 | context["daily_recorded_walks_stats"] = [] 68 | # Iterate over the entire date range 69 | while current_date <= end_date: 70 | context["daily_recorded_walks_stats"].append( 71 | [ 72 | current_date, 73 | recorded_walks_stats.get( 74 | current_date, 75 | {"count": 0, "steps": 0, "time": 0, "miles": 0}, 76 | ), 77 | ] 78 | ) 79 | current_date += delta 80 | context["cumu_recorded_walks_stats"] = [] 81 | total = {"count": 0, "steps": 0, "time": 0, "miles": 0} 82 | for date, stat_obj in context["daily_recorded_walks_stats"]: 83 | # NOTE: Counters represent 0 counts as an empty dict and wont 84 | # guarantee keys existence 85 | total["count"] += stat_obj["count"] 86 | total["steps"] += stat_obj["steps"] 87 | total["time"] += stat_obj["time"] 88 | total["miles"] += stat_obj["miles"] 89 | context["cumu_recorded_walks_stats"].append([date, dict(total)]) 90 | context["total_iw_stats"] = total 91 | context["total_iw_stats"]["time"] = int( 92 | context["total_iw_stats"]["time"] / 3600 93 | ) 94 | 95 | # Get IW users 96 | context["total_iw_users"] = ( 97 | IntentionalWalk.objects.values("account").distinct().count() 98 | ) 99 | context["total_signedup"] = len(all_accounts) 100 | context["percent_usage"] = ( 101 | (context["total_iw_users"] / context["total_signedup"]) * 100 102 | if context["total_signedup"] > 0 103 | else 0 104 | ) 105 | context["total_steps"] = DailyWalk.objects.all().aggregate( 106 | Sum("steps") 107 | )["steps__sum"] 108 | context["percent_steps"] = ( 109 | (context["total_iw_stats"]["steps"] / context["total_steps"]) * 100 110 | if context["total_steps"] > 0 111 | else 0 112 | ) 113 | context["total_distance"] = m_to_mi( 114 | DailyWalk.objects.all().aggregate(Sum("distance"))["distance__sum"] 115 | ) 116 | context["percent_distance"] = ( 117 | (context["total_iw_stats"]["miles"] / context["total_distance"]) 118 | * 100 119 | if context["total_distance"] > 0 120 | else 0 121 | ) 122 | 123 | context["start_date"] = start_date 124 | context["end_date"] = end_date 125 | context["min_date"] = DEFAULT_START_DATE 126 | context["max_date"] = DEFAULT_END_DATE 127 | 128 | return context 129 | -------------------------------------------------------------------------------- /static/home/home_plots.js: -------------------------------------------------------------------------------- 1 | // TODO: This objectively horrible, hacky, spaghetti code that must be refactored 2 | // Function to plot daily and cumulative user growths 3 | google.charts.load('current', {'packages':['bar', 'line', 'corechart']}); 4 | google.charts.setOnLoadCallback(plot); 5 | function plot() { 6 | 7 | // --------------------------------- 1. Bar plot - All Signups --------------------------------- 8 | var data = new google.visualization.DataTable(); 9 | data.addColumn('date', 'Date'); 10 | data.addColumn('number', 'Signups'); 11 | data.addRows(daily_user_signups) 12 | var options = { 13 | height: 400, 14 | legend: { position: 'none' }, 15 | bar: { groupWidth: '95%' }, 16 | vAxis: { 17 | title: "Daily signups", 18 | viewWindow: { min: 0 } 19 | }, 20 | colors: ["#E59866"] 21 | }; 22 | var chart = new google.charts.Bar(document.getElementById('daily_signups')); 23 | chart.draw(data, google.charts.Bar.convertOptions(options)); 24 | 25 | // --------------------------------- 2. Line Plot - All Signups --------------------------------- 26 | var data = new google.visualization.DataTable(); 27 | data.addColumn('date', 'Date'); 28 | data.addColumn('number', 'Signups'); 29 | data.addRows(cumu_user_signups) 30 | var options = { 31 | legend: { position: 'none' }, 32 | height: 400, 33 | vAxis: { 34 | title: "Total signups", 35 | viewWindow: { min: 0 } 36 | }, 37 | colors: ["#E59866"] 38 | }; 39 | var chart = new google.charts.Line(document.getElementById('total_signups')); 40 | chart.draw(data, google.charts.Line.convertOptions(options)); 41 | 42 | // --------------------------------- 3. Bar Plot - All Steps --------------------------------- 43 | var data = new google.visualization.DataTable(); 44 | data.addColumn('date', 'Date'); 45 | data.addColumn('number', 'Steps'); 46 | data.addRows(daily_step_count) 47 | var options = { 48 | height: 400, 49 | legend: { position: 'none' }, 50 | bar: { groupWidth: '95%' }, 51 | vAxis: { 52 | title: "Steps", 53 | viewWindow: { min: 0 } 54 | }, 55 | colors: ["#2ECC71"] 56 | }; 57 | var chart = new google.charts.Bar(document.getElementById('daily_steps')); 58 | chart.draw(data, google.charts.Bar.convertOptions(options)); 59 | 60 | // --------------------------------- 4. Line Plot - All Steps --------------------------------- 61 | var data = new google.visualization.DataTable(); 62 | data.addColumn('date', 'Date'); 63 | data.addColumn('number', 'Steps'); 64 | data.addRows(cumu_step_count) 65 | var options = { 66 | legend: { position: 'none' }, 67 | height: 400, 68 | vAxis: { 69 | title: "Steps", 70 | viewWindow: { min: 0 } 71 | }, 72 | colors: ["#2ECC71"] 73 | }; 74 | var chart = new google.charts.Line(document.getElementById('total_steps')); 75 | chart.draw(data, google.charts.Line.convertOptions(options)); 76 | 77 | // --------------------------------- 5. Bar Plot - All Miles --------------------------------- 78 | var data = new google.visualization.DataTable(); 79 | data.addColumn('date', 'Date'); 80 | data.addColumn('number', 'Miles'); 81 | data.addRows(daily_mile_count) 82 | var options = { 83 | height: 400, 84 | legend: { position: 'none' }, 85 | bar: { groupWidth: '95%' }, 86 | vAxis: { 87 | title: "Miles", 88 | viewWindow: { min: 0 } 89 | }, 90 | colors: ["#1ABC9C"] 91 | }; 92 | var chart = new google.charts.Bar(document.getElementById('daily_miles')); 93 | chart.draw(data, google.charts.Bar.convertOptions(options)); 94 | 95 | // --------------------------------- 6. Line Plot - All Miles --------------------------------- 96 | var data = new google.visualization.DataTable(); 97 | data.addColumn('date', 'Date'); 98 | data.addColumn('number', 'Miles'); 99 | data.addRows(cumu_mile_count) 100 | var options = { 101 | legend: { position: 'none' }, 102 | height: 400, 103 | vAxis: { 104 | title: "Miles", 105 | viewWindow: { min: 0 } 106 | }, 107 | colors: ["#1ABC9C"] 108 | }; 109 | var chart = new google.charts.Line(document.getElementById('total_miles')); 110 | chart.draw(data, google.charts.Line.convertOptions(options)); 111 | 112 | // --------------------------------- 7. Age histogram --------------------------------- 113 | var data = new google.visualization.DataTable(); 114 | data.addColumn('number', 'Age'); 115 | data.addRows(user_age_dist) 116 | var options = { 117 | legend: { position: 'none' }, 118 | height: 600, 119 | colors: ["#AF7AC5"], 120 | histogram: { bucketSize: 5 }, 121 | vAxis: { 122 | title: "Number of users", 123 | viewWindow: { min: 0 } 124 | }, 125 | hAxis: { 126 | title: "Age", 127 | }, 128 | }; 129 | 130 | var chart = new google.visualization.Histogram(document.getElementById('user_age_dist')); 131 | chart.draw(data, options); 132 | 133 | }; -------------------------------------------------------------------------------- /home/tests/integration/weeklygoal/test_get.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | from django.forms.models import model_to_dict 3 | 4 | from home.models import WeeklyGoal, Device 5 | 6 | 7 | class ApiTestCase(TestCase): 8 | def setUp(self): 9 | # Test client 10 | self.client = Client() 11 | 12 | self.email = "abhay@blah.com" 13 | self.account_id = "12345" 14 | 15 | # Create a user 16 | response = self.client.post( 17 | path="/api/appuser/create", 18 | data={ 19 | "name": "Abhay Kashyap", 20 | "email": self.email, 21 | "zip": "72185", 22 | "age": 99, 23 | "account_id": self.account_id, 24 | }, 25 | content_type="application/json", 26 | ) 27 | 28 | # Check for a successful response by the server 29 | self.assertEqual(response.status_code, 200) 30 | # Parse the response 31 | response_data = response.json() 32 | 33 | fail_message = f"Server response - {response_data}" 34 | self.assertEqual(response_data["status"], "success", msg=fail_message) 35 | self.assertEqual( 36 | response_data["message"], 37 | "Device registered & account registered successfully", 38 | msg=fail_message, 39 | ) 40 | device = Device.objects.get(device_id=self.account_id) 41 | self.account = device.account 42 | 43 | # Define weekly goals 44 | self.weekly_goals = [ 45 | WeeklyGoal( 46 | account=self.account, 47 | start_of_week="2023-08-21", 48 | steps=3, 49 | days=2000, 50 | ), 51 | WeeklyGoal( 52 | account=self.account, 53 | start_of_week="2023-08-27", 54 | steps=4, 55 | days=2500, 56 | ), 57 | WeeklyGoal( 58 | account=self.account, 59 | start_of_week="2023-09-04", 60 | steps=5, 61 | days=3000, 62 | ), 63 | ] 64 | 65 | # Create weekly goals 66 | response = WeeklyGoal.objects.bulk_create(self.weekly_goals) 67 | 68 | i = 0 69 | # Check for a successful response by the server 70 | for item in response: 71 | self.assertEqual(item, self.weekly_goals[i]) 72 | i += 1 73 | 74 | # Details for intentional walk list view 75 | self.url = "/api/weeklygoal/get" 76 | # Request parameters 77 | # Content type 78 | self.content_type = "application/json" 79 | self.request_params = {"account_id": self.account_id} 80 | 81 | # Test a successful request for weekly goals 82 | def test_weeklygoal_get_success(self): 83 | # Send the request 84 | response = self.client.post( 85 | path=self.url, 86 | data=self.request_params, 87 | content_type=self.content_type, 88 | ) 89 | # Check for a successful response by the server 90 | self.assertEqual(response.status_code, 200) 91 | # Parse the response 92 | response_data = response.json() 93 | fail_message = f"Server response - {response_data}" 94 | self.assertEqual(response_data["status"], "success", msg=fail_message) 95 | self.assertIn("payload", response_data, msg=fail_message) 96 | i = 2 97 | for goal in response_data["payload"]: 98 | goalDict = model_to_dict(self.weekly_goals[i]) 99 | goalDict["account_id"] = goalDict.get("account") 100 | del goalDict["account"] 101 | self.assertEqual(goal, goalDict) 102 | i -= 1 103 | 104 | # Test getting weekly goals from an account that doesn't exist 105 | def test_weeklygoal_get_failure_invalid_account(self): 106 | self.request_params["account_id"] = "0000000" 107 | 108 | # Send the request 109 | response = self.client.post( 110 | path=self.url, 111 | data=self.request_params, 112 | content_type=self.content_type, 113 | ) 114 | # Check for a successful response by the server 115 | self.assertEqual(response.status_code, 200) 116 | # Parse the response 117 | response_data = response.json() 118 | 119 | fail_message = f"Server response - {response_data}" 120 | self.assertEqual(response_data["status"], "error", msg=fail_message) 121 | self.assertEqual( 122 | response_data["message"], 123 | "Unregistered account - " 124 | f'{self.request_params["account_id"]}.' 125 | " Please register first!", 126 | msg=fail_message, 127 | ) 128 | 129 | # Test getting weekly goals with missing account id param 130 | def test_weeklygoal_get_failure_missing_account_id(self): 131 | del self.request_params["account_id"] 132 | 133 | # Send the request 134 | response = self.client.post( 135 | path=self.url, 136 | data=self.request_params, 137 | content_type=self.content_type, 138 | ) 139 | # Check for a successful response by the server 140 | self.assertEqual(response.status_code, 200) 141 | # Parse the response 142 | response_data = response.json() 143 | 144 | fail_message = f"Server response - {response_data}" 145 | self.assertEqual(response_data["status"], "error", msg=fail_message) 146 | self.assertEqual( 147 | response_data["message"], 148 | "Required input 'account_id' missing in the request", 149 | msg=fail_message, 150 | ) 151 | -------------------------------------------------------------------------------- /home/views/api/serializers/request_serializers.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains serializers that are used for parsing and validating request data. 3 | 4 | Each serializer in this module corresponds to a specific API endpoint. The serializer's 5 | `validate` method is responsible for validating the incoming request data and preparing 6 | it for further processing. 7 | """ 8 | 9 | from rest_framework import serializers 10 | from datetime import timedelta 11 | from home.models import Contest 12 | from django.db.models import ( 13 | BooleanField, 14 | Count, 15 | ExpressionWrapper, 16 | F, 17 | Q, 18 | Sum, 19 | ) 20 | 21 | 22 | class GetUsersReqSerializer(serializers.Serializer): 23 | contest_id = serializers.CharField( 24 | required=False, 25 | help_text="The ID of the contest to filter by." 26 | + "Providing this also will add additional metrics related to te contest.", 27 | ) 28 | # If true, will only return tester accounts. 29 | is_tester = serializers.BooleanField( 30 | required=False, help_text="If true, will only return tester accounts." 31 | ) 32 | # Choices are: age, contests, created, dailywalk, device, email, gender, gender_other, id, 33 | # intentionalwalk, is_latino, is_sf_resident, is_tester, iw_count, iw_distance, iw_steps, 34 | # iw_time, leaderboard, name, race, race_other, sexual_orien, sexual_orien_other, updated, 35 | # weeklygoal, zip. 36 | # TODO: Can move this to the choices field tuple. 37 | # which will allow some tools to auto-pick up. 38 | order_by = serializers.CharField( 39 | required=False, 40 | help_text="The field to order the results by. Prefix with '-' to order in descending order." 41 | + "The secondary sort and default sort will be lexicographically, the 'name'.", 42 | ) 43 | page = serializers.IntegerField( 44 | required=False, 45 | help_text="The page number to return. Defaults to 1.", 46 | default=1, 47 | ) 48 | query = serializers.CharField( 49 | required=False, 50 | help_text="Query string to filter for containment in the name or email.", 51 | ) 52 | 53 | def validate(self, data): 54 | """Validates and prepares the incoming request data. 55 | 56 | Converts the request params into FilterSet params and annotations. 57 | """ 58 | contest_id = data.get("contest_id") 59 | is_tester = data.get("is_tester") 60 | order_by = data.get("order_by") 61 | page = data.get("page") or 1 62 | per_page = 25 63 | query = data.get("query") 64 | 65 | # filter and annotate based on contest_id 66 | filters, annotate, intentionalwalk_filter = None, None, None 67 | if contest_id: 68 | contest = Contest.objects.get(pk=contest_id) 69 | dailywalk_filter = Q( 70 | dailywalk__date__range=(contest.start, contest.end) 71 | ) 72 | 73 | filters = Q(contests__contest_id=contest_id) 74 | annotate = { 75 | "is_new": ExpressionWrapper( 76 | Q( 77 | created__gte=contest.start_promo, 78 | created__lt=contest.end + timedelta(days=1), 79 | ), 80 | output_field=BooleanField(), 81 | ), 82 | "dw_count": Count("dailywalk", filter=dailywalk_filter), 83 | "dw_steps": Sum("dailywalk__steps", filter=dailywalk_filter), 84 | "dw_distance": Sum( 85 | "dailywalk__distance", filter=dailywalk_filter 86 | ), 87 | } 88 | intentionalwalk_filter = Q( 89 | intentionalwalk__start__gte=contest.start, 90 | intentionalwalk__start__lt=contest.end + timedelta(days=1), 91 | ) 92 | else: 93 | filters = Q() 94 | annotate = { 95 | "dw_count": Count("dailywalk"), 96 | "dw_steps": Sum("dailywalk__steps"), 97 | "dw_distance": Sum("dailywalk__distance"), 98 | } 99 | intentionalwalk_filter = Q() 100 | 101 | intentionalwalk_annotate = { 102 | "iw_count": Count( 103 | "intentionalwalk", filter=intentionalwalk_filter 104 | ), 105 | "iw_steps": Sum( 106 | "intentionalwalk__steps", filter=intentionalwalk_filter 107 | ), 108 | "iw_distance": Sum( 109 | "intentionalwalk__distance", filter=intentionalwalk_filter 110 | ), 111 | "iw_time": Sum( 112 | "intentionalwalk__walk_time", filter=intentionalwalk_filter 113 | ), 114 | } 115 | 116 | # filter to show users vs testers 117 | filters &= Q(is_tester=is_tester) 118 | 119 | # filter by search query 120 | if query: 121 | filters &= Q(Q(name__icontains=query) | Q(email__icontains=query)) 122 | 123 | # set ordering 124 | order = [] 125 | if order_by: 126 | desc = order_by.startswith("-") 127 | field = F(order_by[1:] if desc else order_by) 128 | order.append( 129 | field.desc(nulls_last=True) 130 | if desc 131 | else field.asc(nulls_first=None) 132 | ) 133 | order.append(F("name")) 134 | 135 | return { 136 | "annotate": annotate, 137 | "intentionalwalk_annotate": intentionalwalk_annotate, 138 | "contest_id": contest_id, 139 | "filters": filters, 140 | "order_by": order, 141 | "page": page, 142 | "per_page": per_page, 143 | } 144 | -------------------------------------------------------------------------------- /home/urls.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.urls import path 4 | from django.views.generic import TemplateView 5 | 6 | from . import views 7 | 8 | PRODUCTION = os.getenv("DEPLOY_ENV") == "production" 9 | 10 | app_name = "home" 11 | 12 | urlpatterns = [] 13 | if PRODUCTION: 14 | # serve the React SPA index.html as a catch-all 15 | urlpatterns = [ 16 | path("users/", TemplateView.as_view(template_name="index.html")), 17 | path("login/", TemplateView.as_view(template_name="index.html")), 18 | ] 19 | else: 20 | # mount old views for comparison testing until fully deprecated and removed 21 | urlpatterns = [ 22 | path("", views.HomeView.as_view(), name="home_view"), 23 | path("users/", views.UserListView.as_view(), name="user_list_view"), 24 | path( 25 | "intentionalwalks/", 26 | views.IntentionalWalkWebView.as_view(), 27 | name="int_walk_list_view", 28 | ), 29 | path( 30 | "data/users_agg.csv", 31 | views.user_agg_csv_view, 32 | name="users_agg_csv_view", 33 | ), 34 | path("data/users.csv", views.users_csv_view, name="users_csv_view"), 35 | path( 36 | "data/daily_walks.csv", 37 | views.daily_walks_csv_view, 38 | name="dailywalks_csv_view", 39 | ), 40 | path( 41 | "data/intentional_walks.csv", 42 | views.intentional_walks_csv_view, 43 | name="intentionalwalks_csv_view", 44 | ), 45 | ] 46 | 47 | urlpatterns.extend( 48 | [ 49 | path("api/admin/me", views.AdminMeView.as_view(), name="admin_me"), 50 | path( 51 | "api/admin/home", views.AdminHomeView.as_view(), name="admin_home" 52 | ), 53 | path( 54 | "api/admin/home/users/daily", 55 | views.AdminHomeUsersDailyView.as_view(), 56 | name="admin_home_users_daily", 57 | ), 58 | path( 59 | "api/admin/home/users/cumulative", 60 | views.AdminHomeUsersCumulativeView.as_view(), 61 | name="admin_home_users_cumulative", 62 | ), 63 | path( 64 | "api/admin/home/steps/daily", 65 | views.AdminHomeStepsDailyView.as_view(), 66 | name="admin_home_steps_daily", 67 | ), 68 | path( 69 | "api/admin/home/steps/cumulative", 70 | views.AdminHomeStepsCumulativeView.as_view(), 71 | name="admin_home_steps_cumulative", 72 | ), 73 | path( 74 | "api/admin/home/distance/daily", 75 | views.AdminHomeDistanceDailyView.as_view(), 76 | name="admin_home_distance_daily", 77 | ), 78 | path( 79 | "api/admin/home/distance/cumulative", 80 | views.AdminHomeDistanceCumulativeView.as_view(), 81 | name="admin_home_distance_cumulative", 82 | ), 83 | path( 84 | "api/admin/contests", 85 | views.AdminContestsView.as_view(), 86 | name="admin_contests", 87 | ), 88 | path( 89 | "api/admin/users", 90 | views.AdminUsersView.as_view(), 91 | name="admin_users", 92 | ), 93 | path( 94 | "api/admin/users/zip", 95 | views.AdminUsersByZipView.as_view(), 96 | name="admin_users_zip", 97 | ), 98 | path( 99 | "api/admin/users/zip/active", 100 | views.AdminUsersActiveByZipView.as_view(), 101 | name="admin_users_zip_active", 102 | ), 103 | path( 104 | "api/admin/users/zip/steps", 105 | views.AdminUsersByZipMedianStepsView.as_view(), 106 | name="admin_users_zip_steps", 107 | ), 108 | path( 109 | "api/admin//histogram", 110 | views.AdminHistogramView.as_view(), 111 | name="export_users", 112 | ), 113 | path( 114 | "api/appuser/create", 115 | views.AppUserCreateView.as_view(), 116 | name="appuser_create", 117 | ), 118 | path( 119 | "api/appuser/delete", 120 | views.AppUserDeleteView.as_view(), 121 | name="appuser_delete", 122 | ), 123 | path( 124 | "api/dailywalk/create", 125 | views.DailyWalkCreateView.as_view(), 126 | name="dailywalk_create", 127 | ), 128 | path( 129 | "api/dailywalk/get", 130 | views.DailyWalkListView.as_view(), 131 | name="dailywalk_get", 132 | ), 133 | path( 134 | "api/export/users", 135 | views.ExportUsersView.as_view(), 136 | name="export_users", 137 | ), 138 | path( 139 | "api/intentionalwalk/create", 140 | views.IntentionalWalkView.as_view(), 141 | name="intentionalwalk_create", 142 | ), 143 | path( 144 | "api/intentionalwalk/get", 145 | views.IntentionalWalkListView.as_view(), 146 | name="intentionalwalk_get", 147 | ), 148 | path( 149 | "api/contest/current", 150 | views.ContestCurrentView.as_view(), 151 | name="contest_current", 152 | ), 153 | path( 154 | "api/leaderboard/get/", 155 | views.LeaderboardListView.as_view(), 156 | name="leaderboard_get", 157 | ), 158 | path( 159 | "api/weeklygoal/create", 160 | views.WeeklyGoalCreateView.as_view(), 161 | name="weeklygoal_create", 162 | ), 163 | path( 164 | "api/weeklygoal/get", 165 | views.WeeklyGoalsListView.as_view(), 166 | name="weeklygoal_get", 167 | ), 168 | ] 169 | ) 170 | -------------------------------------------------------------------------------- /server/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for server project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | import dj_database_url 17 | from dotenv import find_dotenv, load_dotenv 18 | 19 | import sentry_sdk 20 | 21 | load_dotenv(find_dotenv()) 22 | 23 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 24 | BASE_DIR = Path(__file__).resolve().parent.parent 25 | PROJECT_ROOT = BASE_DIR 26 | 27 | # Initialize Sentry SDK if DSN is set 28 | SENTRY_DSN = os.getenv("SENTRY_DSN") 29 | if SENTRY_DSN: 30 | sentry_sdk.init( 31 | # SECURITY WARNING: keep the Sentry DSN secret! 32 | dsn=SENTRY_DSN, 33 | # Set traces_sample_rate to 1.0 to capture 100% 34 | # of transactions for tracing. 35 | traces_sample_rate=1.0, 36 | # Set profiles_sample_rate to 1.0 to profile 100% 37 | # of sampled transactions. 38 | # We recommend adjusting this value in production. 39 | profiles_sample_rate=1.0, 40 | ) 41 | 42 | # Quick-start development settings - unsuitable for production 43 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 44 | 45 | # SECURITY WARNING: keep the secret key used in production secret! 46 | SECRET_KEY = os.getenv("SECRET_KEY") 47 | 48 | # SECURITY WARNING: don't run with debug turned on in production! 49 | PRODUCTION = os.getenv("DEPLOY_ENV") == "production" 50 | DEBUG = bool(os.getenv("DEBUG", not PRODUCTION)) 51 | 52 | # Static files configuration 53 | STATIC_URL = "static/" 54 | STATICFILES_DIRS = [PROJECT_ROOT / "static"] 55 | STATIC_ROOT = PROJECT_ROOT / "staticfiles" 56 | 57 | if PRODUCTION: 58 | WHITENOISE_INDEX_FILE = True 59 | WHITENOISE_ROOT = BASE_DIR / "client" / "dist" 60 | STATICFILES_STORAGE = ( 61 | "whitenoise.storage.CompressedManifestStaticFilesStorage" 62 | ) 63 | 64 | # Host configuration 65 | ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost").split(",") 66 | 67 | CSRF_TRUSTED_ORIGINS = os.getenv( 68 | "CSRF_TRUSTED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000" 69 | ).split(",") 70 | 71 | # Application definition 72 | 73 | INSTALLED_APPS = [ 74 | "django.contrib.admin", 75 | "django.contrib.auth", 76 | "django.contrib.contenttypes", 77 | "django.contrib.sessions", 78 | "django.contrib.messages", 79 | "django.contrib.admindocs", 80 | "django.contrib.humanize", 81 | "home.apps.HomeConfig", 82 | ] 83 | 84 | if PRODUCTION: 85 | INSTALLED_APPS.append("whitenoise.runserver_nostatic") 86 | INSTALLED_APPS.append("django.contrib.staticfiles") 87 | 88 | MIDDLEWARE = [ 89 | "django.middleware.security.SecurityMiddleware", 90 | "django.contrib.sessions.middleware.SessionMiddleware", 91 | ] 92 | 93 | if PRODUCTION: 94 | MIDDLEWARE.append("whitenoise.middleware.WhiteNoiseMiddleware") 95 | 96 | MIDDLEWARE += [ 97 | "django.middleware.common.CommonMiddleware", 98 | "django.middleware.csrf.CsrfViewMiddleware", 99 | "django.contrib.auth.middleware.AuthenticationMiddleware", 100 | "django.contrib.messages.middleware.MessageMiddleware", 101 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 102 | ] 103 | 104 | ROOT_URLCONF = "server.urls" 105 | 106 | TEMPLATES = [ 107 | { 108 | "BACKEND": "django.template.backends.django.DjangoTemplates", 109 | "DIRS": [BASE_DIR / "client" / "dist"], 110 | "APP_DIRS": True, 111 | "OPTIONS": { 112 | "context_processors": [ 113 | "django.template.context_processors.debug", 114 | "django.template.context_processors.request", 115 | "django.contrib.auth.context_processors.auth", 116 | "django.contrib.messages.context_processors.messages", 117 | ], 118 | }, 119 | }, 120 | ] 121 | 122 | WSGI_APPLICATION = "server.wsgi.application" 123 | 124 | 125 | # Database 126 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 127 | 128 | DATABASES = {"default": {**dj_database_url.config(), "ATOMIC_REQUESTS": True}} 129 | 130 | 131 | # Password validation 132 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 133 | 134 | AUTH_PASSWORD_VALIDATORS = [ 135 | { 136 | "NAME": ( 137 | "django.contrib.auth.password_validation" 138 | ".UserAttributeSimilarityValidator" 139 | ), 140 | }, 141 | { 142 | "NAME": ( 143 | "django.contrib.auth.password_validation.MinimumLengthValidator" 144 | ), 145 | }, 146 | { 147 | "NAME": ( 148 | "django.contrib.auth.password_validation.CommonPasswordValidator" 149 | ), 150 | }, 151 | { 152 | "NAME": ( 153 | "django.contrib.auth.password_validation.NumericPasswordValidator" 154 | ), 155 | }, 156 | ] 157 | 158 | 159 | # Internationalization 160 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 161 | 162 | LANGUAGE_CODE = "en-us" 163 | 164 | TIME_ZONE = os.getenv("TIME_ZONE", "UTC") 165 | 166 | USE_I18N = True 167 | 168 | USE_L10N = True 169 | 170 | USE_TZ = True 171 | 172 | 173 | # Static files (CSS, JavaScript, Images) 174 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 175 | 176 | LOGIN_REDIRECT_URL = "/" 177 | LOGOUT_REDIRECT_URL = "/" 178 | 179 | # Logging 180 | LOGGING = { 181 | "version": 1, 182 | "disable_existing_loggers": False, 183 | "handlers": { 184 | "console": { 185 | "class": "logging.StreamHandler", 186 | }, 187 | }, 188 | "root": { 189 | "handlers": ["console"], 190 | "level": "INFO", 191 | }, 192 | "loggers": { 193 | "django": { 194 | "handlers": ["console"], 195 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 196 | "propagate": False, 197 | }, 198 | }, 199 | } 200 | 201 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 202 | -------------------------------------------------------------------------------- /home/tests/integration/intentionalwalk/test_create.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | 3 | 4 | class ApiTestCase(TestCase): 5 | def setUp(self): 6 | # Test client 7 | self.client = Client() 8 | # Create a user 9 | response = self.client.post( 10 | path="/api/appuser/create", 11 | data={ 12 | "name": "Abhay Kashyap", 13 | "email": "abhay@blah.com", 14 | "zip": "72185", 15 | "age": 99, 16 | "account_id": "12345", 17 | }, 18 | content_type="application/json", 19 | ) 20 | 21 | # Check for a successful response by the server 22 | self.assertEqual(response.status_code, 200) 23 | # Parse the response 24 | response_data = response.json() 25 | fail_message = f"Server response - {response_data}" 26 | self.assertEqual(response_data["status"], "success", msg=fail_message) 27 | self.assertEqual( 28 | response_data["message"], 29 | "Device registered & account registered successfully", 30 | msg=fail_message, 31 | ) 32 | 33 | # Details for Daily walk even creation 34 | self.url = "/api/intentionalwalk/create" 35 | # Request parameters 36 | self.request_params = { 37 | "account_id": "12345", 38 | "intentional_walks": [ 39 | { 40 | "event_id": "8888", 41 | "start": "2020-02-21T12:15:00-05:00", 42 | "end": "2020-02-21T12:45:00-05:00", 43 | "steps": 500, 44 | "distance": 1.3, 45 | "pause_time": 456, 46 | } 47 | ], 48 | } 49 | # Content type 50 | self.content_type = "application/json" 51 | 52 | # Test a successful creation of a intentional walk 53 | def test_create_intentionalwalk(self): 54 | 55 | # Send the request 56 | response = self.client.post( 57 | path=self.url, 58 | data=self.request_params, 59 | content_type=self.content_type, 60 | ) 61 | # Check for a successful response by the server 62 | self.assertEqual(response.status_code, 200) 63 | # Parse the response 64 | response_data = response.json() 65 | fail_message = f"Server response - {response_data}" 66 | self.assertEqual(response_data["status"], "success", msg=fail_message) 67 | self.assertEqual( 68 | response_data["message"], 69 | "Intentional Walks recorded successfully", 70 | msg=fail_message, 71 | ) 72 | self.assertEqual( 73 | response_data["payload"]["account_id"], 74 | self.request_params["account_id"], 75 | msg=fail_message, 76 | ) 77 | self.assertEqual( 78 | response_data["payload"]["intentional_walks"][0]["event_id"], 79 | self.request_params["intentional_walks"][0]["event_id"], 80 | msg=fail_message, 81 | ) 82 | self.assertEqual( 83 | response_data["payload"]["intentional_walks"][0]["start"], 84 | self.request_params["intentional_walks"][0]["start"], 85 | msg=fail_message, 86 | ) 87 | self.assertEqual( 88 | response_data["payload"]["intentional_walks"][0]["end"], 89 | self.request_params["intentional_walks"][0]["end"], 90 | msg=fail_message, 91 | ) 92 | self.assertEqual( 93 | response_data["payload"]["intentional_walks"][0]["steps"], 94 | self.request_params["intentional_walks"][0]["steps"], 95 | msg=fail_message, 96 | ) 97 | self.assertEqual( 98 | response_data["payload"]["intentional_walks"][0]["pause_time"], 99 | self.request_params["intentional_walks"][0]["pause_time"], 100 | msg=fail_message, 101 | ) 102 | self.assertEqual( 103 | response_data["payload"]["intentional_walks"][0]["distance"], 104 | self.request_params["intentional_walks"][0]["distance"], 105 | msg=fail_message, 106 | ) 107 | 108 | # Test creation of a intentional walk with an invalid user account 109 | def test_create_intentionalwalk_invalidaccount(self): 110 | 111 | self.request_params["account_id"] = "0000000" 112 | 113 | # Send the request 114 | response = self.client.post( 115 | path=self.url, 116 | data=self.request_params, 117 | content_type=self.content_type, 118 | ) 119 | # Check for a successful response by the server 120 | self.assertEqual(response.status_code, 200) 121 | # Parse the response 122 | response_data = response.json() 123 | fail_message = f"Server response - {response_data}" 124 | self.assertEqual(response_data["status"], "error", msg=fail_message) 125 | self.assertEqual( 126 | response_data["message"], 127 | "Unregistered device - " 128 | f'{self.request_params["account_id"]}.' 129 | " Please register first!", 130 | msg=fail_message, 131 | ) 132 | 133 | # Test creation of a intentional walk with a missing field 134 | def test_create_intentionalwalk_missing_steps(self): 135 | 136 | del self.request_params["intentional_walks"][0]["steps"] 137 | 138 | # Send the request 139 | response = self.client.post( 140 | path=self.url, 141 | data=self.request_params, 142 | content_type=self.content_type, 143 | ) 144 | # Check for a successful response by the server 145 | self.assertEqual(response.status_code, 200) 146 | # Parse the response 147 | response_data = response.json() 148 | fail_message = f"Server response - {response_data}" 149 | self.assertEqual(response_data["status"], "error", msg=fail_message) 150 | self.assertEqual( 151 | response_data["message"], 152 | "Required input 'steps' missing in the request", 153 | msg=fail_message, 154 | ) 155 | -------------------------------------------------------------------------------- /static/home/iw_plots.js: -------------------------------------------------------------------------------- 1 | // TODO: This objectively horrible, hacky, spaghetti code that must be refactored 2 | // Function to plot daily and cumulative user growths 3 | google.charts.load('current', {'packages':['bar', 'line']}); 4 | google.charts.setOnLoadCallback(plot); 5 | function plot() { 6 | 7 | // --------------------------------- 1. Bar Plot - Recorded walks count --------------------------------- 8 | var data = new google.visualization.DataTable(); 9 | data.addColumn('date', 'Date'); 10 | data.addColumn('number', 'Recorded Walks'); 11 | data.addRows(daily_recorded_walk_count) 12 | var options = { 13 | height: 400, 14 | legend: { position: 'none' }, 15 | bar: { groupWidth: '95%' }, 16 | vAxis: { 17 | title: "Recorded Walks", 18 | viewWindow: { min: 0 } 19 | }, 20 | colors: ["#E59866"] 21 | }; 22 | var chart = new google.charts.Bar(document.getElementById('daily_rw_count')); 23 | chart.draw(data, google.charts.Bar.convertOptions(options)); 24 | 25 | // --------------------------------- 2. Line Plot - Recorded walks count --------------------------------- 26 | console.log(cumu_recorded_walk_count) 27 | var data = new google.visualization.DataTable(); 28 | data.addColumn('date', 'Date'); 29 | data.addColumn('number', 'Recorded Walks'); 30 | data.addRows(cumu_recorded_walk_count) 31 | var options = { 32 | legend: { position: 'none' }, 33 | height: 400, 34 | vAxis: { 35 | title: "Recorded Walks", 36 | viewWindow: { min: 0 } 37 | }, 38 | colors: ["#E59866"] 39 | }; 40 | var chart = new google.charts.Line(document.getElementById('total_rw_count')); 41 | chart.draw(data, google.charts.Line.convertOptions(options)); 42 | 43 | 44 | // --------------------------------- 3. Bar Plot - Recorded walks Steps --------------------------------- 45 | var data = new google.visualization.DataTable(); 46 | data.addColumn('date', 'Date'); 47 | data.addColumn('number', 'Steps'); 48 | data.addRows(daily_recorded_walk_steps) 49 | var options = { 50 | height: 400, 51 | legend: { position: 'none' }, 52 | bar: { groupWidth: '95%' }, 53 | vAxis: { 54 | title: "Steps", 55 | viewWindow: { min: 0 } 56 | }, 57 | colors: ["#2ECC71"] 58 | }; 59 | var chart = new google.charts.Bar(document.getElementById('daily_rw_steps')); 60 | chart.draw(data, google.charts.Bar.convertOptions(options)); 61 | 62 | // --------------------------------- 4. Line Plot - Recorded walks Steps --------------------------------- 63 | var data = new google.visualization.DataTable(); 64 | data.addColumn('date', 'Date'); 65 | data.addColumn('number', 'Steps'); 66 | data.addRows(cumu_recorded_walk_steps) 67 | var options = { 68 | legend: { position: 'none' }, 69 | height: 400, 70 | vAxis: { 71 | title: "Steps", 72 | viewWindow: { min: 0 } 73 | }, 74 | colors: ["#2ECC71"] 75 | }; 76 | var chart = new google.charts.Line(document.getElementById('total_rw_steps')); 77 | chart.draw(data, google.charts.Line.convertOptions(options)); 78 | 79 | 80 | // --------------------------------- 5. Bar Plot - Recorded walks Miles --------------------------------- 81 | var data = new google.visualization.DataTable(); 82 | data.addColumn('date', 'Date'); 83 | data.addColumn('number', 'Miles'); 84 | data.addRows(daily_recorded_walk_miles) 85 | var options = { 86 | height: 400, 87 | legend: { position: 'none' }, 88 | bar: { groupWidth: '95%' }, 89 | vAxis: { 90 | title: "Steps", 91 | viewWindow: { min: 0 } 92 | }, 93 | colors: ["#1ABC9C"] 94 | }; 95 | var chart = new google.charts.Bar(document.getElementById('daily_rw_miles')); 96 | chart.draw(data, google.charts.Bar.convertOptions(options)); 97 | 98 | // --------------------------------- 6. Line Plot - Recorded walks Miles --------------------------------- 99 | var data = new google.visualization.DataTable(); 100 | data.addColumn('date', 'Date'); 101 | data.addColumn('number', 'Miles'); 102 | data.addRows(cumu_recorded_walk_miles) 103 | var options = { 104 | legend: { position: 'none' }, 105 | height: 400, 106 | vAxis: { 107 | title: "Steps", 108 | viewWindow: { min: 0 } 109 | }, 110 | colors: ["#1ABC9C"] 111 | }; 112 | var chart = new google.charts.Line(document.getElementById('total_rw_miles')); 113 | chart.draw(data, google.charts.Line.convertOptions(options)); 114 | 115 | 116 | // --------------------------------- 7. Bar Plot - Recorded walks Time --------------------------------- 117 | var data = new google.visualization.DataTable(); 118 | data.addColumn('date', 'Date'); 119 | data.addColumn('number', 'Minutes'); 120 | data.addRows(daily_recorded_walk_time) 121 | var options = { 122 | height: 400, 123 | legend: { position: 'none' }, 124 | bar: { groupWidth: '95%' }, 125 | vAxis: { 126 | title: "Minutes", 127 | viewWindow: { min: 0 } 128 | }, 129 | colors: ["#5DADE2"] 130 | }; 131 | var chart = new google.charts.Bar(document.getElementById('daily_rw_time')); 132 | chart.draw(data, google.charts.Bar.convertOptions(options)); 133 | 134 | // --------------------------------- 8. Line Plot - Recorded walks Steps --------------------------------- 135 | var data = new google.visualization.DataTable(); 136 | data.addColumn('date', 'Date'); 137 | data.addColumn('number', 'Minutes'); 138 | data.addRows(cumu_recorded_walk_time) 139 | var options = { 140 | legend: { position: 'none' }, 141 | height: 400, 142 | vAxis: { 143 | title: "Minutes", 144 | viewWindow: { min: 0 } 145 | }, 146 | colors: ["#5DADE2"] 147 | }; 148 | var chart = new google.charts.Line(document.getElementById('total_rw_time')); 149 | chart.draw(data, google.charts.Line.convertOptions(options)); 150 | 151 | }; 152 | 153 | 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /home/views/api/weeklygoal.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.http import JsonResponse 7 | from django.utils.decorators import method_decorator 8 | from django.views import View 9 | from django.views.decorators.csrf import csrf_exempt 10 | 11 | from home.models import WeeklyGoal, Device 12 | from home.utils.dates import get_start_of_week, DATE_FORMAT 13 | 14 | 15 | from .utils import validate_request_json 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | # Exempt from csrf validation 21 | @method_decorator(csrf_exempt, name="dispatch") 22 | class WeeklyGoalCreateView(View): 23 | """View to create or update a weeklygoal for an account""" 24 | 25 | model = WeeklyGoal 26 | http_method_names = ["post"] 27 | 28 | def post(self, request): 29 | json_data = json.loads(request.body) 30 | 31 | # Validate json. If any field is missing, send back the response message 32 | json_status = validate_request_json( 33 | json_data, 34 | required_fields=["account_id", "weekly_goal"], 35 | ) 36 | if "status" in json_status and json_status["status"] == "error": 37 | return JsonResponse(json_status) 38 | 39 | # Validate weekly_goal json fields 40 | json_status = validate_request_json( 41 | json_data["weekly_goal"], 42 | required_fields=["start_of_week", "steps", "days"], 43 | ) 44 | 45 | if "status" in json_status and json_status["status"] == "error": 46 | return JsonResponse(json_status) 47 | 48 | # Get the device 49 | try: 50 | device = Device.objects.get(device_id=json_data["account_id"]) 51 | account = device.account 52 | except ObjectDoesNotExist: 53 | return JsonResponse( 54 | { 55 | "status": "error", 56 | "message": ( 57 | "Unregistered device - " 58 | f"{json_data['account_id']}." 59 | " Please register first!" 60 | ), 61 | } 62 | ) 63 | 64 | # Json response template 65 | json_response = { 66 | "status": "success", 67 | "message": "WeeklyGoal saved successfully", 68 | "payload": { 69 | "account_id": account.id, 70 | "weekly_goal": {}, 71 | }, 72 | } 73 | 74 | weekly_goal_update = json_data["weekly_goal"] 75 | 76 | start_of_week = weekly_goal_update["start_of_week"] 77 | start_of_week_update = get_start_of_week( 78 | datetime.strptime(start_of_week, DATE_FORMAT).date() 79 | ) 80 | steps_update = weekly_goal_update["steps"] 81 | days_update = weekly_goal_update["days"] 82 | 83 | # Check if there's already a goal for the week. If there is, 84 | # update the entry. 85 | try: 86 | weekly_goal = WeeklyGoal.objects.get( 87 | account=account, 88 | start_of_week=start_of_week_update, 89 | ) 90 | weekly_goal.steps = steps_update 91 | weekly_goal.days = days_update 92 | weekly_goal.save() 93 | except ObjectDoesNotExist: 94 | # Creation if object is missing 95 | weekly_goal = WeeklyGoal.objects.create( 96 | start_of_week=start_of_week_update, 97 | steps=steps_update, 98 | days=days_update, 99 | account=account, 100 | ) 101 | 102 | # Update the json object 103 | json_response["payload"]["weekly_goal"] = { 104 | "start_of_week": weekly_goal.start_of_week, 105 | "steps": weekly_goal.steps, 106 | "days": weekly_goal.days, 107 | } 108 | 109 | return JsonResponse(json_response) 110 | 111 | def http_method_not_allowed(self, request): 112 | return JsonResponse( 113 | {"status": "error", "message": "Method not allowed!"} 114 | ) 115 | 116 | 117 | @method_decorator(csrf_exempt, name="dispatch") 118 | class WeeklyGoalsListView(View): 119 | """View to retrieve Weekly Goals""" 120 | 121 | model = WeeklyGoal 122 | http_method_name = ["post"] 123 | 124 | def post(self, request): 125 | json_data = json.loads(request.body) 126 | 127 | # Validate json. If any field is missing, send back the response message 128 | json_status = validate_request_json( 129 | json_data, required_fields=["account_id"] 130 | ) 131 | 132 | if "status" in json_status and json_status["status"] == "error": 133 | return JsonResponse(json_status) 134 | 135 | # Get the account 136 | try: 137 | device = Device.objects.get(device_id=json_data["account_id"]) 138 | account = device.account 139 | except ObjectDoesNotExist: 140 | return JsonResponse( 141 | { 142 | "status": "error", 143 | "message": ( 144 | "Unregistered account - " 145 | f"{json_data['account_id']}." 146 | " Please register first!" 147 | ), 148 | } 149 | ) 150 | 151 | # Json response template 152 | json_response = { 153 | "status": "success", 154 | "message": "Weekly goals accessed successfully", 155 | "payload": {}, 156 | } 157 | 158 | # Get weekly goals tied to this account 159 | weekly_goals = list( 160 | WeeklyGoal.objects.filter(account=account).values() 161 | ) 162 | """ for goal in weekly_goals: 163 | goal = model_to_dict(goal) """ 164 | for goal in weekly_goals: 165 | goal["start_of_week"] = goal["start_of_week"].strftime(DATE_FORMAT) 166 | 167 | json_response["payload"] = weekly_goals 168 | 169 | return JsonResponse(json_response) 170 | 171 | def http_method_not_allowed(self, request): 172 | return JsonResponse( 173 | {"status": "error", "message": "Method not allowed!"} 174 | ) 175 | -------------------------------------------------------------------------------- /home/tests/integration/appuser/test_delete.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist 2 | from django.test import Client, TestCase 3 | 4 | 5 | class ApiTestCase(TestCase): 6 | def setUp(self): 7 | # Test client 8 | self.client = Client() 9 | 10 | # Urls for creation and deletion 11 | self.create_url = "/api/appuser/create" 12 | self.del_url = "/api/appuser/delete" 13 | self.content_type = "application/json" 14 | 15 | # Constants 16 | self.account_id1 = "12345" 17 | self.account_id2 = "23456" 18 | self.email1 = "john@blah.com" 19 | self.email2 = "joe@blah.com" 20 | 21 | # Request parameters 22 | base_params = { 23 | "name": "John Doe", 24 | "zip": "72185", 25 | "age": 99, 26 | } 27 | self.request_params1 = base_params.copy() 28 | self.request_params2 = base_params.copy() 29 | self.request_params1.update( 30 | { 31 | "name": "John Doe", 32 | "email": self.email1, 33 | "account_id": self.account_id1, 34 | } 35 | ) 36 | self.request_params2.update( 37 | { 38 | "name": "Joe Doe", 39 | "email": self.email2, 40 | "account_id": self.account_id2, 41 | } 42 | ) 43 | 44 | # Register the users 45 | self.create_user_and_confirm_response(self.request_params1) 46 | self.create_user_and_confirm_response(self.request_params2) 47 | 48 | self.existing_user_accts = [self.account_id1, self.account_id2] 49 | 50 | def tearDown(self) -> None: 51 | for acct_id in self.existing_user_accts: 52 | response = self.client.delete( 53 | path=self.del_url, 54 | data={"account_id": acct_id}, 55 | content_type=self.content_type, 56 | ) 57 | self.check_delete_success(response, self.account_id1) 58 | 59 | def create_user_and_confirm_response(self, request_params): 60 | # Create the user 61 | response = self.client.post( 62 | path=self.create_url, 63 | data=request_params, 64 | content_type=self.content_type, 65 | ) 66 | 67 | # Check for a successful response by the server 68 | self.assertEqual(response.status_code, 200) 69 | # Parse the response 70 | response_data = response.json() 71 | fail_message = f"Server response - {response_data}" 72 | self.assertEqual(response_data["status"], "success", msg=fail_message) 73 | 74 | def check_delete_success(self, response, deleted_account_id): 75 | # Check for a successful delete response by the server 76 | self.assertEqual(response.status_code, 200) 77 | # Parse the response 78 | response_data = response.json() 79 | fail_message = f"Server response - {response_data}" 80 | self.assertEqual(response_data["status"], "success", msg=fail_message) 81 | self.assertEqual( 82 | response_data["message"], 83 | "Account deleted successfully", 84 | msg=fail_message, 85 | ) 86 | 87 | # Check user/device no longer exists by trying & failing to 88 | # update the nonexistent user 89 | with self.assertRaises(ObjectDoesNotExist): 90 | self.client.put( 91 | path=self.create_url, 92 | data={"account_id": deleted_account_id}, 93 | content_type=self.content_type, 94 | ) 95 | 96 | def check_users_still_exist(self, valid_account_ids=[]): 97 | # Check other users still exist 98 | for acct_id in valid_account_ids: 99 | expected_success_update_response = self.client.put( 100 | path=self.create_url, 101 | data={"account_id": acct_id}, 102 | content_type=self.content_type, 103 | ) 104 | # Check for a successful response by the server 105 | self.assertEqual(expected_success_update_response.status_code, 200) 106 | # Parse the response 107 | response_data = expected_success_update_response.json() 108 | fail_message = f"Server response - {response_data}" 109 | self.assertEqual( 110 | response_data["status"], "success", msg=fail_message 111 | ) 112 | self.assertEqual( 113 | response_data["message"], 114 | "Account updated successfully", 115 | msg=fail_message, 116 | ) 117 | 118 | def test_delete_user_success(self): 119 | # Delete the first user 120 | response = self.client.delete( 121 | path=self.del_url, 122 | data={"account_id": self.account_id1}, 123 | content_type=self.content_type, 124 | ) 125 | 126 | self.check_delete_success(response, self.account_id1) 127 | self.check_users_still_exist([self.account_id2]) 128 | 129 | self.existing_user_accts = [self.account_id2] 130 | 131 | def check_delete_failure(self, response, expected_msg): 132 | # Check for a failed delete response by the server 133 | self.assertEqual(response.status_code, 200) 134 | # Parse the response 135 | response_data = response.json() 136 | fail_message = f"Server response - {response_data}" 137 | self.assertEqual(response_data["status"], "error", msg=fail_message) 138 | self.assertEqual( 139 | response_data["message"], expected_msg, msg=fail_message 140 | ) 141 | 142 | def test_delete_user_failure_nonexistent(self): 143 | # Delete nonexistent user 144 | response = self.client.delete( 145 | path=self.del_url, 146 | data={"account_id": "fakeID"}, 147 | content_type=self.content_type, 148 | ) 149 | 150 | self.check_delete_failure( 151 | response, 152 | expected_msg="Cannot find user with specified account id.", 153 | ) 154 | self.check_users_still_exist([self.account_id1, self.account_id2]) 155 | 156 | def test_delete_user_failure_incorrect_params(self): 157 | # Send incorrect params 158 | response = self.client.delete( 159 | path=self.del_url, 160 | data={"what_is_this": self.account_id1}, 161 | content_type=self.content_type, 162 | ) 163 | 164 | self.check_delete_failure( 165 | response, 166 | expected_msg="Required input 'account_id' missing in the request", 167 | ) 168 | self.check_users_still_exist([self.account_id1, self.account_id2]) 169 | --------------------------------------------------------------------------------