Django自学笔记-第三章

内容来源: 极客时间

迭代思维与 MVP 产品规划方法(OOPD)

  • MVP:minimum viable product, 最小可用产品
  • OOPD:Online & Offline Product Development, 线上线下相结合的产品开发方法
  • 内裤原则:MVP 包含了产品的轮廓,核心的功能,让业务可以运转
  • 优先线下:能够走线下的,优先走线下流程,让核心的功能先跑起来,快速做用户验证和方案验证
  • MVP 的核心:忽略掉一切的细枝末节,做合适的假设和简化,使用最短的时间开发出来
  • 迭代思维是最强大的产品思维逻辑,互联网上唯快不破的秘诀
  • 优秀的工程师和优秀的产品经理,善于找出产品 MVP 的功能范围

微信 1.0 的 MVP 迭代

  • 只有 3 个功能
    • 聊天发文本消息
    • 发送图片
    • 自定义头像
  • 没有更改用户昵称的功能
  • 产品的目标
    • 替换掉短信的免费聊天工具

如何找出产品的 MVP 功能范围?

使用这些问题来帮助确定范围

  • 产品的核心目标是什么? 核心用户是谁?核心的场景是什么?
  • 产品目标都需要在线上完成或者呈现吗?
  • 最小 MVP 产品要做哪些事情,能够达到业务目标?
  • 哪些功能不是在用户流程的核心路径上的?
  • 做哪些简化,和假设,能够在最短的时间交付产品,并且可以让业务流程跑起来?

用户场景和功能清单:找出必须的功能

  • 定义最小可用的面试评估系统
  • 哪些是可以线下人肉做的事情
  • 可以做出哪些假设来简化产品
角色 功能 是否必须
HR 可以管理职位
候选人 可以浏览职位列表,详情
候选人 可以在线投递简历
HR 查看候选人投递的简历,审核简历
HR 导入候选人
HR 添加,修改候选人,查看候选人列表
管理员 可以添加面试官
面试官 可以进行一面,二面;HR可以进行终面
管理员 能够管理HR,面试官的角色
HR/面试官 HR和面试官只能看到有权限的内容

核心目标:

  1. 提高面试效率
  2. 使面试结果可以跟踪, 追溯

根据以上核心目标, 其他功能都不属于第一版MVP内容, 因此仅保留与面试过程相关内容即可

企业级数据库设计十个原则

其中: 3个基础原则3个完备性原则所有的企业级数据库均必须遵守, 扩展性原则可选

3个基础原则

  1. 结构清晰:表名、字段命名没有歧义,能一眼看懂
  2. 唯一职责:一表一用,领域定义清晰,不存储无关信息,相关数据在一张表中
  3. 主键原则:设计不带物理意义的主键;有唯一约束,确保幂等

4个扩展性原则(影响系统的性能和容量)

  1. 长短分离:可以扩展,长文本独立存储;有合适的容量设计
  2. 冷热分离:当前数据与历史数据分离
  3. 索引完备:有合适索引方便查询
  4. 不使用关联查询:不使用一切的 SQL Join 操作,不做 2 个表或者更多表的关联查询

3个完备性原则

  1. 完整性:保证数据的准确性和完整性,重要的内容都有记录
  2. 可追溯:可追溯创建时间,修改时间,可以逻辑删除
  3. 一致性原则:数据之间保持一致,尽可能避免同样的数据存储在不同表中

第一个迭代

创建面试应用

使用manage.py创建一个新的应用

1
./manage.py startapp interview

增加自定义模块

修改recuritment/interview/models.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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
from django.db import models

# 第一轮面试结果
FIRST_INTERVIEW_RESULT_TYPE = (('建议复试', '建议复试'), ('待定', '待定'), ('放弃', '放弃'))

# 复试面试建议
INTERVIEW_RESULT_TYPE = (('建议录用', '建议录用'), ('待定', '待定'), ('放弃', '放弃'))

# 候选人学历
DEGREE_TYPE = (('本科', '本科'), ('硕士', '硕士'), ('博士', '博士'))

# HR终面结论
HR_SCORE_TYPE = (('S', 'S'), ('A', 'A'), ('B', 'B'), ('C', 'C'))


class Candidate(models.Model):
# 基础信息
userid = models.IntegerField(unique=True,
blank=True,
null=True,
verbose_name='应聘者ID')
username = models.CharField(max_length=135, verbose_name='姓名')
city = models.CharField(max_length=135, verbose_name='城市')
phone = models.CharField(max_length=135, verbose_name='手机号码')
# 单独使用的
email = models.EmailField(max_length=135, blank=True, verbose_name='邮箱')
apply_position = models.CharField(max_length=135,
blank=True,
verbose_name='应聘职位')
born_address = models.CharField(max_length=135,
blank=True,
verbose_name='生源地')
gender = models.CharField(max_length=135, blank=True, verbose_name='性别')
candidate_remark = models.CharField(max_length=135,
blank=True,
verbose_name='候选人信息备注')

# 学校与学历信息
bachelor_school = models.CharField(max_length=135,
blank=True,
verbose_name='本科学校')
master_school = models.CharField(max_length=135,
blank=True,
verbose_name='研究生学校')
doctor_school = models.CharField(max_length=135,
blank=True,
verbose_name='博士生学校')
degree = models.CharField(max_length=135,
choices=DEGREE_TYPE,
blank=True,
verbose_name='学历')
major = models.CharField(max_length=135, blank=True, verbose_name='专业')

# 综合能力测评成绩,笔试测评成绩
test_score_of_general_ability = models.DecimalField(decimal_places=1,
null=True,
max_digits=3,
blank=True,
verbose_name='综合能力测评成绩')
paper_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=3,
blank=True,
verbose_name='笔试成绩')

# 第一轮面试记录
first_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='初试分')
first_learning_ability = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='学习能力得分')
first_professional_competency = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='专业能力得分')
first_advantage = models.TextField(max_length=1024,
blank=True,
verbose_name='优势')
first_disadvantage = models.TextField(max_length=1024,
blank=True,
verbose_name='顾虑和不足')
first_result = models.CharField(max_length=256,
choices=FIRST_INTERVIEW_RESULT_TYPE,
blank=True,
verbose_name='初试结果')
first_recommend_position = models.CharField(max_length=256,
blank=True,
verbose_name='推荐部门')
first_interviewer = models.CharField(max_length=256,
blank=True,
verbose_name='面试官')
first_remark = models.CharField(max_length=135,
blank=True,
verbose_name='初试备注')

# 第二轮面试记录
second_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='专业复试得分')
second_learning_ability = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='学习能力得分')
second_professional_competency = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='专业能力得分')
second_pursue_of_excellence = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='追求卓越得分')
second_communication_ability = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='沟通能力得分')
second_pressure_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='抗压能力得分')
second_advantage = models.TextField(max_length=1024,
blank=True,
verbose_name='优势')
second_disadvantage = models.TextField(max_length=1024,
blank=True,
verbose_name='顾虑和不足')
second_result = models.CharField(max_length=256,
choices=INTERVIEW_RESULT_TYPE,
blank=True,
verbose_name='专业复试结果')
second_recommend_position = models.CharField(max_length=256,
blank=True,
verbose_name='建议方向或推荐部门')
second_interviewer = models.CharField(max_length=256,
blank=True,
verbose_name='面试官')
second_remark = models.CharField(max_length=135,
blank=True,
verbose_name='专业复试备注')

# HR终面
hr_score = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR复试综合等级')
hr_responsibility = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR责任心')
hr_communication_ability = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR坦诚沟通')
hr_logic_ability = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR逻辑思维')
hr_potential = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR发展潜力')
hr_stability = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR稳定性')
hr_advantage = models.TextField(max_length=1024,
blank=True,
verbose_name='优势')
hr_disadvantage = models.TextField(max_length=1024,
blank=True,
verbose_name='顾虑和不足')
hr_result = models.CharField(max_length=256,
choices=INTERVIEW_RESULT_TYPE,
blank=True,
verbose_name='HR复试结果')
hr_interviewer = models.CharField(max_length=256,
blank=True,
verbose_name='HR面试官')
hr_remark = models.CharField(max_length=256,
blank=True,
verbose_name='HR复试备注')

creator = models.CharField(max_length=256,
blank=True,
verbose_name='候选人数据的创建人')
created_date = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
modified_date = models.DateTimeField(auto_now=True,
null=True,
blank=True,
verbose_name='更新时间')
last_editor = models.CharField(max_length=256,
blank=True,
verbose_name='最后编辑者')

class Meta:
db_table = 'candidate'
verbose_name = '应聘者'
verbose_name_plural = '应聘者'

def __unicode__(self):
return self.username

字段说明

字段类型 字段名称 说明
EmailField 邮箱地址类型 用于显示邮件地址类型
DecimalField 十进制数字类型 可以设置精度的十进制数字

注册应用

修改recuritment/interview/admin.py文件, 注册当前的应用

1
2
3
4
5
6
from django.contrib import admin
from . import models
# Register your models here.

admin.site.register(models.Candidate)

注册应用到项目

同样的, 修改recuritment/settings.py文件, 添加将interview注册到项目中

1
2
3
4
5
6
7
8
9
10
11
12
13
...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'jobs',
'interview'
]
...

此时访问页面, 会发现报错

报错信息

同步数据库

仍旧需要执行数据库同步操作

1
2
./manage.py makemigrations
./manage.py migrate
同步后正常

尝试增加应聘者

增加测试应聘者

发现目前的应用有需要优化的点:

1. 整体表单条目数量过多
1. 需要对多个面试情况进行分区展示处理

通过对指定字段拼接成元组的方式, 可以实现字段分组, 见修改后的模型

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
from django.contrib import admin
from datetime import datetime
from . import models

# Register your models here.


# 候选人管理类
class CandidateAdmin(admin.ModelAdmin):
exclude = ('creator', 'created_date', 'modified_date')

list_display = ('username', 'city', 'bachelor_school', 'first_score',
'first_result', 'first_interviewer', 'second_score',
'second_result', 'second_interviewer', 'hr_score',
'hr_result', 'hr_interviewer', 'last_editor')

# 增加分组设置
fieldsets = (
(None, {
'fields': ('userid', ('username', 'city', 'phone'),
('email', 'apply_position',
'born_address'), ('gender', 'candidate_remark'),
('bachelor_school', 'master_school', 'doctor_school'),
('degree', 'major'), ('test_score_of_general_ability',
'paper_score'), 'last_editor')
}),
('第一轮面试记录', {
'fields': (
('first_score', 'first_learning_ability',
'first_professional_competency'), 'first_advantage',
'first_disadvantage', 'first_result',
'first_recommend_position', 'first_interviewer', 'first_remark')
}),
('第二轮专业复试记录', {
'fields':
(('second_score', 'second_learning_ability',
'second_professional_competency'),
('second_pursue_of_excellence', 'second_communication_ability',
'second_pressure_score'), 'second_advantage',
'second_disadvantage', 'second_result',
'second_recommend_position', 'second_interviewer',
'second_remark')
}),
('HR复试记录', {
'fields': ('hr_score',
('hr_responsibility', 'hr_communication_ability',
'hr_logic_ability'), ('hr_potential', 'hr_stability'),
'hr_advantage', 'hr_disadvantage',
('hr_result', 'hr_interviewer', 'hr_remark'))
}),
)

def save_model(self, request, obj, form, change):
obj.last_editor = request.user.username
if not obj.creator:
obj.creator = request.user.username
obj.modified_date = datetime.now()
obj.save()


admin.site.register(models.Candidate, CandidateAdmin)

展示效果如下:

优化展示

参考网页

使用 Model 模型

https://developer.mozilla.org/zh-CN/docs/Learn/Serverside/Django/Models

https://docs.djangoproject.com/en/3.1/topics/db/models/

使用 Admin 管理类

https://developer.mozilla.org/zh-CN/docs/learn/Serverside/Django/%E7%AE%A1%E7%90%86%E7%AB%99%E7%82%B9

https://docs.djangoproject.com/en/3.1/ref/contrib/admin/

第二个迭代

实现候选人批量导入

  • 怎么样实现一个数据导入的功能最简洁
    • 开发一个自定义的 Web 页面,让用户能够上传 excel/csv 文件
    • 开发一个命令行工具,读取 excel/csv,再访问数据库写入 DB
    • 从数据库的客户端,比如 MySQL 的客户端里面导入数据
  • Django 框架已经考虑到(需要使用到命令行的场景)
    1. 使用自定义的 django management 命令来导入数据
    2. 应用下面创建 management/commands 目录
    3. commands 目录下添加脚本,创建类,继承自 BaseCommand,实现命令行逻辑

在interview项目创建文件management/commands/import_candidates.py, 并实现读取CSV文件功能脚本

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
- Main_Function: 通过使用CSV导入候选人功能
- Author: ZhouRuixi
- Mail: 879582094@qq.com
- License: None
- CreateTime: 2022/4/13 09:37
- Copyright: Copyright © 2022 ChowRex. All rights reserved.
- Project: django-learning
"""

__author__ = "Chow Rex"
__copyright__ = "Copyright © 2022 Chow Rex. All rights reserved."
__credits__ = [__author__]
__license__ = "None"
__maintainer__ = __author__
__email__ = "879582094@qq.com"
__status__ = "Production"
__version__ = "0.0.1"

import csv
from django.core.management import BaseCommand
from interview.models import Candidate

# 如何测试
# python manage.py import_candidates --path xxxx.csv


class Command(BaseCommand):

help = '从一个CSV文件的内容中读取候选人列表, 导入到数据库'

def add_arguments(self, parser):
parser.add_argument('--path', type=str)

def handle(self, *args, **options):
path = options['path']
with open(path, 'rt', encoding='gbk') as file:
reader = csv.reader(file, dialect='excel', delimiter=';')
for row in reader:
kwargs = {
'username': row[0],
'city': row[1],
'phone': row[2],
'bachelor_school': row[3],
'major': row[4],
'degree': row[5],
'test_score_of_general_ability': row[6],
'paper_score': row[7],
}
candidate = Candidate.objects.create(**kwargs)
print(candidate)

使用命令测试脚本是否可用

1
./manage.py import_candidates --path ./candidates.csv

执行完成后, 访问页面查看效果

查看效果

第三个迭代

实现对候选人信息的快速筛选

  • 能够按照名字、手机号码、学校来查询候选人信息
  • 能够按照初试结果,复试结果,HR复试结果,面试官来筛选;能按照复试结果来排序

增加搜索功能

修改interview/admin.py中的CandidateAdmin类, 增加如下字段

1
2
3
# 增加搜索字段
search_fields = ('username', 'city', 'phone', 'email', 'bachelor_school')

实现效果

实现效果

可以对各项设置的搜索项进行模糊搜索功能

增加筛选功能

修改interview/admin.py中的CandidateAdmin类, 增加如下字段

1
2
3
# 增加筛选字段
list_filter = ('city', 'first_result', 'second_result', 'hr_result',
'first_interviewer', 'second_interviewer', 'hr_interviewer')

实现效果

筛选效果

可以对各项设置的过滤项进行过滤功能

增加排序功能

修改interview/admin.py中的CandidateAdmin类, 增加如下字段

1
2
# 增加排序字段
ordering = ('hr_result', 'second_result', 'first_result')

实现效果

多重筛选效果

企业域账号集成

概念

什么是目录服务 Directory Service ?

目录服务对于网络的作用就像白页对电话系统的作用一样。目录服务将有关现实世界中的事物(如人、计算机、打印机等等)的信息存储为具有描述性属性的对象。人们可以使用该服务按名称查找对象或者像使用黄页一样,可使用它们查找服务。

使用目录服务的好处?

  • 可以直接使用域账号登陆
  • 不用手工添加账号,维护独立密码
  • 可以集成 OpenLDAP/ActiveDirecotry

以 Open LDAP 为例

  • DN: 目录服务中的一个唯一的对象 CN=David,OU=Shanghai,DC=ihopeit,DC=com

使用Docker临时创建测试LDAP服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
COMPANY="goldwind"
DOMAIN="com"
PASSWD="9ol.8ik,"

docker run -d -p 389:389 -p 636:636 \
--env LDAP_ORGANISATION="${COMPANY}" \
--env LDAP_DOMAIN="${COMPANY}.${DOMAIN}" \
--env LDAP_ADMIN_PASSWORD="${PASSWD}" \
--name gw-openldap osixia/openldap:1.5.0

docker run -d -p 80:80 -p 443:443 \
--hostname phpldapadmin \
--link gw-openldap:ldap-host \
--env PHPLDAPADMIN_LDAP_HOSTS=ldap-host \
--name phpldapadmin osixia/phpldapadmin:stable

echo "登录地址: https://localhost"
echo "用户名: cn=admin,dc=${COMPANY},dc=${DOMAIN}"
echo "密码: ${PASSWD}"

使用下面创建的LDIF文件可导入几个测试用户

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# LDIF Export for dc=goldwind,dc=com
# Server: ldap-host (ldap-host)
# Search Scope: sub
# Search Filter: (objectClass=*)
# Total Entries: 5
#
# Generated by phpLDAPadmin (http://phpldapadmin.sourceforge.net) on April 19, 2022 1:12 pm
# Version: 1.2.5

version: 1

# Entry 1: dc=goldwind,dc=com
dn: dc=goldwind,dc=com
createtimestamp: 20220419130024Z
creatorsname: cn=admin,dc=goldwind,dc=com
dc: goldwind
entrycsn: 20220419130024.396058Z#000000#000#000000
entrydn: dc=goldwind,dc=com
entryuuid: 6bf21494-542c-103c-915b-6b3d3cb8b5da
hassubordinates: TRUE
modifiersname: cn=admin,dc=goldwind,dc=com
modifytimestamp: 20220419130024Z
o: goldwind
objectclass: top
objectclass: dcObject
objectclass: organization
structuralobjectclass: organization
subschemasubentry: cn=Subschema

# Entry 2: cn=Tom_Yang,dc=goldwind,dc=com
dn: cn=Tom_Yang,dc=goldwind,dc=com
cn: Tom_Yang
createtimestamp: 20220419131054Z
creatorsname: cn=admin,dc=goldwind,dc=com
entrycsn: 20220419131156.211829Z#000000#000#000000
entrydn: cn=Tom_Yang,dc=goldwind,dc=com
entryuuid: e38b01e0-542d-103c-8509-cd70f69865f9
gidnumber: 500
givenname: Tom
hassubordinates: FALSE
homedirectory: /home/users/tyang
loginshell: /bin/bash
mail: tomyang@goldwind.com
modifiersname: cn=admin,dc=goldwind,dc=com
modifytimestamp: 20220419131156Z
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: top
sn: Yang
structuralobjectclass: inetOrgPerson
subschemasubentry: cn=Subschema
uid: tyang
uidnumber: 1000
userpassword: {MD5}ICy5YqxZB1uWSwcVLSNLcA==

# Entry 3: cn=users,dc=goldwind,dc=com
dn: cn=users,dc=goldwind,dc=com
cn: users
createtimestamp: 20220419130951Z
creatorsname: cn=admin,dc=goldwind,dc=com
entrycsn: 20220419130951.756706Z#000000#000#000000
entrydn: cn=users,dc=goldwind,dc=com
entryuuid: be1e78ce-542d-103c-8508-cd70f69865f9
gidnumber: 500
hassubordinates: FALSE
modifiersname: cn=admin,dc=goldwind,dc=com
modifytimestamp: 20220419130951Z
objectclass: posixGroup
objectclass: top
structuralobjectclass: posixGroup
subschemasubentry: cn=Subschema

# Entry 4: ou=Shanghai,dc=goldwind,dc=com
dn: ou=Shanghai,dc=goldwind,dc=com
createtimestamp: 20220419131110Z
creatorsname: cn=admin,dc=goldwind,dc=com
entrycsn: 20220419131110.802786Z#000000#000#000000
entrydn: ou=Shanghai,dc=goldwind,dc=com
entryuuid: ed3beb32-542d-103c-850a-cd70f69865f9
hassubordinates: TRUE
modifiersname: cn=admin,dc=goldwind,dc=com
modifytimestamp: 20220419131110Z
objectclass: organizationalUnit
objectclass: top
ou: Shanghai
structuralobjectclass: organizationalUnit
subschemasubentry: cn=Subschema

# Entry 5: cn=Sandy_Guo,ou=Shanghai,dc=goldwind,dc=com
dn: cn=Sandy_Guo,ou=Shanghai,dc=goldwind,dc=com
cn: Sandy_Guo
createtimestamp: 20220419131130Z
creatorsname: cn=admin,dc=goldwind,dc=com
entrycsn: 20220419131212.194599Z#000000#000#000000
entrydn: cn=Sandy_Guo,ou=Shanghai,dc=goldwind,dc=com
entryuuid: f9276b24-542d-103c-850b-cd70f69865f9
gidnumber: 500
givenname: Sandy
hassubordinates: FALSE
homedirectory: /home/users/sguo
loginshell: /bin/bash
mail: sandyguo@goldwind.com
modifiersname: cn=admin,dc=goldwind,dc=com
modifytimestamp: 20220419131212Z
objectclass: inetOrgPerson
objectclass: posixAccount
objectclass: top
sn: Guo
structuralobjectclass: inetOrgPerson
subschemasubentry: cn=Subschema
uid: sguo
uidnumber: 1001
userpassword: {MD5}JQz4tRx3Pz+NyLS+hnqaAg==

其中Tom Yang的默认密码为123, Sandy Guo的默认密码为456

设置Django支持LDAP认证

使用pip安装ldap包

1
pip install django-python3-ldap

修改recuritment/settings.py文件, 添加django-python3-ldap应用, 并配置该应用

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

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_python3_ldap',
'jobs',
'interview'
]

...

# LDAP 相关配置

# The URL of the LDAP server.
LDAP_AUTH_URL = "ldap://localhost:389"

# Initiate TLS on connection.
LDAP_AUTH_USE_TLS = False

# The LDAP search base for looking up users.
LDAP_AUTH_SEARCH_BASE = "dc=goldwind,dc=com"

# The LDAP class that represents a user.
LDAP_AUTH_OBJECT_CLASS = "inetOrgPerson"

# User model fields mapped to the LDAP
# attributes that represent them.
LDAP_AUTH_USER_FIELDS = {
"username": "cn",
"first_name": "givenName",
"last_name": "sn",
"email": "Email",
}

# A tuple of django model fields used to uniquely identify a user.
LDAP_AUTH_USER_LOOKUP_FIELDS = ("username",)

# Path to a callable that takes a dict of {model_field_name: value},
# returning a dict of clean model data.
# Use this to customize how data loaded from LDAP is saved to the User model.
LDAP_AUTH_CLEAN_USER_DATA = "django_python3_ldap.utils.clean_user_data"

# The LDAP username and password of a user for querying the LDAP database for user
# details. If None, then the authenticated user will be used for querying, and
# the `ldap_sync_users` command will perform an anonymous query.
LDAP_AUTH_CONNECTION_USERNAME = 'admin'
LDAP_AUTH_CONNECTION_PASSWORD = '9ol.8ik,'

AUTHENTICATION_BACKENDS = {
"django_python3_ldap.auth.LDAPBackend",
'django.contrib.auth.backends.ModelBackend',
}

登录前系统用户

检查系统用户

尝试使用域账号登录无法使用域账号登录

图中因为中文缘故无法正确展示报错信息, 修改语言为英文后可见报错

切换英文显示完整报错信息

在后台可以看到, 域账户并非员工账号

域账号默认未开通权限

选择刚才添加的域账号, 勾选Staff status后保存

添加权限

我这里因为使用了空格, 因此保存会报错

空格异常

需要修改用户名为Tom_Yang后方可保存, 并且需要同步修改LDAP中的CN信息

变更为下划线

再次尝试登录, 后台系统可以登录

登录成功

批量添加用户

首先先对ldap用户进行同步操作

1
./manage.py ldap_sync_users
批量添加用户

此时登录后台可观察到用户已同步

同比已成功

设置角色群组

添加面试官群组interviewer

设置群组

添加HR群组hr

添加群组

为导入的用户增加群组

增加群组
修改权限

注意: 用户需勾选工作人员状态, 并附加群组, 否则无法登录

此时使用添加的用户进行登录, 发现权限已生效

权限已生效
应用权限已生效

其中Tom属于interviewer群组, 拥有查看候选人和修改候选人的权限, 而Sandy属于hr群组, 拥有候选人的增删改查权限及发布职位的增删改查权限

第四个迭代

增加导出Action

修改interview/admin.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
import csv
from datetime import datetime

from django.contrib import admin
from django.http import HttpResponse

...
# 定义导出字段列表
exportable_fields = ('username', 'city', 'phone', 'bachelor_school',
'master_school', 'degree', 'first_result',
'first_interviewer', 'second_result', 'second_interviewer',
'hr_result', 'hr_score', 'hr_remark', 'hr_interviewer')

# 定义导出方法
def export_as_csv(model_admin, request, query_set):
# 构造返回结果
response = HttpResponse(content_type='text/csv')
filename = f'recruitment-candidates-list-{datetime.now().strftime("%Y-%m-%d-%H-%M-%S")}.csv'
response['Content-Disposition'] = f'attachment; filename={filename}'

# 写入表头
writer = csv.writer(response)
writer.writerow([
query_set.model._meta.get_field(f).verbose_name.title()
for f in exportable_fields
],)

for obj in query_set:
# 单行 的记录(各个字段的值), 根据字段对象,从当前实例 (obj) 中获取字段值
csv_line_values = []
for field in exportable_fields:
field_object = query_set.model._meta.get_field(field)
field_value = field_object.value_from_object(obj)
csv_line_values.append(field_value)
writer.writerow(csv_line_values)

return response

...

并修改CandidateAdmin类, 添加如下属性

1
2
3
4
5
6
7
8
9
...
# 候选人管理类
class CandidateAdmin(admin.ModelAdmin):
exclude = ('creator', 'created_date', 'modified_date')

# 增加自定义操作方法
actions = (export_as_csv, )
...

刷新后台管理页面, 并进入候选人应用, 发现已支持导出操作

导出功能展示

打开csv文件, 数据正常

检查数据

导出功能汉化

设置自定义Action的属性以支持汉化说明

1
2
3
4
5
6
def export_as_csv(model_admin, request, query_set): ...

# 增加汉化说明
export_as_csv.short_description = '导出CSV文件'

...

此时重新刷新页面, 导出操作已汉化

汉化

增加日志功能

定义项目日志格式字典

修改recuritment/settings.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
...

# 增加日志记录相关配置

LOGGING = {
# Python当前默认版本
'version': 1,
# 是否关闭其他记录器, 建议为False
'disable_existing_loggers': False,
# 日志格式
'formatters': {
'simple': {
# exact format is not important, this is the minimum information
'format': '%(asctime)s %(name)-12s %(lineno)d %(levelname)-8s %(message)s',
},
},
# 日志处理器
'handlers': {
# 控制台输出
'console': {
'class': 'logging.StreamHandler',
'formatter': 'simple',
},
# 邮件输出
# Add Handler for mail_admins for `warning` and above
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler',
},
# 文件输出
'file': {
'class': 'logging.FileHandler',
'formatter': 'simple',
'filename': os.path.join(os.path.dirname(BASE_DIR), 'recruitment.admin.log'),
},
},

# 定义全局默认日志
'root': {
'handlers': ['console', 'file'],
'level': 'INFO',
},

# 定义指定记录器
'loggers': {
"django_python3_ldap": {
"handlers": ["console", "file"],
"level": "DEBUG",
},
},
}

重新使用ldap认证方式登录系统, 发现日志输出

日志LDAP已连接

在自定义Action中使用日志

修改interview/admin.py文件, 引入logging包, 并定义logger记录器

1
2
3
4
5
6
7
8
9
10
11
12
13
import logging
...

logger = logging.getLogger(__name__)


def export_as_csv(model_admin, request, query_set):
...

logger.info("%s has exported %d candidates record", request.user,
len(query_set))

return response

进入Web页面, 尝试导出数据, 发现日志系统已记录

日志记录正常

配置分离

抽离默认环境配置

  1. recuritment项目目录下, 创建Python包settings

  2. 移动recuritment/recuritment/settings.py文件并重命名至recuritment/settings/base.py

  3. 修改recuritment/settings/base.py, 隐藏默认配置

    1
    2
    3
    4
    5
    6
    7
    8
    ...
    DEBUG = False

    ALLOWED_HOSTS = ['127.0.0.1']
    ...
    LDAP_AUTH_CONNECTION_USERNAME = None
    LDAP_AUTH_CONNECTION_PASSWORD = None
    ...

  4. 新建本地配置recuritment/settings/local.py, 并覆盖base中定义的内容

    1
    2
    3
    4
    5
    6
    7
    from .base import *

    ALLOWED_HOSTS = ['127.0.0.1', '0.0.0.0']
    LDAP_AUTH_CONNECTION_USERNAME = 'admin'
    LDAP_AUTH_CONNECTION_PASSWORD = '9ol.8ik,'
    DEBUG = True

  5. 在本地.gitignore文件中, 添加本地配置忽略

    1
    2
    3
    ...
    settings/local.py

  6. 新建生产环境配置recuritment/settings/production.py, 并覆盖base中定义的内容

    1
    2
    3
    4
    5
    6
    7
    8
    from .base import *

    ALLOWED_HOSTS = ['127.0.0.1']
    LDAP_AUTH_URL = "ldap://127.0.0.1:389"
    LDAP_AUTH_CONNECTION_USERNAME = 'admin'
    LDAP_AUTH_CONNECTION_PASSWORD = '9ol.8ik,'
    DEBUG = False

    ## 依据不同环境使用不同配置

    默认情况使用base.py中定义的内容

    1
    python ./manage.py runserver 0.0.0.0:9999

    通过指定配置参数--settings, 可切换不同环境

    1
    2
    3
    4
    # 使用本地配置
    python ./manage.py runserver 0.0.0.0:9999 --settings=settings.local
    # 使用生产环境配置
    python ./manage.py runserver 0.0.0.0:9999 --settings=settings.production

完善产品细节

视频链接: https://time.geekbang.org/course/detail/100061901-300874

修改页面标题

修改recruitment/recruitment/urls.py, 增加页面标题

1
2
3
4
from django.utils.translation import gettext
...
admin.site.site_header = gettext('金风科技招聘管理系统')

刷新页面, 标题修改已生效

标题展示

增加面试官打分提示信息

修改recruitment/interview/models.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
...
# 打分系统文字说明
SCORE_HELP = '打分说明: 5分制. 其中, 极优秀: ≥4.5; 优秀: 4~4.4; 良好: 3.5~3.9; 一般: 3~3.4; 较差: <3'
...

# 第一轮面试记录
first_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='初试分',
help_text=SCORE_HELP)
...

# 第二轮面试记录
second_score = models.DecimalField(decimal_places=1,
null=True,
max_digits=2,
blank=True,
verbose_name='专业复试得分',
help_text=SCORE_HELP)
...

# HR终面
hr_score = models.CharField(max_length=10,
choices=HR_SCORE_TYPE,
blank=True,
verbose_name='HR复试综合等级',
help_text=SCORE_HELP)

刷新页面, 可以看到打分系统增加了提示信息

增加提示信息

修改面试官为下拉选择

修改recruitment/interview/models.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
from django.contrib.auth.models import User
...
class Candidate(models.Model):
...
# first_interviewer = models.CharField(max_length=256,
# blank=True,
# verbose_name='面试官')
first_interviewer_user = models.ForeignKey(
User,
related_name='first_interviewer_user',
null=True,
on_delete=models.CASCADE,
blank=True,
verbose_name='面试官')
...
# second_interviewer = models.CharField(max_length=256,
# blank=True,
# verbose_name='面试官')
second_interviewer_user = models.ForeignKey(
User,
related_name='second_interviewer_user',
null=True,
on_delete=models.CASCADE,
blank=True,
verbose_name='面试官')
...
# hr_interviewer = models.CharField(max_length=256,
# blank=True,
# verbose_name='HR面试官')
hr_interviewer_user = models.ForeignKey(User,
related_name='hr_interviewer',
null=True,
on_delete=models.CASCADE,
blank=True,
verbose_name='HR面试官')

替换所有recruitment/interview/admin.py文件中对原有字段的引用

1
2
3
first_interviewer -> first_interviewer_user
second_interviewer -> second_interviewer_user
hr_interviewer -> hr_interviewer_user

执行数据库创建迁移并迁移

1
2
./manage.py makemigrations
./manage.py migrate
同步数据

刷新页面, 可以看到面试官选择为下拉列表

修改为下拉列表

限制面试官修改权限

目标是限制面试官用户, 无权修改面试官信息, 只能填写相关字段

修改recruitment/interview/admin.py, 重构父类函数

1
2
3
4
5
6
7
8
9
10
11
12
class CandidateAdmin(admin.ModelAdmin):
...
# 覆写父类方法
def get_readonly_fields(self, request, obj=None):
# 提取用户的组
group_names = [_.name for _ in request.user.groups.all()]

if 'interviewer' in group_names:
logger.info('User: %s is a member of interviewer group',
request.user.username)
return 'first_interviewer_user', 'second_interviewer_user'
return tuple()

使用面试官Tom_Yang账号登录, 验证无法修改面试官信息

限制面试官权限

增加列表页编辑面试官功能

因候选人数量很多, HR不方便逐个进入详情页变更面试官信息, 希望在列表页提供直接修改功能

修改recruitment/interview/admin.py, 重构父类函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CandidateAdmin(admin.ModelAdmin):
...
# 覆写父类方法
def get_changelist_instance(self, request):
# 提取用户的组
group_names = [_.name for _ in request.user.groups.all()]

if request.user.is_superuser or 'hr' in group_names:
logger.info('User: %s is super user or a member of hr group',
request.user.username)
self.list_editable = ('first_interviewer_user',
'second_interviewer_user')
return super().get_changelist_instance(request)

使用HR用户登录, 可以批量修改面试官信息

增加HR权限

使用面试官用户登录, 无法修改面试官信息

面试官展示