feat: 打手接单考试(按俱乐部题库、配置与通过状态)
This commit is contained in:
@@ -102,6 +102,8 @@ MENU_ROWS = [
|
||||
'perm_codes': ['8080a']},
|
||||
{'page_id': 'miniapp.kaohe-tags', 'name': '抢单考核标签', 'path': '/miniapp/kaohe-tags', 'parent_id': 'miniapp', 'sort_order': 116,
|
||||
'perm_codes': ['8080a']},
|
||||
{'page_id': 'miniapp.dashou-exam', 'name': '打手接单考试', 'path': '/miniapp/dashou-exam', 'parent_id': 'miniapp', 'sort_order': 117,
|
||||
'perm_codes': ['8080a']},
|
||||
# 店铺
|
||||
{'page_id': 'shop', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
|
||||
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
|
||||
|
||||
83
jituan/migrations/0008_dashou_exam.py
Normal file
83
jituan/migrations/0008_dashou_exam.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0007_club_catalog_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubDashouExamConfig',
|
||||
fields=[
|
||||
('club_id', models.CharField(max_length=16, primary_key=True, serialize=False)),
|
||||
('is_enabled', models.BooleanField(default=False, verbose_name='是否开启考试')),
|
||||
('draw_count', models.PositiveIntegerField(default=10, verbose_name='每次抽题数量')),
|
||||
('pass_count', models.PositiveIntegerField(default=10, verbose_name='至少答对题数')),
|
||||
('require_member', models.BooleanField(default=True, verbose_name='须开通会员方可考试')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={'db_table': 'club_dashou_exam_config'},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubDashouExamQuestion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('stem', models.TextField(verbose_name='题干文字')),
|
||||
('question_type', models.IntegerField(default=1, verbose_name='1单选2多选')),
|
||||
('option_1', models.CharField(blank=True, default='', max_length=500)),
|
||||
('option_2', models.CharField(blank=True, default='', max_length=500)),
|
||||
('option_3', models.CharField(blank=True, default='', max_length=500)),
|
||||
('option_4', models.CharField(blank=True, default='', max_length=500)),
|
||||
('option_5', models.CharField(blank=True, default='', max_length=500)),
|
||||
('option_6', models.CharField(blank=True, default='', max_length=500)),
|
||||
('correct_1', models.BooleanField(default=False)),
|
||||
('correct_2', models.BooleanField(default=False)),
|
||||
('correct_3', models.BooleanField(default=False)),
|
||||
('correct_4', models.BooleanField(default=False)),
|
||||
('correct_5', models.BooleanField(default=False)),
|
||||
('correct_6', models.BooleanField(default=False)),
|
||||
('explanation', models.TextField(blank=True, default='', verbose_name='错题解析')),
|
||||
('is_enabled', models.BooleanField(default=True, verbose_name='是否上架')),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'club_dashou_exam_question',
|
||||
'indexes': [models.Index(fields=['club_id', 'is_enabled'], name='club_dashou_exam_q_club_en_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubDashouExamQuestionImage',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('question_id', models.IntegerField(db_index=True)),
|
||||
('image_url', models.CharField(max_length=500)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'club_dashou_exam_question_image',
|
||||
'indexes': [models.Index(fields=['club_id', 'question_id'], name='club_dashou_exam_img_q_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubDashouExamPass',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('club_id', models.CharField(db_index=True, max_length=16)),
|
||||
('yonghuid', models.CharField(db_index=True, max_length=7)),
|
||||
('passed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'club_dashou_exam_pass',
|
||||
'unique_together': {('club_id', 'yonghuid')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -230,6 +230,73 @@ class IdentityTagBind(QModel):
|
||||
]
|
||||
|
||||
|
||||
class ClubDashouExamConfig(QModel):
|
||||
"""各俱乐部打手抢单前考试配置(互不影响)。"""
|
||||
club_id = models.CharField(max_length=16, primary_key=True)
|
||||
is_enabled = models.BooleanField(default=False, verbose_name='是否开启考试')
|
||||
draw_count = models.PositiveIntegerField(default=10, verbose_name='每次抽题数量')
|
||||
pass_count = models.PositiveIntegerField(default=10, verbose_name='至少答对题数')
|
||||
require_member = models.BooleanField(default=True, verbose_name='须开通会员方可考试')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_dashou_exam_config'
|
||||
|
||||
|
||||
class ClubDashouExamQuestion(QModel):
|
||||
"""俱乐部考试题库(题干+最多6选项)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
stem = models.TextField(verbose_name='题干文字')
|
||||
question_type = models.IntegerField(default=1, verbose_name='1单选2多选')
|
||||
option_1 = models.CharField(max_length=500, blank=True, default='')
|
||||
option_2 = models.CharField(max_length=500, blank=True, default='')
|
||||
option_3 = models.CharField(max_length=500, blank=True, default='')
|
||||
option_4 = models.CharField(max_length=500, blank=True, default='')
|
||||
option_5 = models.CharField(max_length=500, blank=True, default='')
|
||||
option_6 = models.CharField(max_length=500, blank=True, default='')
|
||||
correct_1 = models.BooleanField(default=False)
|
||||
correct_2 = models.BooleanField(default=False)
|
||||
correct_3 = models.BooleanField(default=False)
|
||||
correct_4 = models.BooleanField(default=False)
|
||||
correct_5 = models.BooleanField(default=False)
|
||||
correct_6 = models.BooleanField(default=False)
|
||||
explanation = models.TextField(blank=True, default='', verbose_name='错题解析')
|
||||
is_enabled = models.BooleanField(default=True, verbose_name='是否上架')
|
||||
sort_order = models.IntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_dashou_exam_question'
|
||||
indexes = [models.Index(fields=['club_id', 'is_enabled'])]
|
||||
|
||||
|
||||
class ClubDashouExamQuestionImage(QModel):
|
||||
"""题目配图(题干下方,可多张)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
question_id = models.IntegerField(db_index=True)
|
||||
image_url = models.CharField(max_length=500)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_dashou_exam_question_image'
|
||||
indexes = [models.Index(fields=['club_id', 'question_id'])]
|
||||
|
||||
|
||||
class ClubDashouExamPass(QModel):
|
||||
"""打手在本俱乐部是否已通过考试。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
yonghuid = models.CharField(max_length=7, db_index=True)
|
||||
passed_at = models.DateTimeField(auto_now_add=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_dashou_exam_pass'
|
||||
unique_together = [['club_id', 'yonghuid']]
|
||||
|
||||
|
||||
class KefuMenuPage(QModel):
|
||||
"""客服后台左侧菜单页(固定 page_id,与前端路由对应)。"""
|
||||
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
|
||||
|
||||
@@ -104,5 +104,7 @@ def bootstrap_club_from_template(
|
||||
from jituan.services.club_kaohe_chenghao import copy_club_kaohe_chenghao_config
|
||||
copy_club_shangpin_leixing_config(template_id, new_id)
|
||||
copy_club_kaohe_chenghao_config(template_id, new_id)
|
||||
from jituan.services.dashou_exam import copy_exam_config
|
||||
copy_exam_config(template_id, new_id)
|
||||
|
||||
return club
|
||||
|
||||
366
jituan/services/dashou_exam.py
Normal file
366
jituan/services/dashou_exam.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""打手抢单前考试:按俱乐部独立题库与配置。"""
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from jituan.constants import DATA_SCOPE_ALL
|
||||
from jituan.models import (
|
||||
ClubDashouExamConfig,
|
||||
ClubDashouExamPass,
|
||||
ClubDashouExamQuestion,
|
||||
ClubDashouExamQuestionImage,
|
||||
)
|
||||
from jituan.services.club_context import (
|
||||
club_id_for_write,
|
||||
resolve_club_id_from_request,
|
||||
resolve_club_scope,
|
||||
resolve_effective_club_id,
|
||||
)
|
||||
from products.models import Huiyuangoumai
|
||||
from utils.oss_utils import delete_from_oss, upload_to_oss, validate_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCOPE_MSG = '请切换到具体小程序/俱乐部后再操作'
|
||||
OPTION_FIELDS = [f'option_{i}' for i in range(1, 7)]
|
||||
CORRECT_FIELDS = [f'correct_{i}' for i in range(1, 7)]
|
||||
|
||||
|
||||
def _scope_blocked(request):
|
||||
return resolve_club_scope(request) == DATA_SCOPE_ALL
|
||||
|
||||
|
||||
def get_or_create_config(club_id):
|
||||
row, _ = ClubDashouExamConfig.query.get_or_create(
|
||||
club_id=club_id,
|
||||
defaults={
|
||||
'is_enabled': False,
|
||||
'draw_count': 10,
|
||||
'pass_count': 10,
|
||||
'require_member': True,
|
||||
},
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def config_to_dict(row):
|
||||
return {
|
||||
'club_id': row.club_id,
|
||||
'is_enabled': bool(row.is_enabled),
|
||||
'draw_count': int(row.draw_count or 10),
|
||||
'pass_count': int(row.pass_count or 10),
|
||||
'require_member': bool(row.require_member),
|
||||
'max_wrong': max(0, int(row.draw_count or 0) - int(row.pass_count or 0)),
|
||||
}
|
||||
|
||||
|
||||
def user_has_active_member(yonghuid, club_id):
|
||||
for r in Huiyuangoumai.query.filter(yonghu_id=yonghuid, club_id=club_id):
|
||||
if r.huiyuan_zhuangtai == 1 and not r.jiance_shifou_daoqi():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def user_exam_passed(club_id, yonghuid):
|
||||
return ClubDashouExamPass.query.filter(club_id=club_id, yonghuid=yonghuid).exists()
|
||||
|
||||
|
||||
def validate_question_payload(data, for_update=False):
|
||||
stem = (data.get('stem') or '').strip()
|
||||
if not stem:
|
||||
return '题干不能为空'
|
||||
qtype = int(data.get('question_type') or 1)
|
||||
if qtype not in (1, 2):
|
||||
return '题目类型无效'
|
||||
options = []
|
||||
correct_slots = []
|
||||
for i in range(1, 7):
|
||||
text = (data.get(f'option_{i}') or '').strip()
|
||||
is_corr = bool(data.get(f'correct_{i}'))
|
||||
if text:
|
||||
options.append(i)
|
||||
if is_corr:
|
||||
correct_slots.append(i)
|
||||
if len(options) < 2:
|
||||
return '至少需要两个有效选项'
|
||||
if not correct_slots:
|
||||
return '至少标记一个正确答案'
|
||||
if qtype == 1 and len(correct_slots) != 1:
|
||||
return '单选题只能有一个正确答案'
|
||||
return None
|
||||
|
||||
|
||||
def question_to_admin_dict(q, images=None):
|
||||
item = {
|
||||
'id': q.id,
|
||||
'stem': q.stem,
|
||||
'question_type': q.question_type,
|
||||
'explanation': q.explanation or '',
|
||||
'is_enabled': bool(q.is_enabled),
|
||||
'sort_order': q.sort_order,
|
||||
'images': images or [],
|
||||
}
|
||||
for i in range(1, 7):
|
||||
item[f'option_{i}'] = getattr(q, f'option_{i}', '') or ''
|
||||
item[f'correct_{i}'] = bool(getattr(q, f'correct_{i}', False))
|
||||
return item
|
||||
|
||||
|
||||
def build_question_bundle_item(q, image_urls):
|
||||
options = []
|
||||
correct_slots = []
|
||||
for i in range(1, 7):
|
||||
text = (getattr(q, f'option_{i}', '') or '').strip()
|
||||
if not text:
|
||||
continue
|
||||
options.append({'slot': i, 'text': text})
|
||||
if getattr(q, f'correct_{i}', False):
|
||||
correct_slots.append(i)
|
||||
return {
|
||||
'id': q.id,
|
||||
'stem': q.stem,
|
||||
'question_type': q.question_type,
|
||||
'images': image_urls,
|
||||
'options': options,
|
||||
'correct_slots': correct_slots,
|
||||
'explanation': q.explanation or '',
|
||||
}
|
||||
|
||||
|
||||
def get_exam_status(request, user):
|
||||
club_id = resolve_effective_club_id(request, user)
|
||||
cfg = get_or_create_config(club_id)
|
||||
yonghuid = user.UserUID
|
||||
has_member = user_has_active_member(yonghuid, club_id)
|
||||
passed = user_exam_passed(club_id, yonghuid)
|
||||
enabled = bool(cfg.is_enabled)
|
||||
pool_count = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True).count()
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'exam_enabled': enabled,
|
||||
'exam_passed': passed,
|
||||
'has_member': has_member,
|
||||
'can_take_exam': has_member if cfg.require_member else True,
|
||||
'exam_required': enabled and not passed,
|
||||
'pool_count': pool_count,
|
||||
'config': config_to_dict(cfg),
|
||||
}
|
||||
|
||||
|
||||
def build_exam_bundle(request, user):
|
||||
club_id = resolve_effective_club_id(request, user)
|
||||
cfg = get_or_create_config(club_id)
|
||||
yonghuid = user.UserUID
|
||||
has_member = user_has_active_member(yonghuid, club_id)
|
||||
if cfg.require_member and not has_member:
|
||||
return None, '须先开通会员才能参加考试'
|
||||
|
||||
qs = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True)
|
||||
all_ids = list(qs.values_list('id', flat=True))
|
||||
draw = min(int(cfg.draw_count or 10), len(all_ids))
|
||||
if draw < 1:
|
||||
return None, '题库暂无可用题目,请联系管理员'
|
||||
|
||||
selected_ids = random.sample(all_ids, draw)
|
||||
questions = []
|
||||
for qid in selected_ids:
|
||||
q = ClubDashouExamQuestion.query.get(id=qid)
|
||||
imgs = [
|
||||
r.image_url for r in ClubDashouExamQuestionImage.query.filter(
|
||||
club_id=club_id, question_id=qid,
|
||||
).order_by('sort_order', 'id')
|
||||
]
|
||||
questions.append(build_question_bundle_item(q, imgs))
|
||||
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'config': config_to_dict(cfg),
|
||||
'has_member': has_member,
|
||||
'already_passed': user_exam_passed(club_id, yonghuid),
|
||||
'questions': questions,
|
||||
}, None
|
||||
|
||||
|
||||
def mark_exam_passed(request, user):
|
||||
club_id = resolve_effective_club_id(request, user)
|
||||
cfg = get_or_create_config(club_id)
|
||||
if not cfg.is_enabled:
|
||||
return None, '本俱乐部未开启考试'
|
||||
yonghuid = user.UserUID
|
||||
if cfg.require_member and not user_has_active_member(yonghuid, club_id):
|
||||
return None, '须先开通会员'
|
||||
ClubDashouExamPass.query.update_or_create(
|
||||
club_id=club_id,
|
||||
yonghuid=yonghuid,
|
||||
defaults={'passed_at': timezone.now()},
|
||||
)
|
||||
return {'club_id': club_id, 'passed': True}, None
|
||||
|
||||
|
||||
def build_admin_config_payload(request):
|
||||
if _scope_blocked(request):
|
||||
return {'scope_error': SCOPE_MSG}
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
cfg = get_or_create_config(club_id)
|
||||
pool = ClubDashouExamQuestion.query.filter(club_id=club_id).count()
|
||||
enabled_pool = ClubDashouExamQuestion.query.filter(club_id=club_id, is_enabled=True).count()
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'config': config_to_dict(cfg),
|
||||
'pool_count': pool,
|
||||
'enabled_pool_count': enabled_pool,
|
||||
}
|
||||
|
||||
|
||||
def save_admin_config(request, data):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
draw = int(data.get('draw_count') or 10)
|
||||
pass_cnt = int(data.get('pass_count') or draw)
|
||||
if draw < 1:
|
||||
return None, '抽题数量至少为1'
|
||||
if pass_cnt < 1 or pass_cnt > draw:
|
||||
return None, '及格题数须在 1 到抽题数量之间'
|
||||
cfg = get_or_create_config(club_id)
|
||||
cfg.is_enabled = bool(data.get('is_enabled'))
|
||||
cfg.draw_count = draw
|
||||
cfg.pass_count = pass_cnt
|
||||
cfg.require_member = bool(data.get('require_member', True))
|
||||
cfg.save(update_fields=['is_enabled', 'draw_count', 'pass_count', 'require_member', 'UpdateTime'])
|
||||
return config_to_dict(cfg), None
|
||||
|
||||
|
||||
def list_admin_questions(request):
|
||||
if _scope_blocked(request):
|
||||
return {'list': [], 'scope_error': SCOPE_MSG}
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
rows = ClubDashouExamQuestion.query.filter(club_id=club_id).order_by('-sort_order', 'id')
|
||||
out = []
|
||||
for q in rows:
|
||||
imgs = [
|
||||
{'id': img.id, 'image_url': img.image_url, 'sort_order': img.sort_order}
|
||||
for img in ClubDashouExamQuestionImage.query.filter(
|
||||
club_id=club_id, question_id=q.id,
|
||||
).order_by('sort_order', 'id')
|
||||
]
|
||||
out.append(question_to_admin_dict(q, imgs))
|
||||
return {'club_id': club_id, 'list': out}
|
||||
|
||||
|
||||
def create_question(request, data):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
err = validate_question_payload(data)
|
||||
if err:
|
||||
return None, err
|
||||
club_id = club_id_for_write(request)
|
||||
q = ClubDashouExamQuestion.query.create(
|
||||
club_id=club_id,
|
||||
stem=data.get('stem', '').strip(),
|
||||
question_type=int(data.get('question_type') or 1),
|
||||
explanation=(data.get('explanation') or '').strip(),
|
||||
is_enabled=bool(data.get('is_enabled', True)),
|
||||
sort_order=int(data.get('sort_order') or 0),
|
||||
**{f'option_{i}': (data.get(f'option_{i}') or '').strip() for i in range(1, 7)},
|
||||
**{f'correct_{i}': bool(data.get(f'correct_{i}')) for i in range(1, 7)},
|
||||
)
|
||||
return {'id': q.id}, None
|
||||
|
||||
|
||||
def update_question(request, question_id, data):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
try:
|
||||
q = ClubDashouExamQuestion.query.get(id=question_id, club_id=club_id)
|
||||
except ClubDashouExamQuestion.DoesNotExist:
|
||||
return None, '题目不存在'
|
||||
err = validate_question_payload(data, for_update=True)
|
||||
if err:
|
||||
return None, err
|
||||
q.stem = data.get('stem', q.stem).strip()
|
||||
q.question_type = int(data.get('question_type') or q.question_type)
|
||||
q.explanation = (data.get('explanation') or '').strip()
|
||||
if 'is_enabled' in data:
|
||||
q.is_enabled = bool(data.get('is_enabled'))
|
||||
if 'sort_order' in data:
|
||||
q.sort_order = int(data.get('sort_order') or 0)
|
||||
for i in range(1, 7):
|
||||
if f'option_{i}' in data:
|
||||
q.__setattr__(f'option_{i}', (data.get(f'option_{i}') or '').strip())
|
||||
if f'correct_{i}' in data:
|
||||
q.__setattr__(f'correct_{i}', bool(data.get(f'correct_{i}')))
|
||||
q.save()
|
||||
return {'id': q.id}, None
|
||||
|
||||
|
||||
def delete_question(request, question_id):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
try:
|
||||
q = ClubDashouExamQuestion.query.get(id=question_id, club_id=club_id)
|
||||
except ClubDashouExamQuestion.DoesNotExist:
|
||||
return None, '题目不存在'
|
||||
for img in ClubDashouExamQuestionImage.query.filter(club_id=club_id, question_id=q.id):
|
||||
if img.image_url:
|
||||
delete_from_oss(img.image_url)
|
||||
ClubDashouExamQuestionImage.query.filter(club_id=club_id, question_id=q.id).delete()
|
||||
q.delete()
|
||||
return {'id': question_id}, None
|
||||
|
||||
|
||||
def upload_question_image(request, question_id, file_obj):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
if not ClubDashouExamQuestion.query.filter(id=question_id, club_id=club_id).exists():
|
||||
return None, '题目不存在'
|
||||
ok, msg = validate_image(file_obj)
|
||||
if not ok:
|
||||
return None, msg
|
||||
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
|
||||
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
|
||||
ext = '.png'
|
||||
oss_path = f'club/{club_id}/exam/{question_id}_{uuid.uuid4().hex}{ext}'
|
||||
if not upload_to_oss(file_obj, oss_path):
|
||||
return None, '图片上传失败'
|
||||
row = ClubDashouExamQuestionImage.query.create(
|
||||
club_id=club_id,
|
||||
question_id=question_id,
|
||||
image_url=oss_path,
|
||||
sort_order=int(request.data.get('sort_order') or 0),
|
||||
)
|
||||
return {'id': row.id, 'image_url': oss_path}, None
|
||||
|
||||
|
||||
def delete_question_image(request, image_id):
|
||||
if _scope_blocked(request):
|
||||
return None, SCOPE_MSG
|
||||
club_id = club_id_for_write(request)
|
||||
try:
|
||||
img = ClubDashouExamQuestionImage.query.get(id=image_id, club_id=club_id)
|
||||
except ClubDashouExamQuestionImage.DoesNotExist:
|
||||
return None, '图片不存在'
|
||||
if img.image_url:
|
||||
delete_from_oss(img.image_url)
|
||||
img.delete()
|
||||
return {'id': image_id}, None
|
||||
|
||||
|
||||
def copy_exam_config(template_club_id, new_club_id):
|
||||
tpl = ClubDashouExamConfig.query.filter(club_id=template_club_id).first()
|
||||
if tpl:
|
||||
ClubDashouExamConfig.query.update_or_create(
|
||||
club_id=new_club_id,
|
||||
defaults={
|
||||
'is_enabled': tpl.is_enabled,
|
||||
'draw_count': tpl.draw_count,
|
||||
'pass_count': tpl.pass_count,
|
||||
'require_member': tpl.require_member,
|
||||
},
|
||||
)
|
||||
@@ -51,6 +51,15 @@ from jituan.views import (
|
||||
ClubWechatLoginView,
|
||||
)
|
||||
|
||||
from jituan.views_dashou_exam import (
|
||||
DashouExamBundleView,
|
||||
DashouExamConfigAdminView,
|
||||
DashouExamImageManageView,
|
||||
DashouExamMarkPassedView,
|
||||
DashouExamQuestionListView,
|
||||
DashouExamQuestionManageView,
|
||||
DashouExamStatusView,
|
||||
)
|
||||
from jituan.views_club_catalog import (
|
||||
ClubKaoheChenghaoCatalogView,
|
||||
ClubKaoheChenghaoDisableView,
|
||||
@@ -130,5 +139,12 @@ urlpatterns = [
|
||||
path('houtai/club-kaohe-catalog', ClubKaoheChenghaoCatalogView.as_view(), name='jituan_club_kaohe_catalog'),
|
||||
path('houtai/club-kaohe-enable', ClubKaoheChenghaoEnableView.as_view(), name='jituan_club_kaohe_enable'),
|
||||
path('houtai/club-kaohe-disable', ClubKaoheChenghaoDisableView.as_view(), name='jituan_club_kaohe_disable'),
|
||||
path('dashou-exam/status', DashouExamStatusView.as_view(), name='jituan_dashou_exam_status'),
|
||||
path('dashou-exam/bundle', DashouExamBundleView.as_view(), name='jituan_dashou_exam_bundle'),
|
||||
path('dashou-exam/mark-passed', DashouExamMarkPassedView.as_view(), name='jituan_dashou_exam_mark_passed'),
|
||||
path('houtai/dashou-exam-config', DashouExamConfigAdminView.as_view(), name='jituan_dashou_exam_config'),
|
||||
path('houtai/dashou-exam-question-list', DashouExamQuestionListView.as_view(), name='jituan_dashou_exam_q_list'),
|
||||
path('houtai/dashou-exam-question-manage', DashouExamQuestionManageView.as_view(), name='jituan_dashou_exam_q_manage'),
|
||||
path('houtai/dashou-exam-image-manage', DashouExamImageManageView.as_view(), name='jituan_dashou_exam_img'),
|
||||
path('club/list', ClubListView.as_view(), name='jituan_club_list'),
|
||||
]
|
||||
|
||||
159
jituan/views_dashou_exam.py
Normal file
159
jituan/views_dashou_exam.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""打手考试:后台管理 + 小程序接口。"""
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from backend.utils import verify_kefu_permission
|
||||
|
||||
EXAM_PERM = '8080a'
|
||||
|
||||
|
||||
class DashouExamStatusView(APIView):
|
||||
"""小程序:考试状态(抢单前检查) POST /jituan/dashou-exam/status"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
from jituan.services.dashou_exam import get_exam_status
|
||||
data = get_exam_status(request, request.user)
|
||||
return Response({'code': 200, 'msg': '成功', 'data': data})
|
||||
|
||||
|
||||
class DashouExamBundleView(APIView):
|
||||
"""小程序:拉取整套考题(含答案与解析,前端本地判分) POST /jituan/dashou-exam/bundle"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
from jituan.services.dashou_exam import build_exam_bundle
|
||||
data, err = build_exam_bundle(request, request.user)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 200, 'msg': '成功', 'data': data})
|
||||
|
||||
|
||||
class DashouExamMarkPassedView(APIView):
|
||||
"""小程序:前端判分通过后登记 POST /jituan/dashou-exam/mark-passed"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
from jituan.services.dashou_exam import mark_exam_passed
|
||||
data, err = mark_exam_passed(request, request.user)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 200, 'msg': '考试通过', 'data': data})
|
||||
|
||||
|
||||
class DashouExamConfigAdminView(APIView):
|
||||
"""后台:考试配置 GET/SAVE POST /jituan/houtai/dashou-exam-config"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
if not phone:
|
||||
return Response({'code': 401, 'msg': '缺少客服账号'})
|
||||
kefu, permissions = verify_kefu_permission(request, phone)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if EXAM_PERM not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
action = (request.data.get('action') or 'get').strip()
|
||||
from jituan.services.dashou_exam import build_admin_config_payload, save_admin_config
|
||||
if action == 'save':
|
||||
data, err = save_admin_config(request, request.data)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '保存成功', 'data': {'config': data}})
|
||||
payload = build_admin_config_payload(request)
|
||||
return Response({'code': 0, 'data': payload})
|
||||
|
||||
|
||||
class DashouExamQuestionListView(APIView):
|
||||
"""后台:题库列表 POST /jituan/houtai/dashou-exam-question-list"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
if not phone:
|
||||
return Response({'code': 401, 'msg': '缺少客服账号'})
|
||||
kefu, permissions = verify_kefu_permission(request, phone)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if EXAM_PERM not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
from jituan.services.dashou_exam import list_admin_questions
|
||||
return Response({'code': 0, 'data': list_admin_questions(request)})
|
||||
|
||||
|
||||
class DashouExamQuestionManageView(APIView):
|
||||
"""后台:题目增删改 POST /jituan/houtai/dashou-exam-question-manage"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
if not phone:
|
||||
return Response({'code': 401, 'msg': '缺少客服账号'})
|
||||
kefu, permissions = verify_kefu_permission(request, phone)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if EXAM_PERM not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
action = (request.data.get('action') or '').strip()
|
||||
qid = request.data.get('question_id') or request.data.get('id')
|
||||
from jituan.services.dashou_exam import (
|
||||
create_question,
|
||||
delete_question,
|
||||
update_question,
|
||||
)
|
||||
if action == 'create':
|
||||
data, err = create_question(request, request.data)
|
||||
elif action == 'update':
|
||||
if not qid:
|
||||
return Response({'code': 400, 'msg': '缺少题目ID'})
|
||||
data, err = update_question(request, int(qid), request.data)
|
||||
elif action == 'delete':
|
||||
if not qid:
|
||||
return Response({'code': 400, 'msg': '缺少题目ID'})
|
||||
data, err = delete_question(request, int(qid))
|
||||
else:
|
||||
return Response({'code': 400, 'msg': '无效 action'})
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '成功', 'data': data})
|
||||
|
||||
|
||||
class DashouExamImageManageView(APIView):
|
||||
"""后台:题目图片 POST /jituan/houtai/dashou-exam-image-manage"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||
if not phone:
|
||||
return Response({'code': 401, 'msg': '缺少客服账号'})
|
||||
kefu, permissions = verify_kefu_permission(request, phone)
|
||||
if kefu is None:
|
||||
return permissions
|
||||
if EXAM_PERM not in permissions:
|
||||
return Response({'code': 403, 'msg': '无权限'})
|
||||
action = (request.data.get('action') or 'upload').strip()
|
||||
from jituan.services.dashou_exam import delete_question_image, upload_question_image
|
||||
if action == 'delete':
|
||||
image_id = request.data.get('image_id')
|
||||
if not image_id:
|
||||
return Response({'code': 400, 'msg': '缺少 image_id'})
|
||||
data, err = delete_question_image(request, int(image_id))
|
||||
else:
|
||||
qid = request.data.get('question_id')
|
||||
file_obj = request.FILES.get('file')
|
||||
if not qid or not file_obj:
|
||||
return Response({'code': 400, 'msg': '缺少题目或图片'})
|
||||
data, err = upload_question_image(request, int(qid), file_obj)
|
||||
if err:
|
||||
return Response({'code': 400, 'msg': err})
|
||||
return Response({'code': 0, 'msg': '成功', 'data': data})
|
||||
Reference in New Issue
Block a user