alphakit setup

This commit is contained in:
GabbyPrecious
2021-10-09 09:53:46 +01:00
commit 0ca0a0b9ac
50 changed files with 2222 additions and 0 deletions

62
Makefile Normal file
View File

@@ -0,0 +1,62 @@
COMPOSE_FILE := docker-compose-with-celery.yml
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-24s\033[0m %s\n", $$1, $$2}'
# ----
# Research:
# - https://www.encode.io/reports/april-2020#our-workflow-approach
# - https://github.blog/2015-06-30-scripts-to-rule-them-all/
# ----
.PHONY: bootstrap
bootstrap: ## installs/updates all dependencies
@docker-compose --file $(COMPOSE_FILE) build --force-rm
.PHONY: cibuild
cibuild: ## invoked by continuous integration servers to run tests
@python -m pytest
@python -m black --check .
@interrogate -c pyproject.toml .
.PHONY: console
console: ## opens a console
@docker-compose run --rm web bash
.PHONY: server
server: ## starts app
@docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
@docker-compose up
.PHONY: setup
setup: ## sets up a project to be used for the first time
@docker-compose --file $(COMPOSE_FILE) build --force-rm
@docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
.PHONY: test_interrogate
test_interrogate:
@docker-compose run --rm web interrogate -vv --fail-under 100 --whitelist-regex "test_.*" .
.PHONY: test_pytest
test_pytest:
@docker-compose run --rm web pytest -s
.PHONY: test
test: test_interrogate test_pytest
@docker-compose down
.PHONY: update
update: ## updates a project to run at its current version
@docker-compose --file $(COMPOSE_FILE) rm --force celery
@docker-compose --file $(COMPOSE_FILE) rm --force celery-beat
@docker-compose --file $(COMPOSE_FILE) rm --force web
@docker-compose --file $(COMPOSE_FILE) pull
@docker-compose --file $(COMPOSE_FILE) build --force-rm
@docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
# ----
.PHONY: pip-compile
pip-compile: ## rebuilds our pip requirements
@docker-compose run --rm web pip-compile ./requirements.in --output-file ./requirements.txt

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# AlphaKit
## Overview
A Django project starter kit
## Local Development Setup
This project will use Python 3.8, Docker, and Docker Compose.
Make a Python 3.8.x virtualenv.
Copy .env-dist to .env and adjust values to match your local environment:
```shell
$ cp .env-dist .env
```
Then run:
```shell
# rebuild our services
$ docker-compose build
# start our services
$ docker-compose up
# start our services with daemon mode
$ docker-compose up -d
# to create a superuser
$ docker-compose run --rm web python manage.py createsuperuser
# to create database migrations
$ docker-compose run --rm web python manage.py makemigrations
# to run database migrations
$ docker-compose run --rm web python manage.py migrate
```
This will create the Docker image, install dependencies, start the services defined in `docker-compose.yml`, and start the webserver.
### Cleaning up
To shut down our database and any long running services, we shut everyone down using:
```shell
$ docker-compose down
```
### Running with Celery and Redis
AlphaKit ships with Celery and Redis support, but they are off by default. To rebuild our image with support, we need to pass the `docker-compose-with-celery.yml` config to Docker Compose via:
```shell
# rebuild our services
$ docker-compose -f docker-compose-with-celery.yml build
# start our services
$ docker-compose -f docker-compose-with-celery.yml up
# start our services with daemon mode
$ docker-compose -f docker-compose-with-celery.yml up -d
# stop and unregister all of our services
$ docker-compose -f docker-compose-with-celery.yml down
```
## Running the tests
To run the tests, execute:
```shell
$ docker-compose run --rm web pytest
```
## Deploying
TDB
## Production Environment Considerations
TDB

0
ak/__init__.py Normal file
View File

3
ak/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
ak/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AkConfig(AppConfig):
name = "ak"

View File

3
ak/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

0
ak/tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,53 @@
import pytest
import random
def test_homepage(db, tp):
""" Ensure we can hit the homepage """
# Use any page that is named 'home' otherwise use /
url = tp.reverse("home")
if not url:
url = "/"
response = tp.get(url)
tp.response_200(response)
def test_200_page(db, tp):
""" Test a 200 OK page """
response = tp.get("ok")
tp.response_200(response)
def test_403_page(db, tp):
""" Test a 403 error page """
response = tp.get("forbidden")
tp.response_403(response)
def test_404_page(db, tp):
""" Test a 404 error page """
rando = random.randint(1000, 20000)
url = f"/this/should/not/exist/{rando}/"
response = tp.get(url)
tp.response_404(response)
response = tp.get("not_found")
tp.response_404(response)
def test_500_page(db, tp):
""" Test our 500 error page """
url = tp.reverse("internal_server_error")
# Bail out of this test if this view is not defined
if not url:
pytest.skip()
with pytest.raises(ValueError):
response = tp.get("internal_server_error")
print(response.status_code)

45
ak/views.py Normal file
View File

@@ -0,0 +1,45 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse
from django.views import View
from django.views.generic import TemplateView
class HomepageView(TemplateView):
"""
Our default homepage for AlphaKit. We expect you to not use this view
after you start working on your project.
"""
template_name = "homepage.html"
class ForbiddenView(View):
"""
This view raises an exception to test our 403.html template
"""
def get(self, *args, **kwargs):
raise PermissionDenied("403 Forbidden")
class InternalServerErrorView(View):
"""
This view raises an exception to test our 500.html template
"""
def get(self, *args, **kwargs):
raise ValueError("500 Internal Server Error")
class NotFoundView(View):
"""
This view raises an exception to test our 404.html template
"""
def get(self, *args, **kwargs):
raise Http404("404 Not Found")
class OKView(View):
def get(self, *args, **kwargs):
return HttpResponse("200 OK", status=200)

11
compose-start.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
#
# This script is used to start our Django WSGI process (gunicorn in this case)
# for use with docker-compose. In deployed or production scenarios you would
# not necessarily use this exact setup.
#
./docker/wait-for-it.sh -h db -p 5432 -t 20 -- python manage.py migrate --noinput
python manage.py collectstatic --noinput
gunicorn -c gunicorn.conf.py --log-level INFO --reload -b 0.0.0.0:8000 config.wsgi

3
config/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ["celery_app"]

23
config/celery.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print("Request: {0!r}".format(self.request))

203
config/settings.py Normal file
View File

@@ -0,0 +1,203 @@
import environs
import logging
import structlog
import sys
from django.core.exceptions import ImproperlyConfigured
from pathlib import Path
from pythonjsonlogger import jsonlogger
env = environs.Env()
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
if READ_DOT_ENV_FILE:
env.read_env()
print("The .env file has been loaded. See config/settings.py for more information")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DJANGO_DEBUG", default=False)
if DEBUG:
root = logging.getLogger()
root.setLevel(logging.INFO)
lh = logging.StreamHandler(sys.stderr)
root.addHandler(lh)
env.log_level("LOG_LEVEL", default="DEBUG")
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = Path(__file__).parent.parent
APPS_DIR = BASE_DIR.joinpath("config")
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY")
host_list = env.list("ALLOWED_HOSTS", default="localhost")
ALLOWED_HOSTS = [el.strip() for el in host_list]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
# Third-party apps
INSTALLED_APPS += [
"rest_framework",
"django_extensions",
"health_check",
"health_check.db",
"health_check.contrib.celery",
]
# Our Apps
INSTALLED_APPS += ["ak", "users"]
AUTH_USER_MODEL = "users.User"
MIDDLEWARE = [
"tracer.middleware.RequestID",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
if DEBUG:
# These are necessary to turn on Whitenoise which will serve our static
# files while doing local development
MIDDLEWARE.append("whitenoise.middleware.WhiteNoiseMiddleware")
WHITENOISE_USE_FINDERS = True
WHITENOISE_AUTOREFRESH = True
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [str(BASE_DIR.joinpath("templates"))],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
WSGI_APPLICATION = "config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
try:
DATABASES = {"default": env.dj_db_url("DATABASE_URL")}
except (ImproperlyConfigured, environs.EnvError):
DATABASES = {
"default": {
"ENGINE": "django_db_geventpool.backends.postgresql_psycopg2",
"HOST": env("PGHOST"),
"NAME": env("PGDATABASE"),
"PASSWORD": env("PGPASSWORD"),
"PORT": env.int("PGPORT", default=5432),
"USER": env("PGUSER"),
"CONN_MAX_AGE": 0,
"OPTIONS": {"MAX_CONNS": 100},
}
}
# Password validation
# Only used in production
AUTH_PASSWORD_VALIDATORS = []
# Sessions
# Give each project their own session cookie name to avoid local development
# login conflicts
SESSION_COOKIE_NAME = "config-sessionid"
# Increase default cookie age from 2 to 12 weeks
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 12
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
# The relative URL of where we serve our static files from
STATIC_URL = "/static/"
# Additional directories from where we should collect static files from
STATICFILES_DIRS = [BASE_DIR.joinpath("static")]
# This is the directory where all of the collected static files are put
# after running collectstatic
STATIC_ROOT = str(BASE_DIR.joinpath("deployed_static"))
# Logging setup
# Configure struct log
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.stdlib.render_to_log_kwargs,
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
# Configure Python logging
root = logging.getLogger()
root.setLevel(logging.INFO)
handler = logging.FileHandler("./python.log")
handler.setFormatter(jsonlogger.JsonFormatter())
root.addHandler(handler)
# Configure Redis
REDIS_HOST = env("REDIS_HOST", default="redis")
# Configure Celery
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379"
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"

27
config/test_settings.py Normal file
View File

@@ -0,0 +1,27 @@
import logging
from .settings import * # noqa
# Disable migrations for all-the-things
class DisableMigrations(object):
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
# Disable our logging
logging.disable(logging.CRITICAL)
CELERY_TASK_ALWAYS_EAGER = True
DEBUG = False
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
MIGRATION_MODULES = DisableMigrations()
# User a faster password hasher
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

28
config/urls.py Normal file
View File

@@ -0,0 +1,28 @@
from django.conf.urls import include, url
from django.contrib import admin
from django.urls import path
from rest_framework import routers
from users.views import UserViewSet, CurrentUserView
from ak.views import (
HomepageView,
ForbiddenView,
InternalServerErrorView,
NotFoundView,
OKView,
)
router = routers.SimpleRouter()
router.register(r"users", UserViewSet, basename="users")
urlpatterns = [
path("", HomepageView.as_view(), name="home"),
path("admin/", admin.site.urls),
path("users/me/", CurrentUserView.as_view(), name="current-user"),
url(r"^api/v1/", include(router.urls)),
path("200", OKView.as_view(), name="ok"),
path("403", ForbiddenView.as_view(), name="forbidden"),
path("404", NotFoundView.as_view(), name="not_found"),
path("500", InternalServerErrorView.as_view(), name="internal_server_error"),
path("health/", include("health_check.urls")),
]

11
config/wsgi.py Normal file
View File

@@ -0,0 +1,11 @@
"""
WSGI config for AlphaKit.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

View File

@@ -0,0 +1,90 @@
version: "3.3"
services:
db:
image: postgres:12.0
environment:
- "POSTGRES_HOST_AUTH_METHOD=trust"
networks:
- backend
volumes:
- postgres_data:/var/lib/postgresql/data/
ports:
- "5432:5432"
web:
build:
context: .
dockerfile: docker/Dockerfile
command: ["/bin/bash", "/code/compose-start.sh"]
depends_on:
- db
env_file:
- .env
# init: true
networks:
- backend
- frontend
ports:
- "8000:8000"
volumes:
- .:/code
redis:
image: "redis:alpine"
networks:
- backend
volumes:
- redis_data:/data
celery:
build:
context: .
dockerfile: docker/Dockerfile
command: ["/venv/bin/celery", "-A", "config", "worker", "--concurrency=10", "--loglevel=debug"]
depends_on:
- db
- redis
env_file:
- .env
# init: true
networks:
- backend
volumes:
- .:/code
celery-beat:
build:
context: .
dockerfile: docker/Dockerfile
command: ["/venv/bin/celery", "-A", "config", "beat", "--loglevel=debug"]
depends_on:
- db
- redis
env_file:
- .env
# init: true
networks:
- backend
volumes:
- .:/code
flower:
build: ./
command: python -m flower -A tasks
volumes:
- redis_data:/data
working_dir: /data
ports:
- 5555:5555
env_file:
- .env
networks:
backend:
frontend:
volumes:
postgres_data:
redis_data:

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
version: "3.3"
services:
db:
image: postgres:12.0
environment:
- "POSTGRES_HOST_AUTH_METHOD=trust"
networks:
- backend
volumes:
- postgres_data:/var/lib/postgresql/data/
ports:
- "5432:5432"
web:
build:
context: .
dockerfile: docker/Dockerfile
command: ["/bin/bash", "/code/compose-start.sh"]
depends_on:
- db
env_file:
- .env
# init: true
networks:
- backend
- frontend
ports:
- "8000:8000"
volumes:
- .:/code
networks:
backend:
frontend:
volumes:
postgres_data:
redis_data:

41
docker/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# syntax = docker/dockerfile:experimental
# FROM revolutionsystems/python:3.8.3-wee-optimized-lto as builder-py
FROM python:3.9-slim-buster AS builder-py
RUN apt-get update && apt-get install -y build-essential gcc python-dev && rm -rf /var/lib/apt/lists/*
RUN pip install -U pip
COPY ./requirements.txt ./code/requirements.txt
RUN python3 -m venv /venv
RUN --mount=type=cache,target=/root/.cache \
. /venv/bin/activate && \
pip install -U pip && \
pip install -r /code/requirements.txt
# FROM revolutionsystems/python:3.8.3-wee-optimized-lto AS release
FROM python:3.9-slim-buster AS release
ENV PATH /venv/bin:/bin:/usr/bin:/usr/local/bin
ENV PYTHONDONTWRITEBYTECODE=true
ENV PYTHONPATH /code
ENV PYTHONUNBUFFERED 1
ENV PYTHONWARNINGS ignore
RUN mkdir /code
COPY --from=builder-py /venv/ /venv/
COPY --from=builder-py /code/ /code/
COPY . /code/
WORKDIR /code
CMD ["gunicorn", "-c", "/code/gunicorn.conf.py", "config.wsgi"]
ENV X_IMAGE_TAG v0.0.0
LABEL Description="AlphaKit Image" Vendor="REVSYS"
LABEL Version="${X_IMAGE_TAG}"

178
docker/wait-for-it.sh Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available
WAITFORIT_cmdname=${0##*/}
echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
usage()
{
cat << USAGE >&2
Usage:
$WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
-h HOST | --host=HOST Host or IP under test
-p PORT | --port=PORT TCP port under test
Alternatively, you specify the host and port as host:port
-s | --strict Only execute subcommand if the test succeeds
-q | --quiet Don't output any status messages
-t TIMEOUT | --timeout=TIMEOUT
Timeout in seconds, zero for no timeout
-- COMMAND ARGS Execute command with args after the test finishes
USAGE
exit 1
}
wait_for()
{
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
else
echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
fi
WAITFORIT_start_ts=$(date +%s)
while :
do
if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
nc -z $WAITFORIT_HOST $WAITFORIT_PORT
WAITFORIT_result=$?
else
(echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
WAITFORIT_result=$?
fi
if [[ $WAITFORIT_result -eq 0 ]]; then
WAITFORIT_end_ts=$(date +%s)
echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
break
fi
sleep 1
done
return $WAITFORIT_result
}
wait_for_wrapper()
{
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
else
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
fi
WAITFORIT_PID=$!
trap "kill -INT -$WAITFORIT_PID" INT
wait $WAITFORIT_PID
WAITFORIT_RESULT=$?
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
fi
return $WAITFORIT_RESULT
}
# process arguments
while [[ $# -gt 0 ]]
do
case "$1" in
*:* )
WAITFORIT_hostport=(${1//:/ })
WAITFORIT_HOST=${WAITFORIT_hostport[0]}
WAITFORIT_PORT=${WAITFORIT_hostport[1]}
shift 1
;;
--child)
WAITFORIT_CHILD=1
shift 1
;;
-q | --quiet)
WAITFORIT_QUIET=1
shift 1
;;
-s | --strict)
WAITFORIT_STRICT=1
shift 1
;;
-h)
WAITFORIT_HOST="$2"
if [[ $WAITFORIT_HOST == "" ]]; then break; fi
shift 2
;;
--host=*)
WAITFORIT_HOST="${1#*=}"
shift 1
;;
-p)
WAITFORIT_PORT="$2"
if [[ $WAITFORIT_PORT == "" ]]; then break; fi
shift 2
;;
--port=*)
WAITFORIT_PORT="${1#*=}"
shift 1
;;
-t)
WAITFORIT_TIMEOUT="$2"
if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
shift 2
;;
--timeout=*)
WAITFORIT_TIMEOUT="${1#*=}"
shift 1
;;
--)
shift
WAITFORIT_CLI=("$@")
break
;;
--help)
usage
;;
*)
echoerr "Unknown argument: $1"
usage
;;
esac
done
if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
echoerr "Error: you need to provide a host and port to test."
usage
fi
WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
# check to see if timeout is from busybox?
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
WAITFORIT_ISBUSY=1
WAITFORIT_BUSYTIMEFLAG="-t"
else
WAITFORIT_ISBUSY=0
WAITFORIT_BUSYTIMEFLAG=""
fi
if [[ $WAITFORIT_CHILD -gt 0 ]]; then
wait_for
WAITFORIT_RESULT=$?
exit $WAITFORIT_RESULT
else
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
wait_for_wrapper
WAITFORIT_RESULT=$?
else
wait_for
WAITFORIT_RESULT=$?
fi
fi
if [[ $WAITFORIT_CLI != "" ]]; then
if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
exit $WAITFORIT_RESULT
fi
exec "${WAITFORIT_CLI[@]}"
else
exit $WAITFORIT_RESULT
fi

27
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,27 @@
# vim: ft=python
# pylint: disable=missing-docstring, line-too-long, invalid-name
import os
from psycogreen.gevent import patch_psycopg # use this if you use gevent workers
BASE_DIR = os.environ["H"] if os.environ.get("H", None) else "/code"
accesslog = "-"
bind = "unix:/run/gunicorn.sock"
log_level = "INFO"
workers = 1
worker_class = "gevent"
keepalive = 32
worker_connections = 10000
pythonpath = BASE_DIR
chdir = BASE_DIR
def post_fork(server, worker):
from gevent import monkey
patch_psycopg()
worker.log.info("Made Psycopg2 Green")
monkey.patch_all()

61
justfile Normal file
View File

@@ -0,0 +1,61 @@
COMPOSE_FILE := "docker-compose-with-celery.yml"
ENV_FILE := ".env"
@_default:
just --list
# ----
# Research:
# - https://www.encode.io/reports/april-2020#our-workflow-approach
# - https://github.blog/2015-06-30-scripts-to-rule-them-all/
# ----
bootstrap: ## installs/updates all dependencies
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ENV_FILE}}" ]; then
echo "{{ENV_FILE}} created"
cp .env-dist {{ENV_FILE}}
fi
# docker-compose --file $(COMPOSE_FILE) build --force-rm
@cibuild: ## invoked by continuous integration servers to run tests
python -m pytest
python -m black --check .
interrogate -c pyproject.toml .
@console: ## opens a console
docker-compose run --rm web bash
@server: ## starts app
docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
docker-compose up
@setup: ## sets up a project to be used for the first time
docker-compose --file $(COMPOSE_FILE) build --force-rm
docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
@test_interrogate:
-docker-compose run --rm web interrogate -vv --fail-under 100 --whitelist-regex "test_.*" .
@test_pytest:
-docker-compose run --rm web pytest -s
@test:
just test_pytest
just test_interrogate
docker-compose down
@update: ## updates a project to run at its current version
docker-compose --file $(COMPOSE_FILE) rm --force celery
docker-compose --file $(COMPOSE_FILE) rm --force celery-beat
docker-compose --file $(COMPOSE_FILE) rm --force web
docker-compose --file $(COMPOSE_FILE) pull
docker-compose --file $(COMPOSE_FILE) build --force-rm
docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput
# ----
@pip-compile: ## rebuilds our pip requirements
docker-compose run --rm web pip-compile ./requirements.in --output-file ./requirements.txt

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)
raise
execute_from_command_line(sys.argv)

0
media/.placeholder Normal file
View File

5
pyproject.toml Normal file
View File

@@ -0,0 +1,5 @@
[tool.interrogate]
fail-under = 100
quiet = false
verbose = 2
whitelist-regex = ["test_.*"]

5
pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
DJANGO_SETTINGS_MODULE=config.test_settings
addopts = --reuse-db
norecursedirs = .git config node_modules scss static templates
python_files = test_*.py

0
python.log Normal file
View File

37
requirements.in Normal file
View File

@@ -0,0 +1,37 @@
Django<4.0
bumpversion
django-db-geventpool
django-extensions
django-health-check
django-rest-auth
djangorestframework
environs[django]
gevent
gunicorn
psycopg2-binary
whitenoise
# Logging
django-tracer
python-json-logger
structlog
# REVSYS Teams
https://afcec3a300c9919dcefedb87c15ecfe09869a9b5@github.com/revsys/revsys-teams/archive/master.zip
# Celery
celery
redis
# Testing
black
django-bakery
django-test-plus
interrogate
pytest
pytest-cov
pytest-django
pytest-xdist
# Packaging
pip-tools

224
requirements.txt Normal file
View File

@@ -0,0 +1,224 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile requirements.in
#
amqp==5.0.6
# via kombu
apipkg==1.5
# via execnet
appdirs==1.4.4
# via
# black
# fs
asgiref==3.3.4
# via django
attrs==21.1.0
# via
# interrogate
# pytest
billiard==3.6.4.0
# via celery
black==21.5b0
# via -r requirements.in
boto3==1.17.68
# via django-bakery
botocore==1.20.68
# via
# boto3
# s3transfer
bump2version==1.0.1
# via bumpversion
bumpversion==0.6.0
# via -r requirements.in
celery==5.0.5
# via -r requirements.in
click-didyoumean==0.0.3
# via celery
click-plugins==1.1.1
# via celery
click-repl==0.1.6
# via celery
click==7.1.2
# via
# black
# celery
# click-didyoumean
# click-plugins
# click-repl
# interrogate
# pip-tools
colorama==0.4.4
# via interrogate
coverage==5.5
# via pytest-cov
dj-database-url==0.5.0
# via environs
dj-email-url==1.0.2
# via environs
django-bakery==0.12.7
# via -r requirements.in
django-cache-url==3.2.3
# via environs
django-db-geventpool==4.0.0
# via -r requirements.in
django-extensions==3.1.3
# via -r requirements.in
django-filter==2.3.0
# via revsys-teams
django-health-check==3.16.4
# via -r requirements.in
django-rest-auth==0.9.5
# via -r requirements.in
django-test-plus==1.4.0
# via -r requirements.in
django-tracer==0.9.3
# via -r requirements.in
django==3.2.2
# via
# -r requirements.in
# django-db-geventpool
# django-extensions
# django-filter
# django-health-check
# django-rest-auth
# djangorestframework
# revsys-teams
djangorestframework==3.12.4
# via
# -r requirements.in
# django-rest-auth
# revsys-teams
environs[django]==9.3.2
# via -r requirements.in
execnet==1.8.0
# via pytest-xdist
factory-boy==3.2.0
# via revsys-teams
faker==8.1.2
# via factory-boy
fs==2.4.13
# via django-bakery
gevent==21.1.2
# via -r requirements.in
greenlet==1.1.0
# via gevent
gunicorn==20.1.0
# via -r requirements.in
iniconfig==1.1.1
# via pytest
interrogate==1.3.2
# via -r requirements.in
jmespath==0.10.0
# via
# boto3
# botocore
kombu==5.0.2
# via celery
marshmallow==3.11.1
# via environs
mypy-extensions==0.4.3
# via black
packaging==20.9
# via pytest
pathspec==0.8.1
# via black
pep517==0.10.0
# via pip-tools
pip-tools==6.1.0
# via -r requirements.in
pluggy==0.13.1
# via pytest
prompt-toolkit==3.0.18
# via click-repl
psycogreen==1.0.2
# via django-db-geventpool
psycopg2-binary==2.8.6
# via -r requirements.in
py==1.10.0
# via
# interrogate
# pytest
# pytest-forked
pyparsing==2.4.7
# via packaging
pytest-cov==2.11.1
# via -r requirements.in
pytest-django==4.2.0
# via -r requirements.in
pytest-forked==1.3.0
# via pytest-xdist
pytest-xdist==2.2.1
# via -r requirements.in
pytest==6.2.4
# via
# -r requirements.in
# pytest-cov
# pytest-django
# pytest-forked
# pytest-xdist
python-dateutil==2.8.1
# via
# botocore
# faker
python-dotenv==0.17.1
# via environs
python-json-logger==2.0.1
# via -r requirements.in
pytz==2021.1
# via
# celery
# django
# fs
redis==3.5.3
# via -r requirements.in
regex==2021.4.4
# via black
https://afcec3a300c9919dcefedb87c15ecfe09869a9b5@github.com/revsys/revsys-teams/archive/master.zip
# via -r requirements.in
s3transfer==0.4.2
# via boto3
shortid==0.1.2
# via revsys-teams
six==1.16.0
# via
# click-repl
# django-bakery
# django-rest-auth
# fs
# python-dateutil
sqlparse==0.4.1
# via django
structlog==21.1.0
# via -r requirements.in
tabulate==0.8.9
# via interrogate
text-unidecode==1.3
# via faker
toml==0.10.2
# via
# black
# interrogate
# pep517
# pytest
urllib3==1.26.4
# via botocore
vine==5.0.0
# via
# amqp
# celery
wcwidth==0.2.5
# via prompt-toolkit
whitenoise==5.2.0
# via -r requirements.in
wrapt==1.12.1
# via revsys-teams
zope.event==4.5.0
# via gevent
zope.interface==5.4.0
# via gevent
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

0
static/.placeholder Normal file
View File

14
templates/404.html Normal file
View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Page Not Found{% endblock %}
{% block content_wrapper %}
<div class="flex mb-4 bg-red-600 justify-center items-center">
<h1 class="text-white text-5xl m-10">404 - Page Not Found</h1>
</div>
<div class="container mx-auto px-4 my-8">
<p class="text-2xl text-center">
The page you requested was not found on this site.
</p>
</div>
{% endblock %}

14
templates/500.html Normal file
View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Server Error{% endblock %}
{% block content_wrapper %}
<div class="flex mb-4 bg-red-600 justify-center items-center">
<h1 class="text-white text-5xl m-10">500 - Server Error</h1>
</div>
<div class="container mx-auto px-4 my-8">
<p class="text-2xl text-center">
There was a problem building the page you requested.
</p>
</div>
{% endblock %}

21
templates/base.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>{% load static %}
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# We like Tailwind CSS, but you're welcome to switch to something else #}
<link href="https://unpkg.com/tailwindcss@^1.3/dist/tailwind.min.css" rel="stylesheet">
{% block extra_head %}{% endblock %}
</head>
<body>
{% block content_wrapper %}
{% block content %}{% endblock %}
{% endblock %}
{% block footer_js %}{% endblock %}
</body>
</html>

31
templates/homepage.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content_wrapper %}
<div class="flex mb-4 bg-blue-600 justify-center items-center">
<h1 class="text-white text-5xl m-10">AlphaKit</h1>
</div>
<div class="container mx-auto px-4 my-8">
<h2 class="text-4xl">Welcome to AlphaKit</h2>
<p class="my-2">
This is the default homepage you get with AlphaKit. You should obviously
replace this with a homepage that is appropriate for your application.
But, since you're here anyway here are some useful links
</p>
</div>
<div class="container mx-auto">
<div class="flex items-stretch align-center justify-center">
<a href="/admin/"
class="flex-1 bg-blue-500 hover:bg-blue-700 text-white font-bold py-4 px-4 rounded text-center mx-4">Django
Admin</a>
<a href="/health/"
class="flex-1 bg-green-500 hover:bg-green-700 text-white font-bold py-4 px-4 rounded text-center mx-4">System
Status</a>
<a href="https://github.com/revsys/alphakit"
class="flex-1 bg-blue-500 hover:bg-blue-700 text-white font-bold py-4 px-4 rounded text-center mx-4">AlphaKit
Github
</a>
</div>
</div>
{% endblock %}

0
users/__init__.py Normal file
View File

35
users/admin.py Normal file
View File

@@ -0,0 +1,35 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import ugettext_lazy as _
from .models import User
class EmailUserAdmin(UserAdmin):
fieldsets = (
(None, {"fields": ("email", "password")}),
(_("Personal info"), {"fields": ("first_name", "last_name")}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Data"), {"fields": ("data",)}),
)
add_fieldsets = (
(None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),
)
ordering = ("email",)
list_display = ("email", "first_name", "last_name", "is_staff")
search_fields = ("email" "first_name", "last_name")
admin.site.register(User, EmailUserAdmin)

5
users/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = "users"

27
users/factories.py Normal file
View File

@@ -0,0 +1,27 @@
import factory
from django.utils import timezone
from .models import User
class UserFactory(factory.django.DjangoModelFactory):
email = factory.Sequence(lambda n: "user%s@example.com" % n)
first_name = factory.Sequence(lambda n: "User%s Bob" % n)
last_name = factory.Sequence(lambda n: "User%s Smith" % n)
last_login = factory.LazyFunction(timezone.now)
password = factory.PostGenerationMethodCall("set_password", "password")
class Meta:
model = User
django_get_or_create = ("email",)
class StaffUserFactory(UserFactory):
is_staff = True
class SuperUserFactory(StaffUserFactory):
is_superuser = True

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.1 on 2016-10-16 15:52
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import users.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0008_alter_user_username_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=30, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=30, verbose_name="last name"
),
),
(
"email",
models.EmailField(
max_length=254, unique=True, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.Permission",
verbose_name="user permissions",
),
),
],
options={
"abstract": False,
"verbose_name": "user",
"verbose_name_plural": "users",
},
managers=[
("objects", users.models.UserManager()),
],
),
migrations.CreateModel(
name="LastSeen",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("at", models.DateTimeField(default=django.utils.timezone.now)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="last_seen",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-10-07 15:45
from __future__ import unicode_literals
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="user",
name="data",
field=django.contrib.postgres.fields.jsonb.JSONField(
blank=True, default={}, help_text="Arbitrary user data"
),
),
migrations.AlterField(
model_name="user",
name="email",
field=models.EmailField(
db_index=True, max_length=254, unique=True, verbose_name="email address"
),
),
]

View File

176
users/models.py Normal file
View File

@@ -0,0 +1,176 @@
import logging
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.contrib.auth.models import (
AbstractBaseUser,
PermissionsMixin,
BaseUserManager,
)
from django.contrib.postgres.fields import JSONField
from django.core.mail import send_mail
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
logger = logging.getLogger(__name__)
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, email, password, **extra_fields):
"""
Creates and saves a User with the given username, email and password.
"""
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
logger.info("Creating user with email='%s'", email)
return self._create_user(email, password, **extra_fields)
def create_staffuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", False)
logger.info("Creating staff user with email='%s'", email)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")
logger.info("Creating superuser with email='%s'", email)
return self._create_user(email, password, **extra_fields)
def record_login(self, user=None, email=None):
"""
Record a succesful login to last_login for the user by user
obj or email
"""
if email is None and user is None:
raise ValueError("email and user cannot both be None")
if email:
this_user = self.get(email=email)
else:
this_user = user
this_user.last_login = timezone.now()
this_user.save()
class BaseUser(AbstractBaseUser, PermissionsMixin):
"""
Our email for username user model
"""
first_name = models.CharField(_("first name"), max_length=30, blank=True)
last_name = models.CharField(_("last name"), max_length=30, blank=True)
email = models.EmailField(_("email address"), unique=True, db_index=True)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Designates whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Designates whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
data = JSONField(default=dict, blank=True, help_text="Arbitrary user data")
objects = UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
swappable = "AUTH_USER_MODEL"
abstract = True
def get_full_name(self):
"""
Returns the first_name plus the last_name, with a space in between.
"""
full_name = "%s %s" % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self):
"Returns the short name for the user."
return self.first_name
def email_user(self, subject, message, from_email=None, **kwargs):
"""
Sends an email to this User.
"""
send_mail(subject, message, from_email, [self.email], **kwargs)
def save(self, *args, **kwargs):
""" Ensure email is always lower case """
self.email = self.email.lower()
return super().save(*args, **kwargs)
class User(BaseUser):
pass
class LastSeen(models.Model):
"""
Last time we saw a user. This differs from User.last_login in that
a user may login on Monday and visit the site several times over the
next week before their login cookie expires. This tracks the last time
they were actually on the web UI.
So why isn't it on the User model? Well that would be a lot of database
row churn and contention on the User table itself so I'm breaking this
out into another table. Likely a pre-optimization on my part.
Far Future TODO: Store and update this in Redis as it happens and daily
sync that info to this table.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
related_name="last_seen",
on_delete=models.CASCADE,
)
at = models.DateTimeField(default=timezone.now)
def now(self, commit=True):
"""
Update this row to be right now
"""
self.at = timezone.now()
if commit:
self.save()
@receiver(post_save, sender=User)
def create_last_seen_for_user(sender, instance, created, raw, **kwargs):
""" Create LastSeen row when a User is created """
if raw:
return
if created:
LastSeen.objects.create(user=instance, at=timezone.now())

27
users/permissions.py Normal file
View File

@@ -0,0 +1,27 @@
from rest_framework import permissions
class CustomUserPermissions(permissions.BasePermission):
"""
Custom user API permissions.
- Normal users can only list and retrieve users
- Admins and Superusers can do everything
We rely on the API view itself to give the right type of
user the right serializer to avoid disclosing sensitive information.
"""
def has_permission(self, request, view):
# allow all POST/DELETE/PUT requests
if (
request.method == "POST"
or request.method == "DELETE"
or request.method == "PUT"
):
if request.user.is_staff or request.user.is_superuser:
return True
else:
return False
return request.user.is_authenticated

62
users/serializers.py Normal file
View File

@@ -0,0 +1,62 @@
from rest_framework import serializers
from .models import User
class UserSerializer(serializers.ModelSerializer):
"""
Default serializer that doesn't expose too much possibly sensitive
information
"""
class Meta:
model = User
fields = (
"id",
"first_name",
"last_name",
)
read_only_fields = (
"id",
"first_name",
"last_name",
)
class CurrentUserSerializer(serializers.ModelSerializer):
"""
User serializer for the currently logged in user
"""
class Meta:
model = User
fields = (
"id",
"email",
"first_name",
"last_name",
"date_joined",
"data",
)
read_only_fields = (
"id",
"email", # Users shouldn't change their email this way
"date_joined",
)
class FullUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
"id",
"email",
"first_name",
"last_name",
"is_staff",
"is_active",
"is_superuser",
"date_joined",
"data",
)
read_only_fields = ("id",)

0
users/tests/__init__.py Normal file
View File

226
users/tests/test_api.py Normal file
View File

@@ -0,0 +1,226 @@
from django.urls import reverse
from faker import Faker
from rest_framework.test import APIClient
from test_plus.test import TestCase
from ..factories import UserFactory, StaffUserFactory
from .. import serializers
class UserViewTests(TestCase):
client_class = APIClient
def setUp(self):
self.user = UserFactory()
self.staff = StaffUserFactory()
self.sample_user = UserFactory()
def test_list_user(self):
"""
Tests with a regular user
"""
# Does API work without auth?
response = self.get("users-list")
self.response_403(response)
# Does API work with auth?
with self.login(self.user):
response = self.get("users-list")
self.response_200(response)
self.assertEqual(len(response.data), 3)
# Are non-staff shown/hidden the right fields?
self.assertIn("first_name", response.data[0])
self.assertNotIn("date_joined", response.data[0])
def test_list_staff(self):
"""
Test with a staff user, who use a different serializer
"""
# Are staff shown the right fields?
with self.login(self.staff):
response = self.get("users-list")
self.response_200(response)
self.assertEqual(len(response.data), 3)
self.assertIn("first_name", response.data[0])
self.assertIn("date_joined", response.data[0])
def test_detail(self):
# Does this API work without auth?
response = self.get("users-detail", pk=self.sample_user.pk)
self.response_403(response)
# Does this API work with non-staff auth?
with self.login(self.user):
response = self.get("users-detail", pk=self.sample_user.pk)
self.response_200(response)
self.assertIn("first_name", response.data)
self.assertNotIn("date_joined", response.data)
# Does this API work with staff auth?
with self.login(self.staff):
response = self.get("users-detail", pk=self.sample_user.pk)
self.response_200(response)
self.assertIn("first_name", response.data)
self.assertIn("date_joined", response.data)
def test_create(self):
user = UserFactory.build()
payload = serializers.FullUserSerializer(user).data
# Does API work without auth?
response = self.client.post(reverse("users-list"), data=payload, format="json")
self.response_403(response)
# Does API work with non-staff user?
with self.login(self.user):
response = self.client.post(
reverse("users-list"), data=payload, format="json"
)
self.response_403(response)
# Does API work with staff user?
with self.login(self.staff):
response = self.client.post(
reverse("users-list"), data=payload, format="json"
)
self.response_201(response)
def test_delete(self):
url = reverse("users-detail", kwargs={"pk": self.sample_user.pk})
# Does this API work without auth?
response = self.client.delete(url, format="json")
self.response_403(response)
# Does this API wotk with non-staff user?
with self.login(self.user):
response = self.client.delete(url, format="json")
self.response_403(response)
# Does this API work with staff user?
with self.login(self.staff):
response = self.client.delete(url, format="json")
self.assertEqual(response.status_code, 204)
# Confirm object is gone
response = self.get(url)
self.response_404(response)
def test_update(self):
url = reverse("users-detail", kwargs={"pk": self.sample_user.pk})
old_name = self.sample_user.first_name
payload = serializers.FullUserSerializer(self.sample_user).data
# Does this API work without auth?
response = self.client.put(url, payload, format="json")
self.response_403(response)
# Does this API work with non-staff auth?
with self.login(self.user):
self.sample_user.first_name = Faker().name()
payload = serializers.FullUserSerializer(self.sample_user).data
response = self.client.put(url, payload, format="json")
self.response_403(response)
# Does this APO work with staff auth?
with self.login(self.staff):
self.sample_user.first_name = Faker().name()
payload = serializers.FullUserSerializer(self.sample_user).data
response = self.client.put(url, payload, format="json")
self.response_200(response)
self.assertFalse(response.data["first_name"] == old_name)
# Test updating reversions
self.sample_user.first_name = old_name
payload = serializers.FullUserSerializer(self.sample_user).data
response = self.client.put(url, payload, format="json")
self.assertTrue(response.data["first_name"] == old_name)
class CurrentUserViewTests(TestCase):
client_class = APIClient
def setUp(self):
self.user = UserFactory()
self.staff = StaffUserFactory()
def test_get_current_user(self):
# Does this API work without auth?
response = self.get("current-user")
self.response_403(response)
# Does this API work with auth?
with self.login(self.user):
response = self.get("current-user")
self.response_200(response)
self.assertIn("first_name", response.data)
self.assertIn("date_joined", response.data)
def test_create(self):
user = UserFactory.build()
payload = serializers.CurrentUserSerializer(user).data
# Does API work without auth?
response = self.client.post(
reverse("current-user"), data=payload, format="json"
)
self.response_403(response)
# Does API work with non-staff user?
with self.login(self.user):
response = self.client.post(
reverse("current-user"), data=payload, format="json"
)
self.response_405(response)
# Does API work with staff user?
with self.login(self.staff):
response = self.client.post(
reverse("current-user"), data=payload, format="json"
)
self.response_405(response)
def test_update(self):
old_name = self.user.first_name
payload = serializers.CurrentUserSerializer(self.user).data
# Does this API work without auth?
response = self.client.post(
reverse("current-user"), data=payload, format="json"
)
self.response_403(response)
# Does this API work with auth?
with self.login(self.user):
self.user.first_name = Faker().name()
payload = serializers.CurrentUserSerializer(self.user).data
response = self.client.put(reverse("current-user"), payload, format="json")
self.response_200(response)
self.assertFalse(response.data["first_name"] == old_name)
# Test updating reversions
self.user.first_name = old_name
payload = serializers.CurrentUserSerializer(self.user).data
response = self.client.put(reverse("current-user"), payload, format="json")
self.assertTrue(response.data["first_name"] == old_name)
# Can user update readonly fields?
old_email = self.user.email
with self.login(self.user):
self.user.email = Faker().email()
payload = serializers.CurrentUserSerializer(self.user).data
response = self.client.put(reverse("current-user"), payload, format="json")
self.response_200(response)
self.assertEqual(response.data["email"], old_email)
def test_delete(self):
# Does this API work without auth?
response = self.client.delete(reverse("current-user"), format="json")
self.response_403(response)
# Does this API wotk with auth? Should not.
with self.login(self.user):
response = self.client.delete(reverse("current-user"), format="json")
self.response_405(response)

View File

@@ -0,0 +1,67 @@
from test_plus import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from ..factories import UserFactory, StaffUserFactory, SuperUserFactory
User = get_user_model()
class UserModelTests(TestCase):
def test_simple_user_creation(self):
now = timezone.now()
f = UserFactory()
self.assertTrue(f.is_active)
# Ensure LastSeen model is created
self.assertTrue(f.last_seen.at > now)
def test_staff_creation(self):
s = StaffUserFactory()
self.assertTrue(s.is_active)
self.assertTrue(s.is_staff)
self.assertFalse(s.is_superuser)
def test_superuser_creation(self):
s = SuperUserFactory()
self.assertTrue(s.is_active)
self.assertTrue(s.is_staff)
self.assertTrue(s.is_superuser)
class UserManagerTests(TestCase):
def test_record_login_email(self):
user = UserFactory()
now = timezone.now()
self.assertTrue(user.last_login < now)
User.objects.record_login(email=user.email)
user = User.objects.get(pk=user.pk)
self.assertTrue(user.last_login > now)
def test_record_login_user(self):
user = UserFactory()
now = timezone.now()
self.assertTrue(user.last_login < now)
User.objects.record_login(user=user)
user = User.objects.get(pk=user.pk)
self.assertTrue(user.last_login > now)
def test_create_user(self):
u = User.objects.create_user("t1@example.com", "t1pass")
self.assertTrue(u.is_active)
self.assertFalse(u.is_staff)
self.assertFalse(u.is_superuser)
def test_create_staffuser(self):
u = User.objects.create_staffuser("t2@example.com", "t2pass")
self.assertTrue(u.is_active)
self.assertTrue(u.is_staff)
self.assertFalse(u.is_superuser)
def test_create_superuser(self):
u = User.objects.create_superuser("t3@example.com", "t3pass")
self.assertTrue(u.is_active)
self.assertTrue(u.is_staff)
self.assertTrue(u.is_superuser)

15
users/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from rest_framework.routers import DefaultRouter
from django.urls import path
from . import api
router = DefaultRouter()
router.register(r"users", api.UserViewSet, basename="user")
urlpatterns = [
path("users/me/", api.CurrentUserView.as_view(), name="current-user"),
]
urlpatterns += router.urls

39
users/views.py Normal file
View File

@@ -0,0 +1,39 @@
from rest_framework import viewsets
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from .serializers import UserSerializer, FullUserSerializer, CurrentUserSerializer
from .permissions import CustomUserPermissions
from .models import User
class UserViewSet(viewsets.ModelViewSet):
"""
Main User API ViewSet
"""
queryset = User.objects.all()
permission_classes = [CustomUserPermissions]
def get_serializer_class(self):
""" Pick the right serializer based on the user """
if self.request.user.is_staff or self.request.user.is_superuser:
return FullUserSerializer
else:
return UserSerializer
class CurrentUserView(generics.RetrieveUpdateAPIView):
"""
This gives the current user a convenient way to retrieve or
update slightly more detailed information about themselves.
Typically set to a route of `/api/v1/user/me`
"""
serializer_class = CurrentUserSerializer
permission_classes = [IsAuthenticated]
def get_object(self):
return self.request.user