From 0ca0a0b9ac9dae955e338750a1feabd281f1a203 Mon Sep 17 00:00:00 2001 From: GabbyPrecious Date: Sat, 9 Oct 2021 09:53:46 +0100 Subject: [PATCH] alphakit setup --- Makefile | 62 ++++++ README.md | 83 +++++++ ak/__init__.py | 0 ak/admin.py | 3 + ak/apps.py | 5 + ak/migrations/__init__.py | 0 ak/models.py | 3 + ak/tests/__init__.py | 0 ak/tests/test_default_pages.py | 53 +++++ ak/views.py | 45 ++++ compose-start.sh | 11 + config/__init__.py | 3 + config/celery.py | 23 ++ config/settings.py | 203 ++++++++++++++++++ config/test_settings.py | 27 +++ config/urls.py | 28 +++ config/wsgi.py | 11 + docker-compose-with-celery.yml | 90 ++++++++ docker-compose.yml | 40 ++++ docker/Dockerfile | 41 ++++ docker/wait-for-it.sh | 178 +++++++++++++++ gunicorn.conf.py | 27 +++ justfile | 61 ++++++ manage.py | 22 ++ media/.placeholder | 0 pyproject.toml | 5 + pytest.ini | 5 + python.log | 0 requirements.in | 37 ++++ requirements.txt | 224 +++++++++++++++++++ static/.placeholder | 0 templates/404.html | 14 ++ templates/500.html | 14 ++ templates/base.html | 21 ++ templates/homepage.html | 31 +++ users/__init__.py | 0 users/admin.py | 35 +++ users/apps.py | 5 + users/factories.py | 27 +++ users/migrations/0001_initial.py | 143 +++++++++++++ users/migrations/0002_auto_20171007_1545.py | 30 +++ users/migrations/__init__.py | 0 users/models.py | 176 +++++++++++++++ users/permissions.py | 27 +++ users/serializers.py | 62 ++++++ users/tests/__init__.py | 0 users/tests/test_api.py | 226 ++++++++++++++++++++ users/tests/test_models.py | 67 ++++++ users/urls.py | 15 ++ users/views.py | 39 ++++ 50 files changed, 2222 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 ak/__init__.py create mode 100644 ak/admin.py create mode 100644 ak/apps.py create mode 100644 ak/migrations/__init__.py create mode 100644 ak/models.py create mode 100644 ak/tests/__init__.py create mode 100644 ak/tests/test_default_pages.py create mode 100644 ak/views.py create mode 100755 compose-start.sh create mode 100644 config/__init__.py create mode 100644 config/celery.py create mode 100644 config/settings.py create mode 100644 config/test_settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose-with-celery.yml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100755 docker/wait-for-it.sh create mode 100644 gunicorn.conf.py create mode 100644 justfile create mode 100755 manage.py create mode 100644 media/.placeholder create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 python.log create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100644 static/.placeholder create mode 100644 templates/404.html create mode 100644 templates/500.html create mode 100644 templates/base.html create mode 100644 templates/homepage.html create mode 100644 users/__init__.py create mode 100644 users/admin.py create mode 100644 users/apps.py create mode 100644 users/factories.py create mode 100644 users/migrations/0001_initial.py create mode 100644 users/migrations/0002_auto_20171007_1545.py create mode 100644 users/migrations/__init__.py create mode 100644 users/models.py create mode 100644 users/permissions.py create mode 100644 users/serializers.py create mode 100644 users/tests/__init__.py create mode 100644 users/tests/test_api.py create mode 100644 users/tests/test_models.py create mode 100644 users/urls.py create mode 100644 users/views.py diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0bc3233b --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 00000000..115677cd --- /dev/null +++ b/README.md @@ -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 diff --git a/ak/__init__.py b/ak/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ak/admin.py b/ak/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/ak/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/ak/apps.py b/ak/apps.py new file mode 100644 index 00000000..3336f540 --- /dev/null +++ b/ak/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AkConfig(AppConfig): + name = "ak" diff --git a/ak/migrations/__init__.py b/ak/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ak/models.py b/ak/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/ak/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/ak/tests/__init__.py b/ak/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ak/tests/test_default_pages.py b/ak/tests/test_default_pages.py new file mode 100644 index 00000000..7cafe09e --- /dev/null +++ b/ak/tests/test_default_pages.py @@ -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) diff --git a/ak/views.py b/ak/views.py new file mode 100644 index 00000000..65a7f6e2 --- /dev/null +++ b/ak/views.py @@ -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) diff --git a/compose-start.sh b/compose-start.sh new file mode 100755 index 00000000..4670d323 --- /dev/null +++ b/compose-start.sh @@ -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 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 00000000..370372af --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 00000000..2792fc4c --- /dev/null +++ b/config/celery.py @@ -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)) diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 00000000..da14ae64 --- /dev/null +++ b/config/settings.py @@ -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" diff --git a/config/test_settings.py b/config/test_settings.py new file mode 100644 index 00000000..73b3419e --- /dev/null +++ b/config/test_settings.py @@ -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"] diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 00000000..22451ab0 --- /dev/null +++ b/config/urls.py @@ -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")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 00000000..73375d6d --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/docker-compose-with-celery.yml b/docker-compose-with-celery.yml new file mode 100644 index 00000000..b6cf0df0 --- /dev/null +++ b/docker-compose-with-celery.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4c933446 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..c52447f8 --- /dev/null +++ b/docker/Dockerfile @@ -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}" diff --git a/docker/wait-for-it.sh b/docker/wait-for-it.sh new file mode 100755 index 00000000..071c2bee --- /dev/null +++ b/docker/wait-for-it.sh @@ -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 diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 00000000..630d6888 --- /dev/null +++ b/gunicorn.conf.py @@ -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() diff --git a/justfile b/justfile new file mode 100644 index 00000000..e34d4c57 --- /dev/null +++ b/justfile @@ -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 diff --git a/manage.py b/manage.py new file mode 100755 index 00000000..68141ee9 --- /dev/null +++ b/manage.py @@ -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) diff --git a/media/.placeholder b/media/.placeholder new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..9d7aba75 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.interrogate] +fail-under = 100 +quiet = false +verbose = 2 +whitelist-regex = ["test_.*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..544775f1 --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/python.log b/python.log new file mode 100644 index 00000000..e69de29b diff --git a/requirements.in b/requirements.in new file mode 100644 index 00000000..ca00c19b --- /dev/null +++ b/requirements.in @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..15e85332 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/.placeholder b/static/.placeholder new file mode 100644 index 00000000..e69de29b diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 00000000..e5193805 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block content_wrapper %} +
+

404 - Page Not Found

+
+
+

+ The page you requested was not found on this site. +

+
+{% endblock %} \ No newline at end of file diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 00000000..851bfa6b --- /dev/null +++ b/templates/500.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Server Error{% endblock %} + +{% block content_wrapper %} +
+

500 - Server Error

+
+
+

+ There was a problem building the page you requested. +

+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..070a0e88 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ +{% load static %} + + + + {% block title %}{% endblock %} + + + + {# We like Tailwind CSS, but you're welcome to switch to something else #} + + {% block extra_head %}{% endblock %} + + + + {% block content_wrapper %} + {% block content %}{% endblock %} + {% endblock %} + {% block footer_js %}{% endblock %} + + + diff --git a/templates/homepage.html b/templates/homepage.html new file mode 100644 index 00000000..ef55cdce --- /dev/null +++ b/templates/homepage.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block content_wrapper %} +
+

AlphaKit

+
+ +
+

Welcome to AlphaKit

+

+ 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 +

+
+ +
+ +
+{% endblock %} \ No newline at end of file diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 00000000..00d49332 --- /dev/null +++ b/users/admin.py @@ -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) diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 00000000..3ef1284a --- /dev/null +++ b/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = "users" diff --git a/users/factories.py b/users/factories.py new file mode 100644 index 00000000..9bac5b94 --- /dev/null +++ b/users/factories.py @@ -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 diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 00000000..af4786d1 --- /dev/null +++ b/users/migrations/0001_initial.py @@ -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, + ), + ), + ], + ), + ] diff --git a/users/migrations/0002_auto_20171007_1545.py b/users/migrations/0002_auto_20171007_1545.py new file mode 100644 index 00000000..4cfc7cf3 --- /dev/null +++ b/users/migrations/0002_auto_20171007_1545.py @@ -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" + ), + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/models.py b/users/models.py new file mode 100644 index 00000000..2272f81a --- /dev/null +++ b/users/models.py @@ -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()) diff --git a/users/permissions.py b/users/permissions.py new file mode 100644 index 00000000..1332e1cf --- /dev/null +++ b/users/permissions.py @@ -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 diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 00000000..04e52135 --- /dev/null +++ b/users/serializers.py @@ -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",) diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/tests/test_api.py b/users/tests/test_api.py new file mode 100644 index 00000000..54453b85 --- /dev/null +++ b/users/tests/test_api.py @@ -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) diff --git a/users/tests/test_models.py b/users/tests/test_models.py new file mode 100644 index 00000000..b22b1782 --- /dev/null +++ b/users/tests/test_models.py @@ -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) diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 00000000..b57ad72f --- /dev/null +++ b/users/urls.py @@ -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 diff --git a/users/views.py b/users/views.py new file mode 100644 index 00000000..49520035 --- /dev/null +++ b/users/views.py @@ -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