Django 新项目启动工作流

以下内容仅为本人喜好的工作流,并不一定适用于「大众」,仅供读者参考来构建自己的工作流

第零步:初始化 django 项目

  • 开启 django-admin
  • 新建一个命名为 core 的 app,用于放置「核心」信息
  • 升级 venv 中的 pip:pip install --upgrade pip

第一步:书写 .gitignore

通过 PyCharm 的 .ignore 插件生成,选中以下三种

  • JetBrains
  • Python
  • macOS

另外添加配置文件忽略项

  • /config.ini:配置文件
  • /static.serve/:静态文件

第二步:创建配置文件

配置文件名称:config.ini,配置文件示例项名称:config.example.ini
内容均为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# copy this file and rename to config.ini

[DATABASE]
engine = django.db.backends.postgresql
host = 127.0.0.1
port = 5432
user = test
password = Test
name = test

[CACHE]
# django-redis
# https://django-redis-chs.readthedocs.io/zh_CN/latest/
backend = django_redis.cache.RedisCache
location = redis://127.0.0.1:6379/0

第三步:配置 settings.py

导入相关包

1
2
from configparser import ConfigParser
from django.contrib.messages import constants as messages

导入配置文件

放置于 BASE_DIR = ...

1
2
p = ConfigParser()
p.read(os.path.join(BASE_DIR, 'config.ini'))

设置 DEBUG 条件

这个配置是唯一不读取配置文件而是读取环境变量的,当且仅当环境变量 DEBUG 值为 True 时开启调试模式

修改 DEBUG = True 为下面的代码块

1
DEBUG = os.environ.get('DEBUG') == 'True'

设置 ALLOWED_HOSTS

.test.test 代表所有 test.test 的二级域名

1
2
3
4
5
ALLOWED_HOSTS = [
'localhost',
'127.0.0.1',
'这里填写上线的服务域名',
]

设置登录跳转地址

1
2
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/'

设置 User 模型

Django 自带的 User 模型扩展很麻烦,因此 第一次 migrate 前就要扩展 User 模型

core 应用中新增 User 模型(也可按照复杂程度选择新建一个 accounts 应用)

core.models

1
2
3
4
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
pass

然后在设置中添加

1
AUTH_USER_MODEL = 'core.User'

设置消息 tag

使用 Bootstrap 自带的「alert」需要按照如下设置

所需的 messages 已经在前面导入

1
2
3
4
MESSAGE_TAGS = {
messages.DEBUG: 'secondary',
messages.ERROR: 'danger',
}

设置 Cache

使用 Redis 作为 Cache,先安装依赖

1
pip install django-redis

再添加配置项

1
2
3
4
5
6
7
8
9
10
CACHES = {
'default': {
'BACKEND': p.get('CACHE', 'backend', fallback='django.core.cache.backends.dummy.DummyCache'),
'LOCATION': p.get('CACHE', 'location', fallback=None),
}
}
# Session backend
if not DEBUG:
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"

特意设置了非调试模式下才修改 SESSION_ENGINE 使用 cache 的原因是对于未设置 cache 的情况下使用的 DummyCache 是「假缓存」,如果没有配置 cache 那么会导致 session 丢失

上线前一定要配置好 CACHE

设置语言和时区

1
2
3
4
5
6
7
8
# Internationalization
# https://docs.djangoproject.com/en/2.1/topics/i18n/

LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_L10N = True
USE_TZ = True

设置静态文件

  • STATIC_URL 是用来设置 {% static xxx %} 时的前缀
  • STATICFILES_DIRS 是用来设置项目中静态文件的保存目录的
  • STATIC_ROOT 是用来设置执行 collectstatic 命令时将静态文件拷贝的目录的

在 nginx 中应该设置好将访问到 /static/* 的请求转发至 static.serve/ 目录

1
2
3
4
5
6
7
8
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.1/howto/static-files/

STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'),
)
STATIC_ROOT = os.path.join(BASE_DIR, 'static.serve')

在全局 urls.py 文件中新增

1
2
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
urlpatterns += staticfiles_urlpatterns()

设置转发

用于正确识别 host、schema 等

1
2
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

设置数据库

如果使用了 postgresql 的特殊功能则应该使配置文件中的 engine 并不生效

1
2
3
4
5
6
7
8
9
10
11
12
13
# Database
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': p.get('DATABASE', 'name', fallback='test'),
'USER': p.get('DATABASE', 'user', fallback='test'),
'PASSWORD': p.get('DATABASE', 'password', fallback='Test'),
'HOST': p.get('DATABASE', 'host', fallback='127.0.0.1'),
'PORT': p.get('DATABASE', 'port', fallback='5432'),
}
}

执行 migrate

1
2
3
chmod +x manage.py
./manage.py makemigrations
./manage.py migrate

客制化设置

Bootstrap 4

templates/base/base.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{% load staticfiles %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% if title %}{{ title }} | {% endif %}全局标题</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0">
<link rel="stylesheet" href="https://cdnjs.loli.net/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=" crossorigin="anonymous"/>
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block header %}
{% endblock header %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="">SMU小助手
<small>统一登录平台</small>
</a>

</nav>
<div class="container" id="outer">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% endfor %}
{% block content %}
{% endblock content %}
<footer class="text-center small">XXX</footer>
</div>
<script src="https://cdnjs.loli.net/ajax/libs/jquery/3.3.1/jquery.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.loli.net/ajax/libs/twitter-bootstrap/4.1.3/js/bootstrap.min.js"
integrity="sha256-VsEqElsCHSGmnmHXGQzvoWjWwoznFSZc6hs7ARLRacQ=" crossorigin="anonymous"></script>
{% block script %}
{% endblock script %}
</body>
</html>

static/css/base.css

1
2
3
4
5
6
7
8
9
10
11
footer {
margin-top: 4vh;
}

footer, footer a {
color: darkgray;
}

#outer.container {
margin: 2vh auto;
}

templates/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% extends 'base/base.html' %}
{% block header %}
<style>
h4 {
margin-bottom: 2vh;
}
</style>
{% endblock header %}
{% block content %}
<div class="row" style="margin: 10vh auto;">
<div class="col-sm-6">
<h4>标题</h4>
<p>内容</p>
</div>
</div>
{% endblock content %}

CELERY

安装 celery

1
pip isntall celery django_celery_results

在配置文件中新增

1
2
3
4
[CELERY]
broker_url = amqp://用户名:密码@127.0.0.1:5672/虚拟主机名
# 或使用 Redis
# broker_url = redis://:密码@127.0.0.1:6379/数据库编号

项目配置文件中新增

1
2
3
# CELERY
CELERY_RESULT_BACKEND = 'django-cache'
CELERY_BROKER_URL = p.get('CELERY', 'broker_url', fallback=None)

在项目应用中新增 django_celery_results

执行 migrate

在项目 urls.py 所在目录下新建 celery.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from __future__ import absolute_import, unicode_literals

import os

from celery import Celery

# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '项目名称.settings')

app = Celery('项目名称')

# 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()

在同一目录下的 __init__.py 添加

1
2
3
4
5
6
7
from __future__ import absolute_import, unicode_literals

# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app

__all__ = ('celery_app',)

core 应用目录下新建 tasks.py 文件测试

1
2
3
4
5
6
7
from __future__ import absolute_import
from celery import shared_task


@shared_task
def add(a, b):
return a + b

开启 Celery

1
celery -A 项目名 worker -l info

测试任务:在 ./manage.py shell 中测试

1
2
3
4
5
from core.tasks import add
add(1, 2)
r = add.delay(1, 2)
r
r.get()

DRF (Django Rest Framework)

安装

1
pip install djangorestframework

在项目应用中添加 rest_framework

OAuth2(服务提供)

这里面涉及到的修改 Application Model 必须在项目前期就设置好

安装

1
pip install oauth2_provider

在项目应用中添加 oauth2_provider
在中间件中添加 oauth2_provider.middleware.OAuth2TokenMiddleware(紧跟 AuthenticationMiddleware

设置 Authentication Backends

1
2
3
4
AUTHENTICATION_BACKENDS = [
'oauth2_provider.backends.OAuth2Backend',
'fdjango.contrib.auth.backends.ModelBackend',
]

新建 oauth 应用,新建 Application 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from django.db import models
from oauth2_provider.models import AbstractApplication
from oauth2_provider.settings import oauth2_settings


def get_require_approval_default():
return oauth2_settings.REQUEST_APPROVAL_PROMPT


class Application(AbstractApplication):
allow_http = models.BooleanField(verbose_name='允许HTTP', help_text='允许使用 http 协议,选择否则按照默认值', default=False)
allow_all_scopes = models.BooleanField(verbose_name='允许所有Scope', help_text='信任应用,允许使用所有的 Scope 而无需签约',
default=False)
allow_scopes = models.TextField(verbose_name='允许的Scope',
help_text='用户签约的 Scope 列表,以空格分隔,如果设置了 allow_all_scopes 则直接允许全部而此项无效',
default='basic')

require_approval = models.CharField(max_length=200, verbose_name='授权模式',
choices=(('auto', 'auto'), ('force', 'force')),
help_text='填写 auto 则相同 scope 仅第一次需要确认,填写 force 则每次都需要确认',
default=get_require_approval_default)

def get_allowed_schemes(self):
"""
Returns the list of redirect schemes allowed by the Application.
By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`.
"""

allow_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES

if 'http' in allow_schemes or not self.allow_http:
return allow_schemes
else:
return ['http'] + allow_schemes

oauth 应用下修改 admin.py 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from django.contrib import admin
from .models import Application
from oauth2_provider.admin import ApplicationAdmin as BaseApplicationAdmin

admin.site.unregister(Application)


@admin.register(Application)
class ApplicationAdmin(BaseApplicationAdmin):
radio_fields = {
"client_type": admin.HORIZONTAL,
"authorization_grant_type": admin.VERTICAL,
"require_approval": admin.HORIZONTAL,
}

oauth 应用目录新建 scopes.py 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
from oauth2_provider.scopes import BaseScopes
from oauth2_provider.settings import oauth2_settings
from django.core.cache import cache


class ScopeBackend(BaseScopes):
# scopes 说明
# read - 读取,write - 写入,get - 获取

def get_all_scopes(self):
"""
Return a dict-like object with all the scopes available in the
system. The key should be the scope name and the value should be
the description.

ex: {"read": "A read scope", "write": "A write scope"}
"""

c = cache.get('oauth:all_scopes')

if c:
return c

scopes_dict = {}
for scope_group_key, scope_group_value in oauth2_settings.SCOPES.items():
for scope_key, scope_value in scope_group_value.items():
if 'get' in scope_value:
if scope_group_key == scope_key:
scopes_dict.update({'{}'.format(scope_group_key): scope_value['get']})
else:
scopes_dict.update({'{}.{}'.format(scope_group_key, scope_key): scope_value['get']})
if 'read' in scope_value:
if scope_group_key == scope_key:
scopes_dict.update({'{}:read'.format(scope_group_key): scope_value['read']})
else:
scopes_dict.update({'{}.{}:read'.format(scope_group_key, scope_key): scope_value['read']})
if 'write' in scope_value:
if scope_group_key == scope_key:
scopes_dict.update({'{}:write'.format(scope_group_key): scope_value['write']})
else:
scopes_dict.update({'{}.{}:write'.format(scope_group_key, scope_key): scope_value['write']})

cache.set('oauth:all_scopes', scopes_dict, 86400) # 60*60*24 = 1d
return scopes_dict

def get_available_scopes(self, application=None, request=None, *args, **kwargs):
"""
Return a list of scopes available for the current application/request.

ex: ["read", "write"]
"""

if application is None:
return ['basic']

application_id = application.id
application_cache_label = 'oauth:available_scopes:{}'.format(application_id)
c = cache.get(application_cache_label)
if c:
return c

all_scopes = list(self.get_all_scopes().keys())
if application.allow_all_scopes:
application_scopes = all_scopes
else:
application_scopes_pre = application.allow_scopes.split()
application_scopes = [x for x in application_scopes_pre if x in all_scopes]

cache.set(application_cache_label, application_scopes, 3600) # 60*60 = 1h
print(application_scopes)
return application_scopes

def get_default_scopes(self, application=None, request=None, *args, **kwargs):
"""
Return a list of the default scopes for the current application/request.
This MUST be a subset of the scopes returned by `get_available_scopes`.

ex: ["read"]
"""

return ['basic']

设定项目配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
OAUTH2_PROVIDER = {
'ALLOWED_REDIRECT_URI_SCHEMES': ['https'],
'SCOPES_BACKEND_CLASS': 'oauth.scopes.ScopeBackend',
'SCOPES': {
'basic': {
'basic': {
'get': '...' # basic
}
},
'introspection': {
'introspection': {
'get': '验证您的 AccessToken 是否有效'
}
},
'info': {
'info': {
'get': '...' # info
},
'name': {
'read': '...', # info.name:read
'write': '...', # info.name:write
},
},
},
'REQUEST_APPROVAL_PROMPT': 'auto'
}

OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth.Application'

oauth 应用下修改 views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from django.utils import timezone

from oauth2_provider.views import AuthorizationView as BaseAuthorizationView
from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.models import get_access_token_model, get_application_model
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.exceptions import OAuthToolkitError


class AuthorizationView(BaseAuthorizationView):
template_name = "oauth/authorize.html"

def get(self, request, *args, **kwargs):
try:
scopes, credentials = self.validate_authorization_request(request)
except OAuthToolkitError as error:
# Application is not available at this time.
return self.error_response(error, application=None)

all_scopes = get_scopes_backend().get_all_scopes()
kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes]
kwargs["scopes"] = scopes
# at this point we know an Application instance with such client_id exists in the database

application = get_application_model().objects.get(client_id=credentials["client_id"])

kwargs["application"] = application
kwargs["client_id"] = credentials["client_id"]
kwargs["redirect_uri"] = credentials["redirect_uri"]
kwargs["response_type"] = credentials["response_type"]
kwargs["state"] = credentials["state"]

self.oauth2_data = kwargs
# following two loc are here only because of https://code.djangoproject.com/ticket/17795
form = self.get_form(self.get_form_class())
kwargs["form"] = form

# Check to see if the user has already granted access and return
# a successful response depending on "approval_prompt" url parameter

require_approval = application.require_approval

try:
# If skip_authorization field is True, skip the authorization screen even
# if this is the first use of the application and there was no previous authorization.
# This is useful for in-house applications-> assume an in-house applications
# are already approved.
if application.skip_authorization:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=" ".join(scopes),
credentials=credentials, allow=True
)
return self.redirect(uri, application)

elif require_approval == "auto":
tokens = get_access_token_model().objects.filter(
user=request.user,
application=kwargs["application"],
expires__gt=timezone.now()
).all()

# check past authorizations regarded the same scopes as the current one
for token in tokens:
if token.allow_scopes(scopes):
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=" ".join(scopes),
credentials=credentials, allow=True
)
return self.redirect(uri, application)

except OAuthToolkitError as error:
return self.error_response(error, application)

return self.render_to_response(self.get_context_data(**kwargs))

template_name 被修改了,如果需要默认的则修改回 oauth2_provider/authorize.html

配置全局 urls.py 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
from oauth.views import AuthorizationView
from oauth2_provider.views import TokenView, RevokeTokenView, IntrospectTokenView
urlpatterns = [
path(r"authorize/", AuthorizationView.as_view(), name="authorize"),
path(r"token/", TokenView.as_view(), name="token"),
path(r"revoke_token/", RevokeTokenView.as_view(), name="revoke-token"),
path(r"introspect/", IntrospectTokenView.as_view(), name="introspect"),
]

if settings.DEBUG:
urlpatterns += [
path('o/', include('oauth2_provider.urls'))
]

导出 requirements.txt

1
pip freeze > requirements.txt

push 到服务器

1
2
3
git init
git remote add origin XXX
git push origin master