mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
alphakit setup
This commit is contained in:
62
Makefile
Normal file
62
Makefile
Normal 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
83
README.md
Normal 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
0
ak/__init__.py
Normal file
3
ak/admin.py
Normal file
3
ak/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
ak/apps.py
Normal file
5
ak/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AkConfig(AppConfig):
|
||||
name = "ak"
|
||||
0
ak/migrations/__init__.py
Normal file
0
ak/migrations/__init__.py
Normal file
3
ak/models.py
Normal file
3
ak/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
0
ak/tests/__init__.py
Normal file
0
ak/tests/__init__.py
Normal file
53
ak/tests/test_default_pages.py
Normal file
53
ak/tests/test_default_pages.py
Normal 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
45
ak/views.py
Normal 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
11
compose-start.sh
Executable 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
3
config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
23
config/celery.py
Normal file
23
config/celery.py
Normal 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
203
config/settings.py
Normal 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
27
config/test_settings.py
Normal 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
28
config/urls.py
Normal 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
11
config/wsgi.py
Normal 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()
|
||||
90
docker-compose-with-celery.yml
Normal file
90
docker-compose-with-celery.yml
Normal 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
40
docker-compose.yml
Normal 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
41
docker/Dockerfile
Normal 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
178
docker/wait-for-it.sh
Executable 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
27
gunicorn.conf.py
Normal 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
61
justfile
Normal 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
22
manage.py
Executable 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
0
media/.placeholder
Normal file
5
pyproject.toml
Normal file
5
pyproject.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[tool.interrogate]
|
||||
fail-under = 100
|
||||
quiet = false
|
||||
verbose = 2
|
||||
whitelist-regex = ["test_.*"]
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal 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
0
python.log
Normal file
37
requirements.in
Normal file
37
requirements.in
Normal 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
224
requirements.txt
Normal 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
0
static/.placeholder
Normal file
14
templates/404.html
Normal file
14
templates/404.html
Normal 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
14
templates/500.html
Normal 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
21
templates/base.html
Normal 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
31
templates/homepage.html
Normal 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
0
users/__init__.py
Normal file
35
users/admin.py
Normal file
35
users/admin.py
Normal 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
5
users/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = "users"
|
||||
27
users/factories.py
Normal file
27
users/factories.py
Normal 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
|
||||
143
users/migrations/0001_initial.py
Normal file
143
users/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
30
users/migrations/0002_auto_20171007_1545.py
Normal file
30
users/migrations/0002_auto_20171007_1545.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
users/migrations/__init__.py
Normal file
0
users/migrations/__init__.py
Normal file
176
users/models.py
Normal file
176
users/models.py
Normal 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
27
users/permissions.py
Normal 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
62
users/serializers.py
Normal 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
0
users/tests/__init__.py
Normal file
226
users/tests/test_api.py
Normal file
226
users/tests/test_api.py
Normal 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)
|
||||
67
users/tests/test_models.py
Normal file
67
users/tests/test_models.py
Normal 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
15
users/urls.py
Normal 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
39
users/views.py
Normal 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
|
||||
Reference in New Issue
Block a user