├── .babelrc ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── accounts ├── __init__.py ├── admin.py ├── apps.py ├── authentication.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190410_1316.py │ ├── 0003_auto_20200804_2347.py │ ├── 0004_auto_20200816_1631.py │ └── __init__.py ├── models.py ├── signals.py ├── tests.py ├── urls.py ├── views.py └── webinar_outline.md ├── analytics ├── __init__.py ├── admin.py ├── apps.py ├── management │ └── commands │ │ ├── generate_events.py │ │ └── process_events.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_add_name_index.py │ ├── 0003_auto_20190404_1243.py │ ├── 0004_auto_20200802_1943.py │ ├── 0005_remove_event_user.py │ ├── 0006_event_user.py │ ├── 0007_auto_20200804_2346.py │ ├── 0008_auto_20200805_0604.py │ ├── 0009_auto_20200805_0624.py │ ├── 0010_auto_20200805_0632.py │ ├── 0011_auto_20200809_1949.py │ ├── 0012_auto_20200809_2045.py │ └── __init__.py ├── models.py ├── serializers.py ├── templates │ └── analytics │ │ ├── events.html │ │ ├── events_keyset_pagination.html │ │ └── events_paginated.html ├── tests.py ├── tests │ ├── __init__.py │ ├── test_models.py │ └── test_views.py ├── urls.py └── views.py ├── assets └── js │ └── index.js ├── create_database.sql ├── docker-compose.yml ├── frontend ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── src │ ├── components │ │ ├── DataProvider.js │ │ ├── GoalDetailPage.js │ │ ├── GoalSummary.js │ │ ├── GoalTitle.js │ │ ├── Goals.js │ │ ├── GoalsList.js │ │ ├── GoalsListPage.js │ │ ├── Homepage.js │ │ ├── NewGoalPage.js │ │ ├── Task.js │ │ ├── Tasks.js │ │ ├── TasksFooter.js │ │ └── Utils.js │ ├── css │ │ ├── application.scss │ │ ├── dashboard.scss │ │ ├── learning_goals.scss │ │ ├── materials.scss │ │ ├── scaffolds.scss │ │ └── tasks.scss │ ├── goal.js │ ├── goals_list.js │ ├── home.js │ └── new_goal.js ├── static │ └── frontend │ │ ├── fonts │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 │ │ ├── goal.bundle.js │ │ ├── goals.bundle.js │ │ ├── goals_list.bundle.js │ │ ├── home.bundle.js │ │ ├── main.js │ │ └── new_goal.bundle.js ├── templates │ └── frontend │ │ ├── goal.html │ │ ├── goals_list.html │ │ ├── home.html │ │ └── new_goal.html ├── tests.py ├── urls.py └── views.py ├── goals ├── __init__.py ├── admin.py ├── apps.py ├── management │ └── commands │ │ └── refresh_summaries.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20181226_1723.py │ ├── 0003_auto_20181228_1821.py │ ├── 0004_auto_20181229_1534.py │ ├── 0005_goal_percentage_complete.py │ ├── 0006_remove_goal_percentage_complete.py │ ├── 0007_auto_20190104_2215.py │ ├── 0008_task_completed.py │ ├── 0009_auto_20190115_1411.py │ ├── 0010_goal_is_public.py │ ├── 0011_auto_20190118_1333.py │ ├── 0012_auto_20190123_1341.py │ ├── 0013_auto_20200804_2346.py │ ├── 0014_auto_20200805_0642.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ ├── goals │ │ ├── test.png │ │ └── test_HYq1sow.png │ ├── test_models.py │ └── test_views.py ├── urls.py └── views.py ├── home ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── manage.py ├── newrelic.ini ├── package-lock.json ├── package.json ├── profile_values.py ├── pytest.ini ├── quest.code-workspace ├── quest ├── __init__.py ├── admin.py ├── connections.py ├── models.py ├── redis_key_schema.py ├── settings.py ├── templates │ ├── admin │ │ ├── goal_dashboard.html │ │ └── goal_dashboard_materialized.html │ └── base.html ├── urls.py └── wsgi.py ├── recommendations ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── requirements.txt ├── search ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── static └── img │ └── test │ └── gallery01.jpg ├── webpack-stats.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", "@babel/preset-react" 4 | ], 5 | "plugins": [ 6 | "transform-class-properties" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | postgres-data 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": ["standard", "plugin:react/recommended"], 3 | "rules": { 4 | "react/jsx-uses-vars": 2 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | *.iml 109 | node_modules 110 | postgres-data 111 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from python:3.8 2 | 3 | RUN apt-get update \ 4 | && apt-get install -y --no-install-recommends \ 5 | postgresql-client \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | WORKDIR /usr/src/app 9 | COPY requirements.txt ./ 10 | RUN pip install -r requirements.txt 11 | 12 | EXPOSE 8000 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andrew Brookins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean dev test 2 | 3 | build: 4 | docker-compose build 5 | 6 | dev: 7 | docker-compose up -d 8 | 9 | shell: 10 | docker-compose exec web ./manage.py shell 11 | 12 | migrate: 13 | docker-compose exec web ./manage.py migrate 14 | 15 | test: dev 16 | docker-compose run --rm test pytest 17 | 18 | clean: 19 | docker-compose stop 20 | docker-compose rm -f 21 | 22 | watch_static: 23 | npm install && npm run watch 24 | 25 | runserver: 26 | python manage.py runserver 0.0.0.0:8000 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quest 2 | 3 | This is the example code for the book [The Temple of Django Database Performance](https://spellbookpress.com/books/temple-of-django-database-performance/) by Andrew Brookins. 4 | 5 | ## A Note on Versions 6 | 7 | This code is for version 2 of the book, published August 2020. All buyers of the version 1 ebook (2019) have access to version 2 as a free download. 8 | 9 | If you purchased the 2019 edition of the print book, this code is not substantially different than the code referenced in that book. However, this code includes a new example on using materialized views. 10 | 11 | ## Setup 12 | 13 | This project uses Docker to set up its environment, and it includes a Makefile to let you run `docker-compose` commands more easily. 14 | 15 | ### Initial Setup 16 | 17 | Run `make build` to build the images for the environment. 18 | 19 | You'll also want to run `docker-compose run web ./manage.py createsuperuser` to create a superuser for yourself. 20 | 21 | ### Dev Server 22 | 23 | Run `make dev` to run Redis, Postgres, and the Django web application. The example's servers bind their ports to localhost, so you can visit the app at https://localhost:8000 once it's running. 24 | 25 | ## Viewing Logs 26 | 27 | Run `docker-compose logs web` to view logs for the web application. Likewise, 'postgres' and 'redis' will show logs for those servers. 28 | 29 | ## Running Tests 30 | 31 | Run `make test` to run the tests. Tests run in a container. If you drop in "import ipdb; ipdb.set_trace()" anywhere in the project code, you'll drop into a debugging session if the tests hit that code. 32 | 33 | ## Generating Data for Performance Problems 34 | 35 | Recreating many of the performance problems in this book requires a large amount of data in your database. This project includes a management command that will generate analytics events sufficient to cause performance problems. 36 | 37 | Here's an example of using the management command to generate 500,000 analytics events for the user with 38 | ID 1 (in my case, this is my admin user): 39 | 40 | $ docker-compose run web ./manage.py generate_events --num 500000 --user-id 1 41 | 42 | ## Copyright 43 | 44 | This example code is copyright 2020 Andrew Brookins. 45 | -------------------------------------------------------------------------------- /accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/accounts/__init__.py -------------------------------------------------------------------------------- /accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = 'accounts' 6 | 7 | def ready(self): 8 | import accounts.signals 9 | -------------------------------------------------------------------------------- /accounts/authentication.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth import get_user_model 4 | from rest_framework import authentication 5 | from rest_framework import exceptions 6 | 7 | from quest.connections import redis_connection 8 | from quest import redis_key_schema 9 | 10 | 11 | User = get_user_model() 12 | redis = redis_connection() 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class RedisTokenAuthentication(authentication.BaseAuthentication): 17 | def _get_token(self, request): 18 | header = request.META.get('HTTP_AUTHORIZATION') 19 | if not header: 20 | return None 21 | 22 | parts = header.split() 23 | 24 | if parts[0] != 'Token': 25 | return None 26 | if len(parts) != 2: 27 | log.info(f"Token auth failed: invalid auth header {header}") 28 | raise exceptions.AuthenticationFailed('Invalid token') 29 | 30 | return parts[1] 31 | 32 | def authenticate(self, request): 33 | token = self._get_token(request) 34 | key = redis_key_schema.auth_token(token) 35 | user_id = redis.get(key) 36 | 37 | if not user_id: 38 | log.info(f"Token auth failed: token {token} not found in redis") 39 | raise exceptions.AuthenticationFailed(f'Invalid token') 40 | 41 | try: 42 | user = User.objects.get(id=user_id) 43 | except User.DoesNotExist: 44 | log.info(f"Token auth failed: user {user_id} does not exist") 45 | raise exceptions.AuthenticationFailed('Invalid token') 46 | 47 | return user, None 48 | -------------------------------------------------------------------------------- /accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-04-10 13:11 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Account', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(help_text='Name of the account', max_length=255)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='UserProfile', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('account', models.ForeignKey(help_text='The account to which this user belongs', on_delete=django.db.models.deletion.DO_NOTHING, to='accounts.Account')), 29 | ('user', models.ForeignKey(help_text='The user to whom this profile belongs', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /accounts/migrations/0002_auto_20190410_1316.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-04-10 13:16 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('accounts', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='userprofile', 17 | name='account', 18 | field=models.OneToOneField(help_text='The account to which this user belongs', on_delete=django.db.models.deletion.DO_NOTHING, to='accounts.Account'), 19 | ), 20 | migrations.AlterField( 21 | model_name='userprofile', 22 | name='user', 23 | field=models.OneToOneField(help_text='The user to whom this profile belongs', on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /accounts/migrations/0003_auto_20200804_2347.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-04 23:47 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('accounts', '0002_auto_20190410_1316'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='account', 16 | name='created_at', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='account', 22 | name='updated_at', 23 | field=models.DateTimeField(auto_now=True), 24 | ), 25 | migrations.AddField( 26 | model_name='userprofile', 27 | name='created_at', 28 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 29 | preserve_default=False, 30 | ), 31 | migrations.AddField( 32 | model_name='userprofile', 33 | name='updated_at', 34 | field=models.DateTimeField(auto_now=True), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /accounts/migrations/0004_auto_20200816_1631.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-16 16:31 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 | ('accounts', '0003_auto_20200804_2347'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='userprofile', 16 | name='account', 17 | field=models.OneToOneField(blank=True, help_text='The account to which this user belongs', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='accounts.Account'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | from quest.models import QuestModel 5 | 6 | 7 | # tag::Account[] 8 | class Account(QuestModel): 9 | name = models.CharField(max_length=255, help_text="Name of the account") 10 | # end::Account[] 11 | 12 | 13 | # tag::UserProfile[] 14 | class UserProfile(QuestModel): 15 | user = models.OneToOneField( 16 | User, 17 | related_name='profile', 18 | help_text="The user to whom this profile belongs", 19 | on_delete=models.CASCADE) 20 | account = models.OneToOneField( # <1> 21 | Account, 22 | help_text="The account to which this user belongs", 23 | null=True, 24 | blank=True, 25 | on_delete=models.DO_NOTHING) 26 | # end::UserProfile[] 27 | 28 | -------------------------------------------------------------------------------- /accounts/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db.models.signals import post_save 3 | from django.dispatch import receiver 4 | from .models import UserProfile 5 | 6 | User = get_user_model() 7 | 8 | 9 | @receiver(post_save, sender=User) 10 | def create_user_profile(sender, instance, created, **kwargs): 11 | if created: 12 | UserProfile.objects.create(user=instance) 13 | 14 | 15 | @receiver(post_save, sender=User) 16 | def save_user_profile(sender, instance, **kwargs): 17 | instance.profile.save() 18 | -------------------------------------------------------------------------------- /accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('accounts/', include('django.contrib.auth.urls')), 7 | path('accounts/signup/', views.signup, name='account_signup'), 8 | ] 9 | -------------------------------------------------------------------------------- /accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import login, authenticate 2 | from django.contrib.auth.forms import UserCreationForm 3 | from django.shortcuts import render, redirect 4 | 5 | 6 | def signup(request): 7 | if request.method == 'POST': 8 | form = UserCreationForm(request.POST) 9 | if form.is_valid(): 10 | form.save() 11 | username = form.cleaned_data.get('username') 12 | raw_password = form.cleaned_data.get('password1') 13 | user = authenticate(username=username, password=raw_password) 14 | login(request, user) 15 | return redirect('/') 16 | else: 17 | form = UserCreationForm() 18 | return render(request, 'account/signup.html', { 19 | 'form': form, 20 | 'login_url': '/accounts/login' 21 | }) 22 | -------------------------------------------------------------------------------- /accounts/webinar_outline.md: -------------------------------------------------------------------------------- 1 | # Database Performance Tips with Django 2 | 3 | ## Intro [4 mins] 4 | 5 | * Me 6 | * Redis for Python Developers course August 18, sign up now, free https://university.redislabs.com/courses/ru102py/ 7 | * The Temple of Django Database Performance https://spellbookpress.com 8 | 9 | * Quest app 10 | * Quest Learning Management System 11 | * Postgres 12 | * Redis 13 | 14 | * PyCharm 15 | 16 | 17 | ## Querying [15 mins] 18 | 19 | * Pagination 20 | * Before -- http://localhost:8000/analytics 21 | * Look at code: .all() all items 22 | * Open DB console and get # of items in table 23 | * Use pagination (offset) http://localhost:8000/analytics_offset 24 | * Debug Toolbar - # of queries, speed 25 | * Preview: Keyset Pagination 26 | * Explain why you would need this (DB still has to get rows, deep results can break/be slow) 27 | * CursorPagination with DRF - only show it 28 | * Refer to book for more 29 | * Annotations ("Aggregations") 30 | * What is an annotation? 31 | * Counting with Python - http://localhost:8000/admin/goal_dashboard_sql/ 32 | * Counting with SQL/Annotations - http://localhost:8000/admin/goal_dashboard_sql/ 33 | 34 | * Materialized Views 35 | * Explanation - like caching in the database 36 | * Code [model for materialized view, code for migration] 37 | * GoalSummary 38 | * migration: goals 0014 39 | * Run the migration 40 | * View http://localhost:8000/admin/goal_dashboard_materialized/ 41 | * Show refresh_summaries management command 42 | 43 | 44 | ## Indexing [15 mins] 45 | 46 | * Covering indexes 47 | * An index that can service a query itself, not using a table 48 | * Why? Faster 49 | * First we need to add an index - show index definition 50 | * Look at migration: AddIndexConcurrently new in Django 3 51 | * Explanation (building indexes locks tables, concurrently doesn’t) 52 | * Show queries in database panel 53 | * Explain analyze query with index - Database Panel 54 | * Should be index-only 55 | * May NOT be index-only query yet 56 | * VACUUM if needed 57 | * Run query again - should be index only 58 | 59 | * Partial indexes 60 | * Difference compared to regular index 61 | * Use to EXCLUDE common data from the index (better write perf, smaller index) 62 | * Show index definition analytics/models.py 63 | * Run query again - should be index only 64 | 65 | 66 | ## Caching and Beyond Caching with Redis [15] 67 | 68 | * Using the caching framework 69 | * Why redis? The swiss-army knife of databases 70 | * Code [Settings.py - turn on caching with redis] 71 | * Show middleware -- will cache entire site -- disabled 72 | * Admin dashboard Redis version -- caching the calculated values in Redis 73 | 74 | * Session storage with Redis 75 | 76 | * Explanation 77 | * Code [settings.py] 78 | * Demo: log in, examine redis keys with database tool in PyCharm 79 | 80 | * Custom auth backend for token storage in Redis with DRF 81 | * Explanation [looking up auth tokens is slow, use redis] 82 | * Code [custom auth backend] accounts/authentication.py 83 | * Create a token in redis-cli 84 | * Check that we can authenticate using the token 85 | 86 | ## Q&A [10] 87 | * Any questions? 88 | -------------------------------------------------------------------------------- /analytics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/analytics/__init__.py -------------------------------------------------------------------------------- /analytics/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /analytics/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AnalyticsConfig(AppConfig): 5 | name = 'analytics' 6 | -------------------------------------------------------------------------------- /analytics/management/commands/generate_events.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from analytics.models import Event 4 | 5 | 6 | class Command(BaseCommand): 7 | help = 'Generate semi-random analytics events' 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument( 11 | '--num', 12 | type=int, 13 | default=1000) 14 | parser.add_argument( 15 | '--name', 16 | type=str, 17 | default='goal_viewed.generated') 18 | parser.add_argument( 19 | '--user-id', 20 | type=int, 21 | default=0) 22 | 23 | def handle(self, *args, **options): 24 | events = [] 25 | data = { 26 | "name": options['name'], 27 | "data": {"important!": "you rock"} 28 | } 29 | if options['user_id'] > 0: 30 | data['user_id'] = options['user_id'] 31 | 32 | for i in range(options['num']): 33 | events.append(Event(**data)) 34 | 35 | Event.objects.bulk_create(events) 36 | -------------------------------------------------------------------------------- /analytics/management/commands/process_events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from darksky.api import DarkSky 4 | from django.core.management.base import BaseCommand 5 | from django.conf import settings 6 | 7 | from analytics.models import Event 8 | 9 | 10 | darksky = DarkSky(settings.DARK_SKY_API_KEY) 11 | 12 | 13 | class Command(BaseCommand): 14 | help = 'Annotate events with cloud cover data' 15 | 16 | def add_arguments(self, parser): 17 | today = datetime.date.today() 18 | default_start = today - datetime.timedelta(days=30) 19 | default_end = today 20 | 21 | parser.add_argument( 22 | '--start', 23 | type=lambda s: datetime.datetime.strptime( 24 | s, 25 | '%Y-%m-%d-%z' 26 | ), 27 | default=default_start) 28 | parser.add_argument( 29 | '--end', 30 | type=lambda s: datetime.datetime.strptime( 31 | s, 32 | '%Y-%m-%d-%z' 33 | ), 34 | default=default_end) 35 | 36 | def handle(self, *args, **options): 37 | events = Event.objects.filter( 38 | created_at__range=[options['start'], 39 | options['end']]) 40 | for e in events.exclude( 41 | data__latitude=None, 42 | data__longitude=None).iterator(): # <1> 43 | 44 | # Presumably we captured a meaningful latitude and 45 | # longitude related to the event (perhaps the 46 | # user's location). 47 | latitude = float(e.data.get('latitude')) 48 | longitude = float(e.data.get('longitude')) 49 | 50 | if 'weather' not in e.data: 51 | e.data['weather'] = {} 52 | 53 | if 'cloud_cover' not in e.data['weather']: 54 | forecast = darksky.get_time_machine_forecast( 55 | latitude, longitude, e.created_at) 56 | hourly = forecast.hourly.data[e.created_at.hour] 57 | e.data['weather']['cloud_cover'] = \ 58 | hourly.cloud_cover 59 | 60 | # This could alternatively be done with bulk_update(). 61 | # Doing so would in theory consume more memory but take 62 | # less time. 63 | e.save() 64 | -------------------------------------------------------------------------------- /analytics/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-02-09 13:45 2 | 3 | from django.conf import settings 4 | import django.contrib.postgres.fields.jsonb 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Event', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('name', models.CharField(help_text='The name of the event', max_length=255)), 23 | ('data', django.contrib.postgres.fields.jsonb.JSONField()), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /analytics/migrations/0002_add_name_index.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-02-26 14:05 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | atomic = False # Disable transactions, which are not supported by concurrent indexing. 8 | 9 | dependencies = [ 10 | ('analytics', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RunSQL("CREATE INDEX CONCURRENTLY analytics_name_idx ON analytics_event(name);") 15 | ] 16 | -------------------------------------------------------------------------------- /analytics/migrations/0003_auto_20190404_1243.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-04-04 12:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('analytics', '0002_add_name_index'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='event', 15 | index=models.Index(fields=['name'], name='analytics_e_name_522cb6_idx'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/migrations/0004_auto_20200802_1943.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-02 19:43 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('analytics', '0003_auto_20190404_1243'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='event', 18 | name='user', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to=settings.AUTH_USER_MODEL), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /analytics/migrations/0005_remove_event_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-02 19:51 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('analytics', '0004_auto_20200802_1943'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='event', 15 | name='user', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/migrations/0006_event_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-04 23:16 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('analytics', '0005_remove_event_user'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='event', 18 | name='user', 19 | field=models.ForeignKey(blank=True, help_text='The user associated with this event', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to=settings.AUTH_USER_MODEL), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /analytics/migrations/0007_auto_20200804_2346.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-04 23:46 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('analytics', '0006_event_user'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='event', 16 | name='created_at', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='event', 22 | name='updated_at', 23 | field=models.DateTimeField(auto_now=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /analytics/migrations/0008_auto_20200805_0604.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-05 06:04 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('analytics', '0007_auto_20200804_2346'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveIndex( 14 | model_name='event', 15 | name='analytics_e_name_522cb6_idx', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/migrations/0009_auto_20200805_0624.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-05 06:24 2 | from django.contrib.postgres.operations import AddIndexConcurrently 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | atomic = False 8 | 9 | dependencies = [ 10 | ('analytics', '0008_auto_20200805_0604'), 11 | ] 12 | 13 | operations = [ 14 | AddIndexConcurrently( 15 | model_name='event', 16 | index=models.Index(fields=['name'], name='analytics_event_name_idx')) 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/migrations/0010_auto_20200805_0632.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-05 06:32 2 | from django.contrib.postgres.operations import RemoveIndexConcurrently 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | atomic = False 8 | 9 | dependencies = [ 10 | ('analytics', '0009_auto_20200805_0624'), 11 | ] 12 | 13 | operations = [ 14 | RemoveIndexConcurrently( 15 | model_name='event', name='analytics_event_name_idx'), 16 | ] 17 | 18 | -------------------------------------------------------------------------------- /analytics/migrations/0011_auto_20200809_1949.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-09 19:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('analytics', '0010_auto_20200805_0632'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name='event', 15 | index=models.Index(condition=models.Q(name='goal_viewed'), fields=['name'], name='analytics_event_name_idx'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/migrations/0012_auto_20200809_2045.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-09 20:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('analytics', '0011_auto_20200809_1949'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveIndex( 14 | model_name='event', 15 | name='analytics_event_name_idx', 16 | ), 17 | migrations.AddIndex( 18 | model_name='event', 19 | index=models.Index(condition=models.Q(_negated=True, name='goal_viewed'), fields=['name'], name='analytics_event_name_idx'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /analytics/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/analytics/migrations/__init__.py -------------------------------------------------------------------------------- /analytics/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.postgres.fields import JSONField 3 | from django.db import models 4 | from django.db.models import Q 5 | from quest.models import QuestModel 6 | 7 | 8 | # tag::Event[] 9 | class Event(QuestModel): 10 | name = models.CharField(help_text="The name of the event", max_length=255) 11 | user = models.ForeignKey(settings.AUTH_USER_MODEL, 12 | help_text="The user associated with this event", 13 | on_delete=models.CASCADE, related_name="events", 14 | null=True, blank=True) 15 | data = JSONField() 16 | 17 | class Meta: 18 | indexes = [ 19 | models.Index(fields=['name'], name="analytics_event_name_idx", 20 | condition=~Q(name="goal_viewed")), 21 | ] 22 | # end::Event[] 23 | 24 | -------------------------------------------------------------------------------- /analytics/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from analytics.models import Event 4 | 5 | 6 | class EventSerializer(serializers.ModelSerializer): 7 | user = serializers.PrimaryKeyRelatedField(queryset=Event.objects.all()) 8 | 9 | class Meta: 10 | model = Event 11 | fields = ('id', 'name', 'user', 'data') 12 | 13 | -------------------------------------------------------------------------------- /analytics/templates/analytics/events.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Analytics Events{% endblock %} 5 | 6 | {% block content %} 7 |

Analytics Events

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for event in events %} 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% endfor %} 26 |
EventUserAccountData
{{ event.name }}{{ event.user.username }}{{ event.user.profile.account.name }}{{ event.data }}
27 | {% endblock content %} 28 | -------------------------------------------------------------------------------- /analytics/templates/analytics/events_keyset_pagination.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Analytics Events{% endblock %} 5 | 6 | {% block content %} 7 |

Analytics Events

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for event in events %} 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% endfor %} 26 |
EventUserAccountData
{{ event.name }}{{ event.user.username }}{{ event.user.profile.account.name }}{{ event.data }}
27 | 28 | 35 | {% endblock content %} 36 | -------------------------------------------------------------------------------- /analytics/templates/analytics/events_paginated.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Analytics Events{% endblock %} 5 | 6 | {% block content %} 7 |

Analytics Events

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for event in events %} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 |
EventUserAccountData
{{ event.name }}{{ event.user.username }}{{ event.user.profile.account.name }}{{ event.data }}
28 | 29 | 46 | 47 | {% endblock content %} 48 | -------------------------------------------------------------------------------- /analytics/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /analytics/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/analytics/tests/__init__.py -------------------------------------------------------------------------------- /analytics/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.db import connection, reset_queries 2 | from django.contrib.auth.models import User 3 | from django.test import override_settings 4 | import pytest 5 | 6 | from analytics.models import Event 7 | 8 | 9 | # tag::fixtures[] 10 | @pytest.fixture 11 | def user(): 12 | return User.objects.create_user(username="Mac", password="Daddy") 13 | 14 | 15 | @pytest.fixture 16 | def events(user): # <1> 17 | Event.objects.create(name="goal.viewed", user=user, data="{test:1}") 18 | Event.objects.create(name="goal.viewed", user=user, data="{test:2}") 19 | 20 | 21 | # end::fixtures[] 22 | 23 | # tag::test_only[] 24 | @override_settings(DEBUG=True) 25 | @pytest.mark.django_db 26 | def test_only(events): 27 | reset_queries() 28 | event_with_data = Event.objects.first() 29 | assert event_with_data.data == "{test:1}" 30 | assert len(connection.queries) == 1 # <2> 31 | 32 | event_without_data = Event.objects.only('name').first() 33 | assert event_without_data.data == "{test:1}" 34 | assert event_without_data.name == "goal.viewed" 35 | assert len(connection.queries) == 3 # <3> 36 | # end::test_only[] 37 | 38 | 39 | # tag::test_only_with_relations[] 40 | @override_settings(DEBUG=True) 41 | @pytest.mark.django_db 42 | def test_only_with_relations(events): 43 | reset_queries() 44 | e = Event.objects.select_related('user').only('user').first() # <1> 45 | assert len(connection.queries) == 1 46 | 47 | assert e.name == 'goal.viewed' 48 | assert len(connection.queries) == 2 # <2> 49 | 50 | assert e.user.username == 'Mac' 51 | assert len(connection.queries) == 2 # <3> 52 | # end::test_only_with_relations[] 53 | 54 | 55 | # tag::test_defer[] 56 | @override_settings(DEBUG=True) 57 | @pytest.mark.django_db 58 | def test_defer(events): 59 | reset_queries() 60 | event_with_data = Event.objects.first() 61 | assert event_with_data.data == "{test:1}" 62 | assert len(connection.queries) == 1 63 | 64 | event_without_data = Event.objects.defer('data').first() # <1> 65 | assert event_without_data.data == "{test:1}" 66 | assert len(connection.queries) == 3 67 | # end::test_defer[] 68 | -------------------------------------------------------------------------------- /analytics/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | from django.test import override_settings 4 | from django.urls import reverse 5 | 6 | from analytics.models import Event 7 | from analytics.views import encode_keyset, JsonbFieldIncrementer 8 | 9 | TEST_PASSWORD = "password" 10 | 11 | 12 | # tag::fixtures[] 13 | @pytest.fixture 14 | def user(): 15 | return User.objects.create_user( 16 | username="Mac", password=TEST_PASSWORD) 17 | 18 | 19 | @pytest.fixture 20 | def events(user): 21 | Event.objects.create( 22 | name="goal.viewed", user=user, data={"test": 1}) 23 | Event.objects.create( 24 | name="goal.clicked", user=user, data={"test": 2}) 25 | Event.objects.create( 26 | name="goal.favorited", user=user, data={"test": 3}) 27 | return Event.objects.all().order_by('pk') 28 | 29 | 30 | # end::fixtures[] 31 | 32 | @pytest.fixture 33 | def authenticated_client(user, client): 34 | client.login(username=user.username, password=TEST_PASSWORD) 35 | return client 36 | 37 | 38 | @pytest.fixture 39 | @override_settings(DEBUG=True, EVENTS_PER_PAGE=2) 40 | def page_one_generic(authenticated_client): 41 | url = reverse("events_keyset_generic") 42 | return authenticated_client.get(url) 43 | 44 | 45 | @pytest.fixture 46 | @override_settings(DEBUG=True, EVENTS_PER_PAGE=2) 47 | def page_one_pg(authenticated_client): 48 | url = reverse("events_keyset_pg") 49 | return authenticated_client.get(url) 50 | 51 | 52 | def content(page): 53 | return page.content.decode("utf-8") 54 | 55 | 56 | @pytest.mark.django_db 57 | def test_includes_page_one_results(events, page_one_generic): 58 | assert events[0].name in content(page_one_generic) 59 | assert events[1].name in content(page_one_generic) 60 | 61 | 62 | @pytest.mark.django_db 63 | def test_hides_second_page_results(events, page_one_generic): 64 | assert events[2].name not in content(page_one_generic) 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_has_next_link(events, page_one_generic): 69 | last_page_one_event = events[1] 70 | expected_keyset = encode_keyset(last_page_one_event) 71 | assert expected_keyset in content(page_one_generic) 72 | 73 | 74 | @pytest.mark.django_db 75 | def test_next_link_requests_next_page(events,page_one_generic, 76 | authenticated_client): 77 | next_keyset = page_one_generic.context['next_keyset'] 78 | next_page_url = "{}?keyset={}".format( 79 | reverse("events_keyset_generic"), next_keyset) 80 | page_two_event = events[2] 81 | page_two = authenticated_client.get(next_page_url) 82 | assert page_two_event.name in content(page_two) 83 | 84 | 85 | @pytest.mark.django_db 86 | def test_includes_page_one_results_pg(events, page_one_pg): 87 | assert events[0].name in content(page_one_pg) 88 | assert events[1].name in content(page_one_pg) 89 | 90 | 91 | @pytest.mark.django_db 92 | def test_hides_second_page_results_pg(events, page_one_pg): 93 | assert events[2].name not in content(page_one_pg) 94 | 95 | 96 | @pytest.mark.django_db 97 | def test_has_next_link_pg(events, page_one_pg): 98 | last_page_one_event = events[1] 99 | expected_keyset = encode_keyset(last_page_one_event) 100 | assert expected_keyset in content(page_one_pg) 101 | 102 | 103 | @pytest.mark.django_db 104 | def test_next_link_requests_next_page_pg(events, page_one_pg, 105 | authenticated_client): 106 | next_keyset = page_one_pg.context['next_keyset'] 107 | next_page_url = "{}?keyset={}".format( 108 | reverse("events_keyset_pg"), next_keyset) 109 | page_two_event = events[2] 110 | page_two = authenticated_client.get(next_page_url) 111 | assert page_two_event.name in content(page_two) 112 | 113 | 114 | # tag::testing_jsonb_incrementer[] 115 | @pytest.mark.django_db 116 | def test_json_incrementer_sets_missing_count(events): 117 | assert all(['count' not in e.data for e in events]) 118 | incr_by_one = JsonbFieldIncrementer('data', 'count', 1) 119 | events.update(data=incr_by_one) 120 | for event in events: 121 | assert event.data['count'] == 1 122 | 123 | 124 | @pytest.mark.django_db 125 | def test_json_incrementer_increments_count(events): 126 | events.update(data={"count": 1}) 127 | incr_by_one = JsonbFieldIncrementer('data', 'count', 1) 128 | events.update(data=incr_by_one) 129 | for event in events: 130 | assert event.data['count'] == 2 131 | # end::testing_jsonb_incrementer[] 132 | -------------------------------------------------------------------------------- /analytics/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('analytics', views.all_events, name="events"), 6 | path('analytics_select_related', views.events_select_related, name="events_select_related"), 7 | path('analytics_offset', views.events_offset_paginated, 8 | name="events_offset"), 9 | path('analytics_keyset_pg', views.events_keyset_paginated_postgres, 10 | name="events_keyset_pg"), 11 | path('analytics_keyset_generic', views.events_keyset_paginated_generic, 12 | name="events_keyset_generic"), 13 | path('analytics/api', views.EventListView.as_view(), 14 | name="events_api"), 15 | path('analytics/protected_api', views.ProtectedEventListView.as_view(), 16 | name="events_api_protected") 17 | ] 18 | -------------------------------------------------------------------------------- /analytics/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import datetime 4 | import logging 5 | 6 | from django.conf import settings 7 | from django.contrib.auth.decorators import login_required 8 | from django.core.paginator import Paginator 9 | from django.db.models import F, Func, Value 10 | from django.db.models.expressions import RawSQL 11 | from django.http import HttpResponseBadRequest 12 | from django.shortcuts import render 13 | from rest_framework import generics, permissions 14 | from rest_framework.pagination import CursorPagination 15 | 16 | from analytics.models import Event 17 | from analytics.serializers import EventSerializer 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | @login_required 23 | def all_events(request): 24 | """Render the list of analytics events.""" 25 | events = Event.objects.all() 26 | context = {'events': events} 27 | return render(request, "analytics/events.html", context) 28 | 29 | 30 | # tag::unpaginated[] 31 | @login_required 32 | def events_select_related(request): 33 | """Render the list of analytics events using select_related().""" 34 | events = Event.objects.all().select_related( 35 | 'user', 'user__profile', 36 | 'user__profile__account') 37 | context = {'events': events} 38 | return render(request, "analytics/events.html", context) 39 | # end::unpaginated[] 40 | 41 | 42 | # tag::paginated[] 43 | @login_required 44 | def events_offset_paginated(request): 45 | """Render the list of analytics events. 46 | 47 | Paginate results using Paginator, with select_related(). 48 | """ 49 | events = Event.objects.all().select_related( 50 | 'user', 'user__profile', 51 | 'user__profile__account').order_by('id') # <1> 52 | paginated = Paginator(events, settings.EVENTS_PER_PAGE) 53 | page = request.GET.get('page', 1) 54 | events = paginated.get_page(page) 55 | context = {'events': events} 56 | return render(request, "analytics/events_paginated.html", 57 | context) 58 | # end::paginated[] 59 | 60 | 61 | # tag::keyset_pagination_pg[] 62 | KEYSET_SEPARATOR = '-' 63 | 64 | 65 | class KeysetError(Exception): 66 | pass 67 | 68 | 69 | def encode_keyset(last_in_page): 70 | """Return a URL-safe base64-encoded keyset.""" 71 | return base64.urlsafe_b64encode( # <1> 72 | "{}{}{}".format( 73 | last_in_page.pk, 74 | KEYSET_SEPARATOR, 75 | last_in_page.created_at.timestamp() 76 | ).encode( 77 | "utf-8" 78 | ) 79 | ).decode("utf-8") 80 | 81 | 82 | def decode_keyset(keyset): 83 | """Decode a base64-encoded keyset URL parameter.""" 84 | try: 85 | keyset_decoded = base64.urlsafe_b64decode( 86 | keyset).decode("utf-8") 87 | except (AttributeError, binascii.Error): # <2> 88 | log.debug("Could not base64-decode keyset: %s", 89 | keyset) 90 | raise KeysetError 91 | try: 92 | pk, created_at_timestamp = keyset_decoded.split( 93 | KEYSET_SEPARATOR) 94 | except ValueError: 95 | log.debug("Invalid keyset: %s", keyset) 96 | raise KeysetError 97 | try: 98 | created_at = datetime.datetime.fromtimestamp( 99 | float(created_at_timestamp)) 100 | except (ValueError, OverflowError): 101 | log.debug("Could not parse created_at timestamp " 102 | "from keyset: %s", created_at_timestamp) 103 | raise KeysetError 104 | 105 | return pk, created_at 106 | 107 | 108 | @login_required 109 | def events_keyset_paginated_postgres(request): 110 | """Render the list of analytics events. 111 | 112 | Paginates results using the "keyset" method. This 113 | approach uses the row comparison feature of Postgres and 114 | is thus Postgres-specific. However, note that the latest 115 | versions of MySQL and SQLite also support row 116 | comparisons. 117 | 118 | The client should pass a "keyset" parameter that 119 | contains the set of values used to produce a stable 120 | ordering of the data. The values should be appended to 121 | each other and separated by a period ("."). 122 | """ 123 | keyset = request.GET.get('keyset') 124 | next_keyset = None 125 | 126 | if keyset: 127 | try: 128 | pk, created_at = decode_keyset(keyset) 129 | except KeysetError: 130 | return HttpResponseBadRequest( 131 | "Invalid keyset specified") 132 | 133 | events = Event.objects.raw(""" 134 | SELECT * 135 | FROM analytics_event 136 | LEFT OUTER JOIN "auth_user" 137 | ON ("analytics_event"."user_id" = "auth_user"."id") 138 | LEFT OUTER JOIN "accounts_userprofile" 139 | ON ("auth_user"."id" = "accounts_userprofile"."user_id") 140 | LEFT OUTER JOIN "accounts_account" 141 | ON ("accounts_userprofile"."account_id" = "accounts_account"."id") 142 | WHERE ("analytics_event"."created_at", "analytics_event"."id") > (%s::timestamptz, %s) -- <3> 143 | ORDER BY "analytics_event"."created_at", "analytics_event"."id" -- <4> 144 | FETCH FIRST %s ROWS ONLY 145 | """, [created_at.isoformat(), pk, 146 | settings.EVENTS_PER_PAGE]) 147 | else: 148 | events = Event.objects.all().order_by( 149 | 'created_at', 'pk').select_related( 150 | 'user', 'user__profile', 151 | 'user__profile__account') 152 | 153 | page = events[:settings.EVENTS_PER_PAGE] # <5> 154 | if page: 155 | last_item = page[len(page) - 1] 156 | next_keyset = encode_keyset(last_item) 157 | 158 | context = { 159 | 'events': page, 160 | 'next_keyset': next_keyset 161 | } 162 | 163 | return render( 164 | request, 165 | "analytics/events_keyset_pagination.html", 166 | context) 167 | # end::keyset_pagination_pg[] 168 | 169 | 170 | # tag::keyset_pagination_generic[] 171 | @login_required 172 | def events_keyset_paginated_generic(request): 173 | """Render the list of analytics events. 174 | 175 | Paginates results using the "keyset" method. Instead of 176 | row comparisons, this implementation uses a generic 177 | boolean logic approach to building the keyset query. 178 | 179 | The client should pass a "keyset" parameter that 180 | contains the set of values used to produce a stable 181 | ordering of the data. The values should be appended to 182 | each other and separated by a period ("."). 183 | """ 184 | keyset = request.GET.get('keyset') 185 | events = Event.objects.all().order_by( 186 | 'created_at', 'pk').select_related( 187 | 'user', 'user__profile', 188 | 'user__profile__account') 189 | next_keyset = None 190 | 191 | if keyset: 192 | try: 193 | pk, created_at = decode_keyset(keyset) 194 | except KeysetError: 195 | return HttpResponseBadRequest( 196 | "Invalid keyset specified") 197 | 198 | events.filter( # <1> 199 | created_at__gte=created_at 200 | ).exclude( 201 | created_at=created_at, 202 | pk__lte=pk 203 | ) 204 | 205 | page = events[:settings.EVENTS_PER_PAGE] 206 | if page: 207 | last_item = page[len(page) - 1] 208 | next_keyset = encode_keyset(last_item) 209 | 210 | context = { 211 | 'events': page, 212 | 'next_keyset': next_keyset 213 | } 214 | 215 | return render( 216 | request, 217 | "analytics/events_keyset_pagination.html", 218 | context) 219 | # end::keyset_pagination_generic[] 220 | 221 | 222 | # tag::increment_all_event_versions[] 223 | @login_required 224 | def increment_all_event_versions(): 225 | """Increment all event versions in the database. 226 | 227 | Looping over each event and calling save() generates 228 | a query per event. That could mean a TON of queries! 229 | """ 230 | for event in Event.objects.all(): 231 | event.version = event.version + 1 232 | event.save() 233 | # end::increment_all_event_versions[] 234 | 235 | 236 | # tag::increment_all_event_versions_f_expression[] 237 | @login_required 238 | def increment_all_event_versions_with_f_expression(): 239 | """Increment all event versions in the database. 240 | 241 | An F() expression can use a single query to update 242 | potentially millions of rows. 243 | """ 244 | Event.objects.all().update(version=F('version') + 1) 245 | # end::increment_all_event_versions_f_expression[] 246 | 247 | 248 | # tag::update_all_events[] 249 | @login_required 250 | def increment_all_event_counts(): 251 | """Update all event counts in the database.""" 252 | for event in Event.objects.all(): 253 | if 'count' in event.data: 254 | event.data['count'] += event.data['count'] 255 | else: 256 | event.data['count'] = 1 257 | event.save() 258 | # end::update_all_events[] 259 | 260 | 261 | # tag::update_all_events_func[] 262 | class JsonbFieldIncrementer(Func): # <1> 263 | """Set or increment a property of a JSONB column.""" 264 | function = "jsonb_set" 265 | arity = 3 266 | 267 | def __init__(self, json_column, property_name, 268 | increment_by, **extra): 269 | property_expression = Value("{{{}}}".format 270 | (property_name)) # <2> 271 | set_or_increment_expression = RawSQL( # <3> 272 | "(COALESCE({}->>'{}','0')::int + %s)" \ 273 | "::text::jsonb".format( # <4> 274 | json_column, property_name 275 | ), (increment_by,)) 276 | 277 | super().__init__(json_column, property_expression, 278 | set_or_increment_expression, **extra) 279 | 280 | 281 | @login_required 282 | def increment_all_event_counts_with_func(): 283 | """Increment all event counts.""" 284 | incr_by_one = JsonbFieldIncrementer('data', 'count', 1) 285 | Event.objects.all().update(data=incr_by_one).limit(10) 286 | # end::update_all_events_func[] 287 | 288 | 289 | class Pagination(CursorPagination): 290 | page_size = 10 291 | ordering = '-created_at' 292 | 293 | 294 | class EventListView(generics.ListAPIView): 295 | queryset = Event.objects.all() 296 | serializer_class = EventSerializer 297 | pagination_class = Pagination 298 | 299 | 300 | class ProtectedEventListView(generics.ListAPIView): 301 | queryset = Event.objects.all() 302 | serializer_class = EventSerializer 303 | pagination_class = Pagination 304 | permission_classes = [permissions.IsAuthenticated] 305 | -------------------------------------------------------------------------------- /assets/js/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/assets/js/index.js -------------------------------------------------------------------------------- /create_database.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE quest; 2 | CREATE USER quest with password 'test'; 3 | ALTER USER quest CREATEDB; 4 | GRANT ALL PRIVILEGES ON DATABASE quest to quest; 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:12 6 | restart: always 7 | ports: 8 | - "15432:5432" 9 | environment: 10 | POSTGRES_USER: quest 11 | POSTGRES_PASSWORD: test 12 | POSTGRES_DB: quest 13 | volumes: 14 | - ./postgres-data:/var/lib/postgresql/data 15 | 16 | redis: 17 | image: redis:latest 18 | ports: 19 | - "6379:6379" 20 | expose: 21 | - "6379" 22 | 23 | web: 24 | build: . 25 | ports: 26 | - "8000:8000" 27 | command: "make runserver" 28 | working_dir: /usr/src/app 29 | environment: 30 | QUEST_DATABASE_HOST: db 31 | QUEST_REDIS_URL: "redis://redis:6379/0" 32 | depends_on: 33 | - "db" 34 | volumes: 35 | - ".:/usr/src/app" 36 | 37 | static: 38 | image: node:latest 39 | command: "make watch_static" 40 | working_dir: /usr/src/app 41 | volumes: 42 | - ".:/usr/src/app" 43 | 44 | test: 45 | build: . 46 | environment: 47 | QUEST_DATABASE_HOST: db 48 | QUEST_REDIS_URL: "redis://redis:6379/0" 49 | working_dir: /usr/src/app 50 | volumes: 51 | - ".:/usr/src/app" 52 | -------------------------------------------------------------------------------- /frontend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/__init__.py -------------------------------------------------------------------------------- /frontend/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /frontend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FrontendConfig(AppConfig): 5 | name = 'frontend' 6 | -------------------------------------------------------------------------------- /frontend/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/migrations/__init__.py -------------------------------------------------------------------------------- /frontend/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /frontend/src/components/DataProvider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import axios from 'axios' 4 | 5 | class DataProvider extends Component { 6 | constructor (props) { 7 | super(props) 8 | this.state = { 9 | placeholder: 'Loading...' 10 | } 11 | } 12 | 13 | componentDidMount () { 14 | axios.get(this.props.endpoint) 15 | .then(response => { 16 | if (response.status !== 200) { 17 | return this.setState({ placeholder: 'Something went wrong' }) 18 | } 19 | return response.data 20 | }) 21 | .then((data) => { 22 | this.props.onLoad(data) 23 | }) 24 | } 25 | 26 | render () { 27 | const { model, loaded, placeholder } = this.state 28 | return loaded ? this.props.render(model) :

{placeholder}

29 | } 30 | } 31 | 32 | DataProvider.propTypes = { 33 | onLoad: PropTypes.func.isRequired, 34 | endpoint: PropTypes.string.isRequired, 35 | render: PropTypes.func.isRequired, 36 | model: PropTypes.object 37 | } 38 | 39 | export default DataProvider 40 | -------------------------------------------------------------------------------- /frontend/src/components/GoalDetailPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ReactDOM from 'react-dom' 4 | import { Router } from 'director/build/director' 5 | import Goals from './Goals' 6 | import GoalTitle from './GoalTitle' 7 | import TasksFooter from './TasksFooter' 8 | import Task from './Task' 9 | import Tasks from './Tasks' 10 | import Utils from './Utils' 11 | import '../css/application.scss' 12 | import '../css/tasks.scss' 13 | 14 | const goalId = document.getElementById('goal-id').dataset.id 15 | 16 | const ALL = 'all' 17 | const ACTIVE = 'active' 18 | const COMPLETED = 'completed' 19 | const ENTER_KEY = 13 20 | 21 | class GoalDetailPage extends React.Component { 22 | constructor (props) { 23 | super(props) 24 | this.state = { 25 | goal: null, 26 | loaded: false, 27 | placeholder: 'Loading...', 28 | mode: ALL, 29 | editingTask: null, 30 | editingGoal: false, 31 | newTask: '', 32 | descriptionText: '' 33 | } 34 | 35 | this.handleTaskChange = this.handleTaskChange.bind(this) 36 | this.handleNewTaskKeyDown = this.handleNewTaskKeyDown.bind(this) 37 | this.toggleAllTasks = this.toggleAllTasks.bind(this) 38 | this.toggleTask = this.toggleTask.bind(this) 39 | this.destroyTask = this.destroyTask.bind(this) 40 | this.editTask = this.editTask.bind(this) 41 | this.saveTask = this.saveTask.bind(this) 42 | this.cancelEditTask = this.cancelEditTask.bind(this) 43 | this.clearCompleted = this.clearCompleted.bind(this) 44 | 45 | this.editGoal = this.editGoal.bind(this) 46 | this.saveGoal = this.saveGoal.bind(this) 47 | this.cancelEditGoal = this.cancelEditGoal.bind(this) 48 | this.handleDescriptionSubmit = this.handleDescriptionSubmit.bind(this) 49 | this.handleDescriptionChange = this.handleDescriptionChange.bind(this) 50 | } 51 | 52 | componentDidMount () { 53 | const setState = this.setState 54 | let router = Router({ 55 | '/': setState.bind(this, { mode: ALL }), 56 | '/active': setState.bind(this, { mode: ACTIVE }), 57 | '/completed': setState.bind(this, { mode: COMPLETED }) 58 | }) 59 | router.init('/') 60 | 61 | Goals.get(this.props.goalId) 62 | .catch(response => { 63 | console.log('Error retrieving goal.', response) 64 | this.setState({ placeholder: 'Something went wrong' }) 65 | }) 66 | .then((response) => { 67 | const tasks = response.data.tasks 68 | delete response.data.tasks 69 | let goal = response.data 70 | this.setState({ goal: goal, tasks: tasks, loaded: true, descriptionText: goal.description }) 71 | }) 72 | } 73 | 74 | handleTaskChange (event) { 75 | this.setState({ newTask: event.target.value }) 76 | } 77 | 78 | handleNewTaskKeyDown (event) { 79 | if (event.keyCode !== ENTER_KEY) { 80 | return 81 | } 82 | 83 | event.preventDefault() 84 | 85 | const val = this.state.newTask.trim() 86 | 87 | if (val) { 88 | Tasks.add(this.state.goal.id, val).then((response) => { 89 | this.setState({ tasks: this.state.tasks.concat(response.data) }) 90 | }) 91 | this.setState({ newTask: '' }) 92 | } 93 | } 94 | 95 | toggleAllTasks (event) { 96 | const checked = event.target.checked 97 | this.setState({ 98 | tasks: this.state.tasks.map(function (task) { 99 | return Utils.extend({}, task, { completed: checked }) 100 | }) 101 | }) 102 | } 103 | 104 | toggleTask (taskToToggle) { 105 | let newTasks = this.state.tasks.map((task) => { 106 | return task !== taskToToggle 107 | ? task 108 | : Utils.extend({}, task, { completed: !task.completed }) 109 | }) 110 | this.setState({ 111 | tasks: newTasks 112 | }, () => { 113 | let task = this.state.tasks.find((task) => task.id === taskToToggle.id) 114 | Tasks.update(task) 115 | }) 116 | } 117 | 118 | destroyTask (task) { 119 | Tasks.destroy(task).then(() => { 120 | this.setState({ 121 | tasks: this.state.tasks.filter(function (candidate) { 122 | return candidate !== task 123 | }) 124 | }) 125 | }) 126 | } 127 | 128 | editTask (task) { 129 | this.setState({ editingTask: task.id }) 130 | } 131 | 132 | saveTask (taskToSave, text) { 133 | this.setState({ 134 | tasks: this.state.tasks.map(function (task) { 135 | return task !== taskToSave 136 | ? task 137 | : Utils.extend({}, task, { name: text }) 138 | }) 139 | }, () => { 140 | let task = this.state.tasks.filter((task) => task.id === taskToSave.id)[0] 141 | Tasks.update(task).then((response) => { 142 | this.setState({ editingTask: false }) 143 | }) 144 | }) 145 | } 146 | 147 | cancelEditTask () { 148 | this.setState({ editingTask: null }) 149 | } 150 | 151 | clearCompleted () { 152 | let completedTasks = this.state.tasks.filter((task) => task.completed) 153 | completedTasks.map((task) => Tasks.destroy(task)) 154 | this.setState({ tasks: this.state.tasks.filter((task) => !task.completed) }) 155 | } 156 | 157 | editGoal () { 158 | this.setState({ editingGoal: true }) 159 | } 160 | 161 | handleDescriptionSubmit (event) { 162 | this.goal.description = this.state.descriptionText 163 | this.saveGoal() 164 | } 165 | 166 | handleDescriptionChange (event) { 167 | if (this.state.editingGoal) { 168 | this.setState({ descriptionText: event.target.value }) 169 | } 170 | } 171 | 172 | saveGoal (newName) { 173 | Goals.update(this.state.goal.id, newName).then((response) => { 174 | this.setState({ goal: response.data }) 175 | this.setState({ editingGoal: false }) 176 | }) 177 | } 178 | 179 | cancelEditGoal () { 180 | this.setState({ editingGoal: false }) 181 | } 182 | 183 | render () { 184 | let newTaskInput 185 | let main 186 | let footer 187 | 188 | if (!this.state.loaded) { 189 | return
190 | } 191 | 192 | let tasks = this.state.tasks 193 | 194 | const shownTasks = tasks.filter(function (task) { 195 | switch (this.state.mode) { 196 | case ACTIVE: 197 | return !task.completed 198 | case COMPLETED: 199 | return task.completed 200 | default: 201 | return true 202 | } 203 | }, this) 204 | 205 | const taskItems = shownTasks.map((task) => { 206 | return ( 207 | 218 | ) 219 | }, this) 220 | 221 | const activeTaskCount = tasks.reduce(function (accum, task) { 222 | return task.completed ? accum : accum + 1 223 | }, 0) 224 | 225 | const completedCount = tasks.length - activeTaskCount 226 | 227 | if (!this.state.goal.is_public) { 228 | newTaskInput = 236 | } 237 | 238 | if (tasks.length) { 239 | main = ( 240 |
241 | 248 |
255 | ) 256 | } 257 | 258 | if (activeTaskCount || completedCount) { 259 | footer = 260 | 266 | } 267 | 268 | return ( 269 |
270 |
271 | 273 | {newTaskInput} 274 |
275 | {main} 276 | {footer} 277 |
278 | ) 279 | } 280 | } 281 | 282 | GoalDetailPage.propTypes = { 283 | goalId: PropTypes.string.isRequired 284 | } 285 | 286 | const wrapper = document.getElementById('goal') 287 | wrapper ? ReactDOM.render(, wrapper) : null 288 | -------------------------------------------------------------------------------- /frontend/src/components/GoalSummary.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const GoalSummary = (props) => { 5 | const showProgress = props.goal.user_has_started || !props.goal.is_public 6 | const progress = showProgress ?
7 | 9 | {props.goal.percentage_complete === 100 ? ( 10 | {props.goal.percentage_complete} Done! 11 | ) : ( 12 | {props.goal.percentage_complete} % Complete (success) 14 | )} 15 | 16 |
: 18 | 19 | const tasks = props.goal.tasks.map((task) => { 20 | return ( 21 |
  • {task.name}
  • 22 | ) 23 | }, this) 24 | 25 | return
    26 |
    27 |

    28 | {props.goal.name} 29 |

    30 |

    31 | 32 |

    33 |
    34 |
    35 |
    36 |
      37 | {tasks} 38 |
    39 |
    40 |
    41 | {progress} 42 |
    43 | } 44 | 45 | GoalSummary.propTypes = { 46 | goal: PropTypes.object.isRequired, 47 | handleDelete: PropTypes.func.isRequired, 48 | handleStart: PropTypes.func.isRequired 49 | } 50 | 51 | export default GoalSummary 52 | -------------------------------------------------------------------------------- /frontend/src/components/GoalTitle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | 5 | const ESCAPE_KEY = 27 6 | const ENTER_KEY = 13 7 | 8 | class GoalTitle extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { editText: this.props.name } 12 | 13 | this.handleSubmit = this.handleSubmit.bind(this) 14 | this.handleEdit = this.handleEdit.bind(this) 15 | this.handleKeyDown = this.handleKeyDown.bind(this) 16 | this.handleChange = this.handleChange.bind(this) 17 | } 18 | 19 | handleSubmit (event) { 20 | const val = this.state.editText.trim() 21 | if (val) { 22 | this.props.onSave(val) 23 | this.setState({ editText: val }) 24 | } 25 | } 26 | 27 | handleEdit () { 28 | this.props.onEdit() 29 | this.setState({ editText: this.props.name }) 30 | } 31 | 32 | handleKeyDown (event) { 33 | if (event.which === ESCAPE_KEY) { 34 | this.setState({ editText: this.props.name }) 35 | this.props.onCancel(event) 36 | } else if (event.which === ENTER_KEY) { 37 | this.handleSubmit(event) 38 | } 39 | } 40 | 41 | handleChange (event) { 42 | if (this.props.editing) { 43 | this.setState({ editText: event.target.value }) 44 | } 45 | } 46 | 47 | render () { 48 | return ( 49 |

    50 |
    55 |
    56 | {this.props.name} 57 |
    58 | 66 |
    67 | 68 | 69 |
    70 |
    71 |

    72 | ) 73 | } 74 | } 75 | 76 | GoalTitle.propTypes = { 77 | name: PropTypes.string.isRequired, 78 | editing: PropTypes.bool.isRequired, 79 | onEdit: PropTypes.func.isRequired, 80 | onSave: PropTypes.func.isRequired, 81 | onCancel: PropTypes.func.isRequired 82 | } 83 | 84 | export default GoalTitle 85 | -------------------------------------------------------------------------------- /frontend/src/components/Goals.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN' 4 | axios.defaults.xsrfCookieName = 'csrftoken' 5 | 6 | const GoalUrl = '/api/goal' 7 | 8 | const getAll = () => axios.get(`${GoalUrl}/`).catch((error) => { 9 | console.log(error) 10 | window.alert('Could not retrieve goals due to an error.') 11 | }) 12 | 13 | const get = (id) => axios.get(`${GoalUrl}/${id}/`).catch((error) => { 14 | console.log(error) 15 | window.alert('Could not retrieve goal due to an error.') 16 | }) 17 | 18 | const create = (name) => { 19 | let data = { name: name, is_public: false } 20 | return axios.post(`${GoalUrl}/`, data).catch((error) => { 21 | console.log(error) 22 | window.alert('An error prevented creating the goal.') 23 | }) 24 | } 25 | 26 | const update = (id, name) => axios.put(`${GoalUrl}/${id}/`, { 27 | name: name, 28 | id: id 29 | }).catch((error) => { 30 | console.log(error) 31 | window.alert('An error prevented updating the goal.') 32 | }) 33 | 34 | const start = (id) => axios.post(`${GoalUrl}/${id}/start/`).catch((error) => { 35 | console.log(error) 36 | window.alert('An error prevented starting the goal.') 37 | }) 38 | 39 | const destroy = (id) => axios.delete(`${GoalUrl}/${id}/`).catch((error) => { 40 | console.log(error) 41 | window.alert('An error prevented deleting the goal.') 42 | }) 43 | 44 | export default { 45 | getAll: getAll, 46 | get: get, 47 | create: create, 48 | update: update, 49 | start: start, 50 | delete: destroy 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/GoalsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import key from 'weak-key' 4 | import GoalSummary from './GoalSummary' 5 | 6 | class GoalsList extends React.Component { 7 | render () { 8 | const addButton = this.props.showAddButton ?

    9 | Add goal 10 |

    : '' 11 | const moreLink = this.props.moreUrl ?

    12 | See All 13 |

    : '' 14 | 15 | return !this.props.goals.length ? ( 16 |
    17 |

    18 | Create a new learning goal. 19 |

    20 |
    21 | ) : ( 22 |
    23 | {moreLink} 24 |
    25 | {this.props.goals.map( 26 | goal => 28 | )} 29 |
    30 | {addButton} 31 |
    32 | ) 33 | } 34 | } 35 | 36 | GoalsList.propTypes = { 37 | goals: PropTypes.array.isRequired, 38 | showAddButton: PropTypes.bool, 39 | handleDelete: PropTypes.func.isRequired, 40 | handleStart: PropTypes.func.isRequired, 41 | moreUrl: PropTypes.string 42 | } 43 | 44 | export default GoalsList 45 | -------------------------------------------------------------------------------- /frontend/src/components/GoalsListPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Goals from './Goals' 4 | import Utils from './Utils' 5 | import GoalsList from './GoalsList' 6 | import '../css/application.scss' 7 | 8 | class GoalsListPage extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { 12 | goals: [], 13 | loaded: false, 14 | placeholder: 'Loading...' 15 | } 16 | this.handleDelete = this.handleDelete.bind(this) 17 | this.handleStart = this.handleStart.bind(this) 18 | } 19 | 20 | componentDidMount () { 21 | Goals.getAll() 22 | .catch(() => { 23 | this.setState({ placeholder: 'Something went wrong' }) 24 | }) 25 | .then(response => this.setState({ goals: response.data, loaded: true })) 26 | } 27 | 28 | handleDelete (id) { 29 | if (!window.confirm('Delete learning goal?')) { 30 | return 31 | } 32 | 33 | let goal = this.state.goals.filter(goal => goal.id === id)[0] 34 | 35 | Goals.delete(id).then(() => { 36 | if (goal.is_public) { 37 | this.setState({ 38 | goals: this.state.goals.map((goal) => { 39 | return goal.id !== id 40 | ? goal 41 | : Utils.extend({}, goal, { user_has_started: false }) 42 | }) 43 | }) 44 | return 45 | } 46 | this.setState({ 47 | goals: this.state.goals.filter(goal => goal.id !== id) 48 | }) 49 | }) 50 | } 51 | 52 | handleStart (id) { 53 | Goals.start(id).then((request) => { 54 | let goals = this.state.goals.map((goal) => { 55 | return goal.id !== id ? goal : request.data 56 | }) 57 | this.setState({ 58 | goals: goals 59 | }) 60 | }) 61 | } 62 | 63 | render () { 64 | const yourGoals = this.state.goals.filter((goal) => goal.user_has_started || !goal.is_public) 65 | 66 | return
    67 |

    Your Learning Goals

    68 | 70 |
    71 | } 72 | } 73 | 74 | const wrapper = document.getElementById('goals_list') 75 | 76 | wrapper ? ReactDOM.render(, wrapper) : null 77 | -------------------------------------------------------------------------------- /frontend/src/components/Homepage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import GoalsList from './GoalsList' 4 | import Goals from './Goals' 5 | import Utils from './Utils' 6 | import '../css/application.scss' 7 | 8 | class Homepage extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { 12 | goals: [], 13 | loaded: false, 14 | placeholder: 'Loading...' 15 | } 16 | this.handleDelete = this.handleDelete.bind(this) 17 | this.handleStart = this.handleStart.bind(this) 18 | } 19 | 20 | componentDidMount () { 21 | Goals.getAll() 22 | .catch(() => { 23 | this.setState({ placeholder: 'Something went wrong' }) 24 | className="hidden" }) 25 | .then(response => this.setState({ goals: response.data, loaded: true })) 26 | } 27 | 28 | handleDelete (id) { 29 | if (!window.confirm('Delete learning goal?')) { 30 | return 31 | } 32 | 33 | let goal = this.state.goals.filter(goal => goal.id === id)[0] 34 | 35 | Goals.delete(id).then(() => { 36 | if (goal.is_public) { 37 | this.setState({ 38 | goals: this.state.goals.map((goal) => { 39 | return goal.id !== id 40 | ? goal 41 | : Utils.extend({}, goal, { user_has_started: false }) 42 | }) 43 | }) 44 | return 45 | } 46 | this.setState({ 47 | goals: this.state.goals.filter(goal => goal.id !== id) 48 | }) 49 | }) 50 | } 51 | 52 | handleStart (id) { 53 | Goals.start(id).then((request) => { 54 | let goals = this.state.goals.map((goal) => { 55 | return goal.id !== id ? goal : request.data 56 | }) 57 | this.setState({ 58 | goals: goals 59 | }) 60 | }) 61 | } 62 | 63 | render () { 64 | const yourGoals = this.state.goals.filter((goal) => goal.user_has_started || !goal.is_public) 65 | const recommendedGoals = this.state.goals.filter((goal) => goal.is_public && !goal.user_has_started) 66 | let recommended = '' 67 | 68 | if (recommendedGoals.length) { 69 | recommended =
    70 |

    Recommended Goals

    71 | 73 |
    74 | } 75 | 76 | return
    77 |

    Your Learning Goals

    78 | 80 | {recommended} 81 |
    82 | } 83 | } 84 | 85 | const wrapper = document.getElementById('homepage') 86 | 87 | wrapper ? ReactDOM.render(, wrapper) : null 88 | -------------------------------------------------------------------------------- /frontend/src/components/NewGoalPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Goals from './Goals' 4 | import '../css/application.scss' 5 | 6 | const wrapper = document.getElementById('goal') 7 | 8 | class NewGoalPage extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { 12 | name: '' 13 | } 14 | this.handleChange = this.handleChange.bind(this) 15 | this.handleCancel = this.handleCancel.bind(this) 16 | this.handleSubmit = this.handleSubmit.bind(this) 17 | } 18 | 19 | handleChange (event) { 20 | this.setState({ name: event.target.value }) 21 | } 22 | 23 | handleCancel (event) { 24 | window.location.replace('/') 25 | } 26 | 27 | handleSubmit (event) { 28 | event.preventDefault() 29 | Goals.create(this.state.name).then((response) => { 30 | window.location.replace(`/goal/${response.data.id}`) 31 | }) 32 | } 33 | 34 | render () { 35 | return ( 36 |
    37 |

    New Goal

    38 |
    39 |
    40 | 41 |
    42 | 44 |
    45 |
    46 | 47 |
    48 |
    49 | 50 |
    51 |
    52 | Cancel 53 |
    54 |
    55 |
    56 |
    57 | ) 58 | } 59 | } 60 | 61 | wrapper ? ReactDOM.render(, wrapper) : null 62 | -------------------------------------------------------------------------------- /frontend/src/components/Task.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classNames from 'classnames' 4 | 5 | const ESCAPE_KEY = 27 6 | const ENTER_KEY = 13 7 | 8 | class Task extends React.Component { 9 | constructor (props) { 10 | super(props) 11 | this.state = { editText: this.props.task.name } 12 | 13 | this.handleSubmit = this.handleSubmit.bind(this) 14 | this.handleEdit = this.handleEdit.bind(this) 15 | this.handleKeyDown = this.handleKeyDown.bind(this) 16 | this.handleChange = this.handleChange.bind(this) 17 | } 18 | 19 | handleSubmit (event) { 20 | const val = this.state.editText.trim() 21 | if (val) { 22 | this.props.onSave(val) 23 | this.setState({ editText: val }) 24 | } else { 25 | this.props.onDestroy() 26 | } 27 | } 28 | 29 | handleEdit () { 30 | this.props.onEdit(this.props.task) 31 | this.setState({ editText: this.props.task.name }) 32 | } 33 | 34 | handleKeyDown (event) { 35 | if (event.which === ESCAPE_KEY) { 36 | this.setState({ editText: this.props.task.name }) 37 | this.props.onCancel(event) 38 | } else if (event.which === ENTER_KEY) { 39 | this.handleSubmit(event) 40 | } 41 | } 42 | 43 | handleChange (event) { 44 | if (this.props.editing) { 45 | this.setState({ editText: event.target.value }) 46 | } 47 | } 48 | 49 | render () { 50 | let destroyButton 51 | 52 | if (!this.props.goal.is_public) { 53 | destroyButton = 22 | ) 23 | } 24 | 25 | const mode = this.props.mode 26 | return ( 27 | 58 | ) 59 | } 60 | } 61 | 62 | TasksFooter.propTypes = { 63 | count: PropTypes.number.isRequired, 64 | completedCount: PropTypes.number.isRequired, 65 | onClearCompleted: PropTypes.func.isRequired, 66 | mode: PropTypes.string.isRequired 67 | } 68 | 69 | export default TasksFooter 70 | -------------------------------------------------------------------------------- /frontend/src/components/Utils.js: -------------------------------------------------------------------------------- 1 | /* global localStorage */ 2 | 3 | const Utils = { 4 | uuid: function () { 5 | let i, random 6 | let uuid = '' 7 | 8 | for (i = 0; i < 32; i++) { 9 | random = Math.random() * 16 | 0 10 | if (i === 8 || i === 12 || i === 16 || i === 20) { 11 | uuid += '-' 12 | } 13 | uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) 14 | .toString(16) 15 | } 16 | 17 | return uuid 18 | }, 19 | 20 | pluralize: function (count, word) { 21 | return count === 1 ? word : word + 's' 22 | }, 23 | 24 | store: function (namespace, data) { 25 | if (data) { 26 | return localStorage.setItem(namespace, JSON.stringify(data)) 27 | } 28 | 29 | const store = localStorage.getItem(namespace) 30 | return (store && JSON.parse(store)) || [] 31 | }, 32 | 33 | extend: function () { 34 | const newObj = {} 35 | for (let i = 0; i < arguments.length; i++) { 36 | const obj = arguments[i] 37 | for (const key in obj) { 38 | if (obj.hasOwnProperty(key)) { 39 | newObj[key] = obj[key] 40 | } 41 | } 42 | } 43 | return newObj 44 | } 45 | } 46 | 47 | export default Utils 48 | -------------------------------------------------------------------------------- /frontend/src/css/application.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | */ 14 | @import "dashboard"; 15 | @import "learning_goals"; 16 | 17 | 18 | // Extra small screen / phone 19 | //** Deprecated `$screen-xs` as of v3.0.1 20 | $screen-xs: 480px !default; 21 | 22 | //** Deprecated `$screen-xs-min` as of v3.2.0 23 | $screen-xs-min: $screen-xs !default; 24 | 25 | //** Deprecated `$screen-phone` as of v3.0.1 26 | $screen-phone: $screen-xs-min !default; 27 | 28 | // Small screen / tablet 29 | //** Deprecated `$screen-sm` as of v3.0.1 30 | $screen-sm: 768px !default; 31 | $screen-sm-min: $screen-sm !default; 32 | 33 | //** Deprecated `$screen-tablet` as of v3.0.1 34 | $screen-tablet: $screen-sm-min !default; 35 | 36 | // Medium screen / desktop 37 | //** Deprecated `$screen-md` as of v3.0.1 38 | $screen-md: 992px !default; 39 | $screen-md-min: $screen-md !default; 40 | 41 | //** Deprecated `$screen-desktop` as of v3.0.1 42 | $screen-desktop: $screen-md-min !default; 43 | 44 | // Large screen / wide desktop 45 | //** Deprecated `$screen-lg` as of v3.0.1 46 | $screen-lg: 1200px !default; 47 | $screen-lg-min: $screen-lg !default; 48 | 49 | //** Deprecated `$screen-lg-desktop` as of v3.0.1 50 | $screen-lg-desktop: $screen-lg-min !default; 51 | 52 | // So media queries don't overlap when required, provide a maximum 53 | $screen-xs-max: $screen-sm-min - 1 !default; 54 | $screen-sm-max: $screen-md-min - 1 !default; 55 | $screen-md-max: $screen-lg-min - 1 !default; 56 | 57 | .hidden { 58 | display: none; 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/css/dashboard.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the dashboard controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | 5 | .demo-container { 6 | height: 400px; 7 | } 8 | 9 | .demo-placeholder { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/css/learning_goals.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the LearningGoals controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | 5 | .learning-goals { 6 | overflow: auto; 7 | display: grid; 8 | grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); 9 | grid-gap: 0.5em; 10 | } 11 | 12 | .goal-button { 13 | float: right; 14 | margin-left: 5px; 15 | cursor: pointer; 16 | } 17 | 18 | .panel-heading { 19 | overflow: hidden; 20 | } 21 | 22 | .progress-container { 23 | margin: 1rem; 24 | } 25 | 26 | h4 { 27 | overflow: hidden; 28 | } 29 | 30 | .row-card { 31 | height: max-content 32 | progress { 33 | border-radius: 0; 34 | padding: 2rem; 35 | } 36 | } 37 | 38 | .learning-goal { 39 | .content { 40 | min-height: 100px; 41 | } 42 | 43 | /* .card-header-icon {*/ 44 | //display: none; 45 | //} 46 | 47 | //&.card:hover { 48 | //.card-header-icon { 49 | //display: block; 50 | //} 51 | /*}*/ 52 | } 53 | 54 | .learning-nav { 55 | padding: 3.5rem 1.5rem 0; 56 | } 57 | 58 | .learning-goals { 59 | padding: 2rem 1rem 2rem; 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/css/materials.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the Materials controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /frontend/src/css/scaffolds.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | color: #333; 4 | font-family: verdana, arial, helvetica, sans-serif; 5 | font-size: 13px; 6 | line-height: 18px; 7 | margin: 33px; 8 | } 9 | 10 | p, ol, ul, td { 11 | font-family: verdana, arial, helvetica, sans-serif; 12 | font-size: 13px; 13 | line-height: 18px; 14 | margin: 33px; 15 | } 16 | 17 | pre { 18 | background-color: #eee; 19 | padding: 10px; 20 | font-size: 11px; 21 | } 22 | 23 | a { 24 | color: #000; 25 | 26 | &:visited { 27 | color: #666; 28 | } 29 | 30 | &:hover { 31 | color: #fff; 32 | background-color: #000; 33 | } 34 | } 35 | 36 | th { 37 | padding-bottom: 5px; 38 | } 39 | 40 | td { 41 | padding-bottom: 7px; 42 | padding-left: 5px; 43 | padding-right: 5px; 44 | } 45 | 46 | div { 47 | &.field, &.actions { 48 | margin-bottom: 10px; 49 | } 50 | } 51 | 52 | #notice { 53 | color: green; 54 | } 55 | 56 | .field_with_errors { 57 | padding: 2px; 58 | background-color: red; 59 | display: table; 60 | } 61 | 62 | #error_explanation { 63 | width: 450px; 64 | border: 2px solid red; 65 | padding: 7px; 66 | padding-bottom: 0; 67 | margin-bottom: 20px; 68 | background-color: #f0f0f0; 69 | 70 | h2 { 71 | text-align: left; 72 | font-weight: bold; 73 | padding: 5px 5px 5px 15px; 74 | font-size: 12px; 75 | margin: -7px; 76 | margin-bottom: 0; 77 | background-color: #c00; 78 | color: #fff; 79 | } 80 | 81 | ul li { 82 | font-size: 12px; 83 | list-style: square; 84 | } 85 | } 86 | 87 | label { 88 | display: block; 89 | } 90 | -------------------------------------------------------------------------------- /frontend/src/css/tasks.scss: -------------------------------------------------------------------------------- 1 | :focus { 2 | outline: 0; 3 | } 4 | 5 | #goal { 6 | background: #fff; 7 | margin: 100px 0 40px 0; 8 | position: relative; 9 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 10 | 0 25px 50px 0 white; 11 | } 12 | 13 | #goal input::-webkit-input-placeholder { 14 | font-style: italic; 15 | font-weight: 300; 16 | color: #808080; 17 | } 18 | 19 | #goal input::-moz-placeholder { 20 | font-style: italic; 21 | font-weight: 300; 22 | color: #808080; 23 | } 24 | 25 | #goal input::input-placeholder { 26 | font-style: italic; 27 | font-weight: 300; 28 | color: #808080; 29 | } 30 | 31 | #goal h1 { 32 | position: absolute; 33 | top: -100px; 34 | width: 100%; 35 | //font-size: 100px; 36 | //font-weight: 100; 37 | //text-align: center; 38 | //color: rgba(175, 47, 47, 0.15); 39 | //-webkit-text-rendering: optimizeLegibility; 40 | //-moz-text-rendering: optimizeLegibility; 41 | //text-rendering: optimizeLegibility; 42 | } 43 | 44 | .goal-title .edit { 45 | display: none; 46 | } 47 | 48 | .goal-title.editing .edit { 49 | display: block; 50 | } 51 | 52 | .goal-title.editing .view { 53 | display: none; 54 | } 55 | 56 | .new-task, 57 | .edit { 58 | position: relative; 59 | margin: 0; 60 | width: 100%; 61 | font-size: 28px; 62 | font-family: inherit; 63 | font-weight: inherit; 64 | line-height: 1.4em; 65 | border: 0; 66 | color: inherit; 67 | padding: 6px; 68 | border: 1px solid #999; 69 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 70 | box-sizing: border-box; 71 | -webkit-font-smoothing: antialiased; 72 | -moz-osx-font-smoothing: grayscale; 73 | } 74 | 75 | .new-task { 76 | padding: 16px 16px 16px 60px; 77 | border: none; 78 | background: rgba(0, 0, 0, 0.003); 79 | box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); 80 | } 81 | 82 | .main { 83 | position: relative; 84 | z-index: 2; 85 | border-top: 1px solid #e6e6e6; 86 | } 87 | 88 | .toggle-all { 89 | text-align: center; 90 | border: none; /* Mobile Safari */ 91 | opacity: 0; 92 | position: absolute; 93 | } 94 | 95 | .toggle-all + label { 96 | width: 60px; 97 | height: 34px; 98 | font-size: 0; 99 | position: absolute; 100 | top: -52px; 101 | left: -13px; 102 | -webkit-transform: rotate(90deg); 103 | transform: rotate(90deg); 104 | } 105 | 106 | .toggle-all + label:before { 107 | content: '❯'; 108 | font-size: 22px; 109 | color: #e6e6e6; 110 | padding: 10px 27px 10px 27px; 111 | } 112 | 113 | .toggle-all:checked + label:before { 114 | color: #737373; 115 | } 116 | 117 | .task-list { 118 | margin: 0; 119 | padding: 0; 120 | list-style: none; 121 | } 122 | 123 | .task-list li { 124 | position: relative; 125 | font-size: 24px; 126 | border-bottom: 1px solid #ededed; 127 | } 128 | 129 | .task-list li:last-child { 130 | border-bottom: none; 131 | } 132 | 133 | .task-list li.editing { 134 | border-bottom: none; 135 | padding: 0; 136 | } 137 | 138 | .task-list li.editing .edit { 139 | display: block; 140 | width: 506px; 141 | padding: 12px 16px; 142 | margin: 0 0 0 43px; 143 | } 144 | 145 | .task-list li.editing .view { 146 | display: none; 147 | } 148 | 149 | .task-list li .toggle { 150 | text-align: center; 151 | width: 40px; 152 | /* auto, since non-WebKit browsers doesn't support input styling */ 153 | height: auto; 154 | position: absolute; 155 | top: 0; 156 | bottom: 0; 157 | margin: auto 0; 158 | border: none; /* Mobile Safari */ 159 | -webkit-appearance: none; 160 | appearance: none; 161 | } 162 | 163 | .task-list li .toggle { 164 | opacity: 0; 165 | } 166 | 167 | .task-list li .toggle + label { 168 | /* 169 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 170 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 171 | */ 172 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 173 | background-repeat: no-repeat; 174 | background-position: center left; 175 | } 176 | 177 | .task-list li .toggle:checked + label { 178 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 179 | } 180 | 181 | .task-list li label { 182 | word-break: break-all; 183 | padding: 15px 15px 15px 60px; 184 | display: block; 185 | line-height: 1.2; 186 | transition: color 0.4s; 187 | } 188 | 189 | .task-list li.completed label { 190 | color: #d9d9d9; 191 | text-decoration: line-through; 192 | } 193 | 194 | .task-list li .destroy { 195 | display: none; 196 | position: absolute; 197 | top: 0; 198 | right: 10px; 199 | bottom: 0; 200 | width: 40px; 201 | height: 40px; 202 | margin: auto 0; 203 | font-size: 30px; 204 | color: #cc9a9a; 205 | margin-bottom: 11px; 206 | transition: color 0.2s ease-out; 207 | } 208 | 209 | .task-list li .destroy:hover { 210 | color: #af5b5e; 211 | } 212 | 213 | .task-list li .destroy:after { 214 | content: '×'; 215 | } 216 | 217 | .task-list li:hover .destroy { 218 | display: block; 219 | } 220 | 221 | .task-list li .edit { 222 | display: none; 223 | } 224 | 225 | .task-list li.editing:last-child { 226 | margin-bottom: -1px; 227 | } 228 | 229 | .footer { 230 | color: #777; 231 | padding: 10px 15px; 232 | height: 45px; 233 | text-align: center; 234 | border-top: 1px solid #e6e6e6; 235 | } 236 | 237 | .footer:before { 238 | content: ''; 239 | position: absolute; 240 | right: 0; 241 | bottom: 0; 242 | left: 0; 243 | height: 50px; 244 | overflow: hidden; 245 | } 246 | 247 | .task-count { 248 | float: left; 249 | text-align: left; 250 | } 251 | 252 | .task-count strong { 253 | font-weight: 300; 254 | } 255 | 256 | .filters { 257 | margin: 0; 258 | padding: 0; 259 | list-style: none; 260 | position: absolute; 261 | right: 0; 262 | left: 0; 263 | } 264 | 265 | .filters li { 266 | display: inline; 267 | } 268 | 269 | .filters li a { 270 | color: inherit; 271 | margin: 3px; 272 | padding: 3px 7px; 273 | text-decoration: none; 274 | border: 1px solid transparent; 275 | border-radius: 3px; 276 | } 277 | 278 | .filters li a:hover { 279 | border-color: rgba(175, 47, 47, 0.1); 280 | } 281 | 282 | .filters li a.selected { 283 | border-color: rgba(175, 47, 47, 0.2); 284 | } 285 | 286 | .clear-completed, 287 | html .clear-completed:active { 288 | float: right; 289 | position: relative; 290 | line-height: 20px; 291 | text-decoration: none; 292 | cursor: pointer; 293 | } 294 | 295 | .clear-completed:hover { 296 | text-decoration: underline; 297 | } 298 | 299 | .info { 300 | margin: 65px auto 0; 301 | color: #bfbfbf; 302 | font-size: 10px; 303 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 304 | text-align: center; 305 | } 306 | 307 | .info p { 308 | line-height: 1; 309 | } 310 | 311 | .info a { 312 | color: inherit; 313 | text-decoration: none; 314 | font-weight: 400; 315 | } 316 | 317 | .info a:hover { 318 | text-decoration: underline; 319 | } 320 | 321 | /* 322 | Hack to remove background from Mobile Safari. 323 | Can't use it globally since it destroys checkboxes in Firefox 324 | */ 325 | @media screen and (-webkit-min-device-pixel-ratio: 0) { 326 | .toggle-all, 327 | .task-list li .toggle { 328 | background: none; 329 | } 330 | 331 | .task-list li .toggle { 332 | height: 40px; 333 | } 334 | } 335 | 336 | @media (max-width: 430px) { 337 | .footer { 338 | height: 50px; 339 | } 340 | 341 | .filters { 342 | bottom: 10px; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /frontend/src/goal.js: -------------------------------------------------------------------------------- 1 | import GoalDetailPage from './components/GoalDetailPage' 2 | -------------------------------------------------------------------------------- /frontend/src/goals_list.js: -------------------------------------------------------------------------------- 1 | import GoalListPage from "./components/GoalsListPage"; 2 | -------------------------------------------------------------------------------- /frontend/src/home.js: -------------------------------------------------------------------------------- 1 | import Homepage from './components/Homepage' 2 | -------------------------------------------------------------------------------- /frontend/src/new_goal.js: -------------------------------------------------------------------------------- 1 | import NewGoalPage from './components/NewGoalPage' 2 | -------------------------------------------------------------------------------- /frontend/static/frontend/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/static/frontend/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /frontend/static/frontend/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/static/frontend/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /frontend/static/frontend/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/static/frontend/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /frontend/static/frontend/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/frontend/static/frontend/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /frontend/templates/frontend/goal.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | 5 | {% block title %}Learning Goals{% endblock %} 6 | 7 | {% block content %} 8 |
    9 |
    10 | {% endblock content %} 11 | 12 | {% block extra_javascript %} 13 | 14 | {% endblock extra_javascript %} 15 | -------------------------------------------------------------------------------- /frontend/templates/frontend/goals_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Learning Goals{% endblock %} 5 | 6 | {% block content %} 7 |
    8 | {% endblock content %} 9 | 10 | {% block extra_javascript %} 11 | 12 | {% endblock extra_javascript %} 13 | -------------------------------------------------------------------------------- /frontend/templates/frontend/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block title %}Home{% endblock %} 5 | 6 | {% block content %} 7 |
    8 | {% endblock content %} 9 | 10 | {% block extra_javascript %} 11 | 12 | {% endblock extra_javascript %} 13 | -------------------------------------------------------------------------------- /frontend/templates/frontend/new_goal.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | 5 | {% block title %}Learning Goals{% endblock %} 6 | 7 | {% block content %} 8 |
    9 | {% endblock content %} 10 | 11 | {% block extra_javascript %} 12 | 13 | {% endblock extra_javascript %} 14 | -------------------------------------------------------------------------------- /frontend/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /frontend/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | path('', views.home), 6 | path('goals', views.goals_list), 7 | path('goal/new/', views.new_goal, name='new_goal'), 8 | path('goal//', views.goal_detail, name='goal_detail'), 9 | ] 10 | -------------------------------------------------------------------------------- /frontend/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.shortcuts import render 3 | 4 | 5 | @login_required 6 | def home(request): 7 | """Render the authenticated homepage""" 8 | return render(request, "frontend/home.html") 9 | 10 | 11 | @login_required 12 | def goals_list(request): 13 | """Render the goals list page""" 14 | return render(request, 'frontend/goals_list.html') 15 | 16 | 17 | @login_required 18 | def goal_detail(request, pk): 19 | """Render the goal page for an existing goal""" 20 | return render(request, 'frontend/goal.html', { 21 | "goal_id": pk 22 | }) 23 | 24 | 25 | @login_required 26 | def new_goal(request): 27 | """Render the new goal form page""" 28 | return render(request, 'frontend/new_goal.html') 29 | -------------------------------------------------------------------------------- /goals/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/goals/__init__.py -------------------------------------------------------------------------------- /goals/admin.py: -------------------------------------------------------------------------------- 1 | from quest.admin import admin_site 2 | 3 | from .models import Goal, Task, TaskStatus 4 | 5 | admin_site.register(Goal) 6 | admin_site.register(Task) 7 | admin_site.register(TaskStatus) 8 | -------------------------------------------------------------------------------- /goals/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GoalsConfig(AppConfig): 5 | name = 'goals' 6 | -------------------------------------------------------------------------------- /goals/management/commands/refresh_summaries.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db import connection, transaction 3 | 4 | 5 | # tag::refreshing-materialized-views[] 6 | class Command(BaseCommand): 7 | help = 'Refresh Goal summaries' 8 | 9 | def handle(self, *args, **options): 10 | with transaction.atomic(): 11 | with connection.cursor() as cursor: # <1> 12 | cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY goals_summaries") # <2> 13 | # end::refreshing-materialized-views[] 14 | -------------------------------------------------------------------------------- /goals/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2018-12-26 17:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Goal', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.TextField(help_text='The title of the goal')), 19 | ('description', models.TextField(help_text='The description of the goal')), 20 | ('summary_image', models.ImageField(help_text='An image for the goal', upload_to='goals')), 21 | ('slug', models.SlugField(help_text='The text for this goal used in its URL', max_length=100)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /goals/migrations/0002_auto_20181226_1723.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2018-12-26 17:23 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='goal', 15 | old_name='summary_image', 16 | new_name='image', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /goals/migrations/0003_auto_20181228_1821.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2018-12-28 18:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0002_auto_20181226_1723'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='goal', 15 | name='title', 16 | ), 17 | migrations.AddField( 18 | model_name='goal', 19 | name='name', 20 | field=models.TextField(default='Goal', help_text='The name of the goal'), 21 | preserve_default=False, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /goals/migrations/0004_auto_20181229_1534.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2018-12-29 15:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0003_auto_20181228_1821'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='goal', 15 | name='name', 16 | field=models.CharField(help_text='The name of the goal', max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /goals/migrations/0005_goal_percentage_complete.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-01 15:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0004_auto_20181229_1534'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='goal', 15 | name='percentage_complete', 16 | field=models.PositiveIntegerField(default=0, help_text='The percentage of the goal complete'), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /goals/migrations/0006_remove_goal_percentage_complete.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-04 22:08 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0005_goal_percentage_complete'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='goal', 15 | name='percentage_complete', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /goals/migrations/0007_auto_20190104_2215.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-04 22:15 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 | ('goals', '0006_remove_goal_percentage_complete'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Task', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(help_text='The name of the goal', max_length=255)), 19 | ('url', models.URLField(help_text='The URL of this task')), 20 | ], 21 | ), 22 | migrations.AlterField( 23 | model_name='goal', 24 | name='description', 25 | field=models.TextField(blank=True, help_text='The description of the goal', null=True), 26 | ), 27 | migrations.AddField( 28 | model_name='task', 29 | name='goal', 30 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='goals.Goal'), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /goals/migrations/0008_task_completed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-04 22:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0007_auto_20190104_2215'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='task', 15 | name='completed', 16 | field=models.BooleanField(default=False, help_text='Whether or not this task is complete'), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /goals/migrations/0009_auto_20190115_1411.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-15 14:11 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('goals', '0008_task_completed'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='goal', 18 | name='user', 19 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='goals', to=settings.AUTH_USER_MODEL), 20 | preserve_default=False, 21 | ), 22 | migrations.AlterField( 23 | model_name='task', 24 | name='completed', 25 | field=models.BooleanField(default=False, help_text='Whether or not this task is complete'), 26 | ), 27 | migrations.AlterField( 28 | model_name='task', 29 | name='goal', 30 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='goals.Goal'), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /goals/migrations/0010_goal_is_public.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-16 13:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goals', '0009_auto_20190115_1411'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='goal', 15 | name='is_public', 16 | field=models.BooleanField(default=False, help_text='Whether or not this goal is publicly accessible'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /goals/migrations/0011_auto_20190118_1333.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-18 13:33 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('goals', '0010_goal_is_public'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='TaskStatus', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('status', models.PositiveSmallIntegerField(choices=[(1, 'Incomplete'), (2, 'Done')], default=False, help_text='The status of this task')), 21 | ], 22 | ), 23 | migrations.RemoveField( 24 | model_name='task', 25 | name='completed', 26 | ), 27 | migrations.AddField( 28 | model_name='taskstatus', 29 | name='task', 30 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='goals.Task'), 31 | ), 32 | migrations.AddField( 33 | model_name='taskstatus', 34 | name='user', 35 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='task_statuses', to=settings.AUTH_USER_MODEL), 36 | ), 37 | migrations.AlterUniqueTogether( 38 | name='taskstatus', 39 | unique_together={('user', 'task')}, 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /goals/migrations/0012_auto_20190123_1341.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-23 13:41 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('goals', '0011_auto_20190118_1333'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='goal', 17 | name='user', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goals', to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /goals/migrations/0013_auto_20200804_2346.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-04 23:46 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('goals', '0012_auto_20190123_1341'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='goal', 16 | name='created_at', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='goal', 22 | name='updated_at', 23 | field=models.DateTimeField(auto_now=True), 24 | ), 25 | migrations.AddField( 26 | model_name='task', 27 | name='created_at', 28 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 29 | preserve_default=False, 30 | ), 31 | migrations.AddField( 32 | model_name='task', 33 | name='updated_at', 34 | field=models.DateTimeField(auto_now=True), 35 | ), 36 | migrations.AddField( 37 | model_name='taskstatus', 38 | name='created_at', 39 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 40 | preserve_default=False, 41 | ), 42 | migrations.AddField( 43 | model_name='taskstatus', 44 | name='updated_at', 45 | field=models.DateTimeField(auto_now=True), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /goals/migrations/0014_auto_20200805_0642.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2020-08-05 06:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ('goals', '0013_auto_20200804_2346'), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='GoalSummary', 14 | fields=[ 15 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 16 | ('created_at', models.DateTimeField(auto_now_add=True)), 17 | ('updated_at', models.DateTimeField(auto_now=True)), 18 | ('completed_tasks', models.PositiveSmallIntegerField(default=0, help_text='Completed tasks for this goal')), 19 | ], 20 | options={ 21 | 'managed': False, 22 | }, 23 | ), 24 | migrations.AlterField( 25 | model_name='taskstatus', 26 | name='status', 27 | field=models.PositiveSmallIntegerField(choices=[(1, 'Started'), (2, 'Done')], default=False, help_text='The status of this task'), 28 | ), 29 | migrations.RunSQL( # <1> 30 | """ 31 | CREATE MATERIALIZED VIEW goals_goalsummary AS 32 | SELECT "goals_goal"."id" as goal_id, CURRENT_DATE as "date", 33 | COALESCE((SELECT COUNT(U0."id") AS "count" FROM "goals_taskstatus" U0 INNER JOIN "goals_task" U1 ON (U0."task_id" = U1."id") WHERE (U0."status" = 2 AND U1."goal_id" = "goals_goal"."id") GROUP BY U1."goal_id"), 0) AS "completed_tasks" 34 | FROM "goals_goal" 35 | ORDER BY "completed_tasks" DESC; 36 | CREATE UNIQUE INDEX goals_goalsummary_pk ON goals_goalsummary(goal_id); 37 | """, 38 | """ 39 | DROP MATERIALIZED VIEW goals_goalsummary; 40 | """ 41 | ) 42 | ] 43 | -------------------------------------------------------------------------------- /goals/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/goals/migrations/__init__.py -------------------------------------------------------------------------------- /goals/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.db.models import Q 4 | 5 | from quest.models import QuestModel 6 | 7 | 8 | # tag::Task[] 9 | class Task(QuestModel): 10 | goal = models.ForeignKey( 11 | 'Goal', on_delete=models.CASCADE, related_name='tasks') 12 | name = models.CharField(help_text="The name of the goal", max_length=255) 13 | url = models.URLField(help_text="The URL of this task") 14 | 15 | # ... 16 | # end::Task[] 17 | 18 | def is_completed(self, user): 19 | return self.statuses.filter(user=user, status=TaskStatus.DONE).exists() 20 | 21 | def complete(self, user): 22 | status, _ = self.statuses.get_or_create(user=user) 23 | status.status = TaskStatus.DONE 24 | status.save() 25 | 26 | def __str__(self): 27 | return 'Task: {}'.format(self.name) 28 | 29 | 30 | # tag::TaskStatus[] 31 | class TaskStatusManager(models.Manager): 32 | def completed(self): 33 | return self.filter(status=TaskStatus.DONE) 34 | 35 | def started(self): 36 | return self.filter(status=TaskStatus.STARTED) 37 | 38 | 39 | class TaskStatus(QuestModel): 40 | STARTED = 1 41 | DONE = 2 42 | CHOICES = ( 43 | (STARTED, 'Started'), 44 | (DONE, 'Done'), 45 | ) 46 | 47 | task = models.ForeignKey( 48 | 'Task', on_delete=models.CASCADE, related_name='statuses') 49 | user = models.ForeignKey( 50 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, 51 | related_name='task_statuses') 52 | status = models.PositiveSmallIntegerField( 53 | help_text="The status of this task", default=False, choices=CHOICES) 54 | 55 | objects = TaskStatusManager() 56 | 57 | # ... 58 | 59 | # end::TaskStatus[] 60 | 61 | def complete(self): 62 | self.status = self.DONE 63 | self.save() 64 | 65 | def status_text(self): 66 | if self.status == self.STARTED: 67 | return 'incomplete' 68 | elif self.status == self.DONE: 69 | return 'done' 70 | return 'done' 71 | 72 | def __str__(self): 73 | return "{} {} by {}".format(self.task.name, self.status_text, 74 | self.user) 75 | 76 | class Meta: 77 | unique_together = ('user', 'task') 78 | 79 | 80 | # tag::Goal[] 81 | class Goal(QuestModel): 82 | user = models.ForeignKey( 83 | settings.AUTH_USER_MODEL, 84 | on_delete=models.CASCADE, 85 | related_name='goals', 86 | null=True, 87 | blank=True) 88 | name = models.CharField(help_text="The name of the goal", max_length=255) 89 | description = models.TextField( 90 | help_text="The description of the goal", blank=True, null=True) 91 | image = models.ImageField( 92 | help_text="An image for the goal", upload_to="goals") 93 | slug = models.SlugField( 94 | max_length=100, help_text="The text for this goal used in its URL") 95 | is_public = models.BooleanField( 96 | default=False, 97 | help_text="Whether or not this goal is publicly accessible") 98 | 99 | # ... 100 | # end::Goal[] 101 | 102 | def __str__(self): 103 | return self.name 104 | 105 | def percentage_complete(self, user): 106 | completed = self.tasks.filter( 107 | statuses__status=TaskStatus.DONE, statuses__user=user).count() 108 | if completed == 0: 109 | return 0 110 | return (completed / self.tasks.count()) * 100 111 | 112 | def has_started(self, user): 113 | return self.tasks.filter( 114 | Q(statuses__status=TaskStatus.STARTED) 115 | | Q(statuses__status=TaskStatus.STARTED), 116 | statuses__user=user).exists() 117 | 118 | def start(self, user): 119 | first_task = self.tasks.first() 120 | if first_task: 121 | first_task.statuses.get_or_create( 122 | status=TaskStatus.STARTED, user=user) 123 | 124 | def clear_status_for_user(self, user): 125 | TaskStatus.objects.filter( 126 | task__in=self.tasks.all(), user=user).delete() 127 | 128 | 129 | # tag::GoalSummary[] 130 | class GoalSummary(models.Model): 131 | goal = models.OneToOneField( 132 | 'Goal', on_delete=models.CASCADE, related_name='summaries', 133 | primary_key=True) 134 | completed_tasks = models.PositiveSmallIntegerField( 135 | help_text="Completed tasks for this goal", default=0) 136 | date = models.DateTimeField() 137 | 138 | class Meta: 139 | managed = False # <1> 140 | # end::GoalSummary[] 141 | -------------------------------------------------------------------------------- /goals/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from analytics.models import Event 4 | from goals.models import Goal, Task, TaskStatus 5 | 6 | 7 | class NewTaskSerializer(serializers.ModelSerializer): 8 | goal = serializers.PrimaryKeyRelatedField(queryset=Goal.objects.all()) 9 | completed = serializers.SerializerMethodField('is_completed', read_only=True) 10 | 11 | class Meta: 12 | model = Task 13 | fields = ('id', 'name', 'goal', 'completed') 14 | 15 | def is_completed(self, task): 16 | # TODO: New tasks are always "started" (incomplete) -- 17 | # is there a better way to express that? 18 | return False 19 | 20 | 21 | class UpdateTaskSerializer(serializers.ModelSerializer): 22 | goal = serializers.PrimaryKeyRelatedField(queryset=Goal.objects.all()) 23 | completed = serializers.BooleanField(write_only=True) 24 | 25 | class Meta: 26 | model = Task 27 | fields = ('id', 'name', 'goal', 'completed') 28 | 29 | def update(self, instance, validated_data): 30 | # TODO: This should be somewhere else - maybe a service object. 31 | completed = validated_data.pop('completed') 32 | task = super().update(instance, validated_data) 33 | status = TaskStatus.DONE if completed else TaskStatus.STARTED 34 | task_status, _ = task.statuses.get_or_create( 35 | user=self.context['request'].user) 36 | task_status.status = status 37 | task_status.save() 38 | return instance 39 | 40 | 41 | class TaskSerializer(serializers.ModelSerializer): 42 | goal = serializers.PrimaryKeyRelatedField(queryset=Goal.objects.all()) 43 | completed = serializers.SerializerMethodField('is_completed') 44 | 45 | class Meta: 46 | model = Task 47 | fields = ('id', 'name', 'goal', 'completed') 48 | 49 | def is_completed(self, task): 50 | return task.statuses.filter( 51 | user=self.context['request'].user, 52 | task=task, 53 | status=TaskStatus.DONE).exists() 54 | 55 | 56 | class NewGoalSerializer(serializers.ModelSerializer): 57 | user = serializers.HiddenField(default=serializers.CurrentUserDefault()) 58 | 59 | class Meta: 60 | model = Goal 61 | fields = ('id', 'user', 'name') 62 | 63 | 64 | # tag::goal-serializer-a[] 65 | class GoalSerializer(serializers.ModelSerializer): 66 | # ... 67 | # end::goal-serializer-a[] 68 | tasks = TaskSerializer(many=True) 69 | percentage_complete = serializers.SerializerMethodField( 70 | 'calc_percentage_complete') 71 | user_has_started = serializers.SerializerMethodField('has_started') 72 | total_have_started = serializers.SerializerMethodField('total_started') 73 | # tag::goal-serializer-b[] 74 | total_views = serializers.SerializerMethodField('views') 75 | # ... 76 | # end::goal-serializer-b[] 77 | 78 | class Meta: 79 | model = Goal 80 | fields = ('id', 'name', 'description', 'slug', 'tasks', 81 | 'percentage_complete', 'user_has_started', 'total_have_started', 82 | 'is_public', 'total_views') 83 | 84 | def calc_percentage_complete(self, goal): 85 | return goal.percentage_complete(self.context['request'].user) 86 | 87 | def has_started(self, goal): 88 | return goal.has_started(self.context['request'].user) 89 | 90 | def total_started(self, goal): 91 | return goal.tasks.filter(statuses__status=TaskStatus.STARTED).count() 92 | 93 | # tag::goal-serializer-c[] 94 | def views(self, goal): 95 | return Event.objects.filter( 96 | name='user.viewed', 97 | data__goal=goal.id 98 | ).values() # <1> 99 | # end::goal-serializer-c[] 100 | -------------------------------------------------------------------------------- /goals/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/goals/tests/__init__.py -------------------------------------------------------------------------------- /goals/tests/goals/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/goals/tests/goals/test.png -------------------------------------------------------------------------------- /goals/tests/goals/test_HYq1sow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/goals/tests/goals/test_HYq1sow.png -------------------------------------------------------------------------------- /goals/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.contrib.auth.models import User 3 | from django.core.files.uploadedfile import SimpleUploadedFile 4 | from django.test import TestCase 5 | 6 | from accounts.models import Account 7 | from goals.models import Goal, TaskStatus 8 | 9 | 10 | class TestGoal(TestCase): 11 | 12 | def setUp(self): 13 | self.account = Account.objects.create(name="My Account") 14 | self.user = User.objects.create_user('user', 'user@example.com', 'pass') 15 | self.test_file = SimpleUploadedFile('test.png', b'') 16 | 17 | def test_has_name(self): 18 | goal = Goal(name="The name") 19 | expected = "The name" 20 | assert expected == goal.name 21 | 22 | def test_required_fields(self): 23 | goal = Goal(name="The name", description="The description", 24 | image=self.test_file, slug="the-slug",) 25 | expected = None 26 | assert expected == goal.clean_fields() 27 | 28 | def test_has_description(self): 29 | goal = Goal(description="The description") 30 | expected = "The description" 31 | assert expected == goal.description 32 | 33 | def test_has_image(self): 34 | goal = Goal.objects.create(name="The name", description="The description", 35 | image=self.test_file) 36 | assert "test" in goal.image.file.name 37 | 38 | # Clean up the copy of the image file 39 | goal.image.delete() 40 | 41 | def test_has_slug(self): 42 | goal = Goal(slug="the-slug") 43 | expected = "the-slug" 44 | assert expected == goal.slug 45 | 46 | def test_percentage_complete_with_some_items_completed(self): 47 | goal = Goal.objects.create(name="Django") 48 | goal.tasks.create(name="Item 1") 49 | item_two = goal.tasks.create(name="Item 2") 50 | item_two.complete(self.user) 51 | assert goal.percentage_complete(self.user) == 50 52 | 53 | def test_percentage_complete_with_zero_items_completed(self): 54 | goal = Goal.objects.create(name="Django") 55 | goal.tasks.create(name="Item 1") 56 | assert goal.percentage_complete(self.user) == 0 57 | 58 | def test_percentage_complete_with_all_items_completed(self): 59 | goal = Goal.objects.create(name="Django") 60 | task = goal.tasks.create(name="Item 1") 61 | task.complete(self.user) 62 | assert goal.percentage_complete(self.user) == 100 63 | -------------------------------------------------------------------------------- /goals/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | -------------------------------------------------------------------------------- /goals/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('api/goal/', views.GoalListCreateView.as_view(), name='list_goals'), 7 | path('api/goal//', views.GoalView.as_view(), name='goal'), 8 | path( 9 | 'api/goal//start/', 10 | views.GoalStartView.as_view(), 11 | name='start_goal'), 12 | path('api/task/', views.TaskListCreateView.as_view(), name='list_tasks'), 13 | path('api/task//', views.TaskView.as_view(), name='task'), 14 | ] 15 | -------------------------------------------------------------------------------- /goals/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, status 2 | from rest_framework.response import Response 3 | 4 | from analytics.models import Event 5 | from goals.models import Goal, Task, TaskStatus 6 | from goals.serializers import (GoalSerializer, NewGoalSerializer, 7 | TaskSerializer, NewTaskSerializer, 8 | UpdateTaskSerializer) 9 | 10 | 11 | class UserOwnedGoalMixin: 12 | def get_queryset(self, *args, **kwargs): 13 | return self.queryset.filter( 14 | user=self.request.user) | self.queryset.filter(is_public=True) 15 | 16 | 17 | class UserOwnedTaskMixin: 18 | def get_queryset(self, *args, **kwargs): 19 | return self.queryset.filter( 20 | goal__user=self.request.user) | self.queryset.filter( 21 | goal__is_public=True) 22 | 23 | 24 | class TaskListCreateView(UserOwnedTaskMixin, generics.ListCreateAPIView): 25 | queryset = Task.objects.all() 26 | serializer_class = NewTaskSerializer 27 | 28 | def perform_create(self, serializer): 29 | serializer.save() 30 | Event.objects.create(name="task_created", data=serializer.data, 31 | user=self.request.user) 32 | 33 | 34 | class TaskView(UserOwnedTaskMixin, generics.RetrieveUpdateDestroyAPIView): 35 | queryset = Task.objects.all() 36 | serializer_class = TaskSerializer 37 | 38 | def get_serializer_context(self): 39 | return {'request': self.request} 40 | 41 | def get_serializer_class(self): 42 | if self.request.method == 'PUT': 43 | return UpdateTaskSerializer 44 | return TaskSerializer 45 | 46 | 47 | class GoalListCreateView(UserOwnedGoalMixin, generics.ListCreateAPIView): 48 | def perform_create(self, serializer): 49 | goal = serializer.save() 50 | Event.objects.create(name="goal_created", user=goal.user, 51 | data=serializer.data) 52 | 53 | def get(self, request, *args, **kwargs): 54 | Event.objects.create(name="goal_list_viewed", user=request.user, 55 | data={}) 56 | return self.list(request, *args, **kwargs) 57 | 58 | def get_serializer_class(self): 59 | if self.request.method == 'POST': 60 | return NewGoalSerializer 61 | return GoalSerializer 62 | 63 | def get_queryset(self, *args, **kwargs): 64 | queryset = Goal.objects.all() 65 | is_public = self.request.query_params.get('is_public', None) 66 | has_started = self.request.query_params.get('has_started', None) 67 | 68 | if self.request.user.is_authenticated: 69 | queryset = queryset.filter(user=self.request.user) 70 | if is_public is not None: 71 | queryset = queryset.filter(is_public=is_public == 'true') 72 | else: 73 | queryset = queryset.filter(is_public=True) 74 | 75 | if has_started is not None: 76 | if has_started == 'true': 77 | queryset = queryset.filter( 78 | tasks__statuses__status=TaskStatus.DONE) | queryset.filter( 79 | tasks__statuses__status=TaskStatus.STARTED) 80 | else: 81 | queryset = queryset.exclude( 82 | tasks__statuses__status=TaskStatus.DONE).exclude( 83 | tasks__statuses__status=TaskStatus.STARTED) 84 | 85 | return queryset 86 | 87 | 88 | # tag::goal-view-a[] 89 | class GoalView(UserOwnedGoalMixin, generics.RetrieveUpdateDestroyAPIView): 90 | queryset = Goal.objects.all() 91 | serializer_class = GoalSerializer # <1> 92 | 93 | # end::goal-view-a[] 94 | 95 | def get(self, request, *args, **kwargs): 96 | instance = self.get_object() 97 | Event.objects.create(name="goal_viewed", data={"goal_id": instance.id}, 98 | user=request.user) 99 | return super().get(request, *args, **kwargs) 100 | 101 | def get_serializer_class(self): 102 | if self.request.method == 'PUT': 103 | return NewGoalSerializer 104 | return GoalSerializer 105 | 106 | def get_serializer_context(self): 107 | return {'request': self.request} 108 | 109 | def delete(self, request, *args, **kwargs): 110 | instance = self.get_object() 111 | if instance.is_public: 112 | instance.clear_status_for_user(self.request.user) 113 | return Response(status=status.HTTP_204_NO_CONTENT) 114 | return self.destroy(request, *args, **kwargs) 115 | 116 | 117 | class GoalStartView(UserOwnedGoalMixin, generics.GenericAPIView): 118 | queryset = Goal.objects.all() 119 | serializer_class = GoalSerializer 120 | 121 | def post(self, request, pk, *args, **kwargs): 122 | goal = self.get_object() 123 | goal.start(self.request.user) 124 | serializer = self.get_serializer(goal) 125 | return Response(serializer.data) 126 | -------------------------------------------------------------------------------- /home/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/home/__init__.py -------------------------------------------------------------------------------- /home/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /home/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HomeConfig(AppConfig): 5 | name = 'home' 6 | -------------------------------------------------------------------------------- /home/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/home/migrations/__init__.py -------------------------------------------------------------------------------- /home/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /home/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /home/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quest.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /newrelic.ini: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------------------- 2 | 3 | # 4 | # This file configures the New Relic Python Agent. 5 | # 6 | # The path to the configuration file should be supplied to the function 7 | # newrelic.agent.initialize() when the agent is being initialized. 8 | # 9 | # The configuration file follows a structure similar to what you would 10 | # find for Microsoft Windows INI files. For further information on the 11 | # configuration file format see the Python ConfigParser documentation at: 12 | # 13 | # http://docs.python.org/library/configparser.html 14 | # 15 | # For further discussion on the behaviour of the Python agent that can 16 | # be configured via this configuration file see: 17 | # 18 | # http://newrelic.com/docs/python/python-agent-configuration 19 | # 20 | 21 | # --------------------------------------------------------------------------- 22 | 23 | # Here are the settings that are common to all environments. 24 | 25 | [newrelic] 26 | 27 | # You must specify the license key associated with your New 28 | # Relic account. This key binds the Python Agent's data to your 29 | # account in the New Relic service. 30 | license_key = 96a172a96691175a584795bcf96209b7d6f26771 31 | 32 | # The application name. Set this to be the name of your 33 | # application as you would like it to show up in New Relic UI. 34 | # The UI will then auto-map instances of your application into a 35 | # entry on your home dashboard page. 36 | app_name = Python Application 37 | 38 | # When "true", the agent collects performance data about your 39 | # application and reports this data to the New Relic UI at 40 | # newrelic.com. This global switch is normally overridden for 41 | # each environment below. 42 | monitor_mode = true 43 | 44 | # Sets the name of a file to log agent messages to. Useful for 45 | # debugging any issues with the agent. This is not set by 46 | # default as it is not known in advance what user your web 47 | # application processes will run as and where they have 48 | # permission to write to. Whatever you set this to you must 49 | # ensure that the permissions for the containing directory and 50 | # the file itself are correct, and that the user that your web 51 | # application runs as can write to the file. If not able to 52 | # write out a log file, it is also possible to say "stderr" and 53 | # output to standard error output. This would normally result in 54 | # output appearing in your web server log. 55 | #log_file = /tmp/newrelic-python-agent.log 56 | 57 | # Sets the level of detail of messages sent to the log file, if 58 | # a log file location has been provided. Possible values, in 59 | # increasing order of detail, are: "critical", "error", "warning", 60 | # "info" and "debug". When reporting any agent issues to New 61 | # Relic technical support, the most useful setting for the 62 | # support engineers is "debug". However, this can generate a lot 63 | # of information very quickly, so it is best not to keep the 64 | # agent at this level for longer than it takes to reproduce the 65 | # problem you are experiencing. 66 | log_level = info 67 | 68 | # High Security Mode enforces certain security settings, and prevents 69 | # them from being overridden, so that no sensitive data is sent to New 70 | # Relic. Enabling High Security Mode means that request parameters are 71 | # not collected and SQL can not be sent to New Relic in its raw form. 72 | # To activate High Security Mode, it must be set to 'true' in this 73 | # local .ini configuration file AND be set to 'true' in the 74 | # server-side configuration in the New Relic user interface. For 75 | # details, see 76 | # https://docs.newrelic.com/docs/subscriptions/high-security 77 | high_security = false 78 | 79 | # The Python Agent will attempt to connect directly to the New 80 | # Relic service. If there is an intermediate firewall between 81 | # your host and the New Relic service that requires you to use a 82 | # HTTP proxy, then you should set both the "proxy_host" and 83 | # "proxy_port" settings to the required values for the HTTP 84 | # proxy. The "proxy_user" and "proxy_pass" settings should 85 | # additionally be set if proxy authentication is implemented by 86 | # the HTTP proxy. The "proxy_scheme" setting dictates what 87 | # protocol scheme is used in talking to the HTTP proxy. This 88 | # would normally always be set as "http" which will result in the 89 | # agent then using a SSL tunnel through the HTTP proxy for end to 90 | # end encryption. 91 | # proxy_scheme = http 92 | # proxy_host = hostname 93 | # proxy_port = 8080 94 | # proxy_user = 95 | # proxy_pass = 96 | 97 | # Capturing request parameters is off by default. To enable the 98 | # capturing of request parameters, first ensure that the setting 99 | # "attributes.enabled" is set to "true" (the default value), and 100 | # then add "request.parameters.*" to the "attributes.include" 101 | # setting. For details about attributes configuration, please 102 | # consult the documentation. 103 | # attributes.include = request.parameters.* 104 | 105 | # The transaction tracer captures deep information about slow 106 | # transactions and sends this to the UI on a periodic basis. The 107 | # transaction tracer is enabled by default. Set this to "false" 108 | # to turn it off. 109 | transaction_tracer.enabled = true 110 | 111 | # Threshold in seconds for when to collect a transaction trace. 112 | # When the response time of a controller action exceeds this 113 | # threshold, a transaction trace will be recorded and sent to 114 | # the UI. Valid values are any positive float value, or (default) 115 | # "apdex_f", which will use the threshold for a dissatisfying 116 | # Apdex controller action - four times the Apdex T value. 117 | transaction_tracer.transaction_threshold = apdex_f 118 | 119 | # When the transaction tracer is on, SQL statements can 120 | # optionally be recorded. The recorder has three modes, "off" 121 | # which sends no SQL, "raw" which sends the SQL statement in its 122 | # original form, and "obfuscated", which strips out numeric and 123 | # string literals. 124 | transaction_tracer.record_sql = obfuscated 125 | 126 | # Threshold in seconds for when to collect stack trace for a SQL 127 | # call. In other words, when SQL statements exceed this 128 | # threshold, then capture and send to the UI the current stack 129 | # trace. This is helpful for pinpointing where long SQL calls 130 | # originate from in an application. 131 | transaction_tracer.stack_trace_threshold = 0.5 132 | 133 | # Determines whether the agent will capture query plans for slow 134 | # SQL queries. Only supported in MySQL and PostgreSQL. Set this 135 | # to "false" to turn it off. 136 | transaction_tracer.explain_enabled = true 137 | 138 | # Threshold for query execution time below which query plans 139 | # will not not be captured. Relevant only when "explain_enabled" 140 | # is true. 141 | transaction_tracer.explain_threshold = 0.5 142 | 143 | # Space separated list of function or method names in form 144 | # 'module:function' or 'module:class.function' for which 145 | # additional function timing instrumentation will be added. 146 | transaction_tracer.function_trace = 147 | 148 | # The error collector captures information about uncaught 149 | # exceptions or logged exceptions and sends them to UI for 150 | # viewing. The error collector is enabled by default. Set this 151 | # to "false" to turn it off. 152 | error_collector.enabled = true 153 | 154 | # To stop specific errors from reporting to the UI, set this to 155 | # a space separated list of the Python exception type names to 156 | # ignore. The exception name should be of the form 'module:class'. 157 | error_collector.ignore_errors = 158 | 159 | # Browser monitoring is the Real User Monitoring feature of the UI. 160 | # For those Python web frameworks that are supported, this 161 | # setting enables the auto-insertion of the browser monitoring 162 | # JavaScript fragments. 163 | browser_monitoring.auto_instrument = true 164 | 165 | # A thread profiling session can be scheduled via the UI when 166 | # this option is enabled. The thread profiler will periodically 167 | # capture a snapshot of the call stack for each active thread in 168 | # the application to construct a statistically representative 169 | # call tree. 170 | thread_profiler.enabled = true 171 | 172 | # Your application deployments can be recorded through the 173 | # New Relic REST API. To use this feature provide your API key 174 | # below then use the `newrelic-admin record-deploy` command. 175 | # api_key = 176 | 177 | # Distributed tracing lets you see the path that a request takes 178 | # through your distributed system. Enabling distributed tracing 179 | # changes the behavior of some New Relic features, so carefully 180 | # consult the transition guide before you enable this feature: 181 | # https://docs.newrelic.com/docs/transition-guide-distributed-tracing 182 | distributed_tracing.enabled = false 183 | 184 | # --------------------------------------------------------------------------- 185 | 186 | # 187 | # The application environments. These are specific settings which 188 | # override the common environment settings. The settings related to a 189 | # specific environment will be used when the environment argument to the 190 | # newrelic.agent.initialize() function has been defined to be either 191 | # "development", "test", "staging" or "production". 192 | # 193 | 194 | [newrelic:development] 195 | monitor_mode = false 196 | 197 | [newrelic:test] 198 | monitor_mode = false 199 | 200 | [newrelic:staging] 201 | app_name = Python Application (Staging) 202 | monitor_mode = true 203 | 204 | [newrelic:production] 205 | monitor_mode = true 206 | 207 | # --------------------------------------------------------------------------- 208 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quest", 3 | "version": "1.0.0", 4 | "description": "## Setup", 5 | "main": "", 6 | "author": "Andrew Brookins", 7 | "license": "ISC", 8 | "dependencies": { 9 | "@babel/cli": "^7.10.5", 10 | "@babel/core": "^7.11.0", 11 | "@babel/preset-env": "^7.11.0", 12 | "@babel/preset-react": "^7.10.4", 13 | "acorn": "^6.4.1", 14 | "axios": "^0.18.1", 15 | "babel-loader": "^8.1.0", 16 | "babel-plugin-transform-class-properties": "^6.24.1", 17 | "classnames": "^2.2.6", 18 | "css-loader": "^2.1.1", 19 | "director": "^1.2.8", 20 | "ellipsize": "^0.1.0", 21 | "eslint-config-airbnb": "^17.1.1", 22 | "eslint-plugin-jsx-a11y": "^6.3.1", 23 | "node-sass": "^4.14.1", 24 | "prettier": "^1.19.1", 25 | "prop-types": "^15.7.2", 26 | "react": "^16.13.1", 27 | "react-dom": "^16.13.1", 28 | "sass-loader": "^7.3.1", 29 | "style-loader": "^0.23.1", 30 | "weak-key": "^1.0.2", 31 | "webpack": "^4.44.1", 32 | "webpack-bundle-tracker": "^0.4.3", 33 | "webpack-cli": "^3.3.12" 34 | }, 35 | "scripts": { 36 | "build": "webpack --config webpack.config.js --progress --colors --mode development", 37 | "watch": "webpack --config webpack.config.js --watch --mode development", 38 | "watchprod": "webpack -p --config webpack.config.js --watch --mode production" 39 | }, 40 | "devDependencies": { 41 | "eslint": "^5.16.0", 42 | "eslint-config-standard": "^12.0.0", 43 | "eslint-plugin-import": "^2.22.0", 44 | "eslint-plugin-node": "^8.0.1", 45 | "eslint-plugin-promise": "^4.2.1", 46 | "eslint-plugin-react": "^7.20.5", 47 | "eslint-plugin-standard": "^4.0.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /profile_values.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import gc 3 | import os 4 | import sys 5 | import time 6 | 7 | project_path = os.path.split( 8 | os.path.abspath(os.path.dirname(__file__)))[0] 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", 10 | "quest.settings") 11 | sys.path.append(project_path) 12 | 13 | # Load models 14 | from django.core.wsgi import get_wsgi_application 15 | application = get_wsgi_application() 16 | 17 | import psutil 18 | 19 | # Disable garbage collection to get a more accurate 20 | # idea of how much memory is used. 21 | gc.disable() 22 | 23 | from analytics.models import Event 24 | 25 | 26 | def mb_used(): 27 | """Return the number of megabytes used by the current process.""" 28 | process = psutil.Process(os.getpid()) 29 | return process.memory_info().rss / 1e+6 30 | 31 | 32 | @contextlib.contextmanager 33 | def profile(): 34 | """A context manager that measures MB and CPU time used.""" 35 | snapshot_before = mb_used() 36 | time_before = time.time() 37 | 38 | yield 39 | 40 | time_after = time.time() 41 | snapshot_after = mb_used() 42 | 43 | print("{} mb used".format(snapshot_after - snapshot_before)) 44 | print("{} seconds elapsed".format(time_after - time_before)) 45 | print() 46 | 47 | 48 | def main(): 49 | if not len(sys.argv) == 2 or \ 50 | sys.argv[1] not in ('values', 'models'): 51 | print("Usage: python values.py ") 52 | exit(1) 53 | 54 | if sys.argv[1] == 'values': 55 | print("Running values query -- 1,000,000 records") 56 | with profile(): 57 | events = Event.objects.all()[:1000000].values( 58 | 'name', 'data') 59 | for e in events: 60 | e['name'] 61 | e['data'] 62 | elif sys.argv[1] == 'models': 63 | print("Running ORM query -- 1,000,000 records") 64 | with profile(): 65 | for e in Event.objects.all()[:1000000]: 66 | e.name 67 | e.data 68 | 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = quest.settings 3 | norecursedirs = node_modules env .git 4 | -------------------------------------------------------------------------------- /quest.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "git.ignoreLimitWarning": true, 9 | "python.pythonPath": "env/bin/python3.6" 10 | } 11 | } -------------------------------------------------------------------------------- /quest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/quest/__init__.py -------------------------------------------------------------------------------- /quest/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import AdminSite 2 | from django.core.cache import cache 3 | from django.db.models import Count, OuterRef, Subquery, IntegerField, Avg 4 | from django.db.models.functions import Coalesce 5 | from django.shortcuts import render 6 | from django.urls import path 7 | 8 | from goals.models import Goal, TaskStatus, GoalSummary 9 | from quest import redis_key_schema 10 | 11 | ONE_HOUR = 60 * 60 12 | 13 | 14 | # tag::admin-site[] 15 | class QuestAdminSite(AdminSite): 16 | def get_urls(self): 17 | urls = super().get_urls() + [ 18 | path('goal_dashboard_python/', 19 | self.admin_view( 20 | self.goals_dashboard_view_py)), 21 | path('goal_dashboard_sql/', 22 | self.admin_view( 23 | self.goals_dashboard_view_sql)), 24 | path('goal_dashboard_materialized/', 25 | self.admin_view( 26 | self.goals_dashboard_view_materialized)), 27 | path('goal_dashboard_with_avg_completions/', 28 | self.admin_view( 29 | self.goals_avg_completions_view)), 30 | path('goal_dashboard_redis/', 31 | self.admin_view( 32 | self.goals_dashboard_view_redis)) 33 | ] 34 | return urls 35 | # end::admin-site[] 36 | 37 | # tag::counting-with-python[] 38 | def goals_dashboard_view_py(self, request): 39 | """Render the top ten goals by completed tasks. 40 | 41 | WARNING: Don't do this! This example is of an 42 | anti-pattern: running an inefficient calculation in 43 | Python that you could offload to the database 44 | instead. See the goals_dashboard_view_sql() view 45 | instead. 46 | """ 47 | goals = Goal.objects.all() 48 | 49 | for g in goals: # <1> 50 | completions = TaskStatus.objects.completed() 51 | completed_tasks = completions.filter( 52 | task__in=g.tasks.values('id')) # <2> 53 | setattr(g, 'completed_tasks', 54 | completed_tasks.count()) # <3> 55 | 56 | goals = sorted(goals, key=lambda g: g.completed_tasks, 57 | reverse=True)[:10] # <4> 58 | 59 | return render(request, "admin/goal_dashboard.html", 60 | {"goals": goals}) 61 | # end::counting-with-python[] 62 | 63 | # tag::counting-with-sql[] 64 | def goals_dashboard_view_sql(self, request): 65 | completed_tasks = Subquery( # <1> 66 | TaskStatus.objects.filter( 67 | task__goal=OuterRef('pk'), # <2> 68 | status=TaskStatus.DONE 69 | ).values( 70 | 'task__goal' 71 | ).annotate( # <3> 72 | count=Count('pk') 73 | ).values('count'), 74 | output_field=IntegerField()) # <4> 75 | 76 | goals = Goal.objects.all().annotate( 77 | completed_tasks=Coalesce(completed_tasks, 0) 78 | ).order_by('-completed_tasks')[:10] 79 | 80 | return render(request, "admin/goal_dashboard.html", 81 | {"goals": goals}) 82 | # end::counting-with-sql[] 83 | 84 | # tag::caching-view-in-redis[] 85 | def goals_dashboard_view_redis(self, request): 86 | key = redis_key_schema.admin_goals_dashboard() 87 | cached_result = cache.get(key) 88 | 89 | if not cached_result: 90 | dashboard = self.goals_dashboard_view_sql(request) 91 | cache.set(key, dashboard, timeout=ONE_HOUR) 92 | return dashboard 93 | 94 | return cached_result 95 | # end::caching-view-in-redis[] 96 | 97 | # tag::aggregations[] 98 | def goals_avg_completions_view(self, request): 99 | completed_tasks = Subquery( 100 | TaskStatus.objects.filter( 101 | task__goal=OuterRef('pk'), 102 | status=TaskStatus.DONE 103 | ).values( 104 | 'task__goal' 105 | ).annotate( 106 | count=Count('pk') 107 | ).values('count'), 108 | output_field=IntegerField()) 109 | 110 | goals = Goal.objects.all().annotate( 111 | completed_tasks=Coalesce(completed_tasks, 0)) 112 | top_ten_goals = goals.order_by('-completed_tasks')[:10] 113 | average_completions = goals.aggregate( 114 | Avg('completed_tasks')) # <1> 115 | avg = int(average_completions['completed_tasks__avg']) 116 | 117 | other_stats = ( 118 | { 119 | 'name': 'Average Completed Tasks', 120 | 'stat': avg 121 | }, 122 | ) 123 | return render(request, "admin/goal_dashboard.html", { 124 | "goals": top_ten_goals, 125 | "other_stats": other_stats 126 | }) 127 | # end::aggregations[] 128 | 129 | # tag::querying-materialized-views[] 130 | def goals_dashboard_view_materialized(self, request): 131 | return render(request, "admin/goal_dashboard_materialized.html", 132 | {"summaries": GoalSummary.objects.all().select_related()}) 133 | # end::querying-materialized-views[] 134 | 135 | 136 | admin_site = QuestAdminSite() 137 | -------------------------------------------------------------------------------- /quest/connections.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | import redis 5 | 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def redis_connection(): 11 | return redis.Redis.from_url(settings.REDIS_URL, decode_responses=True) 12 | -------------------------------------------------------------------------------- /quest/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class QuestModel(models.Model): 5 | created_at = models.DateTimeField(auto_now_add=True) 6 | updated_at = models.DateTimeField(auto_now=True) 7 | 8 | class Meta: 9 | abstract = True 10 | -------------------------------------------------------------------------------- /quest/redis_key_schema.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def auth_token(token): 4 | """ 5 | A token stored for a user. 6 | 7 | Format: token:[token] 8 | """ 9 | return f"token:{token}" 10 | 11 | 12 | def admin_goals_dashboard(): 13 | """ 14 | The rendered Admin Goals Dashboard. 15 | 16 | Format: admin:goals-dashboard 17 | """ 18 | return "admin:goals-dashboard" 19 | -------------------------------------------------------------------------------- /quest/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for quest project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Read from the environment so we can support Docker or local dev. 16 | DATABASE_HOST = os.environ.get("QUEST_DATABASE_HOST", "localhost") 17 | REDIS_URL = os.environ.get("QUEST_REDIS_URL", "redis://localhost:6379/0") 18 | DATABASE_PASSWORD = os.environ.get("QUEST_DATABASE_PASSWORD", "test") 19 | 20 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 21 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = 'u06zoy2&g+le1tr21om=j(545ct$j$fmo01(tpl@iki8h!y2_f' 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = ['localhost', '127.0.0.1'] 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | 'quest', 38 | 'goals.apps.GoalsConfig', 39 | 'accounts.apps.AccountsConfig', 40 | 'frontend.apps.FrontendConfig', 41 | 'analytics.apps.AnalyticsConfig', 42 | 'bulma', # Comes first to override Django admin auth templates. 43 | 'django.contrib.auth', 44 | 'django.contrib.admin', 45 | 'django.contrib.contenttypes', 46 | 'django.contrib.sessions', 47 | 'django.contrib.messages', 48 | 'django.contrib.staticfiles', 49 | 'django.contrib.humanize', 50 | 'rest_framework', 51 | 'debug_toolbar', 52 | #'silk', 53 | ] 54 | 55 | MIDDLEWARE = [ 56 | # 'django.middleware.cache.UpdateCacheMiddleware', # Must be first 57 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 58 | 'django.middleware.security.SecurityMiddleware', 59 | 'django.contrib.sessions.middleware.SessionMiddleware', 60 | 'django.middleware.common.CommonMiddleware', 61 | 'django.middleware.csrf.CsrfViewMiddleware', 62 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 63 | 'django.contrib.messages.middleware.MessageMiddleware', 64 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 65 | # 'django.middleware.cache.FetchFromCacheMiddleware', # Must be last 66 | ] 67 | 68 | ROOT_URLCONF = 'quest.urls' 69 | 70 | TEMPLATES = [ 71 | { 72 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 73 | 'DIRS': [], 74 | 'APP_DIRS': True, 75 | 'OPTIONS': { 76 | 'context_processors': [ 77 | 'django.template.context_processors.debug', 78 | 'django.template.context_processors.request', 79 | 'django.contrib.auth.context_processors.auth', 80 | 'django.contrib.messages.context_processors.messages', 81 | ], 82 | }, 83 | }, 84 | ] 85 | 86 | WSGI_APPLICATION = 'quest.wsgi.application' 87 | 88 | # Database 89 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 90 | 91 | DATABASES = { 92 | 'default': { 93 | 'ENGINE': 'django.db.backends.postgresql', 94 | 'NAME': 'quest', 95 | 'USER': 'quest', 96 | 'PASSWORD': DATABASE_PASSWORD, 97 | 'HOST': DATABASE_HOST 98 | } 99 | } 100 | 101 | CACHES = { 102 | "default": { 103 | "BACKEND": "redis_cache.RedisCache", 104 | "LOCATION": REDIS_URL 105 | } 106 | } 107 | 108 | SESSION_ENGINE = "django.contrib.sessions.backends.cache" 109 | SESSION_CACHE_ALIAS = "default" 110 | 111 | 112 | # Password validation 113 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 114 | 115 | AUTH_PASSWORD_VALIDATORS = [ 116 | { 117 | 'NAME': 118 | 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 119 | }, 120 | { 121 | 'NAME': 122 | 'django.contrib.auth.password_validation.MinimumLengthValidator', 123 | }, 124 | { 125 | 'NAME': 126 | 'django.contrib.auth.password_validation.CommonPasswordValidator', 127 | }, 128 | { 129 | 'NAME': 130 | 'django.contrib.auth.password_validation.NumericPasswordValidator', 131 | }, 132 | ] 133 | 134 | # Internationalization 135 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 136 | 137 | LANGUAGE_CODE = 'en-us' 138 | 139 | TIME_ZONE = 'UTC' 140 | 141 | USE_I18N = True 142 | 143 | USE_L10N = True 144 | 145 | USE_TZ = True 146 | 147 | # Static files (CSS, JavaScript, Images) 148 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 149 | 150 | STATIC_URL = '/static/' 151 | 152 | LOGIN_REDIRECT_URL = '/' 153 | 154 | REST_FRAMEWORK = { 155 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 156 | 'rest_framework.authentication.SessionAuthentication', 157 | 'accounts.authentication.RedisTokenAuthentication', 158 | ] 159 | } 160 | 161 | CSRF_COOKIE_NAME = "csrftoken" 162 | 163 | INTERNAL_IPS = ('127.0.0.1', '192.168.78.132', '192.168.78.1', '172.18.0.1') 164 | 165 | # How many analytics events to show on a page. 166 | EVENTS_PER_PAGE = 10 167 | 168 | DARK_SKY_API_KEY = os.environ.get('DARK_SKY_API_KEY', "") 169 | -------------------------------------------------------------------------------- /quest/templates/admin/goal_dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/index.html" %} 2 | {% load i18n %} 3 | {% load humanize %} 4 | 5 | {% block bodyclass %}{{ block.super }} app-{{ app_label }}{% endblock %} 6 | 7 | {% if not is_popup %} 8 | {% block breadcrumbs %} 9 | 14 | {% endblock %} 15 | {% endif %} 16 | 17 | {% block content %} 18 |

    Top Ten Goals by Number of Completed Tasks

    19 |
    20 | {% if average_completions %} 21 | Average completions: {{ average_completions | intcomma }} 22 | {% endif %} 23 | 24 | 25 | 26 | 31 | 36 | 37 | 38 | 39 | 40 | {% for goal in goals %} 41 | 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 | 48 |
    27 |
    28 | Goal 29 |
    30 |
    32 |
    33 | Completed Tasks 34 |
    35 |
    {{ goal.name }} {{ goal.completed_tasks | intcomma }}
    49 |
    50 | 51 | {% if other_stats %} 52 |

    Other Stats

    53 |
    54 | 55 | 56 | 57 | 62 | 67 | 68 | 69 | 70 | 71 | {% for stat in other_stats %} 72 | 73 | 74 | 75 | 76 | {% endfor %} 77 | 78 | 79 |
    58 |
    59 | Stat 60 |
    61 |
    63 |
    64 | Value 65 |
    66 |
    {{ stat.name }} {{ stat.stat | intcomma }}
    80 |
    81 | {% endif %} 82 | 83 | {% endblock content %} 84 | 85 | {% block sidebar %}{% endblock %} 86 | -------------------------------------------------------------------------------- /quest/templates/admin/goal_dashboard_materialized.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/index.html" %} 2 | {% load i18n %} 3 | {% load humanize %} 4 | 5 | {% block bodyclass %}{{ block.super }} app-{{ app_label }}{% endblock %} 6 | 7 | {% if not is_popup %} 8 | {% block breadcrumbs %} 9 | 14 | {% endblock %} 15 | {% endif %} 16 | 17 | {% block content %} 18 |

    Top Ten Goals by Number of Completed Tasks

    19 |
    20 | {% if average_completions %} 21 | Average completions: {{ average_completions | intcomma }} 22 | {% endif %} 23 | 24 | 25 | 26 | 31 | 36 | 37 | 38 | 39 | 40 | {% for summary in summaries %} 41 | 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 | 48 |
    27 |
    28 | Goal 29 |
    30 |
    32 |
    33 | Completed Tasks 34 |
    35 |
    {{ summary.goal.name }} {{ summary.completed_tasks | intcomma }}
    49 |
    50 | 51 | {% endblock content %} 52 | 53 | {% block sidebar %}{% endblock %} 54 | -------------------------------------------------------------------------------- /quest/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static bulma_tags %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}{% endblock title %} 11 | {% block css %} 12 | 13 | 14 | {% block extra_css %}{% endblock extra_css %} 15 | {% endblock css %} 16 | 17 | 18 | 19 | 20 | {% block header %} 21 |
    22 | 70 |
    71 | {% endblock header %} 72 | 73 | {% block hero %}{% endblock hero %} 74 | 75 |
    76 |
    77 | {% block messages %} 78 | {% if messages %} 79 |
    80 |
    81 | {% for message in messages %} 82 |
    83 |
    {{ message }}
    84 |
    85 | {% endfor %} 86 |
    87 |
    88 | {% endif %} 89 | {% endblock messages %} 90 | 91 | {% block content_area %} 92 | {% block content_title %}{% endblock content_title %} 93 | {% block content %}{% endblock content %} 94 | {% endblock content_area %} 95 |
    96 |
    97 | 98 | {% block modal %}{% endblock modal %} 99 | 100 | {% block footer %} 101 |
    102 |
    103 |
    104 |

    105 | Quest by Spellbook Press. 106 |

    107 |
    108 |
    109 |
    110 | {% endblock footer %} 111 | 112 | {% block javascript %} 113 | {% block extra_javascript %}{% endblock extra_javascript %} 114 | {% endblock javascript %} 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /quest/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path 3 | 4 | from .admin import admin_site 5 | 6 | urlpatterns = [ 7 | path('', include('frontend.urls')), 8 | path('', include('goals.urls')), 9 | path('', include('accounts.urls')), 10 | path('', include('analytics.urls')), 11 | path('accounts/', include('django.contrib.auth.urls')), 12 | path('admin/', admin_site.urls), 13 | ] 14 | 15 | if settings.DEBUG: 16 | import debug_toolbar 17 | urlpatterns = [ 18 | path('__debug__/', include(debug_toolbar.urls)), 19 | ] + urlpatterns 20 | -------------------------------------------------------------------------------- /quest/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for quest 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/2.1/howto/deployment/wsgi/ 8 | """ 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'quest.settings') 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /recommendations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/recommendations/__init__.py -------------------------------------------------------------------------------- /recommendations/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /recommendations/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RecommendationsConfig(AppConfig): 5 | name = 'recommendations' 6 | -------------------------------------------------------------------------------- /recommendations/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/recommendations/migrations/__init__.py -------------------------------------------------------------------------------- /recommendations/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /recommendations/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /recommendations/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==3.0 2 | django-bulma==0.8.0 3 | django-debug-toolbar==2.2 4 | django-model-utils==4.0.0 5 | django-silk==4.1.0 6 | djangorestframework==3.11.1 7 | newrelic==5.16.0.145 8 | Pillow==7.2.0 9 | pluggy==0.13.1 10 | psycopg2-binary==2.8.5 11 | darksky_weather==1.9.0 12 | redis==3.5.3 13 | django-redis-cache 14 | 15 | # Pinned requirements for darksky-weather 16 | pytz==2019.1 17 | requests==2.21.0 18 | 19 | 20 | # TODO: Move into separate file. 21 | # Dev dependencies. 22 | pylint==2.5.3 23 | pylint-django==2.3.0 24 | pytest==6.0.1 25 | pytest-django==3.9.0 26 | yapf==0.30.0 27 | ipython 28 | -------------------------------------------------------------------------------- /search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/search/__init__.py -------------------------------------------------------------------------------- /search/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /search/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SearchConfig(AppConfig): 5 | name = 'search' 6 | -------------------------------------------------------------------------------- /search/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/search/migrations/__init__.py -------------------------------------------------------------------------------- /search/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /search/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /search/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /static/img/test/gallery01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abrookins/quest/302e985ed4702d977990bc5438c1a6d0521d236e/static/img/test/gallery01.jpg -------------------------------------------------------------------------------- /webpack-stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "error", 3 | "error": "ModuleBuildError", 4 | "message": "Module build failed (from ./node_modules/babel-loader/lib/index.js):\nError: Plugin/Preset files are not allowed to export objects, only functions. In /mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-preset-react/lib/index.js\n at createDescriptor (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-descriptors.js:178:11)\n at items.map (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-descriptors.js:109:50)\n at Array.map (native)\n at createDescriptors (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-descriptors.js:109:29)\n at createPresetDescriptors (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-descriptors.js:101:10)\n at presets (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-descriptors.js:47:19)\n at mergeChainOpts (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-chain.js:320:26)\n at /mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-chain.js:283:7\n at buildRootChain (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/config-chain.js:120:22)\n at loadPrivatePartialConfig (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/partial.js:85:55)\n at Object.loadPartialConfig (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/@babel/core/lib/config/partial.js:110:18)\n at Object. (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:140:26)\n at next (native)\n at asyncGeneratorStep (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:3:103)\n at _next (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:5:194)\n at /mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:5:364\n at Object. (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:5:97)\n at Object._loader (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:220:18)\n at Object.loader (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:56:18)\n at Object. (/mnt/c/Users/AndrewBrookins/src/quest/node_modules/babel-loader/lib/index.js:51:12)" 5 | } 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: { 5 | goals_list: './frontend/src/goals_list.js', 6 | goal: './frontend/src/goal.js', 7 | new_goal: './frontend/src/new_goal.js', 8 | home: './frontend/src/home.js' 9 | }, 10 | output: { 11 | path: path.resolve('./frontend/static/frontend/'), 12 | filename: '[name].bundle.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | exclude: /node_modules/, 19 | use: { 20 | loader: 'babel-loader' 21 | } 22 | }, 23 | { 24 | test: /\.scss$/, 25 | use: [ 26 | 'style-loader', 27 | 'css-loader', 28 | 'sass-loader' 29 | ] 30 | } 31 | ] 32 | } 33 | } 34 | --------------------------------------------------------------------------------