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