def create_email(self, request): subject = request.json.get('subject') if not subject: return api.bad_request(form_errors=dict(subject=['标题不能为空'])) content = request.json.get('content') if not content: return api.bad_request(form_errors=dict(content=['内容不能为空'])) from_email = request.json.get('from_email') if not from_email: return api.bad_request(form_errors=dict(from_email=['发件人不能为空'])) recipients = request.json.get('recipients') if not recipients: return api.bad_request(form_errors=dict(recipients=['收件人列表不能为空'])) if not isinstance(recipients, list): return api.resopnse(400, form_errors=dict(recipients=['收件人列表必须是数组'])) email = send_email(recipients, subject, from_email=from_email, content=content) return api.ok(data=email.serialize())
def create_model(self, request): create_args = super().create_model(request, no_save=True) project_id = request.json.get('project') if not project_id: return api.bad_request(message='missing project id') create_args['project_id'] = project_id pagetype = request.json.get('pagetype') Page = ProjectPage.PAGE_TYPES.get(pagetype) if not Page: return api.bad_request(message=f'invalid pagetype: {pagetype}') page = Page.objects.create(**create_args) if pagetype == 'gallery': self.set_gallery_images(page, request.json.get('images', [])) if pagetype == 'homepage': self.set_homepage_carousel_items( page, request.json.get('carousel_items', [])) self.set_attachments(page, request.json.get('attachments', [])) return api.ok(data=page)
def api_reset_password(request): """修改密码 API 支持使用旧密码来设置新密码,也支持使用验证码来设置新密码。 在实现时,会将所有参数传递给 authenticate(),如果成功,则设置新密码。 POST /api/account/reset-password { // 与登录 API 中的登录凭证一致 **credentials, // 设置的新密码 new_password: '******', } """ new_password = request.json.pop('new_password', None) if not new_password: return api.bad_request(message='Missing "new_password"') user = authenticate(request, **request.json, usage=otp_tools.USAGES.reset_password) if user: user.set_password(new_password) user.save() return api.ok(message='密码已更新') else: # TODO 应该给出更多的判断,从而给用户提供一些有效的报错信息 # * 使用短信验证码重置密码时,如果手机号不存在,应该提示用户未注册 # 注意,不应该在发送验证码时提示,而应该在发送之后再提示,避免信息泄漏。 # 如果验证码错误应该提示验证码错误。 # * 如果用户被锁定,可以提示用户被锁定 # * 根据实际使用场景,提示旧密码错误/验证码错误等 return api.bad_request(message='用户不存在或校验失败')
def create_sms(self, request): phone_numbers = request.json.get('phone_numbers') if not phone_numbers: return api.bad_request(form_errors=dict( phone_numbers=['接收方手机号不能为空'])) signature_name = request.json.get('signature_name') if not signature_name: return api.bad_request(form_errors=dict(signature_name=['签名不能为空'])) if signature_name not in AliSms.SIGNATURES: return api.bad_request(form_errors=dict( signature_name=['不允许使用的签名'])) template_code = request.json.get('template_code') if not template_code: return api.bad_request(form_errors=dict( template_code=['模板编号不能为空'])) if template_code not in AliSms.TEMPLATES: return api.bad_request(form_errors=dict(template_code=['模板编号不存在'])) template_param = reuqest.json.get('template_param', {}) sms = send_sms(phone_numbers, signature_name, template_code, template_param) return api.ok(data=sms.serialize())
def api_verify_code(request): """检查验证码是否正确 POST /api/account/verify-code { // 验证码用途,可以是:register, login, reset-password "usage": "register", // 与 send-code 类似,有两种方式指定发送对象 // * 提供 phone 或 email 字段 // * 提供 identity 字段 "phone": "13912345678", "email": "*****@*****.**", "identity": "13912345678", // 用户输入的验证码 "code": "123456", } 如果成功,返回 200,否则返回 400。 """ usage = request.json.get('usage') if usage not in otp_tools.USAGES: return api.bad_request(message='Invalid "usage"') code = request.json.get('code') identity = request.json.get('identity') phone = request.json.get('phone') email = request.json.get('email') if phone: if not is_valid_phone(phone): return errors.InvalidPhone() elif email: if not is_valid_email(email): return errors.InvalidEmail() elif identity: if is_valid_phone(identity): phone = identity elif is_valid_email(identity): email = identity else: return errors.InvalidIdentity() else: return api.bad_request(message='Missing "phone", "email" or "identity"') recipient = phone or email engine = 'sms' if phone else 'email' valid = otp_tools.verify_code(engine, request, recipient, usage, code, False) if valid: return api.ok(message='验证码正确') else: return errors.InvalidVerifyCode()
def create_file(request, Model): bucket = request.POST.get('bucket') if not bucket: return api.bad_request(message=f'无效的 "bucket": {bucket}') if not request.FILES: return api.bad_request(message=f'没有上传文件') files = [] for file in request.FILES.values(): files.append(Model.objects.create(bucket, file, owner=request.user)) return api.ok(data=[file.serialize(request=request) for file in files])
def patch_model(self, request, pk, no_save=False): """ PATCH /adpi/admin/.../{model}/<pk> """ try: model = self.get_queryset(request).get(pk=pk) except self.MODEL.DoesNotExist: return api.not_found() update_fields = [] for panel in self.get_edit_panels(request): kwargs = panel.get_edit_kwargs(request) if kwargs: for key, value in kwargs.items(): setattr(model, key, value) update_fields.append(key) if not update_fields: return api.bad_request(message='No field to update') if hasattr(model, 'updated_at'): update_fields.append('updated_at') if no_save: return model else: model.save() return api.ok(data=model.serialize( **self.get_serialize_kwargs(request)))
def delete_file(request, Model, id): try: file = Model.objects.filter(deleted_at__isnull=True).get(id=id) file.delete() return api.ok(data=file) except Model.DoesNotExist: return api.not_found() except Exception as e: # NOTE 可能是由于其他资源依赖该文件,而相应资源上设置了 on_delete=models.PROTECT # 由于该请求仅在管理后台使用,所以可以直接把 exception 输出 return api.bad_request(message=f'删除失败:{e}')
def patch_file(request, Model, id): try: file = Model.objects.filter(deleted_at__isnull=True).get(id=id) except Model.DoesNotExist: return api.not_found() update_fields = [] for field in ['filename', 'title']: if field in request.json: value = request.json[field] if not value: return api.bad_request(message=f'无效的 {field}: {value}') update_fields.append(field) setattr(file, field, value) file.save(update_fields=update_fields) return api.ok(data=file, message='文件修改成功')
def patch_model(self, request, pk): model = super().patch_model(request, pk, no_save=True) if 'project' in request.json: model.project_id = request.json['project'] if 'pagetype' in request.json: pagetype = request.json['pagetype'] if pagetype != model.PAGE_TYPE: return api.bad_request(message=f'不能修改页面类型') model.save() if model.PAGE_TYPE == 'gallery': self.set_gallery_images(model, request.json.get('images', [])) if pagetype == 'homepage': self.set_homepage_carousel_items( model, request.json.get('carousel_items', [])) self.set_attachments(model, request.json.get('attachments', [])) return api.ok(data=model)
def training(request): """ 知享呼吸培训认证相关页面(课程调研、培训课程、资格认证、考试评定) 当使用 GET 请求时,渲染网页。用户在课程调研、资格认证、考试评定页面, 可以点击「立即参加」按钮,此时需使用 API 的方式发送 POST 请求(注意, 不是 form 的方式,必须是 API 的方式),POST 返回的 data 中有 redirect 字段,该字段的值即为需要跳转的字段,前端可以直接跳转。 POST /zhixiang/training/ { action: 'start-a', // 取值:start-a, start-c, start-d } """ status, _ = ZhixiangTraining.objects.select_related( 'examination').get_or_create(user=request.user) # 首先处理 POST 请求。我们只考虑正常情况,其他情况均返回 400 bad request if request.method == 'POST': action = request.json.get('action') if not action or action not in ('start-a', 'start-c', 'start-d'): return api.bad_request(message='缺少 action 参数或无效的 action') if action == 'start-a' and status.a1: status.set_timestamp('a_start') return api.ok(data=dict(redirect=ZX_SETTINGS['training'] ['investigation_wjx_url_pattern'] % request.user.id, )) elif action == 'start-c' and (status.c1 or status.c4): status.set_timestamp('c_start') return api.ok(data=dict(redirect=ZX_SETTINGS['training'] ['qualification_wjx_url_pattern'] % request.user.id, )) elif action == 'start-d' and status.b2 and status.d1: status.set_timestamp('d_start') return api.ok(data=dict(redirect=status.examination.wjx_url, )) # 所有其他情况,返回 400 # 在前端展示合理的情况下,用户正常操作不应该触发其他情况 return api.bad_request() # 接下来处理 GET 请求,我们需要准备每一个 tab 需要展示的文案信息 """ 给前端提供的 context: { status: obj, // 这是 ZhixiangTraining 对象,正常情况下前端应该不需要使用 default_tab: 'a', // 取值 a, b, c, d,表示默认显示哪一个 tab a: { status: 1, // 有两套文案,1 表示提示用户参与调研,2 为用户已参与调研后提示的内容 // 两种状态的处理 // 1. 用户未参加调研,显示介绍文案,以及「立即参加」按钮 // 2. 用户已参加调研,文案提示已参与调研,无参加按钮 }, b: { courses: [course], // 课程列表 // 其中,course 有这些有用的字段: // course.thumbnail.url: 课程缩略图 // course.introduction: 课程介绍 // course.lesson_url: 点击课程后需要跳转的地址 status: 1, // 取值:1,2 // 两种状态 // 1. 用户点击课程时,提示请先参与课程调研 // 2. 用户点击后进入课程页面 }, c: { status: 1, // 取值 1,2,3,4 // 四种状态的处理: // 1. 未参加资格认证,显示介绍文案,以及「立即参加」按钮 // 2. 已填表、待审核,文案提示已填表、等待审核,无参加按钮 // 3. 已通过审核,文案提示已已审核通过,无参加按钮 // 4. 审核被驳回,文案提示审核被驳回,可以重新填表,以及「立即参加」按钮 }, d: { status: 1, // 取值 1,2,3,4 // 四种状态的处理: // 1. 未通过资质认证,文案提示请先通过资质认证,无参加按钮 // 2. 已通过资质认证,未学习完课程,文案提示请先完成 xx 课程学习,无参加按钮 // 3. 可以参加考试,显示「立即参加」按钮 // 4. 已参与考试,文案提示等待官方公布结果 course: obj, // 如果用户已经通过了资格认证,course 的值是其需要完成的课程,可以用于渲染文案 }, } """ # (a1, *, *, *): 默认显示课程调研页 # (a2, b2, c3, *): 默认显示考试页 if status.a1: default_tab = 'a' elif status.a2 and status.b2 and status.c3: default_tab = 'd' else: default_tab = 'b' # 如果有 show 参数,则使用 show 参数 show = request.GET.get('show') if show in ['a', 'b', 'c', 'd']: default_tab = show # 课程调研页状态(前端状态码正好等于后端 a 的值 ) # (a1, *, *, *): 显示「立即参加」按钮 # (a2, *, *, *): 文案提示已参与调研,无按钮 a_status = status.a # 培训课程页状态(前端状态码正好等于后端 a 的值) # (a1, *, *, *): 显示课程列表,点击课程时,弹窗提示:“参与调研后,才可参加培训!” # (a2, *, *, *): 显示课程列表 b_status = status.a # 资格认证页状态(前端状态码正好等于后端 c 的值) # (*, *, C1, *): 显示「立即参加」按钮 # (*, *, C2, *): 文案提示已填表,等待审核,不显示「立即参加」按钮 # (*, *, C3, *): 文案提示已通过,不显示「立即参加」按钮 # (*, *, C4, *): 文案提示审核被驳回,可以重新填表,显示「立即参加」按钮 c_status = status.c # 考试评定页 # (*, *, C1|C2|C4, *): 文案提示请先通过资格审核 # (*, B1, C3, D1): 文案提示需要学习完某一个课程才能参加考试,请先学习 # (*, B2, C3, D1): 可以参加考试,显示「立即参加」按钮 # (*, *, *, D2):文案提示已参加考试,等待官方公布考试结果,不显示「立即参加」按钮 if status.c1 or status.c2 or status.c4: d_status = 1 elif status.b1 and status.c3 and status.d1: d_status = 2 elif status.b2 and status.c3 and status.d1: d_status = 3 elif status.d2: d_status = 4 exam_course = status.examination.course if status.examination else None courses = Course.objects \ .filter(zhixiang_exams__isnull=False) \ .select_related('thumbnail') \ .distinct() courses = [course for course in courses if course.published] for course in courses: course.fetch_presentationlesson_details(request.user) course.lesson_url = reverse( 'zhixiang-lesson', kwargs=dict(id=course.default_presentationlesson.id)) context = { 'default_tab': default_tab, 'a': { 'status': a_status, }, 'b': { 'status': b_status, 'courses': courses }, 'c': { 'status': c_status, }, 'd': { 'status': d_status, 'course': exam_course }, } return render_for_ua(request, 'cardpc/zhixiang/training.html', context=context)
def api_login(request): """ 登录,网站前端、管理后台均使用该 API 登录。 POST /api/account/login { // 登录凭证,直接传递给各个 Auth 后端,如何使用由 Auth 后端决定 **credentials, // 可选参数,如果提供了,则只有指定角色的用户可以登录 // 一般网站登录时无需此参数,管理后台登录时,需 role = "admin" "role": "superuser", } 项目中启用了哪几个 Auth 后端,请在 settings.py 中找 AUTHENTICATION_BACKENDS, 如果 AUTHENTICATION_BACKENDS 中没有配置的话,默认只有一个,即 ModelBackend。 以下是几个 Backend 接受的 **credentails 参数: ModelBackend: * username(注意,cardpc 中通过 username 查找用户时,会搜索 username、phone、email) * password n.d.account.backends.SmsCodeBackend: * phone * code * identity: 如果没有提供 phone 参数,但是提供了 identity,则用 identity 作为 phone n.d.account.backends.EmailCodeBackend: * email * code * identity: 如果没有提供 email 参数,但是提供了 identity,则用 identity 作为 email 简单的说,前端使用密码登录时,可以发送这样的请求: POST /api/account/login { username: xxx, password: xxx } 使用邮箱或验证码登录时,可以发送这样的请求: POST /api/account/login { identity: xxx, // xxx 可以是邮箱或手机号,会自动检测 code: xxx } 如果登录时仅希望短信验证码、不希望尝试邮箱验证码,可以这样: POST /api/account/login { phone: xxx, code: xxx } """ role = request.json.pop('role', None) user = authenticate(request, **request.json) if user is None: return api.bad_request(message='用户名或密码不正确') if role and role not in get_user_roles(user): return api.bad_request(message='用户名或密码不正确') login(request, user) return api.ok(message='登录成功', data=serialize_user(user))
def api_send_code(request): """发送验证码,此函数仅支持发送注册、短信登录、重置密码三种验证码 POST /api/account/send-code { // 验证码用途,可以是:register, login, reset-password "usage": "register", // 有两种方式指定发送对象, // * 提供 phone 字段,则会发送短信, 提供 email 字段,则会发送邮件 // * 提供 identity 字段,则会自动判断 identity 是手机号还是邮箱,从而执行相应的操作 // phone、email、identity 三个参数只能提供一个 "phone": "13912345678", "email": "*****@*****.**", "identity": "13912345678", } * 如果缺少参数(一般只在开发阶段),会返回 400,message 描述错误信息。 * 如果传递的 phone、email 或 identity 非法,会返回 InvalidPhone, InvalidEmail 或 InvalidIdentity * 如果在静默期内重复请求,会返回 RateExceeded * 其他所有情况都会返回 200(比如被后端认定为恶意请求而没有发送,比如登录请求中提供的 username 不存在而未发送等等),这些错误无需告知用户。 登录、重置密码验证码,后端会查找用户,如果用户不存在,则不会发送验证码。 如果用户存在,但是该用户的 phone_validated 为 False(即手机号未验证), 也不会发送验证码。邮箱也是如此。即发送验证码的前置条件是相应的短信或邮箱已经验证过。 对于注册验证码,我们不检查账号是否已经存在,也不会因为账号已存在而不发送短信。原因: * 如果我们告诉用户账号已存在,那么有心人可以遍历手机号来判断我们有哪些注册账号 * 如果我们不告诉用户账号已存在、也不发送验证码,那么用户会很奇怪(用户收不到验证码, 会觉得很奇怪)。让用户正常收验证码,在注册时会提示账号已存在。 """ usage = request.json.get('usage') if usage not in otp_tools.USAGES: return api.bad_request(message='Invalid "usage"') identity = request.json.get('identity') phone = request.json.get('phone') email = request.json.get('email') if phone: if not is_valid_phone(phone): return errors.InvalidPhone() elif email: if not is_valid_email(email): return errors.InvalidEmail() elif identity: if is_valid_phone(identity): phone = identity elif is_valid_email(identity): email = identity else: return errors.InvalidIdentity() else: return api.bad_request(message='Missing "phone", "email" or "identity"') # 对于登录、重置密码的验证码,我们需要校验用户存在 if usage in ['login', 'reset-password']: if phone: user = User.objects.filter(phone=phone, phone_validated=True).first() else: user = User.objects.filter(email=email, email_validated=True).first() if not user or not user.is_active: # 用户不存在或用户已锁定,不发送验证码,但仍然返回“验证码已发送” return api.ok(message='验证码已发送') recipient = phone or email engine = 'sms' if phone else 'email' vcode, result = otp_tools.generate_code(engine, request, recipient, usage=usage) if result == otp_tools.GENERATE_RESULTS.silent: return errors.RateExceeded() return api.ok(message='验证码已发送')
def api_register(request): """ 注册 API POST /api/account/register { // 与登录 API 类似,可以明确指定 phone+code 或 email+code 或 identity+code "identity": "13912345678", "code": "123456", "phone": "13912345678", "code": "123456", "email": "*****@*****.**", "code": "123456", "password": "******", } """ code = request.json.get('code') if not code: return errors.InvalidVerifyCode() password = request.json.get('password') if not password: return api.bad_request('Missing "password"') identity = request.json.get('identity') phone = request.json.get('phone') email = request.json.get('email') if phone: if not is_valid_phone(phone): return errors.InvalidPhone() elif email: if not is_valid_email(email): return errors.InvalidEmail() elif identity: if is_valid_phone(identity): phone = identity elif is_valid_email(identity): email = identity else: return errors.InvalidIdentity() else: return api.bad_request( message='Missing "phone", "email" or "identity"') if phone: find_user_args = dict(phone=phone, phone_validated=True) create_user_args = dict(phone=phone, phone_validated=True, password=password) vcode_engine = 'sms' recipient = phone else: create_user_args = dict(email=email, email_validated=True, password=password) vcode_engine = 'email' recipient = email valid = otp_tools.verify_code(vcode_engine, request, recipient, 'register', code, True) if not valid: return errors.InvalidVerifyCode() # 由于数据库中不宜为 phone、email 添加 unique contraint # (主要是 Django 里的实现,如果要添加 unique contraint,那么 phone、email # 比如允许为 null,但 django 中 email 并未声明允许为 null,一些地方的代码 # 也有这样的假定,因此我们尽量不打破这种惯例) # 我们手动判断邮箱或手机号是否存在,如果存在则报错。这会存在 race condition, # 有可能两个并发的请求导致创建了邮箱或手机号相同的账号,不过这种概率应该很低。 # 除非两个请求同时运行上述 verify_code 并且都返回了 True (极小概率时间), # 而且这里又同时读取数据库、创建新用户,才会导致问题。因此暂时不考虑这个问题。 if phone: if User.objects.filter(phone=phone, phone_validated=True).exists(): return errors.PhoneExists() user = User.objects.create_user(phone=phone, phone_validated=True, password=password) else: if User.objects.filter(email=email, email_validated=True).exists(): return errors.EmailExists() user = User.objects.create_user(email=email, email_validated=True, password=password) return api.ok(data=serialize_user(user))