├── .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 | Event |
13 | User |
14 | Account |
15 | Data |
16 |
17 |
18 | {% for event in events %}
19 |
20 | {{ event.name }} |
21 | {{ event.user.username }} |
22 | {{ event.user.profile.account.name }} |
23 | {{ event.data }} |
24 |
25 | {% endfor %}
26 |
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 | Event |
13 | User |
14 | Account |
15 | Data |
16 |
17 |
18 | {% for event in events %}
19 |
20 | {{ event.name }} |
21 | {{ event.user.username }} |
22 | {{ event.user.profile.account.name }} |
23 | {{ event.data }} |
24 |
25 | {% endfor %}
26 |
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 | Event |
13 | User |
14 | Account |
15 | Data |
16 |
17 |
18 |
19 | {% for event in events %}
20 |
21 | {{ event.name }} |
22 | {{ event.user.username }} |
23 | {{ event.user.profile.account.name }} |
24 | {{ event.data }} |
25 |
26 | {% endfor %}
27 |
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 |
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 |
16 |
:
18 |
19 | const tasks = props.goal.tasks.map((task) => {
20 | return (
21 | {task.name}
22 | )
23 | }, this)
24 |
25 | return
26 |
34 |
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 |
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 |
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 =