diff --git a/.gitignore b/.gitignore index e2df6b3..0db7600 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .vs/ venv/ media/ +temp/ *.log static/ __pycache__/ diff --git a/client/.env.development b/client/.env.development index aacde3e..487e749 100644 --- a/client/.env.development +++ b/client/.env.development @@ -3,7 +3,7 @@ ENV = 'development' # base api #VUE_APP_BASE_API = 'http://10.0.11.127:8000/api' -#VUE_APP_BASE_API = 'http://127.0.0.1:8000/api' +#VUE_APP_BASE_API = 'http://127.0.0.1:2222/api' VUE_APP_BASE_API = 'https://testsearch.ctc.ac.cn/api' #VUE_APP_BASE_API = 'http://47.95.0.242:9101/api' diff --git a/client/package.json b/client/package.json index 80319cf..086cf64 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "dependencies": { "@riophae/vue-treeselect": "^0.4.0", "axios": "0.18.1", + "echarts": "^5.4.0", "element-china-area-data": "^5.0.2", "element-ui": "2.13.0", "file-saver": "^2.0.2", @@ -48,9 +49,9 @@ "eslint-plugin-vue": "5.2.2", "html-webpack-plugin": "3.2.0", "mockjs": "1.0.1-beta3", - "node-sass": "^4.13.1", + "node-sass": "^6.0.1", "runjs": "^4.3.2", - "sass-loader": "^7.1.0", + "sass-loader": "^10.0.1", "script-ext-html-webpack-plugin": "2.1.3", "script-loader": "0.7.2", "serve-static": "^1.13.2", diff --git a/client/src/api/exam.js b/client/src/api/exam.js new file mode 100644 index 0000000..1e374b5 --- /dev/null +++ b/client/src/api/exam.js @@ -0,0 +1,213 @@ +import request from '@/utils/request' +//获取题目类型 +export function getQuestioncatList(query) { + return request({ + url: '/exam/questioncat/', + method: 'get', + params: query + }) +} + +//新增题目类型 +export function createQuestioncat(data) { + return request({ + url: `/exam/questioncat/`, + method: 'post', + data + }) +} +//编辑题目类型 +export function updateQuestioncat(id, data) { + return request({ + url: `/exam/questioncat/${id}/`, + method: 'put', + data + }) + } + //删除题目类型 +export function deleteQuestioncat(id) { + return request({ + url: `/exam/questioncat/${id}/`, + method: 'delete' + }) +} +//题目列表 +export function getQuestionList(query) { + return request({ + url: '/exam/question/', + method: 'get', + params: query + }) +} +//题目详情 +export function getQuestionDetail(id) { + return request({ + url: `/exam/question/${id}/`, + method: 'get' + }) +} +//新增题目 +export function createQuestion(data) { + return request({ + url: `/exam/question/`, + method: 'post', + data + }) +} +//编辑题目 +export function updateQuestion(id, data) { + return request({ + url: `/exam/question/${id}/`, + method: 'put', + data + }) + } + //删除题目 +export function deleteQuestion(id) { + return request({ + url: `/exam/question/${id}/`, + method: 'delete' + }) +} +//导入题目 +export function importQuestion(data) { + return request({ + url: `/exam/question/import/`, + method: 'post', + data + }) +} +//导出题目 +export function exportQuestion(data) { + return request({ + url: `/exam/question/export/`, + method: 'get', + params: query + }) +} +//启用题目 +export function enableQuestions(data) { + return request({ + url: `/exam/question/enable/`, + method: 'post', + data + }) +} +//试卷增删改查 +//获取试卷 +export function getPaperList(query) { + return request({ + url: '/exam/paper/', + method: 'get', + params: query + }) +} +//试卷详情 +export function getPaperDetail(id) { + return request({ + url: `/exam/paper/${id}/`, + method: 'get' + }) +} +//新增试卷 +export function createPaper(data) { + return request({ + url: `/exam/paper/`, + method: 'post', + data + }) +} +//编辑试卷 +export function updatePaper(id, data) { + return request({ + url: `/exam/paper/${id}/`, + method: 'put', + data + }) + } + //删除试卷 +export function deletePaper(id) { + return request({ + url: `/exam/paper/${id}/`, + method: 'delete' + }) +} +//考试增删改查 +//获取考试列表 +export function getExamList(query) { + return request({ + url: '/exam/exam/', + method: 'get', + params: query + }) +} +//考试详情 +export function getExamDetail(id) { + return request({ + url: `/exam/exam/${id}/`, + method: 'get' + }) +} +//新增考试 +export function createExam(data) { + return request({ + url: `/exam/exam/`, + method: 'post', + data + }) +} +//编辑考试 +export function updateExam(id, data) { + return request({ + url: `/exam/exam/${id}/`, + method: 'put', + data + }) + } + //删除考试 +export function deleteExam(id) { + return request({ + url: `/exam/exam/${id}/`, + method: 'delete' + }) +} + +//考试记录增删改查 +//考试记录列表和详情 +export function getExamRecordList(query) { + return request({ + url: '/exam/examrecord/', + method: 'get', + params: query + }) +} +//考试记录 +export function getExamRecordDetail(id) { + return request({ + url: `/exam/examrecord/${id}/`, + method: 'get' + }) +} +//新增考试记录 +export function createExamRecord(data) { + return request({ + url: `/exam/examrecord/`, + method: 'post', + data + }) +} +//编辑考试记录 +export function updateExamRecord(id, data) { + return request({ + url: `/exam/examrecord/${id}/`, + method: 'put', + data + }) + } + //删除考试记录 +export function deleteExamRecord(id) { + return request({ + url: `/exam/examrecord/${id}/`, + method: 'delete' + }) +} \ No newline at end of file diff --git a/client/src/api/video.js b/client/src/api/video.js index 4ec4bc3..491c062 100644 --- a/client/src/api/video.js +++ b/client/src/api/video.js @@ -49,11 +49,108 @@ export function getVideoPlayCode(id) { method: 'get' }) } - +//已弃用 export function getMyView(id, data) { + return request({ + url: `/vod/video/${id}/myview/`, + method: 'get', + data + }) +} +//已弃用 +export function refreshMyView(id, data) { return request({ url: `/vod/video/${id}/myview/`, method: 'put', data }) } + +//开始播放 +export function videoStart(id) { + return request({ + url: `/vod/video/${id}/start/`, + method: 'get' + }) +} +//观看统计 +export function videoView2(data) { + return request({ + url: `/vod/view2/`, + method: 'get', + data + }) +} +//我的观看统计 +export function myVideoView2(data) { + return request({ + url: `/vod/view2/my/`, + method: 'get', + data + }) +} +//观看记录 +export function viewItem(data) { + return request({ + url: `/vod/viewitem/`, + method: 'get', + data + }) +} +//我的观看记录 +export function myViewItem(data) { + return request({ + url: `/vod/viewitem/my/`, + method: 'get', + data + }) +} +//更新观看记录 +export function refreshViewItem(id, data) { + return request({ + url: `/vod/viewitem/${id}/`, + method: 'put', + data + }) +} + +//本视频的我的观看统计 +export function myView(id) { + return request({ + url: `/vod/video/${id}/my/`, + method: 'get' + }) +} +//播放完成 +export function viewItemComplete(id) { + return request({ + url: `/vod/viewitem/${id}/complete/`, + method: 'get' + }) +} + + +//单位观看量统计 +export function groupByOrgView(data) { + return request({ + url: '/vod/analyse/group_by_org_view/', + method: 'post', + data + }) +} +//个人观看量统计 +export function groupByUserView(data) { + return request({ + url: '/vod/analyse/group_by_user_view/', + method: 'post', + data + }) +} +//视频大类播放量统计 +export function groupByCategoryView(data) { + return request({ + url: '/vod/analyse/group_by_video_category_big/', + method: 'post', + data + }) +} \ No newline at end of file diff --git a/client/src/router/index.js b/client/src/router/index.js index 1631574..1b5c7d5 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -300,12 +300,19 @@ export const asyncRoutes = [ meta: { title: '上传视频', perms: ['video_create'] } }, { - path: 'index/:id', - name: 'Index', + path: 'index', + name: 'index', component: () => import('@/views/testvideo/index.vue'), meta: { title: '视频播放', perms: ['video_view'] }, hidden: true }, + { + path: 'videoStatistics', + name: 'videoStatistics', + component: () => import('@/views/testvideo/videoStatistics.vue'), + meta: { title: '视频播放统计', perms: ['video_view'] }, + // hidden: true + }, ] }, { @@ -330,6 +337,74 @@ export const asyncRoutes = [ }, ] }, + { + path: '/exam', + component: Layout, + redirect: '/exam/questions', + name: 'exam', + meta: { title: '考试', icon: 'PT', perms: ['pt_view'] }, + alwaysShow: true, + children: [ + { + path: 'classify', + name: '题目分类', + component: () => import('@/views/exam/classify.vue'), + meta: { title: '题目分类', perms: ['pt_view'] } + }, + { + path: 'questions', + name: '题目列表', + component: () => import('@/views/exam/questions.vue'), + meta: { title: '题目列表', perms: ['pt_view'] } + }, + { + path: 'questionCreate', + name: '新增题目', + component: () => import('@/views/exam/questioncreate.vue'), + meta: { title: '新增题目'}, + hidden: true + }, + { + path: 'questionUpdate', + name: '编辑题目', + component: () => import('@/views/exam/questionupdate.vue'), + meta: { title: '编辑题目'}, + hidden: true + }, + { + path: 'testPaper', + name: '考试试卷', + component: () => import('@/views/exam/testPaper.vue'), + meta: { title: '考试试卷', perms: ['pt_view'] } + }, + { + path: 'paperCreate', + name: '新建试卷', + component: () => import('@/views/exam/testPaperCreate.vue'), + meta: { title: '新建试卷'}, + hidden: true + }, + { + path: 'paperUpdate', + name: '编辑试卷', + component: () => import('@/views/exam/testPaperUpdate.vue'), + meta: { title: '编辑试卷'}, + hidden: true + }, + { + path: 'index', + name: '考试', + component: () => import('@/views/exam/index.vue'), + meta: { title: '考试', perms: ['pt_view'] } + }, + { + path: 'record', + name: '考试记录', + component: () => import('@/views/exam/examRecord.vue'), + meta: { title: '考试记录', perms: ['pt_view'] } + }, + ] + }, { path: '/system', component: Layout, diff --git a/client/src/store/modules/user.js b/client/src/store/modules/user.js index 45701e8..18712d5 100644 --- a/client/src/store/modules/user.js +++ b/client/src/store/modules/user.js @@ -7,7 +7,8 @@ const getDefaultState = () => { token: getToken(), name: '', avatar: '', - perms: [] + perms: [], + dept:'' } } diff --git a/client/src/views/ability/mQualityTask.vue b/client/src/views/ability/mQualityTask.vue index 789e94e..35da8ab 100644 --- a/client/src/views/ability/mQualityTask.vue +++ b/client/src/views/ability/mQualityTask.vue @@ -249,24 +249,24 @@ - + - + - + - + @@ -682,8 +682,13 @@ style="padding: 10px 20px;position: relative" >

新增能力

-

能力类型 :{{showData.data.atype_name}}

-

能力领域:{{showData.data.afield_name}}

+

能力类型 :{{showData.data.atype_name}}

+

能力领域:{{showData.data.afield_name}}

+ +

对象数量:{{showData.data.num}}

+

参数数量:{{showData.data.num2}}

+

方法标准数量:{{showData.data.num3}}

+

产品标准数量:{{showData.data.num4}}

上传文件:{{showData.data.file_.name}}

{{showData.data.file_.name}} @@ -716,8 +721,9 @@ diff --git a/client/src/views/ability/qualityTaskDo.vue b/client/src/views/ability/qualityTaskDo.vue index 1df7a06..1cfd1bd 100644 --- a/client/src/views/ability/qualityTaskDo.vue +++ b/client/src/views/ability/qualityTaskDo.vue @@ -278,12 +278,15 @@

新增能力

能力类型 :{{atype_name}}

能力领域:{{afield_name}}

-

新增对象数量:{{item.num}}

-

新增参数数量:{{item.num2}}

-

新增方法标准数量:{{item.num3}}

-

新增产品标准数量:{{item.num4}}

-

上传文件:{{showData.data.file_.name}}

- {{showData.data.file_.name}} +

对象数量:{{showData.data.num}}

+

参数数量:{{showData.data.num2}}

+

方法标准数量:{{showData.data.num3}}

+

产品标准数量:{{showData.data.num4}}

+

上传文件: + {{showData.data.file_.name}} + 无上传文件 +

+ {{showData.data.file_.name}} @@ -116,7 +115,7 @@ - + diff --git a/client/src/views/exam/classify.vue b/client/src/views/exam/classify.vue new file mode 100644 index 0000000..f79a45d --- /dev/null +++ b/client/src/views/exam/classify.vue @@ -0,0 +1,166 @@ + + + + \ No newline at end of file diff --git a/client/src/views/exam/examRecord.vue b/client/src/views/exam/examRecord.vue new file mode 100644 index 0000000..2f1f641 --- /dev/null +++ b/client/src/views/exam/examRecord.vue @@ -0,0 +1,280 @@ + + + + \ No newline at end of file diff --git a/client/src/views/exam/index.vue b/client/src/views/exam/index.vue new file mode 100644 index 0000000..fc6cfcb --- /dev/null +++ b/client/src/views/exam/index.vue @@ -0,0 +1,282 @@ + + + \ No newline at end of file diff --git a/client/src/views/exam/questionChoose.vue b/client/src/views/exam/questionChoose.vue new file mode 100644 index 0000000..b0d03e2 --- /dev/null +++ b/client/src/views/exam/questionChoose.vue @@ -0,0 +1,220 @@ + + + + \ No newline at end of file diff --git a/client/src/views/exam/questioncreate.vue b/client/src/views/exam/questioncreate.vue new file mode 100644 index 0000000..8181e46 --- /dev/null +++ b/client/src/views/exam/questioncreate.vue @@ -0,0 +1,217 @@ + + + \ No newline at end of file diff --git a/client/src/views/exam/questions.vue b/client/src/views/exam/questions.vue new file mode 100644 index 0000000..0180fe8 --- /dev/null +++ b/client/src/views/exam/questions.vue @@ -0,0 +1,270 @@ + + + + \ No newline at end of file diff --git a/client/src/views/exam/questionupdate.vue b/client/src/views/exam/questionupdate.vue new file mode 100644 index 0000000..65049ca --- /dev/null +++ b/client/src/views/exam/questionupdate.vue @@ -0,0 +1,198 @@ + + \ No newline at end of file diff --git a/client/src/views/exam/testPaper.vue b/client/src/views/exam/testPaper.vue new file mode 100644 index 0000000..de8dae3 --- /dev/null +++ b/client/src/views/exam/testPaper.vue @@ -0,0 +1,167 @@ + + + + \ No newline at end of file diff --git a/client/src/views/exam/testPaperCreate.vue b/client/src/views/exam/testPaperCreate.vue new file mode 100644 index 0000000..6b0087e --- /dev/null +++ b/client/src/views/exam/testPaperCreate.vue @@ -0,0 +1,191 @@ + + \ No newline at end of file diff --git a/client/src/views/exam/testPaperUpdate.vue b/client/src/views/exam/testPaperUpdate.vue new file mode 100644 index 0000000..e0313f5 --- /dev/null +++ b/client/src/views/exam/testPaperUpdate.vue @@ -0,0 +1,203 @@ + + \ No newline at end of file diff --git a/client/src/views/testvideo/index.vue b/client/src/views/testvideo/index.vue index faf75ad..71f1854 100644 --- a/client/src/views/testvideo/index.vue +++ b/client/src/views/testvideo/index.vue @@ -1,6 +1,6 @@ @@ -173,7 +257,7 @@ color: #333; text-align: center; line-height: 40px; - position: fixed; + position: absolute; width: 100%; z-index: 1010; } @@ -224,4 +308,30 @@ border: 1px solid #e74e4e; border-radius: 15px; } + .viewRecordList{ + display: flex; + flex-direction: column; + } + .viewRecordItemWrap{ + margin: 10px 0; + padding: 5px 0; + border-bottom: 1px solid #dddddd; + } + .recordName{ + font-size: 15px; + font-weight: 600; + color: #333333; + margin-bottom: 10px; + } + .viewInfo{ + font-size: 14px; + color: #a2a2a2; + padding-bottom: 5px; + } + .viewInfo_tiem{ + color:#6090e6 + } + .viewInfo_current{ + color:#86c793 + } diff --git a/client/src/views/testvideo/videoStatistics.vue b/client/src/views/testvideo/videoStatistics.vue new file mode 100644 index 0000000..e293150 --- /dev/null +++ b/client/src/views/testvideo/videoStatistics.vue @@ -0,0 +1,610 @@ + + diff --git a/client/src/views/testvideo/videolist.vue b/client/src/views/testvideo/videolist.vue index 4147db5..0e55a62 100644 --- a/client/src/views/testvideo/videolist.vue +++ b/client/src/views/testvideo/videolist.vue @@ -54,12 +54,12 @@
- {{o.views}} + {{o.views_n}} {{o.viewsp}} + background-color: white;" icon="el-icon-s-custom">{{o.viewsp_n}} @@ -182,7 +182,16 @@ }else{ sessionStorage.removeItem('videoType'); } - this.$router.push({name: "Index", params: {fileid: a.fileid, id: a.id}}) + let routeData = this.$router.resolve({ + path: "/test/index", + query: { + id: a.id + } + }); + + //必要操作,否则不会打开新页面 + window.open(routeData.href, '_blank'); + // this.$router.push({path: "index", query: {fileid: a.fileid, id: a.id}}) } }, }; diff --git a/client_mp/App.vue b/client_mp/App.vue index 051fa44..0453561 100644 --- a/client_mp/App.vue +++ b/client_mp/App.vue @@ -60,4 +60,5 @@ diff --git a/client_mp/common/http.api.js b/client_mp/common/http.api.js index 90377a5..c8225e3 100644 --- a/client_mp/common/http.api.js +++ b/client_mp/common/http.api.js @@ -44,6 +44,15 @@ const install = (Vue, vm) => { let getVideos = (params={})=>vm.$u.get('/vod/video/', params);//点播视频 let getDickey = (params={})=>vm.$u.get('/system/dict/', params);//查询字典 let putMyVideoView =(id)=>vm.$u.put(`/vod/video/${id}/myview/`);//更新本人观看信息 + + + //考试有关 + let getExamList = (params={})=>vm.$u.get('/exam/exam/', params);//考试列表 + let startExam = (id)=>vm.$u.post(`/exam/exam/${id}/start/`);//开始考试 + let submitExam = (id,params={})=>vm.$u.post(`/exam/examrecord/${id}/submit/`,params);//开始考试 + let examRecord = (id,params={})=>vm.$u.get(`/exam/examrecord/self/`,params);//我的考试记录 + let examRecordDetail = (id,params={})=>vm.$u.get(`/exam/examrecord/${id}/`,params);//我的考试记录 + vm.$u.api = {getUserInfo, getCode, codeLogin, @@ -64,7 +73,13 @@ const install = (Vue, vm) => { getDocument, getVideos, getDickey, - putMyVideoView + putMyVideoView, + + getExamList, + startExam, + submitExam, + examRecord, + examRecordDetail }; } diff --git a/client_mp/common/uni-ui.scss b/client_mp/common/uni-ui.scss new file mode 100644 index 0000000..71bb980 --- /dev/null +++ b/client_mp/common/uni-ui.scss @@ -0,0 +1,119 @@ +.uni-flex { + display: flex; +} + +.uni-flex-row { + @extend .uni-flex; + flex-direction: row; + box-sizing: border-box; +} + +.uni-flex-column { + @extend .uni-flex; + flex-direction: column; +} + +.uni-color-gary { + color: #3b4144; +} + +/* 标题 */ +.uni-title { + display: flex; + margin-bottom: $uni-spacing-col-base; + font-size: $uni-font-size-lg; + font-weight: bold; + color: #3b4144; +} + +.uni-title-sub { + display: flex; + // margin-bottom: $uni-spacing-col-base; + font-size: $uni-font-size-base; + font-weight: 500; + color: #3b4144; +} + +/* 描述 额外文本 */ +.uni-note { + margin-top: 10px; + color: #999; + font-size: $uni-font-size-sm; +} + +/* 列表内容 */ +.uni-list-box { + @extend .uni-flex-row; + flex: 1; + margin-top: 10px; +} + +/* 略缩图 */ +.uni-thumb { + flex-shrink: 0; + margin-right: $uni-spacing-row-base; + width: 125px; + height: 75px; + border-radius: $uni-border-radius-lg; + overflow: hidden; + border: 1px #f5f5f5 solid; + image { + width: 100%; + height: 100%; + } +} + +.uni-media-box { + @extend .uni-flex-row; + // margin-bottom: $uni-spacing-col-base; + border-radius: $uni-border-radius-lg; + overflow: hidden; + .uni-thumb { + margin: 0; + margin-left: 4px; + flex-shrink: 1; + width: 33%; + border-radius:0; + &:first-child { + margin: 0; + } + } +} + +/* 内容 */ +.uni-content { + @extend .uni-flex-column; + justify-content: space-between; +} + +/* 列表footer */ +.uni-footer { + @extend .uni-flex-row; + justify-content: space-around; + margin-top: $uni-spacing-col-lg; +} +.uni-footer-text { + font-size: $uni-font-size-sm; + color: $uni-text-color-grey; + margin-left: 5px; +} + +/* 标签 */ + +.uni-tag { + flex-shrink: 0; + padding: 0 5px; + border: 1px $uni-border-color solid; + margin-right: $uni-spacing-row-sm; + border-radius: $uni-border-radius-base; + background: $uni-bg-color-grey; + color: $uni-text-color; + font-size: $uni-font-size-sm; +} + +/* 链接 */ +.uni-link { + margin-left: 10px; + color: $uni-text-color; + text-decoration: underline; +} \ No newline at end of file diff --git a/client_mp/pages.json b/client_mp/pages.json index 3305407..52a5514 100644 --- a/client_mp/pages.json +++ b/client_mp/pages.json @@ -11,15 +11,6 @@ // }] // }, "pages": [ - { - "path" : "pages/login/login", - "style" : - { - "navigationBarTitleText": "验证码登录", - "enablePullDownRefresh": false - } - - }, { "path" : "pages/home/home", "style" : @@ -27,8 +18,16 @@ "navigationBarTitleText": "", "enablePullDownRefresh": false } - }, + { + "path" : "pages/login/login", + "style" : + { + "navigationBarTitleText": "验证码登录", + "enablePullDownRefresh": false + } + }, + { "path" : "pages/my/my", "style" : @@ -124,7 +123,60 @@ "path" : "pages/vod/video", "style" : { - "navigationBarTitleText": "云点播", + "navigationBarTitleText": "视频观看", + "enablePullDownRefresh": false + } + + } + ,{ + "path" : "pages/exam/index", + "style" : + { + "navigationBarTitleText": "考试列表", + "enablePullDownRefresh": false + } + + } + ,{ + "path" : "pages/exam/preview", + "style" : + { + "navigationBarTitleText": "考试须知", + "enablePullDownRefresh": false + } + + } + ,{ + "path" : "pages/exam/main", + "style" : + { + "navigationBarTitleText": "答题中", + "enablePullDownRefresh": false + } + + } + ,{ + "path" : "pages/exam/record", + "style" : + { + "navigationBarTitleText": "答题记录", + "enablePullDownRefresh": false + } + + } + ,{ + "path" : "pages/exam/result", + "style" : + { + "navigationBarTitleText": "答题结束", + "enablePullDownRefresh": false + } + + },{ + "path" : "pages/exam/detail", + "style" : + { + "navigationBarTitleText": "答题详情", "enablePullDownRefresh": false } @@ -147,12 +199,18 @@ "selectedIconPath": "static/common/homec.png", "text": "主页" }, - /* { + { + "pagePath": "pages/exam/index", + "iconPath": "static/common/dati.png", + "selectedIconPath": "static/common/datic.png", + "text": "答题" + }, + { "pagePath": "pages/vod/video", "iconPath": "static/common/play.png", "selectedIconPath": "static/common/playc.png", "text": "点播" - }, */ + }, { "pagePath": "pages/my/my", "iconPath": "static/common/me.png", diff --git a/client_mp/pages/exam/detail.vue b/client_mp/pages/exam/detail.vue new file mode 100644 index 0000000..058e1ab --- /dev/null +++ b/client_mp/pages/exam/detail.vue @@ -0,0 +1,326 @@ + + + + + \ No newline at end of file diff --git a/client_mp/pages/exam/exam.vue b/client_mp/pages/exam/exam.vue new file mode 100644 index 0000000..8183fd0 --- /dev/null +++ b/client_mp/pages/exam/exam.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/client_mp/pages/exam/index.vue b/client_mp/pages/exam/index.vue new file mode 100644 index 0000000..085c88e --- /dev/null +++ b/client_mp/pages/exam/index.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/client_mp/pages/exam/main.vue b/client_mp/pages/exam/main.vue new file mode 100644 index 0000000..aff9d3c --- /dev/null +++ b/client_mp/pages/exam/main.vue @@ -0,0 +1,399 @@ + + + + + \ No newline at end of file diff --git a/client_mp/pages/exam/preview.vue b/client_mp/pages/exam/preview.vue new file mode 100644 index 0000000..440ddc9 --- /dev/null +++ b/client_mp/pages/exam/preview.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/client_mp/pages/exam/record.vue b/client_mp/pages/exam/record.vue new file mode 100644 index 0000000..ed027df --- /dev/null +++ b/client_mp/pages/exam/record.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/client_mp/pages/exam/result.vue b/client_mp/pages/exam/result.vue new file mode 100644 index 0000000..4a71fcc --- /dev/null +++ b/client_mp/pages/exam/result.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/client_mp/pages/my/my.vue b/client_mp/pages/my/my.vue index 13754f9..cec9ba4 100644 --- a/client_mp/pages/my/my.vue +++ b/client_mp/pages/my/my.vue @@ -23,25 +23,10 @@ --> - - - + @@ -83,7 +68,13 @@ }).catch(e=>{}) } }); - } + }, + examRecord(){ + uni.navigateTo({ + url:'/pages/exam/record' + }) + }, + } } diff --git a/client_mp/pages/vod/video.vue b/client_mp/pages/vod/video.vue index 8a5a0d6..d306c83 100644 --- a/client_mp/pages/vod/video.vue +++ b/client_mp/pages/vod/video.vue @@ -31,7 +31,7 @@ {{video.name}} - {{video.viewsp}}人观看 + {{video.views}}次播放 diff --git a/client_mp/uview-ui/theme.scss b/client_mp/uview-ui/theme.scss index f3bb36d..37264d5 100644 --- a/client_mp/uview-ui/theme.scss +++ b/client_mp/uview-ui/theme.scss @@ -2,6 +2,7 @@ // uni.scss中引入的样式会同时混入到全局样式文件和单独每一个页面的样式中,造成微信程序包太大, // 故uni.scss只建议放scss变量名相关样式,其他的样式可以通过main.js或者App.vue引入 +$theme-color:#0080d1; $u-main-color: #303133; $u-content-color: #606266; $u-tips-color: #909399; @@ -36,3 +37,9 @@ $u-type-info-light: #f4f4f5; $u-form-item-height: 70rpx; $u-form-item-border-color: #dcdfe6; + +/* 文字尺寸 */ +$u-font-size-sm:24rpx; +$u-font-size-lg:32rpx; +$u-font-size-base:28rpx; +$u-font-size-title:36rpx; \ No newline at end of file diff --git a/server/apps/ability/views.py b/server/apps/ability/views.py index ce3ec75..16a02ee 100644 --- a/server/apps/ability/views.py +++ b/server/apps/ability/views.py @@ -19,6 +19,7 @@ from apps.supervision.models import Content, Record from apps.supervision.serializers import ContentSerializer, RecordCreateSerializer, RecordSerializer from apps.system.mixins import CreateUpdateCustomMixin from utils.queryset import get_child_queryset2 +from apps.system.permission_data import RbacFilterSet from django.utils import timezone from apps.supervision.permission import RecordPermission from django.utils.decorators import method_decorator @@ -60,7 +61,7 @@ class AbilityContentViewSet(CreateUpdateCustomMixin, ModelViewSet): def perform_update(self, serializer): serializer.save(update_by = self.request.user) -class AbilityRecordViewSet(PageOrNot, CreateUpdateCustomMixin, ModelViewSet): +class AbilityRecordViewSet(RbacFilterSet, PageOrNot, CreateUpdateCustomMixin, ModelViewSet): perms_map = {'get': '*', 'post': '*', 'put': '*', 'delete': '*'} queryset = Record.objects.filter(content__cate=2) @@ -70,25 +71,6 @@ class AbilityRecordViewSet(PageOrNot, CreateUpdateCustomMixin, ModelViewSet): ordering = ['-task', 'content__sortnum', '-create_time'] filterset_fields = ['content','content__cate', 'belong_dept', 'state'] - def get_queryset(self): - queryset = self.queryset - if hasattr(self.get_serializer_class(), 'setup_eager_loading'): - queryset = self.get_serializer_class().setup_eager_loading(queryset) - if self.request.user.is_superuser: - pass - if hasattr(queryset.model, 'belong_dept'): - user = self.request.user - roles = user.roles - data_range = roles.values_list('datas', flat=True) - if '全部' in data_range: - pass - elif '本级及以下' in data_range: - belong_depts = get_child_queryset2(user.dept) - queryset = queryset.filter(belong_dept__in = belong_depts) - elif '本级' in data_range: - queryset = queryset.filter(belong_dept = user.dept) - return queryset - def filter_queryset(self, queryset): if not self.request.query_params.get('pageoff', None): queryset = queryset.exclude(state='待发布') diff --git a/server/apps/ability/views_qtask.py b/server/apps/ability/views_qtask.py index 9052f70..dbe800a 100644 --- a/server/apps/ability/views_qtask.py +++ b/server/apps/ability/views_qtask.py @@ -12,6 +12,9 @@ from utils.pagination import PageOrNot from rest_framework.exceptions import ParseError from rest_framework import serializers from rest_framework.exceptions import ParseError +from utils.queryset import get_child_queryset2 +from apps.system.permission_data import RbacFilterSet + class QualiLibViewSet(PageOrNot, ListModelMixin, GenericViewSet): perms_map = {'get': '*'} @@ -20,21 +23,24 @@ class QualiLibViewSet(PageOrNot, ListModelMixin, GenericViewSet): search_fields = ['name'] ordering = ['-create_time'] + class QualiViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): perms_map = {'get': '*'} - queryset = Quali.objects.select_related('org', 'province', 'city', 'file').prefetch_related('citys') + queryset = Quali.objects.select_related( + 'org', 'province', 'city', 'file').prefetch_related('citys') serializer_class = QualiListSerializer search_fields = ['name', 'type', 'grade', 'scope', 'level', 'description'] ordering = ['org', 'org__sort', 'create_time'] filterset_fields = ['org', 'type', 'grade', 'province', 'city', 'citys'] - @action(methods=['get'], detail=False, perms_map = {'get':'*'}) + @action(methods=['get'], detail=False, perms_map={'get': '*'}) def my(self, request, *args, **kwargs): """ 我的资质 """ user = self.request.user - queryset = self.filter_queryset(self.get_queryset().filter(org=user.dept)) + queryset = self.filter_queryset( + self.get_queryset().filter(org=user.dept)) page = self.paginate_queryset(queryset) if page is not None: @@ -44,8 +50,10 @@ class QualiViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + class QTaskViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): - perms_map = {'get': 'qtask_view', 'post': 'qtask_create', 'put': 'qtask_update'} + perms_map = {'get': 'qtask_view', + 'post': 'qtask_create', 'put': 'qtask_update'} queryset = QTask.objects.all() serializer_class = QTaskListSerializer ordering = ['-create_time'] @@ -54,8 +62,8 @@ class QTaskViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, Up if self.action in ['create', 'update']: return QTaskCreateUpdateSerializer return super().get_serializer_class() - - @action(methods=['put'], detail=True, perms_map = {'put':'qtask_start'}, serializer_class=serializers.Serializer) + + @action(methods=['put'], detail=True, perms_map={'put': 'qtask_start'}, serializer_class=serializers.Serializer) @transaction.atomic def start(self, request, *args, **kwargs): """ @@ -68,6 +76,7 @@ class QTaskViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, Up return Response() return Response('任务状态错误', status=status.HTTP_400_BAD_REQUEST) + class QOrgViewSet(ListModelMixin, GenericViewSet): perms_map = {'get': 'qtask_view'} queryset = QOrg.objects.select_related('qtask', 'org') @@ -75,22 +84,27 @@ class QOrgViewSet(ListModelMixin, GenericViewSet): serializer_class = QOrgListSerializer ordering = ['-create_time'] - @action(methods=['get'], detail=False, perms_map = {'get':'qtask_my'}) + @action(methods=['get'], detail=False, perms_map={'get': 'qtask_my'}) def my(self, request, *args, **kwargs): """ 我的报送任务 """ user = self.request.user - queryset = self.filter_queryset(self.get_queryset().filter(org=user.dept, qtask__is_deleted=False).exclude(qtask__state='待发布')) - + queryset = self.filter_queryset(self.get_queryset().filter(qtask__is_deleted=False).exclude(qtask__state='待发布')) + mydept = user.dept + belong_depts = get_child_queryset2(mydept) + queryset = queryset.filter(org__in = belong_depts) + deptId = self.request.query_params.get('org', mydept.id) + if deptId: + queryset = queryset.filter(org__id=deptId) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) - serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + def cal_count(qtask, org): qs = QAction.objects.filter(qtask=qtask, belong_dept=org) qorg = QOrg.objects.get(qtask=qtask, org=org) @@ -98,9 +112,11 @@ def cal_count(qtask, org): qorg.count_confirmed = qs.filter(confirmed=True).count() qorg.save() -class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModelMixin,GenericViewSet): + +class QActionViewSet(RbacFilterSet, PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModelMixin, GenericViewSet): perms_map = {'get': '*', 'delete': 'qaction_delete'} - queryset = QAction.objects.select_related('file', 'atype', 'afield', 'qtask', 'belong_dept', 'create_by') + queryset = QAction.objects.select_related( + 'file', 'atype', 'afield', 'qtask', 'belong_dept', 'create_by') filterset_fields = ['qtask', 'belong_dept', 'atype', 'afield'] serializer_class = QActionListSerializer @@ -112,22 +128,24 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel return super().get_serializer_class() - @action(methods=['get'], detail=False, perms_map = {'get':'qaction_my'}) - def my(self, request, *args, **kwargs): - """ - 我的报送操作 - """ - user = self.request.user - queryset = self.filter_queryset(self.get_queryset().filter(belong_dept=user.dept)) - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) + # @action(methods=['get'], detail=False, perms_map={'get': '*'}) + # def my(self, request, *args, **kwargs): + # """ + # 我的报送操作 + # """ + # user = self.request.user + # queryset = self.filter_queryset( + # self.get_queryset().filter(belong_dept=user.dept)) + + # page = self.paginate_queryset(queryset) + # if page is not None: + # serializer = self.get_serializer(page, many=True) + # return self.get_paginated_response(serializer.data) + + # serializer = self.get_serializer(queryset, many=True) + # return Response(serializer.data) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - def perform_destroy(self, instance): user = self.request.user if not instance.confirmed: @@ -137,7 +155,7 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel raise ParseError('报送已确认, 不可删除') @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionServiceSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionServiceSerializer) @transaction.atomic def service_update(self, request, *args, **kwargs): """ @@ -152,7 +170,7 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel return Response() @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionQualiCreateSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionQualiCreateSerializer) @transaction.atomic def quali_create(self, request, *args, **kwargs): """ @@ -167,7 +185,7 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel return Response() @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionQualiUpdateSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionQualiUpdateSerializer) @transaction.atomic def quali_update(self, request, *args, **kwargs): """ @@ -186,22 +204,25 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel obj.save() for k, v in value2.items(): if v != old_data[k]: - QActionItem.objects.create(action='update', field=k, value1=old_data[k], value2=v, qaction=obj) + QActionItem.objects.create( + action='update', field=k, value1=old_data[k], value2=v, qaction=obj) if k == 'citys': old_citys = set(old_data['citys']) new_citys = set(v) removes = old_citys.difference(new_citys) adds = new_citys.difference(old_citys) for i in removes: - QActionItem.objects.create(action='city:remove', field='citys', city=City.objects.get(id=i), qaction=obj) + QActionItem.objects.create( + action='city:remove', field='citys', city=City.objects.get(id=i), qaction=obj) for i in adds: - QActionItem.objects.create(action='city:add', field='citys', city=City.objects.get(id=i), qaction=obj) + QActionItem.objects.create( + action='city:add', field='citys', city=City.objects.get(id=i), qaction=obj) cal_count(vdata['qtask'], user.dept) return Response() - + @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionACreateSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionACreateSerializer) @transaction.atomic def ability_create(self, request, *args, **kwargs): """ @@ -216,7 +237,7 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel return Response() @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionNoChangeSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionNoChangeSerializer) @transaction.atomic def ability_nochange(self, request, *args, **kwargs): """ @@ -225,13 +246,14 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel user = request.user serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(create_by=user, belong_dept=user.dept, action='ability:nochange') + serializer.save(create_by=user, belong_dept=user.dept, + action='ability:nochange') vdata = serializer.validated_data cal_count(vdata['qtask'], user.dept) return Response() - + @action(methods=['post'], detail=False, - perms_map = {'post':'qaction_create'}, serializer_class=QActionNoChangeSerializer) + perms_map={'post': 'qaction_create'}, serializer_class=QActionNoChangeSerializer) @transaction.atomic def quali_nochange(self, request, *args, **kwargs): """ @@ -240,13 +262,13 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel user = request.user serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(create_by=user, belong_dept=user.dept, action='quali:nochange') + serializer.save(create_by=user, belong_dept=user.dept, + action='quali:nochange') vdata = serializer.validated_data cal_count(vdata['qtask'], user.dept) return Response() - - @action(methods=['put'], detail=True, perms_map = {'put':'qaction_confirm'}, + @action(methods=['put'], detail=True, perms_map={'put': 'qaction_confirm'}, serializer_class=serializers.Serializer) @transaction.atomic def confirm(self, request, *args, **kwargs): @@ -263,9 +285,11 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel elif obj.action == 'quali:create': serializer = QualiCreateSerializer(data=obj.value2) serializer.is_valid(raise_exception=True) - instance = serializer.save(org=obj.belong_dept, create_by=obj.create_by) + instance = serializer.save( + org=obj.belong_dept, create_by=obj.create_by) if instance.type == 'OTHER': - qualiLib, _ = QualiLib.objects.get_or_create(name=instance.name) + qualiLib, _ = QualiLib.objects.get_or_create( + name=instance.name) levels = qualiLib.levels levels.append(instance.level) le = list(set(levels)) @@ -284,8 +308,3 @@ class QActionViewSet(PageOrNot, ListModelMixin, DestroyModelMixin, RetrieveModel obj.save() cal_count(obj.qtask, obj.belong_dept) return Response(status=status.HTTP_200_OK) - - - - - \ No newline at end of file diff --git a/server/apps/exam/__init__.py b/server/apps/exam/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/exam/admin.py b/server/apps/exam/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/server/apps/exam/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/server/apps/exam/apps.py b/server/apps/exam/apps.py new file mode 100644 index 0000000..9649087 --- /dev/null +++ b/server/apps/exam/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExamConfig(AppConfig): + name = 'exam' diff --git a/server/apps/exam/exports.py b/server/apps/exam/exports.py new file mode 100644 index 0000000..e509192 --- /dev/null +++ b/server/apps/exam/exports.py @@ -0,0 +1,27 @@ +from openpyxl.workbook import Workbook +from django.conf import settings +from datetime import datetime +from openpyxl.styles import Font, Fill +import json +import os + +def export_question(questions): + ''' + params: serializer questions + return: xlsx path + ''' + wb = Workbook() + ws1 = wb.active + ws1.title = '题目表' + ws1.append(['分类','题型', '题干', '选项', '正确答案', '解析']) + row = ws1.row_dimensions[1] + row.font = Font(bold=True) + for i in questions: + ws1.append([i.questioncat.name, i.type, i.name, json.dumps(i.options, ensure_ascii=False), ''.join(sorted(i.right)), i.resolution]) + filename = 'questions' + datetime.now().strftime("%Y%m%d%H%M%S") +'.xlsx' + path = '/media/temp/' + full_path = settings.BASE_DIR + '/media/temp/' + if not os.path.exists(full_path): + os.makedirs(full_path) + wb.save(full_path+filename) + return path + filename \ No newline at end of file diff --git a/server/apps/exam/filters.py b/server/apps/exam/filters.py new file mode 100644 index 0000000..8b14f43 --- /dev/null +++ b/server/apps/exam/filters.py @@ -0,0 +1,21 @@ +from django_filters import rest_framework as filters +from .models import ExamRecord, Exam +class ExamRecordFilter(filters.FilterSet): + class Meta: + model = ExamRecord + fields = { + 'start_time': ['exact', 'gte', 'lte'], + 'is_pass': ['exact'], + 'type': ['exact'], + 'is_submited': ['exact'], + } + +class ExamFilter(filters.FilterSet): + class Meta: + model = Exam + fields = { + 'close_time': ['exact', 'gte', 'lte'], + 'paper': ['exact'], + 'code': ['exact'], + 'is_open': ['exact'] + } \ No newline at end of file diff --git a/server/apps/exam/migrations/0001_initial.py b/server/apps/exam/migrations/0001_initial.py new file mode 100644 index 0000000..22b7f26 --- /dev/null +++ b/server/apps/exam/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 3.0.5 on 2022-10-31 02:26 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Questioncat', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questioncat_parent', to='exam.Questioncat', verbose_name='父')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '题目分类', + 'verbose_name_plural': '题目分类', + }, + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.TextField(verbose_name='题干')), + ('img', models.CharField(blank=True, max_length=1000, null=True, verbose_name='题干图片')), + ('type', models.CharField(choices=[('单选', '单选'), ('多选', '多选'), ('判断', '判断')], default='单选', max_length=50, verbose_name='题型')), + ('level', models.CharField(choices=[('低', '低'), ('中', '中'), ('高', '高')], default='低', max_length=50, verbose_name='难度')), + ('options', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='选项')), + ('right', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='正确答案')), + ('resolution', models.TextField(blank=True, verbose_name='解析')), + ('enabled', models.BooleanField(default=False, verbose_name='是否启用')), + ('year', models.IntegerField(blank=True, null=True, verbose_name='真题年份')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='question_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('questioncat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questioncat', to='exam.Questioncat', verbose_name='所属题库')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='question_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '题目', + 'verbose_name_plural': '题目', + }, + ), + ] diff --git a/server/apps/exam/migrations/0002_auto_20221107_1356.py b/server/apps/exam/migrations/0002_auto_20221107_1356.py new file mode 100644 index 0000000..dc77a2c --- /dev/null +++ b/server/apps/exam/migrations/0002_auto_20221107_1356.py @@ -0,0 +1,153 @@ +# Generated by Django 3.0.5 on 2022-11-07 05:56 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('exam', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AnswerDetail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('user_answer', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('score', models.FloatField(default=0, verbose_name='本题得分')), + ('is_right', models.BooleanField(default=False, verbose_name='是否正确')), + ], + options={ + 'verbose_name': '答题记录', + 'verbose_name_plural': '答题记录', + }, + ), + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('code', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='考试编号')), + ('name', models.CharField(max_length=100, verbose_name='名称')), + ('place', models.CharField(blank=True, max_length=100, null=True, verbose_name='考试地点')), + ('open_time', models.DateTimeField(blank=True, null=True, verbose_name='开启时间')), + ('close_time', models.DateTimeField(blank=True, null=True, verbose_name='关闭时间')), + ('proctor_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='监考人姓名')), + ('proctor_phone', models.CharField(blank=True, max_length=100, null=True, verbose_name='监考人联系方式')), + ('chance', models.IntegerField(default=3, verbose_name='考试机会')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exam_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Paper', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('limit', models.IntegerField(default=0, verbose_name='限时(分钟)')), + ('total_score', models.FloatField(default=0, verbose_name='满分')), + ('pass_score', models.FloatField(default=0, verbose_name='通过分数')), + ('danxuan_count', models.IntegerField(default=0, verbose_name='单选数量')), + ('danxuan_score', models.FloatField(default=2, verbose_name='单选分数')), + ('duoxuan_count', models.IntegerField(default=0, verbose_name='多选数量')), + ('duoxuan_score', models.FloatField(default=4, verbose_name='多选分数')), + ('panduan_count', models.IntegerField(default=0, verbose_name='判断数量')), + ('panduan_score', models.FloatField(default=2, verbose_name='判断分数')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paper_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ], + options={ + 'verbose_name': '押题卷', + 'verbose_name_plural': '押题卷', + }, + ), + migrations.CreateModel( + name='PaperQuestion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('total_score', models.FloatField(default=0, verbose_name='单题满分')), + ('sort', models.PositiveSmallIntegerField(default=1)), + ('paper', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exam.Paper', verbose_name='试卷')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exam.Question', verbose_name='试题')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='paper', + name='questions', + field=models.ManyToManyField(through='exam.PaperQuestion', to='exam.Question'), + ), + migrations.AddField( + model_name='paper', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paper_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.CreateModel( + name='ExamRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('type', models.CharField(choices=[('自助模考', '自助模考'), ('押卷模考', '押卷模考'), ('正式考试', '正式考试')], default='自助模考', max_length=50, verbose_name='考试类型')), + ('limit', models.IntegerField(default=0, verbose_name='限时(分钟)')), + ('total_score', models.FloatField(default=0, verbose_name='总分')), + ('score', models.FloatField(default=0, verbose_name='得分')), + ('took', models.IntegerField(default=0, verbose_name='耗时(秒)')), + ('start_time', models.DateTimeField(verbose_name='开始答题时间')), + ('end_time', models.DateTimeField(verbose_name='结束答题时间')), + ('is_pass', models.BooleanField(default=True, verbose_name='是否通过')), + ('questions', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=list, verbose_name='下发的题目列表')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='examrecord_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('detail', models.ManyToManyField(through='exam.AnswerDetail', to='exam.Question', verbose_name='答题记录')), + ('exam', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='exam.Exam', verbose_name='关联的正式考试')), + ('paper', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='exam.Paper', verbose_name='所用试卷')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='examrecord_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '考试记录', + 'verbose_name_plural': '考试记录', + }, + ), + migrations.AddField( + model_name='exam', + name='paper', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='exam.Paper', verbose_name='使用的试卷'), + ), + migrations.AddField( + model_name='exam', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exam_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AddField( + model_name='answerdetail', + name='examrecord', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exam.ExamRecord'), + ), + migrations.AddField( + model_name='answerdetail', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exam.Question'), + ), + ] diff --git a/server/apps/exam/migrations/0003_auto_20221108_0901.py b/server/apps/exam/migrations/0003_auto_20221108_0901.py new file mode 100644 index 0000000..64aedfc --- /dev/null +++ b/server/apps/exam/migrations/0003_auto_20221108_0901.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.5 on 2022-11-08 01:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exam', '0002_auto_20221107_1356'), + ] + + operations = [ + migrations.RemoveField( + model_name='examrecord', + name='questions', + ), + migrations.RemoveField( + model_name='paperquestion', + name='sort', + ), + migrations.AddField( + model_name='answerdetail', + name='total_score', + field=models.FloatField(default=0, verbose_name='该题满分'), + ), + migrations.AddField( + model_name='examrecord', + name='is_submited', + field=models.BooleanField(default=False), + ), + ] diff --git a/server/apps/exam/migrations/0004_auto_20221114_1108.py b/server/apps/exam/migrations/0004_auto_20221114_1108.py new file mode 100644 index 0000000..a26aad5 --- /dev/null +++ b/server/apps/exam/migrations/0004_auto_20221114_1108.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2022-11-14 03:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exam', '0003_auto_20221108_0901'), + ] + + operations = [ + migrations.AlterField( + model_name='examrecord', + name='end_time', + field=models.DateTimeField(blank=True, null=True, verbose_name='结束答题时间'), + ), + ] diff --git a/server/apps/exam/migrations/0005_exam_is_open.py b/server/apps/exam/migrations/0005_exam_is_open.py new file mode 100644 index 0000000..d637bd7 --- /dev/null +++ b/server/apps/exam/migrations/0005_exam_is_open.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2022-11-22 02:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exam', '0004_auto_20221114_1108'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='is_open', + field=models.BooleanField(default=True, verbose_name='是否公开'), + ), + ] diff --git a/server/apps/exam/migrations/__init__.py b/server/apps/exam/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/exam/models.py b/server/apps/exam/models.py new file mode 100644 index 0000000..3b088b0 --- /dev/null +++ b/server/apps/exam/models.py @@ -0,0 +1,135 @@ +from django.db import models +from apps.system.models import CommonAModel +from django.contrib.postgres.fields import JSONField +from utils.model import BaseModel + +# Create your models here. +class Questioncat(CommonAModel): + name = models.CharField(max_length=200, verbose_name='名称') + parent = models.ForeignKey('self', verbose_name='父', null=True, blank=True, on_delete=models.CASCADE, related_name='questioncat_parent') + class Meta: + verbose_name = '题目分类' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + @property + def tmtotal(self): + return self.questioncat.count() + + +class Question(CommonAModel): + type_choices = ( + ('单选', '单选'), + ('多选', '多选'), + ('判断', '判断'), + ) + level_choices = ( + ('低', '低'), + ('中', '中'), + ('高', '高'), + ) + name = models.TextField(verbose_name='题干') + img = models.CharField(max_length=1000, null=True, blank=True, verbose_name='题干图片') + type = models.CharField(max_length=50, default='单选', choices=type_choices, verbose_name='题型') + level = models.CharField(max_length=50, default='低', choices=level_choices, verbose_name='难度') + questioncat = models.ForeignKey(Questioncat, blank=True, null=True, on_delete=models.SET_NULL, verbose_name='所属题库', related_name='questioncat') + options = JSONField(verbose_name='选项') + right = JSONField(verbose_name='正确答案') + resolution = models.TextField(verbose_name='解析', blank=True) + enabled = models.BooleanField('是否启用', default=False) + year = models.IntegerField('真题年份', null=True, blank=True) + class Meta: + verbose_name = '题目' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class Paper(CommonAModel): + name = models.CharField(max_length=200, verbose_name='名称') + questions = models.ManyToManyField(Question, through='PaperQuestion') + limit = models.IntegerField(default=0, verbose_name='限时(分钟)') + total_score = models.FloatField(default=0, verbose_name='满分') + pass_score = models.FloatField(default=0, verbose_name='通过分数') + danxuan_count = models.IntegerField(default=0, verbose_name='单选数量') + danxuan_score = models.FloatField(default=2, verbose_name='单选分数') + duoxuan_count = models.IntegerField(default=0, verbose_name='多选数量') + duoxuan_score = models.FloatField(default=4, verbose_name='多选分数') + panduan_count = models.IntegerField(default=0, verbose_name='判断数量') + panduan_score = models.FloatField(default=2, verbose_name='判断分数') + class Meta: + verbose_name = '押题卷' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class PaperQuestion(BaseModel): + paper = models.ForeignKey(Paper, on_delete=models.CASCADE, verbose_name='试卷') + question = models.ForeignKey(Question, on_delete=models.CASCADE, verbose_name='试题') + total_score = models.FloatField(default=0, verbose_name='单题满分') + + +class Exam(CommonAModel): + """ + 组织的正式考试 + """ + code = models.CharField('考试编号', max_length=100, null=True, blank=True, unique=True) + name = models.CharField('名称', max_length=100) + place = models.CharField('考试地点', max_length=100, null=True, blank=True) + open_time = models.DateTimeField('开启时间', null=True, blank=True) + close_time = models.DateTimeField('关闭时间', null=True, blank=True) + proctor_name = models.CharField('监考人姓名', max_length=100, null=True, blank=True) + proctor_phone = models.CharField('监考人联系方式', max_length=100, null=True, blank=True) + chance = models.IntegerField('考试机会', default=3) + paper = models.ForeignKey(Paper, verbose_name='使用的试卷', on_delete=models.CASCADE, null=True, blank=True) + is_open = models.BooleanField('是否公开', default=True) + + def __str__(self): + return self.name + +class ExamRecord(CommonAModel): + ''' + 考试记录表 + ''' + type_choices = ( + ('自助模考', '自助模考'), + ('押卷模考', '押卷模考'), + ('正式考试', '正式考试') + ) + name = models.CharField(max_length=200, verbose_name='名称') + type = models.CharField(max_length=50, default='自助模考',choices = type_choices, verbose_name='考试类型') + limit = models.IntegerField(default=0, verbose_name='限时(分钟)') + paper = models.ForeignKey(Paper, on_delete=models.SET_NULL, verbose_name='所用试卷', null=True, blank=True) + total_score = models.FloatField(default=0, verbose_name='总分') + score = models.FloatField(default=0, verbose_name='得分') + took = models.IntegerField(default=0, verbose_name='耗时(秒)') + start_time = models.DateTimeField(verbose_name='开始答题时间') + end_time = models.DateTimeField(verbose_name='结束答题时间', null=True, blank=True) + detail = models.ManyToManyField(Question, verbose_name='答题记录', through='AnswerDetail') + is_pass = models.BooleanField(default=True, verbose_name='是否通过') + exam = models.ForeignKey(Exam, verbose_name='关联的正式考试', null=True, blank=True, on_delete= models.SET_NULL) + is_submited = models.BooleanField(default=False) + + class Meta: + verbose_name = '考试记录' + verbose_name_plural = verbose_name + + + +class AnswerDetail(BaseModel): + examrecord = models.ForeignKey(ExamRecord, on_delete=models.CASCADE) + total_score = models.FloatField(default=0, verbose_name='该题满分') + question = models.ForeignKey(Question, on_delete=models.CASCADE) + user_answer = JSONField(null=True,blank=True) + score = models.FloatField(default=0, verbose_name='本题得分') + is_right = models.BooleanField(default=False, verbose_name='是否正确') + + class Meta: + verbose_name = '答题记录' + verbose_name_plural = verbose_name + \ No newline at end of file diff --git a/server/apps/exam/serializers.py b/server/apps/exam/serializers.py new file mode 100644 index 0000000..6212763 --- /dev/null +++ b/server/apps/exam/serializers.py @@ -0,0 +1,184 @@ +from rest_framework.serializers import ModelSerializer, CharField, Serializer, SerializerMethodField, FloatField +from rest_framework import serializers + +from apps.exam.models import Question, Questioncat, Paper, Exam, PaperQuestion, ExamRecord, AnswerDetail + + +class QuestioncatSerializer(ModelSerializer): + class Meta: + model = Questioncat + fields = '__all__' + + +class QuestionSerializer(ModelSerializer): + questioncat_name = serializers.CharField(source='questioncat.name', read_only=True) + class Meta: + model = Question + fields = '__all__' + + +class PaperSerializer(ModelSerializer): + class Meta: + model = Paper + exclude = ('questions',) + + +class PaperQuestionSerializer(ModelSerializer): + class Meta: + model = PaperQuestion + fields = ['question', 'total_score'] + + +class PaperCreateUpdateSerializer(ModelSerializer): + questions_ = PaperQuestionSerializer(many=True) + + class Meta: + model = Paper + fields = '__all__' + + +class PaperQuestionDetailSerializer(ModelSerializer): + name = serializers.ReadOnlyField(source='question.name') + options = serializers.ReadOnlyField(source='question.options') + right = serializers.ReadOnlyField(source='question.right') + type = serializers.ReadOnlyField(source='question.type') + img = serializers.ReadOnlyField(source='question.img') + questioncat_name = serializers.ReadOnlyField( + source='question.questioncat.name') + level = serializers.ReadOnlyField(source='question.level') + + class Meta: + model = PaperQuestion + fields = ('id', 'name', 'options', 'right', 'type', 'level', + 'total_score', 'questioncat_name', 'img', 'question') + + +class PaperQuestionShortSerializer(ModelSerializer): + class Meta: + model = PaperQuestion + fields = '__all__' + + +class PaperDetailSerializer(ModelSerializer): + questions_ = SerializerMethodField() + + class Meta: + model = Paper + fields = '__all__' + + def get_questions_(self, instance): + pqs = PaperQuestion.objects.filter(paper=instance) + return PaperQuestionDetailSerializer(pqs, many=True).data + + +class ExamCreateUpdateSerializer(ModelSerializer): + class Meta: + model = Exam + fields = ['name', 'place', 'open_time', + 'close_time', 'proctor_name', 'proctor_phone', 'chance', 'paper'] + + +class ExamListSerializer(ModelSerializer): + create_by_name = CharField(source='create_by.name', read_only=True) + paper_ = PaperSerializer(source='paper', read_only=True) + + class Meta: + model = Exam + fields = '__all__' + + +class ExamDetailSerializer(ModelSerializer): + create_by_name = CharField(source='create_by.name', read_only=True) + paper_ = PaperSerializer(source='paper', read_only=True) + + class Meta: + model = Exam + fields = '__all__' + + +class ExamAttendSerializer(Serializer): + code = CharField(label="考试编号") + + +class ExamRecordListSerializer(serializers.ModelSerializer): + """ + 考试列表序列化 + """ + took_format = serializers.SerializerMethodField() + create_by_name = serializers.CharField( + source='create_by.name', read_only=True) + + class Meta: + model = ExamRecord + exclude = ('detail',) + + def get_took_format(self, obj): + m, s = divmod(obj.took, 60) + h, m = divmod(m, 60) + return "%02d:%02d:%02d" % (h, m, s) + + +class ExamRecordDetailSerializer(serializers.ModelSerializer): + """ + 考试详情序列化 + """ + took_format = serializers.SerializerMethodField() + questions_ = serializers.SerializerMethodField() + + class Meta: + model = ExamRecord + exclude = ('detail',) + + def get_took_format(self, obj): + m, s = divmod(obj.took, 60) + h, m = divmod(m, 60) + return "%02d:%02d:%02d" % (h, m, s) + + def get_questions_(self, obj): + objs = AnswerDetail.objects.select_related('question').filter( + examrecord=obj).order_by('id') + return AnswerDetailSerializer(instance=objs, many=True).data + + +class AnswerDetailUpdateSerializer(serializers.Serializer): + id = serializers.CharField(label='下发ID') + user_answer = serializers.JSONField(label='作答') + + +class ExamRecordSubmitSerializer(serializers.ModelSerializer): + questions_ = AnswerDetailUpdateSerializer(many=True) + + class Meta: + model = ExamRecord + fields = ['questions_'] + + +class AnswerDetailSerializer(ModelSerializer): + name = serializers.ReadOnlyField(source='question.name') + options = serializers.ReadOnlyField(source='question.options') + right = serializers.ReadOnlyField(source='question.right') + type = serializers.ReadOnlyField(source='question.type') + img = serializers.ReadOnlyField(source='question.img') + questioncat_name = serializers.ReadOnlyField( + source='question.questioncat.name') + level = serializers.ReadOnlyField(source='question.level') + + class Meta: + model = AnswerDetail + fields = ['id', 'question', 'name', 'options', 'right', 'type', 'level', + 'total_score', 'questioncat_name', 'img', 'user_answer', 'score', 'is_right'] + + +class AnswerDetailOutSerializer(ModelSerializer): + name = serializers.ReadOnlyField(source='question.name') + options = serializers.ReadOnlyField(source='question.options') + type = serializers.ReadOnlyField(source='question.type') + img = serializers.ReadOnlyField(source='question.img') + questioncat_name = serializers.ReadOnlyField( + source='question.questioncat.name') + level = serializers.ReadOnlyField(source='question.level') + + class Meta: + model = AnswerDetail + fields = ['id', 'question', 'name', 'options', 'type', 'level', + 'total_score', 'questioncat_name', 'img', 'user_answer', 'score', 'is_right'] diff --git a/server/apps/exam/tests.py b/server/apps/exam/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/server/apps/exam/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/apps/exam/urls.py b/server/apps/exam/urls.py new file mode 100644 index 0000000..93ba0f1 --- /dev/null +++ b/server/apps/exam/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework import routers + +from apps.exam.views import QuestionViewSet, QuestioncatViewSet, PaperViewSet, ExamViewSet, ExamRecordViewSet + +API_BASE_URL = 'api/exam/' +HTML_BASE_URL = 'exam/' +router = routers.DefaultRouter() +router.register('questioncat', QuestioncatViewSet, basename='questioncat') +router.register('question', QuestionViewSet, basename="question") +router.register('paper', PaperViewSet, basename="paper") +router.register('exam', ExamViewSet, basename="exam") +router.register('examrecord', ExamRecordViewSet, basename="examrecord") +urlpatterns = [ + path(API_BASE_URL, include(router.urls)) +] diff --git a/server/apps/exam/views.py b/server/apps/exam/views.py new file mode 100644 index 0000000..e4c641e --- /dev/null +++ b/server/apps/exam/views.py @@ -0,0 +1,421 @@ +from django.shortcuts import render +from rest_framework.viewsets import ModelViewSet, GenericViewSet +from rest_framework.mixins import ListModelMixin, DestroyModelMixin, RetrieveModelMixin +from apps.exam.exports import export_question +from apps.exam.models import Question, Questioncat, PaperQuestion +from apps.exam.serializers import (QuestionSerializer, QuestioncatSerializer, PaperSerializer, ExamDetailSerializer, ExamRecordDetailSerializer, ExamListSerializer, + ExamCreateUpdateSerializer, ExamListSerializer, ExamRecordSubmitSerializer, PaperDetailSerializer, PaperCreateUpdateSerializer, AnswerDetailOutSerializer, ExamRecordListSerializer) +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import ParseError +from openpyxl import Workbook, load_workbook +from django.conf import settings +from apps.exam.models import Paper, Exam, ExamRecord, AnswerDetail +from django.utils import timezone +from django.db import transaction +from rest_framework.serializers import Serializer +from datetime import datetime +from apps.exam.filters import ExamRecordFilter, ExamFilter +from datetime import timedelta +from apps.system.mixins import CreateUpdateCustomMixin + +# Create your views here. + + +def enctry(s): + k = 'ez9z3a4m*$%srn9ve_t71yd!v+&xn9@0k(e(+l6#g1h=e5i4da' + encry_str = "" + for i, j in zip(s, k): + # i为字符,j为秘钥字符 + temp = str(ord(i)+ord(j))+'_' # 加密字符 = 字符的Unicode码 + 秘钥的Unicode码 + encry_str = encry_str + temp + return encry_str + + +# 解密 +def dectry(p): + k = 'ez9z3a4m*$%srn9ve_t71yd!v+&xn9@0k(e(+l6#g1h=e5i4da' + dec_str = "" + for i, j in zip(p.split("_")[:-1], k): + # i 为加密字符,j为秘钥字符 + # 解密字符 = (加密Unicode码字符 - 秘钥字符的Unicode码)的单字节字符 + temp = chr(int(i) - ord(j)) + dec_str = dec_str+temp + return dec_str + + +class QuestioncatViewSet(CreateUpdateCustomMixin, ModelViewSet): + perms_map = {'get': '*', 'post':'question', 'put':'question', 'delete':'question'} + queryset = Questioncat.objects.all() + serializer_class = QuestioncatSerializer + filterset_fields = ['parent'] + search_fields = ['name'] + + +class QuestionViewSet(CreateUpdateCustomMixin, ModelViewSet): + perms_map = {'get': '*', 'post':'question', 'put':'question', 'delete':'question'} + queryset = Question.objects.all() + serializer_class = QuestionSerializer + filterset_fields = ['level', 'type', 'year'] + search_fields = ['name', 'options', 'resolution'] + + @action(methods=['get'], detail=False, + url_path='export', url_name='export_question', perms_map=[{'get': '*'}], serializer_class=Serializer) + def export_question(self, request): + """ + 导出题目 + + 导出题目 + """ + queryset = self.filter_queryset(self.get_queryset()) + path = export_question(queryset) + return Response({'path': path}) + + @action(methods=['post'], detail=False, url_name='enable_question', perms_map={'post': 'question'}, serializer_class=Serializer) + def enable(self, request): + """ + 启用题目 + + 启用题目 + """ + ids = request.data.get('ids', None) + if ids: + Question.objects.filter(pk__in=ids).update(enabled=True) + return Response(status=200) + + @action(methods=['post'], detail=False, + url_path='import', url_name='import_question', perms_map={'post': 'question'}, serializer_class=Serializer) + def import_question(self, request): + """ + 导入题目 + + 导入题目 + """ + xlsxpath = request.data['path'] + fullpath = settings.BASE_DIR + xlsxpath + wb = load_workbook(fullpath) + sheet = wb.worksheets[0] + qlist = ['A', 'B', 'C', 'D', 'E', 'F'] + leveldict = {'低': '低', '中': '中', '高': '高'} + notinlist = [] + # 验证文件内容 + if sheet['a2'].value != '题目类型': + return Response({"error": "类型列错误!"}) + if sheet['b2'].value != '分类': + return Response({"error": "分类列错误!"}) + if sheet['c2'].value != '题目': + return Response({"error": "题目列错误!"}) + questioncatdict = {} + questioncats = Questioncat.objects.all() + for i in questioncats: + questioncatdict[i.name] = i.id + i = 3 + while sheet['c'+str(i)].value: + type = sheet['a'+str(i)].value.replace(' ', '') + questioncat = sheet['b'+str(i)].value + if questioncat: + questioncat = questioncat.replace(' ', '') + else: + return Response(str(i)+'行没有分类', status=400) + name = sheet['c'+str(i)].value + + answer = {} + if sheet['d'+str(i)].value: + answer['A'] = sheet['d'+str(i)].value + if sheet['e'+str(i)].value: + answer['B'] = sheet['e'+str(i)].value + if sheet['f'+str(i)].value: + answer['C'] = sheet['f'+str(i)].value + if sheet['g'+str(i)].value: + answer['D'] = sheet['g'+str(i)].value + if sheet['h'+str(i)].value: + answer['E'] = sheet['h'+str(i)].value + if sheet['i'+str(i)].value: + answer['F'] = sheet['i'+str(i)].value + right = sheet['j'+str(i)].value + if right: + right = right.replace(' ', '') + else: + return Response(str(i)+'行没有答案', status=400) + resolution = sheet['k'+str(i)].value + level = sheet['l'+str(i)].value + year = sheet['m' + str(i)].value + if level: + level = level.replace(' ', '') + cateobj = None + if questioncat not in questioncatdict: + return Response(str(i)+"行不存在分类("+questioncat+")!请先新建", status=400) + else: + cateobj = Questioncat.objects.get( + id=questioncatdict[questioncat]) + if type == '单选': + if Question.objects.filter(type='单选', name=name, year=year, options=answer, questioncat=cateobj).exists(): + notinlist.append(i) + else: + if right in ['A', 'B', 'C', 'D', 'E', 'F']: + obj = Question() + obj.type = '单选' + if cateobj: + obj.questioncat = cateobj + obj.name = name + obj.options = answer + obj.right = right + obj.resolution = resolution if resolution else '' + obj.year = year if year else None + if level in leveldict: + obj.level = leveldict[level] + else: + obj.level = '低' + obj.save() + elif type == '多选': + right = list(right.strip()) + if Question.objects.filter(type='多选', name=name, year=year, options=answer, questioncat=cateobj).exists(): + notinlist.append(i) + else: + if [False for c in right if c not in qlist]: + pass + else: + obj = Question() + obj.type = '多选' + obj.questioncat = cateobj + obj.name = name + obj.options = answer + obj.right = right + obj.resolution = resolution if resolution else '' + obj.year = year if year else None + if level in leveldict: + obj.level = leveldict[level] + else: + obj.level = '低' + obj.save() + elif type == '判断': + if right == 'A' or right == '对' or right == '正确': + right = 'A' + else: + right = 'B' + if Question.objects.filter(type='判断', name=name, is_delete=0, options={'A': '对', 'B': '错'}, questioncat=cateobj).exists(): + notinlist.append(i) + else: + obj = Question() + obj.type = '判断' + obj.questioncat = cateobj + obj.name = name + obj.options = {'A': '对', 'B': '错'} + obj.right = right + obj.resolution = resolution if resolution else '' + obj.year = year if year else None + if level in leveldict: + obj.level = leveldict[level] + else: + obj.level = '低' + obj.save() + i = i + 1 + return Response(notinlist, status=200) + + +class PaperViewSet(ModelViewSet): + """ + 试卷增删改查 + """ + perms_map = {'get': '*', 'post':'paper', 'put':'paper', 'delete':'paper'} + queryset = Paper.objects.all() + serializer_class = PaperSerializer + ordering = ['id'] + search_fields = ('name',) + + def get_serializer_class(self): + if self.action in ['retrieve']: + return PaperDetailSerializer + elif self.action in ['create', 'update']: + return PaperCreateUpdateSerializer + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + sr = PaperCreateUpdateSerializer(data=request.data) + sr.is_valid(raise_exception=True) + vdata = sr.validated_data + vdata['create_by'] = request.user + questions_ = vdata.pop('questions_') + paper = Paper.objects.create(**vdata) + q_list = [] + for i in questions_: + q_list.append(PaperQuestion(question=i['question'], total_score=i['total_score'], paper=paper)) + PaperQuestion.objects.bulk_create(q_list) + return Response(status=201) + + def update(self, request, *args, **kwargs): + # 有考试在执行,不可更新 + now = timezone.now() + paper = self.get_object() + if Exam.objects.filter(close_time__gte=now, paper=paper).exists(): + raise ParseError('存在考试,不可编辑') + sr = PaperCreateUpdateSerializer(instance=paper, data=request.data) + sr.is_valid(raise_exception=True) + vdata = sr.validated_data + questions_ = vdata.pop('questions_') + vdata['update_by'] = request.user + Paper.objects.filter(id=paper.id).update(**vdata) + q_list = [] + for i in questions_: + q_list.append(PaperQuestion(question=i['question'], total_score=i['total_score'], paper=paper)) + PaperQuestion.objects.filter(paper=paper).delete() + PaperQuestion.objects.bulk_create(q_list) + return Response() + + +class ExamViewSet(CreateUpdateCustomMixin, ModelViewSet): + perms_map = {'get': '*', 'post':'exam', 'put':'exam', 'delete':'exam'} + queryset = Exam.objects.all().select_related('paper', 'create_by') + ordering = ['-id'] + search_fields = ('name',) + serializer_class = ExamListSerializer + filterset_class = ExamFilter + + def get_serializer_class(self): + if self.action in ['create', 'update']: + return ExamCreateUpdateSerializer + elif self.action in ['retrieve']: + return ExamDetailSerializer + return super().get_serializer_class() + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if ExamRecord.objects.filter(exam=instance).exists(): + raise ParseError('存在考试记录,禁止删除') + instance.delete(soft=False) + return Response(status=204) + + @action(methods=['post'], detail=True, perms_map=[{'post': '*'}], serializer_class=Serializer, permission_classes = [IsAuthenticated]) + @transaction.atomic + def start(self, request, *args, **kwargs): + """ + 开始考试 + + 开始考试具体题目信息 + """ + exam = self.get_object() + now = timezone.now() + if now < exam.open_time or now > exam.close_time: + raise ParseError('不在考试时间范围') + tests = ExamRecord.objects.filter( + exam=exam, create_by=request.user) + chance_used = tests.count() + if chance_used > exam.chance: + raise ParseError('考试机会已用完') + if exam.paper: + er = ExamRecord() + er.type = '正式考试' + er.name = '正式考试' + datetime.now().strftime('%Y%m%d%H%M') + er.limit = exam.paper.limit + er.paper = exam.paper + er.total_score = exam.paper.total_score + er.start_time = now + er.is_pass = False + er.exam = exam + er.create_by = request.user + er.save() + ret = {} + ret['examrecord'] = er.id + pqs = PaperQuestion.objects.filter(paper=exam.paper).order_by('id') + details = [] + for i in pqs: + details.append(AnswerDetail(examrecord=er, question=i.question, total_score=i.total_score)) + AnswerDetail.objects.bulk_create(details) + ads = AnswerDetail.objects.select_related('question').filter(examrecord=er).order_by('id') + ret['questions_'] = AnswerDetailOutSerializer(instance=ads, many=True).data + return Response(ret) + raise ParseError('暂不支持') + + +class ExamRecordViewSet(ListModelMixin, DestroyModelMixin, RetrieveModelMixin, GenericViewSet): + """ + 考试记录列表和详情 + """ + perms_map = {'get': '*', 'post': '*', 'delete':'examrecord'} + queryset = ExamRecord.objects.select_related('create_by') + serializer_class = ExamRecordListSerializer + ordering_fields = ['create_time', 'score', 'took', 'update_time'] + ordering = ['-update_time'] + search_fields = ('create_by__name', 'create_by__username') + filterset_class = ExamRecordFilter + + def get_serializer_class(self): + if self.action == 'retrieve': + return ExamRecordDetailSerializer + return super().get_serializer_class() + + def perform_destroy(self, instance): # 考试记录物理删除 + instance.delete(soft=False) + + @action(methods=['post'], detail=False, perms_map=[{'post': '*'}], serializer_class=Serializer, permission_classes = [IsAuthenticated]) + def clear(self, request, pk=None): + """ + 清除七日前未提交的考试记录 + + 清除七日前未提交的考试记录 + """ + now = timezone.now + days7_ago = now - timedelta(days=7) + ExamRecord.objects.filter(create_time__lte=days7_ago, is_submited=False).delete(soft=False) + return Response(status=False) + + @action(methods=['get'], detail=False, permission_classes=[IsAuthenticated]) + def self(self, request, pk=None): + ''' + 个人考试记录 + + 个人考试记录 + ''' + queryset = ExamRecord.objects.filter(create_by=request.user).order_by('-update_time') + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + @action(methods=['post'], detail=True, perms_map=[{'post': '*'}], serializer_class=ExamRecordSubmitSerializer, permission_classes = [IsAuthenticated]) + @transaction.atomic + def submit(self, request, pk=None): + ''' + 提交答卷 + + 提交答卷 + ''' + er = self.get_object() + now = timezone.now() + if er.create_by != request.user: + raise ParseError('提交人有误') + exam = er.exam + if not exam: + raise ParseError('暂不支持') + if now > exam.close_time + timedelta(minutes=30): + raise ParseError('考试时间已过, 提交失败') + serializer = ExamRecordSubmitSerializer(data = request.data) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data + questions_ = vdata['questions_'] + # 后端判卷 + ads = AnswerDetail.objects.select_related('question').filter(examrecord=er).order_by('id') + total_score = 0 + try: + for index, ad in enumerate(ads): + ad.user_answer = questions_[index]['user_answer'] + if ad.question.type == '多选': + if set(ad.question.right) == set(ad.user_answer): + ad.is_right = True + ad.score = ad.total_score + else: + if ad.question.right == ad.user_answer: + ad.is_right = True + ad.score = ad.total_score + ad.save() + total_score = total_score + ad.score + except Exception as e: + raise ParseError('判卷失败, 请检查试卷:' + str(e)) + er.score = total_score + if er.score > 0.6*er.total_score: + er.is_pass = True + er.took = (now - er.create_time).total_seconds() + er.end_time = now + er.is_submited = True + er.save() + return Response(ExamRecordListSerializer(instance=er).data) \ No newline at end of file diff --git a/server/apps/ops/__init__.py b/server/apps/ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/ops/admin.py b/server/apps/ops/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/server/apps/ops/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/server/apps/ops/apps.py b/server/apps/ops/apps.py new file mode 100644 index 0000000..23b85df --- /dev/null +++ b/server/apps/ops/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OpsConfig(AppConfig): + name = 'ops' + verbose_name = '系统监控' diff --git a/server/apps/ops/errors.py b/server/apps/ops/errors.py new file mode 100644 index 0000000..3bd94a2 --- /dev/null +++ b/server/apps/ops/errors.py @@ -0,0 +1 @@ +LOG_NOT_FONED = {"code": "log_not_found", "detail": "日志不存在"} diff --git a/server/apps/ops/filters.py b/server/apps/ops/filters.py new file mode 100644 index 0000000..1f1756d --- /dev/null +++ b/server/apps/ops/filters.py @@ -0,0 +1,7 @@ +from django_filters import rest_framework as filters + + +class DrfLogFilterSet(filters.FilterSet): + start_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='gte') + end_request = filters.DateTimeFilter(field_name="requested_at", lookup_expr='lte') + id = filters.CharFilter() diff --git a/server/apps/ops/migrations/0001_initial.py b/server/apps/ops/migrations/0001_initial.py new file mode 100644 index 0000000..aceeb38 --- /dev/null +++ b/server/apps/ops/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.5 on 2022-10-12 06:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DrfRequestLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('requested_at', models.DateTimeField(db_index=True)), + ('response_ms', models.PositiveIntegerField(default=0)), + ('path', models.CharField(db_index=True, help_text='请求地址', max_length=400)), + ('view', models.CharField(blank=True, db_index=True, help_text='执行视图', max_length=400, null=True)), + ('view_method', models.CharField(blank=True, db_index=True, max_length=20, null=True)), + ('remote_addr', models.GenericIPAddressField()), + ('host', models.URLField()), + ('method', models.CharField(max_length=10)), + ('query_params', models.TextField(blank=True, null=True)), + ('data', models.TextField(blank=True, null=True)), + ('response', models.TextField(blank=True, null=True)), + ('errors', models.TextField(blank=True, null=True)), + ('agent', models.TextField(blank=True, null=True)), + ('status_code', models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'DRF请求日志', + }, + ), + ] diff --git a/server/apps/ops/migrations/0002_auto_20221012_1455.py b/server/apps/ops/migrations/0002_auto_20221012_1455.py new file mode 100644 index 0000000..e7c6210 --- /dev/null +++ b/server/apps/ops/migrations/0002_auto_20221012_1455.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.5 on 2022-10-12 06:55 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ops', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='drfrequestlog', + name='data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='drfrequestlog', + name='query_params', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='drfrequestlog', + name='response', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + ] diff --git a/server/apps/ops/migrations/__init__.py b/server/apps/ops/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/ops/mixins.py b/server/apps/ops/mixins.py new file mode 100644 index 0000000..e09b3f3 --- /dev/null +++ b/server/apps/ops/mixins.py @@ -0,0 +1,235 @@ +import ast +import json +import logging +import uuid +from django.utils.timezone import now +import traceback +from django.db import connection +from apps.ops.models import DrfRequestLog +import ipaddress +from user_agents import parse + +# 实例化myLogger +myLogger = logging.getLogger('log') + +class MyLoggingMixin(object): + """Mixin to log requests""" + + CLEANED_SUBSTITUTE = "********************" + + # logging_methods = "__all__" + logging_methods = '__all__' + sensitive_fields = {} + + def __init__(self, *args, **kwargs): + assert isinstance( + self.CLEANED_SUBSTITUTE, str + ), "CLEANED_SUBSTITUTE must be a string." + super().__init__(*args, **kwargs) + + def initial(self, request, *args, **kwargs): + request_id = uuid.uuid4() + self.log = {"requested_at": now(), "id": request_id} + setattr(request, 'request_id', request_id) + if not getattr(self, "decode_request_body", False): + self.log["data"] = "" + else: + self.log["data"] = self._clean_data(request.body) + + super().initial(request, *args, **kwargs) + + try: + # Accessing request.data *for the first time* parses the request body, which may raise + # ParseError and UnsupportedMediaType exceptions. It's important not to swallow these, + # as (depending on implementation details) they may only get raised this once, and + # DRF logic needs them to be raised by the view for error handling to work correctly. + data = self.request.data.dict() + except AttributeError: + data = self.request.data + self.log["data"] = self._clean_data(data) + + def handle_exception(self, exc): + response = super().handle_exception(exc) + self.log["errors"] = traceback.format_exc() + return response + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response( + request, response, *args, **kwargs + ) + # Ensure backward compatibility for those using _should_log hook + should_log = ( + self._should_log if hasattr(self, "_should_log") else self.should_log + ) + if should_log(request, response): + if (connection.settings_dict.get("ATOMIC_REQUESTS") and + getattr(response, "exception", None) and connection.in_atomic_block): + # response with exception (HTTP status like: 401, 404, etc) + # pointwise disable atomic block for handle log (TransactionManagementError) + connection.set_rollback(True) + connection.set_rollback(False) + + if response.streaming: + rendered_content = None + elif hasattr(response, "rendered_content"): + rendered_content = response.rendered_content + else: + rendered_content = response.getvalue() + + self.log.update( + { + "remote_addr": self._get_ip_address(request), + "view": self._get_view_name(request), + "view_method": self._get_view_method(request), + "path": self._get_path(request), + "host": request.get_host(), + "method": request.method, + "query_params": self._clean_data(request.query_params.dict()), + "user": self._get_user(request), + "response_ms": self._get_response_ms(), + "response": self._clean_data(rendered_content), + "status_code": response.status_code, + "agent": self._get_agent(request), + } + ) + try: + self.handle_log() + except Exception: + # ensure that all exceptions raised by handle_log + # doesn't prevent API call to continue as expected + myLogger.exception("Logging API call raise exception!") + return response + + def handle_log(self): + """ + Hook to define what happens with the log. + + Defaults on saving the data on the db. + """ + DrfRequestLog(**self.log).save() + + def _get_path(self, request): + """Get the request path and truncate it""" + return request.path + + def _get_ip_address(self, request): + """Get the remote ip address the request was generated from.""" + ipaddr = request.META.get("HTTP_X_FORWARDED_FOR", None) + if ipaddr: + ipaddr = ipaddr.split(",")[0] + else: + ipaddr = request.META.get("REMOTE_ADDR", "") + + # Account for IPv4 and IPv6 addresses, each possibly with port appended. Possibilities are: + # + # + # :port + # []:port + # Note that ipv6 addresses are colon separated hex numbers + possibles = (ipaddr.lstrip("[").split("]")[0], ipaddr.split(":")[0]) + + for addr in possibles: + try: + return str(ipaddress.ip_address(addr)) + except ValueError: + pass + + return ipaddr + + def _get_view_name(self, request): + """Get view name.""" + method = request.method.lower() + try: + attributes = getattr(self, method) + return ( + type(attributes.__self__).__module__ + "." + type(attributes.__self__).__name__ + ) + + except AttributeError: + return None + + def _get_view_method(self, request): + """Get view method.""" + if hasattr(self, "action"): + return self.action or None + return request.method.lower() + + def _get_user(self, request): + """Get user.""" + user = request.user + if user.is_anonymous: + return None + return user + + def _get_agent(self, request): + """Get os string""" + return str(parse(request.META['HTTP_USER_AGENT'])) + + def _get_response_ms(self): + """ + Get the duration of the request response cycle is milliseconds. + In case of negative duration 0 is returned. + """ + response_timedelta = now() - self.log["requested_at"] + response_ms = int(response_timedelta.total_seconds() * 1000) + return max(response_ms, 0) + + def should_log(self, request, response): + """ + Method that should return a value that evaluated to True if the request should be logged. + By default, check if the request method is in logging_methods. + """ + return self.logging_methods == "__all__" or response.status_code > 404 or response.status_code == 400 \ + or (request.method in self.logging_methods and response.status_code not in [401, 403, 404]) + + def _clean_data(self, data): + """ + Clean a dictionary of data of potentially sensitive info before + sending to the database. + Function based on the "_clean_credentials" function of django + (https://github.com/django/django/blob/stable/1.11.x/django/contrib/auth/__init__.py#L50) + + Fields defined by django are by default cleaned with this function + + You can define your own sensitive fields in your view by defining a set + eg: sensitive_fields = {'field1', 'field2'} + """ + + if isinstance(data, bytes): + data = data.decode(errors="replace") + try: + data = json.loads(data) + except: + pass + + if isinstance(data, list): + return [self._clean_data(d) for d in data] + + if isinstance(data, dict): + SENSITIVE_FIELDS = { + "api", + "token", + "key", + "secret", + "password", + "signature", + "access", + "refresh" + } + + data = dict(data) + if self.sensitive_fields: + SENSITIVE_FIELDS = SENSITIVE_FIELDS | { + field.lower() for field in self.sensitive_fields + } + + for key, value in data.items(): + try: + value = ast.literal_eval(value) + except (ValueError, SyntaxError): + pass + if isinstance(value, (list, dict)): + data[key] = self._clean_data(value) + if key.lower() in SENSITIVE_FIELDS: + data[key] = self.CLEANED_SUBSTITUTE + return data \ No newline at end of file diff --git a/server/apps/ops/models.py b/server/apps/ops/models.py new file mode 100644 index 0000000..a4efddd --- /dev/null +++ b/server/apps/ops/models.py @@ -0,0 +1,49 @@ +import uuid +from django.db import models +from django.contrib.postgres.fields import JSONField + +class DrfRequestLog(models.Model): + """Logs Django rest framework API requests""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + user = models.ForeignKey( + 'system.user', + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + requested_at = models.DateTimeField(db_index=True) + response_ms = models.PositiveIntegerField(default=0) + path = models.CharField( + max_length=400, + db_index=True, + help_text="请求地址", + ) + view = models.CharField( + max_length=400, + null=True, + blank=True, + db_index=True, + help_text="执行视图", + ) + view_method = models.CharField( + max_length=20, + null=True, + blank=True, + db_index=True, + ) + remote_addr = models.GenericIPAddressField() + host = models.URLField() + method = models.CharField(max_length=10) + query_params = JSONField(null=True, blank=True) + data = JSONField(null=True, blank=True) + response = JSONField(null=True, blank=True) + errors = models.TextField(null=True, blank=True) + agent = models.TextField(null=True, blank=True) + status_code = models.PositiveIntegerField(null=True, blank=True, db_index=True) + + class Meta: + verbose_name = "DRF请求日志" + + def __str__(self): + return "{} {}".format(self.method, self.path) diff --git a/server/apps/ops/tasks.py b/server/apps/ops/tasks.py new file mode 100644 index 0000000..43300a8 --- /dev/null +++ b/server/apps/ops/tasks.py @@ -0,0 +1,17 @@ +# Create your tasks here +from __future__ import absolute_import, unicode_literals +from datetime import timedelta +from apps.ops.models import DrfRequestLog +# from celery import shared_task +from django.utils import timezone + + +# @shared_task() +# def clear_drf_log(): +# """清除7天前的日志记录 + +# 清除7天前的日志记录 +# """ +# now = timezone.now() +# days7_ago = now - timedelta(days=7) +# DrfRequestLog.objects.filter(create_time__lte=days7_ago).delete() diff --git a/server/apps/ops/tests.py b/server/apps/ops/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/server/apps/ops/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/apps/ops/urls.py b/server/apps/ops/urls.py new file mode 100644 index 0000000..d00faeb --- /dev/null +++ b/server/apps/ops/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework import routers +from .views import CpuView, DiskView, DrfRequestLogViewSet, LogView, LogDetailView, MemoryView + +API_BASE_URL = 'api/ops/' +HTML_BASE_URL = 'ops/' +router = routers.DefaultRouter() +router.register('request_log', DrfRequestLogViewSet, basename="request_log") +urlpatterns = [ + path(API_BASE_URL, include(router.urls)), + path(API_BASE_URL + 'log/', LogView.as_view()), + path(API_BASE_URL + 'log//', LogDetailView.as_view()), + path(API_BASE_URL + 'server/cpu/', CpuView.as_view()), + path(API_BASE_URL + 'server/memory/', MemoryView.as_view()), + path(API_BASE_URL + 'server/disk/', DiskView.as_view()) +] diff --git a/server/apps/ops/views.py b/server/apps/ops/views.py new file mode 100644 index 0000000..9a735cd --- /dev/null +++ b/server/apps/ops/views.py @@ -0,0 +1,159 @@ + +from django.shortcuts import render +import psutil +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from django.conf import settings +import os +from rest_framework import serializers +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.exceptions import NotFound +from rest_framework.mixins import ListModelMixin +from apps.ops.filters import DrfLogFilterSet +from apps.ops.models import DrfRequestLog +from rest_framework.viewsets import GenericViewSet +from apps.ops.errors import LOG_NOT_FONED +from rest_framework.decorators import action + +# Create your views here. + +class CpuView(APIView): + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + """ + 获取服务器cpu当前状态 + + 获取服务器cpu当前状态 + """ + ret = {'cpu': {}} + ret['cpu']['count'] = psutil.cpu_count() + ret['cpu']['lcount'] = psutil.cpu_count(logical=False) + ret['cpu']['percent'] = psutil.cpu_percent(interval=1) + return Response(ret) + +class MemoryView(APIView): + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + """ + 获取服务器内存当前状态 + + 获取服务器内存当前状态 + """ + ret = {'memory': {}} + memory = psutil.virtual_memory() + ret['memory']['total'] = round(memory.total/1024/1024/1024, 2) + ret['memory']['used'] = round(memory.used/1024/1024/1024, 2) + ret['memory']['percent'] = memory.percent + return Response(ret) + +class DiskView(APIView): + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + """ + 获取服务器硬盘当前状态 + + 获取服务器硬盘当前状态 + """ + ret = {'disk': {}} + disk = psutil.disk_usage('/') + ret['disk']['total'] = round(disk.total/1024/1024/1024, 2) + ret['disk']['used'] = round(disk.used/1024/1024/1024, 2) + ret['disk']['percent'] = disk.percent + return Response(ret) + +def get_file_list(file_path): + dir_list = os.listdir(file_path) + if not dir_list: + return + else: + # 注意,这里使用lambda表达式,将文件按照最后修改时间顺序升序排列 + # os.path.getmtime() 函数是获取文件最后修改时间 + # os.path.getctime() 函数是获取文件最后创建时间 + dir_list = sorted(dir_list, key=lambda x: os.path.getmtime( + os.path.join(file_path, x)), reverse=True) + # print(dir_list) + return dir_list + + +class LogView(APIView): + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter('name', openapi.IN_QUERY, + description='日志文件名', type=openapi.TYPE_STRING) + ]) + def get(self, request, *args, **kwargs): + """ + 查看最近的日志列表 + + 查看最近的日志列表 + """ + logs = [] + name = request.GET.get('name', None) + # for root, dirs, files in os.walk(settings.LOG_PATH): + # files.reverse() + for file in get_file_list(settings.LOG_PATH): + if len(logs) > 50: + break + filepath = os.path.join(settings.LOG_PATH, file) + if name: + if name in filepath: + fsize = os.path.getsize(filepath) + if fsize: + logs.append({ + "name": file, + "filepath": filepath, + "size": round(fsize/1024, 1) + }) + else: + fsize = os.path.getsize(filepath) + if fsize: + logs.append({ + "name": file, + "filepath": filepath, + "size": round(fsize/1024, 1) + }) + return Response(logs) + + +class LogDetailView(APIView): + + def get(self, request, name): + """ + 查看日志详情 + + 查看日志详情 + """ + try: + with open(os.path.join(settings.LOG_PATH, name)) as f: + data = f.read() + return Response(data) + except Exception: + raise NotFound(**LOG_NOT_FONED) + + +class DrfRequestLogSerializer(serializers.ModelSerializer): + class Meta: + model = DrfRequestLog + fields = '__all__' + + +class DrfRequestLogViewSet(ListModelMixin, GenericViewSet): + """请求日志 + + 请求日志 + """ + perms_map = {'get': '*'} + queryset = DrfRequestLog.objects.all() + serializer_class = DrfRequestLogSerializer + ordering = ['-requested_at'] + filterset_class = DrfLogFilterSet + + @action(methods=['delete'], detail=False, perms_map = {'delete':'log_delete'}) + def clear(self, request, *args, **kwargs): + """ + 清空日志 + """ + DrfRequestLog.objects.all().delete() + return Response() diff --git a/server/apps/quality/views.py b/server/apps/quality/views.py index 484e209..57f8f82 100644 --- a/server/apps/quality/views.py +++ b/server/apps/quality/views.py @@ -98,7 +98,7 @@ class SubtaskViewSet(PageOrNot, CreateUpdateCustomMixin, OptimizationMixin, Mode if has_permission('inspecttask_create', self.request.user): return queryset else: - return queryset.filter(team_subtask__member=self.request.user).exclude(state='待发布') + return queryset.filter(team_subtask__member__id=self.request.user.id).exclude(state='待发布') @action(methods=['get'], detail=False, perms_map = {'get':'*'}) def self(self, request, *args, **kwargs): diff --git a/server/apps/supervision/views.py b/server/apps/supervision/views.py index 5ff50e4..c6c307a 100644 --- a/server/apps/supervision/views.py +++ b/server/apps/supervision/views.py @@ -209,7 +209,7 @@ from utils.queryset import get_child_queryset2 from .permission import RecordPermission -class RecordViewSet(PageOrNot, CreateUpdateCustomMixin, ModelViewSet): +class RecordViewSet(RbacFilterSet, PageOrNot, CreateUpdateCustomMixin, ModelViewSet): perms_map = {'get': '*', 'post': 'record_create', 'put': 'record_update', 'delete': 'record_delete'} queryset = Record.objects.filter(content__cate=1) @@ -219,25 +219,6 @@ class RecordViewSet(PageOrNot, CreateUpdateCustomMixin, ModelViewSet): ordering = ['-task', 'content__sortnum', '-create_time'] filter_class = RecordFilter # 过滤类 - def get_queryset(self): - queryset = self.queryset - if hasattr(self.get_serializer_class(), 'setup_eager_loading'): - queryset = self.get_serializer_class().setup_eager_loading(queryset) - if self.request.user.is_superuser: - pass - if hasattr(queryset.model, 'belong_dept'): - user = self.request.user - roles = user.roles - data_range = roles.values_list('datas', flat=True) - if '全部' in data_range: - pass - elif '本级及以下' in data_range: - belong_depts = get_child_queryset2(user.dept) - queryset = queryset.filter(belong_dept__in = belong_depts) - elif '本级' in data_range: - queryset = queryset.filter(belong_dept = user.dept) - return queryset - def filter_queryset(self, queryset): if not self.request.query_params.get('pageoff', None): queryset = queryset.exclude(state='待发布') diff --git a/server/apps/system/admin.py b/server/apps/system/admin.py index e47b0d7..166eb4a 100644 --- a/server/apps/system/admin.py +++ b/server/apps/system/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin -from simple_history.admin import SimpleHistoryAdmin from .models import User, Organization, Role, Permission, DictType, Dict, File # Register your models here. admin.site.register(User) @@ -7,5 +6,5 @@ admin.site.register(Organization) admin.site.register(Role) admin.site.register(Permission) admin.site.register(DictType) -admin.site.register(Dict, SimpleHistoryAdmin) +admin.site.register(Dict) admin.site.register(File) \ No newline at end of file diff --git a/server/apps/system/migrations/0003_auto_20200528_1716.py b/server/apps/system/migrations/0003_auto_20200528_1716.py index 9779308..0125b22 100644 --- a/server/apps/system/migrations/0003_auto_20200528_1716.py +++ b/server/apps/system/migrations/0003_auto_20200528_1716.py @@ -4,7 +4,6 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -import simple_history.models class Migration(migrations.Migration): @@ -54,7 +53,6 @@ class Migration(migrations.Migration): 'ordering': ('-history_date', '-history_id'), 'get_latest_by': 'history_date', }, - bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( name='File', diff --git a/server/apps/system/migrations/0022_delete_historicaldict.py b/server/apps/system/migrations/0022_delete_historicaldict.py new file mode 100644 index 0000000..c2520b3 --- /dev/null +++ b/server/apps/system/migrations/0022_delete_historicaldict.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.5 on 2022-10-12 06:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0021_auto_20220530_1520'), + ] + + operations = [ + migrations.DeleteModel( + name='HistoricalDict', + ), + ] diff --git a/server/apps/system/models.py b/server/apps/system/models.py index fa5eed7..8b6781f 100644 --- a/server/apps/system/models.py +++ b/server/apps/system/models.py @@ -4,7 +4,6 @@ import django.utils.timezone as timezone from django.db.models.query import QuerySet from utils.model import SoftModel, BaseModel -from simple_history.models import HistoricalRecords class Province(models.Model): id = models.CharField('id', primary_key=True, max_length=20) @@ -160,7 +159,6 @@ class Dict(SoftModel): pid = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='父') is_used = models.BooleanField('是否有效', default=True) - history = HistoricalRecords() class Meta: verbose_name = '字典' diff --git a/server/apps/system/permission.py b/server/apps/system/permission.py index 590c1f1..7fbc595 100644 --- a/server/apps/system/permission.py +++ b/server/apps/system/permission.py @@ -12,7 +12,7 @@ def get_permission_list(user): perms_list = ['admin'] else: perms = Permission.objects.none() - roles = user.roles.all() + roles = user.roles.all() if hasattr(user, 'roles') else None if roles: for i in roles: perms = perms | i.perms.all() diff --git a/server/apps/system/permission_data.py b/server/apps/system/permission_data.py index 65bf5c9..ad42ec0 100644 --- a/server/apps/system/permission_data.py +++ b/server/apps/system/permission_data.py @@ -34,7 +34,9 @@ class RbacFilterSet(object): if hasattr(queryset.model, 'belong_dept'): user = self.request.user - roles = user.roles + roles = user.roles if hasattr(user, 'roles') else [] + if not roles: + return queryset.none() data_range = roles.values_list('datas', flat=True) if '全部' in data_range: return queryset @@ -57,6 +59,8 @@ class RbacFilterSet(object): elif '仅本人' in data_range: queryset = queryset.filter(Q(create_by=user)|Q(update_by=user)) return queryset + else: + return queryset.none() return queryset diff --git a/server/apps/system/views.py b/server/apps/system/views.py index c2a09e9..c716e5d 100644 --- a/server/apps/system/views.py +++ b/server/apps/system/views.py @@ -1,4 +1,5 @@ import logging +from re import L from django.conf import settings from django.contrib.auth.hashers import check_password, make_password @@ -23,6 +24,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework_simplejwt.tokens import RefreshToken +from apps.ops.mixins import MyLoggingMixin from utils.pagination import PageOrNot from utils.queryset import get_child_queryset2 @@ -30,6 +32,7 @@ from .filters import UserFilter from .models import (City, Dict, DictType, File, Message, Organization, Permission, Position, Province, Role, User, UserThird) from .permission import RbacPermission, get_permission_list +from rest_framework_simplejwt.views import TokenObtainPairView from .permission_data import RbacFilterSet from .serializers import (CitySerializer, DictSerializer, DictTypeSerializer, FileSerializer, OrganizationSerializer, PermissionSerializer, @@ -71,8 +74,11 @@ def get_tokens_for_user(user): } import datetime +class MyTokenView(MyLoggingMixin, TokenObtainPairView): + def should_log(self, request, response): + return response.status_code == 200 -class Login2View(APIView): +class Login2View(MyLoggingMixin, APIView): """ 邮箱验证码登录 """ @@ -88,6 +94,9 @@ class Login2View(APIView): user = User.objects.get(username=mail) return Response(get_tokens_for_user(user), status=status.HTTP_200_OK) return Response('验证码错误', status=status.HTTP_400_BAD_REQUEST) + + def should_log(self, request, response): + return response.status_code == 200 class sendMsg(APIView): authentication_classes = [] @@ -407,7 +416,7 @@ class UserViewSet(PageOrNot, ModelViewSet): -class WXMPlogin(APIView): +class WXMPlogin(MyLoggingMixin, APIView): authentication_classes=[] permission_classes=[] @@ -428,6 +437,9 @@ class WXMPlogin(APIView): return Response(get_tokens_for_user(user), status=status.HTTP_200_OK) except: raise AuthenticationFailed + + def should_log(self, request, response): + return response.status_code == 200 diff --git a/server/apps/vod/migrations/0006_viewrecord_total_seconds.py b/server/apps/vod/migrations/0006_viewrecord_total_seconds.py new file mode 100644 index 0000000..9183cf7 --- /dev/null +++ b/server/apps/vod/migrations/0006_viewrecord_total_seconds.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2022-10-12 08:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0005_video_sort_str'), + ] + + operations = [ + migrations.AddField( + model_name='viewrecord', + name='total_seconds', + field=models.PositiveIntegerField(default=0, verbose_name='总观看秒数'), + ), + ] diff --git a/server/apps/vod/migrations/0007_auto_20221116_1446.py b/server/apps/vod/migrations/0007_auto_20221116_1446.py new file mode 100644 index 0000000..777c129 --- /dev/null +++ b/server/apps/vod/migrations/0007_auto_20221116_1446.py @@ -0,0 +1,86 @@ +# Generated by Django 3.0.5 on 2022-11-16 06:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vod', '0006_viewrecord_total_seconds'), + ] + + operations = [ + migrations.AlterField( + model_name='video', + name='views', + field=models.IntegerField(default=0, verbose_name='总观看次数'), + ), + migrations.AlterField( + model_name='video', + name='viewsp', + field=models.IntegerField(default=0, verbose_name='总观看人数'), + ), + migrations.AlterField( + model_name='viewrecord', + name='video', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='viewrecord_video', to='vod.Video', verbose_name='点播视频'), + ), + migrations.AlterField( + model_name='viewrecord', + name='views', + field=models.IntegerField(default=0, verbose_name='总观看次数'), + ), + migrations.CreateModel( + name='ViewItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('current', models.IntegerField(default=0, verbose_name='当前观看进度(秒)')), + ('total_seconds', models.PositiveIntegerField(default=0, verbose_name='本次总观看秒数')), + ('create_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='viewitem_user', to=settings.AUTH_USER_MODEL, verbose_name='观看人')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='viewitem_video', to='vod.Video', verbose_name='点播视频')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='View2', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('current', models.IntegerField(default=0, verbose_name='当前观看进度(秒)')), + ('views', models.IntegerField(default=0, verbose_name='总观看次数')), + ('total_seconds', models.PositiveIntegerField(default=0, verbose_name='总观看秒数')), + ('is_completed', models.BooleanField(default=False)), + ('create_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view2_user', to=settings.AUTH_USER_MODEL, verbose_name='观看人')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view2_video', to='vod.Video', verbose_name='点播视频')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='View1', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('views', models.IntegerField(default=0, verbose_name='总观看次数')), + ('viewsp', models.IntegerField(default=0, verbose_name='总观看人数')), + ('video', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='view1_video', to='vod.Video')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/server/apps/vod/migrations/0008_auto_20221116_1453.py b/server/apps/vod/migrations/0008_auto_20221116_1453.py new file mode 100644 index 0000000..3d52d1f --- /dev/null +++ b/server/apps/vod/migrations/0008_auto_20221116_1453.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.5 on 2022-11-16 06:53 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('vod', '0007_auto_20221116_1446'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='view2', + unique_together={('create_by', 'video')}, + ), + ] diff --git a/server/apps/vod/migrations/0009_auto_20221117_0941.py b/server/apps/vod/migrations/0009_auto_20221117_0941.py new file mode 100644 index 0000000..5c726c0 --- /dev/null +++ b/server/apps/vod/migrations/0009_auto_20221117_0941.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.5 on 2022-11-17 01:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0022_delete_historicaldict'), + ('vod', '0008_auto_20221116_1453'), + ] + + operations = [ + migrations.AddField( + model_name='video', + name='category_big', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='video_catebig', to='system.Dict', verbose_name='视频大类'), + ), + migrations.AlterField( + model_name='video', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='video_cate', to='system.Dict', verbose_name='视频分类'), + ), + ] diff --git a/server/apps/vod/models.py b/server/apps/vod/models.py index ba65284..76b5c24 100644 --- a/server/apps/vod/models.py +++ b/server/apps/vod/models.py @@ -1,3 +1,4 @@ +from tabnanny import verbose from django.db import models from utils.model import BaseModel from apps.system.models import User, CommonAModel, Dict @@ -6,14 +7,15 @@ from apps.system.models import User, CommonAModel, Dict class Video(CommonAModel): name = models.CharField(verbose_name='视频名称', max_length=100) - category = models.ForeignKey(Dict, verbose_name='视频分类', on_delete=models.DO_NOTHING) + category_big = models.ForeignKey(Dict, verbose_name='视频大类', on_delete=models.SET_NULL, null=True, blank=True, related_name='video_catebig') + category = models.ForeignKey(Dict, verbose_name='视频分类', on_delete=models.SET_NULL, null=True, blank=True, related_name='video_cate') description = models.TextField(verbose_name='视频描述', default='') fileid = models.CharField(verbose_name='云点播视频id', unique=True, max_length=200) mediaurl = models.CharField(verbose_name='视频地址', max_length=200) coverurl = models.CharField(verbose_name='封面地址', max_length=200) duration = models.IntegerField(verbose_name='时长(秒)', default=0) - views = models.IntegerField(verbose_name='观看次数', default=0) - viewsp = models.IntegerField(verbose_name='观看人数', default=0) + views = models.IntegerField(verbose_name='总观看次数', default=0) + viewsp = models.IntegerField(verbose_name='总观看人数', default=0) sort_str = models.CharField('排序字符', max_length=10, null=True, blank=True) class Meta: @@ -24,15 +26,51 @@ class Video(CommonAModel): class ViewRecord(BaseModel): - - # 观看记录 + """ + 某视频-某人的观看记录统计 + """ user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='观看人', related_name='viewrecord_user') - views = models.IntegerField(verbose_name='观看次数', default=0) current = models.IntegerField(verbose_name='当前观看进度(秒)', default=0) - video = models.ForeignKey(Video, verbose_name='点播视频', on_delete=models.CASCADE, related_name='record_video') - - + video = models.ForeignKey(Video, verbose_name='点播视频', on_delete=models.CASCADE, related_name='viewrecord_video') + views = models.IntegerField(verbose_name='总观看次数', default=0) + total_seconds = models.PositiveIntegerField(verbose_name='总观看秒数', default=0) + class Meta: verbose_name = '点播观看记录' verbose_name_plural = verbose_name + +class ViewItem(BaseModel): + """ + 单次观看记录 + """ + create_by = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='观看人', related_name='viewitem_user') + video = models.ForeignKey(Video, verbose_name='点播视频', on_delete=models.CASCADE, related_name='viewitem_video') + current = models.IntegerField(verbose_name='当前观看进度(秒)', default=0) + total_seconds = models.PositiveIntegerField(verbose_name='本次总观看秒数', default=0) + + +class View1(BaseModel): + """ + 视频播放统计 + """ + video = models.OneToOneField(Video, on_delete=models.CASCADE, related_name='view1_video') + views = models.IntegerField(verbose_name='总观看次数', default=0) + viewsp = models.IntegerField(verbose_name='总观看人数', default=0) + + +class View2(BaseModel): + """ + 某视频-某人的观看记录统计 + """ + create_by = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='观看人', related_name='view2_user') + current = models.IntegerField(verbose_name='当前观看进度(秒)', default=0) + video = models.ForeignKey(Video, verbose_name='点播视频', on_delete=models.CASCADE, related_name='view2_video') + views = models.IntegerField(verbose_name='总观看次数', default=0) + total_seconds = models.PositiveIntegerField(verbose_name='总观看秒数', default=0) + is_completed = models.BooleanField(default=False) + + class Meta: + unique_together = ( + ('create_by','video'), # 联合唯一 + ) \ No newline at end of file diff --git a/server/apps/vod/serializers.py b/server/apps/vod/serializers.py index c9e6703..6c03717 100644 --- a/server/apps/vod/serializers.py +++ b/server/apps/vod/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Video, ViewRecord +from .models import Video, ViewRecord, ViewItem, View1, View2 from apps.system.serializers import UserSimpleSerializer class VideoSerializer(serializers.ModelSerializer): @@ -7,13 +7,34 @@ class VideoSerializer(serializers.ModelSerializer): class Meta: model = Video fields = '__all__' + + def create(self, validated_data): + video = super().create(validated_data) + cate = video.category + video.category_big = cate + if cate.pid: + video.category_big = cate.pid + video.save() + View1.objects.get_or_create(video=video, defaults={'video': video}) + return video class VideoUpdateSerializer(serializers.ModelSerializer): class Meta: model = Video fields = ['name', 'category', 'description', 'sort_str'] + + def update(self, instance, validated_data): + video = super().update(instance, validated_data) + cate = video.category + video.category_big = cate + if cate.pid: + video.category_big = cate.pid + video.save() + return video class VideoListDetailSerializer(serializers.ModelSerializer): + views_n = serializers.IntegerField(source='view1_video.views', read_only=True, label="总观看次数") + viewsp_n = serializers.IntegerField(source='view1_video.viewsp', read_only=True, label="总观看秒数") class Meta: model = Video exclude = ['mediaurl'] @@ -38,4 +59,29 @@ class VRecordSerializer(serializers.ModelSerializer): class VRecordUpdateSerializer(serializers.ModelSerializer): class Meta: model = ViewRecord - fields=['num', 'current'] \ No newline at end of file + fields=['num', 'current'] + + +class ViewItemSerializer(serializers.ModelSerializer): + create_by_ = UserSimpleSerializer(source='create_by', read_only=True) + video_ = VideoSimpleSerializer(source='video', read_only=True) + class Meta: + model = ViewItem + fields = '__all__' + +class ViewItemUpdateSerializer(serializers.Serializer): + current = serializers.IntegerField(min_value=1) + seconds = serializers.IntegerField(min_value=0) + + +class View2Serializer(serializers.Serializer): + create_by_ = UserSimpleSerializer(source='create_by', read_only=True) + video_ = VideoSimpleSerializer(source='video', read_only=True) + class Meta: + model = View2 + fields = '__all__' + +class DatetimeSerializer(serializers.Serializer): + start_time = serializers.DateTimeField(label="开始时间") + end_time = serializers.DateTimeField(label="结束时间") + limit = serializers.IntegerField(min_value=10, label="展示数量") \ No newline at end of file diff --git a/server/apps/vod/urls.py b/server/apps/vod/urls.py index 77a38a5..59eb67e 100644 --- a/server/apps/vod/urls.py +++ b/server/apps/vod/urls.py @@ -1,11 +1,15 @@ from django.db.models import base from django.urls import path, include -from .views import ClassView, PlayCodeAPIView, SignatureAPIView, VideoView, VideoViewSet, VRecordViewSet, MyViewRecordAPIView +from .views import ClassView, PlayCodeAPIView, SignatureAPIView, ViewItemViewSet, VideoViewSet, VRecordViewSet, MyViewRecordAPIView, View2ViewSet +from .views2 import AnalyseViewSet from rest_framework import routers router = routers.DefaultRouter() router.register('video', VideoViewSet, basename="video") router.register('viewrecord', VRecordViewSet, basename='viewrecord') +router.register('viewitem', ViewItemViewSet, basename='viewitem') +router.register('view2', View2ViewSet, basename='view2') +router.register('analyse', AnalyseViewSet, basename='analyse') urlpatterns = [ path('', include(router.urls)), path('video//myview/', MyViewRecordAPIView.as_view()), diff --git a/server/apps/vod/views.py b/server/apps/vod/views.py index f210001..884a9e0 100644 --- a/server/apps/vod/views.py +++ b/server/apps/vod/views.py @@ -1,9 +1,9 @@ from datetime import timedelta from time import timezone from apps.system.models import Dict -from rest_framework.mixins import ListModelMixin -from apps.vod.serializers import VRecordSerializer, VRecordUpdateSerializer, VideoListDetailSerializer, VideoSerializer, VideoUpdateSerializer -from apps.vod.models import Video, ViewRecord +from rest_framework.mixins import ListModelMixin, UpdateModelMixin +from apps.vod.serializers import VRecordSerializer, VRecordUpdateSerializer, VideoListDetailSerializer, VideoSerializer, VideoUpdateSerializer, ViewItemSerializer, ViewItemUpdateSerializer, View2Serializer, DatetimeSerializer +from apps.vod.models import Video, ViewRecord, ViewItem, View1, View2 from django.shortcuts import render from .vodclient import getAllClass, getPlayCode, searchMedia, getSignature from rest_framework.views import APIView @@ -17,6 +17,8 @@ from rest_framework.status import HTTP_400_BAD_REQUEST from django.utils import timezone from rest_framework.exceptions import ParseError from utils.queryset import get_child_queryset2 +from django.db.models import Sum +from django.db import connection # Create your views here. class ClassView(APIView): @@ -69,6 +71,8 @@ class VideoViewSet(PageOrNot, CreateUpdateModelAMixin, ModelViewSet): @action(methods=['get'], detail=False, perms_map={'get':'video_view'}) def myview(self, request, *args, **kwargs): """ + 废弃接口 + 个人观看记录 """ queryset = ViewRecord.objects.filter(user=request.user).order_by('-id') @@ -78,10 +82,72 @@ class VideoViewSet(PageOrNot, CreateUpdateModelAMixin, ModelViewSet): return self.get_paginated_response(serializer.data) serializer = VRecordSerializer(queryset, many=True) return Response(serializer.data) + + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def my(self, request, *args, **kwargs): + """ + 本视频的我的观看统计 + 本视频的我的观看统计 + """ + video = self.get_object() + user = request.user + ins, _ = View2.objects.get_or_create(create_by=user, video=video, defaults={'video': video, 'create_by': user}) + return Response(View2Serializer(instance=ins).data) + + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def start(self, request, *args, **kwargs): + """ + 开始播放 + + 本次播放开始返回记录ID,后续计时使用 + """ + video = self.get_object() + user = request.user + vi = ViewItem.objects.create(create_by=user, video=video) + cal_view1(vi) + return Response({"vi": vi.id}) + + # @action(methods=['get'], detail=False, perms_map={'get':'*'}) + # def correct_cate(self, request, *args, **kwargs): + # for video in Video.objects.get_queryset(all=True).all(): + # cate = video.category + # video.category_big = cate + # if cate.pid: + # video.category_big = cate.pid + # video.save() + # return Response() + +def cal_view1(vi: ViewItem): + """ + 统计视频播放数量 + + 统计视频播放数量 + """ + v1, _ = View1.objects.get_or_create(video=vi.video, defaults={"video": vi.video}) + v2, _ = View2.objects.get_or_create(video=vi.video, create_by=vi.create_by, defaults={"video": vi.video, "create_by": vi.create_by}) + v1.views = ViewItem.objects.filter(video=vi.video).count() + v1.viewp = View2.objects.filter(video=vi.video).count() + v1.save() + +def cal_view2(vi: ViewItem): + """ + 统计个人播放记录 + + 统计个人播放记录 + """ + v2, _ = View2.objects.get_or_create(video=vi.video, create_by=vi.create_by, defaults={"video": vi.video, "create_by": vi.create_by}) + v2.views = ViewItem.objects.filter(video=vi.video).count() + v2.total_seconds = ViewItem.objects.filter(video=vi.video).aggregate(total=Sum('total_seconds'))['total'] + v2.save() class VRecordViewSet(ListModelMixin, GenericViewSet): + """ + 废弃接口 + + 废弃接口 + """ perms_map = {'get':'viewrecord_view'} queryset = ViewRecord.objects.all() search_fields = ['user__name', 'video__name'] @@ -89,24 +155,111 @@ class VRecordViewSet(ListModelMixin, GenericViewSet): ordering = ['-update_time'] +class ViewItemViewSet(ListModelMixin, UpdateModelMixin, GenericViewSet): + perms_map = {'get': 'viewitem_view', 'put': '*'} + queryset = ViewItem.objects.select_related('create_by', 'video').all() + search_fields = ['create_by__name', 'video__name'] + serializer_class = ViewItemSerializer + ordering = ['-id'] + + def get_serializer_class(self): + if self.action == "update": + return ViewItemUpdateSerializer + return super().get_serializer_class() + + @action(methods=['get'], detail=False, perms_map={'get':'*'}) + def my(self, request, *args, **kwargs): + """ + 我的观看记录 + + 我的观看记录 + """ + queryset = ViewItem.objects.filter(create_by=request.user).order_by('-id') + page = self.paginate_queryset(queryset) + if page is not None: + serializer = ViewItemSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = ViewItemSerializer(queryset, many=True) + return Response(serializer.data) + + def update(self, request, *args, **kwargs): + user = request.user + data = request.data + obj = self.get_object() + if obj.create_by != user: + raise ParseError('非创建人') + if ViewItem.objects.filter(video=obj.video, create_by=user).order_by('-id').first() != obj: + raise ParseError('存在新播放记录') + obj.current = data['current'] + obj.total_seconds = obj.total_seconds + data['seconds'] + obj.save() + cal_view2(obj) + return Response({'id': obj.id, 'current': obj.current, 'total_seconds': obj.total_seconds}) + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def complete(self, request, *args, **kwargs): + """ + 完成播放 + + 完成播放 + """ + obj = self.get_object() + obj.current = 0 + v2 = View2.objects.get(video=obj.video, create_by=obj.create_by) + if v2.total_seconds >= obj.video.duration * 0.6: + v2.is_completed = True + v2.save() + return Response() + raise ParseError('观看时长不足') + +class View2ViewSet(ListModelMixin, GenericViewSet): + perms_map = {'get': 'view2_view', 'put': '*'} + queryset = View2.objects.select_related('create_by', 'video').all() + search_fields = ['create_by__name', 'video__name'] + serializer_class = View2Serializer + ordering = ['-id'] + + @action(methods=['get'], detail=False, perms_map={'get':'*'}) + def my(self, request, *args, **kwargs): + """ + 我的观看统计 + + 我的观看统计 + """ + queryset = View2.objects.filter(create_by=request.user).order_by('-id') + page = self.paginate_queryset(queryset) + if page is not None: + serializer = View2Serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = View2Serializer(queryset, many=True) + return Response(serializer.data) + + class MyViewRecordAPIView(APIView): permission_classes = [IsAuthenticated] def get(self, request, id , format=None): """ + 废弃接口 + 该视频的本人观看信息 """ try: video = Video.objects.get(pk=id) except: return Response('视频不存在', status=HTTP_400_BAD_REQUEST) - record, _ = ViewRecord.objects.get_or_create(video=video, user=request.user, defaults={'video':video, 'user':request.user}) + record = ViewRecord.objects.filter(video=video, user=request.user).first() + if record: + pass + else: + record = ViewRecord.objects.create(video=video, user=request.user) serializer = VRecordSerializer(instance=record) return Response(serializer.data) def put(self, request, id, format=None): """ + 废弃接口 + 更新该视频本人的观看信息 params: {current:int} """ @@ -129,10 +282,13 @@ class MyViewRecordAPIView(APIView): video.save() if request.data.get('current', None): record.current = request.data.get('current') - if timezone.now() > record.update_time + timedelta(hours=6): + if timezone.now() > record.update_time + timedelta(minutes=30): record.views = record.views + 1 video.views = video.views + 1 video.save() + else: + record.total_seconds = record.total_seconds + 10 + record.save() record.save() return Response() diff --git a/server/apps/vod/views2.py b/server/apps/vod/views2.py new file mode 100644 index 0000000..208b268 --- /dev/null +++ b/server/apps/vod/views2.py @@ -0,0 +1,93 @@ +from rest_framework.viewsets import GenericViewSet +from apps.vod.serializers import DatetimeSerializer +from rest_framework.decorators import action +from rest_framework.response import Response +from utils.sql import query_all_dict + +class AnalyseViewSet(GenericViewSet): + perms_map = {'post': '*'} + serializer_class = DatetimeSerializer + + def is_valid(self, request): + data = request.data + sr = self.get_serializer(data=data) + sr.is_valid(raise_exception=True) + vdata = sr.validated_data + return vdata + + @action(methods=['post'], detail=False) + def group_by_video_category_big(self, request): + """ + 视频大类播放量统计 + + 视频大类播放量统计 + """ + vdata = self.is_valid(request) + sql_str = """select + d.name as "视频大类", + count(v.id) as "视频数量", + count(vi.id) as "观看总次数", + count(distinct vi.create_by_id) as "观看总人数" + from vod_video v + left join system_dict d on d.id = v.category_big_id + left join vod_viewitem vi on vi.video_id = v.id + and vi.create_time >= %s + and vi.create_time <= %s + group by d.id + order by "视频数量" desc, d.sort + limit %s + """ + return Response(query_all_dict(sql_str, [vdata['start_time'], vdata['end_time'], vdata['limit']])) + + @action(methods=['post'], detail=False) + def group_by_user_view(self, request): + """ + 个人观看量统计 + + 个人观看量统计 + """ + vdata = self.is_valid(request) + sql_str = """select + u.name as "姓名", + u.username as "账号", + count(v2.is_completed is true) as "观看完成视频总数", + count(distinct vi.video_id) as "观看视频总数", + count(vi.id) as "观看总次数" , + sum(vi.total_seconds/60) as "观看总时间" + from vod_viewitem vi + left join vod_video v on v.id = vi.video_id + left join system_user u on u.id = vi.create_by_id + left join vod_view2 v2 on v2.create_by_id = vi.create_by_id and v2.video_id = vi.video_id + where vi.create_time >= %s and vi.create_time <= %s + group by u.id + order by "观看完成视频总数" desc, "账号" + limit %s + """ + return Response(query_all_dict(sql_str, [vdata['start_time'], vdata['end_time'], vdata['limit']])) + + + @action(methods=['post'], detail=False) + def group_by_org_view(self, request): + """ + 单位观看量统计 + + 单位观看量统计 + """ + vdata = self.is_valid(request) + sql_str = """select + o.name as "单位名称", + count(v2.is_completed is true) as "观看完成视频总数", + count(distinct vi.video_id) as "观看视频总数", + count(vi.id) as "观看总次数" , + sum(vi.total_seconds/60) as "观看总时间" + from vod_viewitem vi + left join vod_video v on v.id = vi.video_id + left join system_user u on u.id = vi.create_by_id + left join system_organization o on o.id = u.dept_id + left join vod_view2 v2 on v2.create_by_id = vi.create_by_id and v2.video_id = vi.video_id + where vi.create_time >= %s and vi.create_time <= %s + group by o.id + order by "观看完成视频总数" desc, o.sort + limit %s + """ + return Response(query_all_dict(sql_str, [vdata['start_time'], vdata['end_time'], vdata['limit']])) \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index 5a59d6b..77fa7d5 100644 Binary files a/server/requirements.txt and b/server/requirements.txt differ diff --git a/server/server/settings.py b/server/server/settings.py index 36c0ac0..b7396ec 100644 --- a/server/server/settings.py +++ b/server/server/settings.py @@ -48,7 +48,9 @@ INSTALLED_APPS = [ 'apps.supervision', 'apps.quality', 'apps.vod', - 'apps.consulting' + 'apps.consulting', + 'apps.exam', + 'apps.ops' ] @@ -167,8 +169,8 @@ REST_FRAMEWORK = { 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', 'DATE_FORMAT': '%Y-%m-%d', 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', - 'UNAUTHENTICATED_USER': None, - 'UNAUTHENTICATED_TOKEN': None, + # 'UNAUTHENTICATED_USER': None, + # 'UNAUTHENTICATED_TOKEN': None, } # simplejwt配置 SIMPLE_JWT = { diff --git a/server/server/settings_pro.py b/server/server/settings_pro.py deleted file mode 100644 index 2e98f3d..0000000 --- a/server/server/settings_pro.py +++ /dev/null @@ -1,8 +0,0 @@ -from .settings import * -DEBUG = False -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} diff --git a/server/server/urls.py b/server/server/urls.py index 7a4d726..b4b8c41 100644 --- a/server/server/urls.py +++ b/server/server/urls.py @@ -19,17 +19,13 @@ from django.contrib import admin from django.urls import include, path from rest_framework import routers from rest_framework.documentation import include_docs_urls -from rest_framework_simplejwt.views import (TokenObtainPairView, - TokenRefreshView) +from rest_framework_simplejwt.views import TokenRefreshView -from apps.system.views import FileViewSet, LogoutView, Login2View +from apps.system.views import FileViewSet, LogoutView, Login2View, MyTokenView from django.views.generic.base import TemplateView router = routers.DefaultRouter() router.register('file', FileViewSet, basename="file") from django.conf.urls import url - -from rest_framework_simplejwt.serializers import TokenObtainPairSerializer -from rest_framework_simplejwt.views import TokenViewBase from apps.system.views import WXMPlogin,mediaauth from drf_yasg import openapi from drf_yasg.views import get_schema_view @@ -54,7 +50,7 @@ urlpatterns = [ path('api/admin/', admin.site.urls), path('api/mediaauth/',mediaauth), path('api/wxmplogin/',WXMPlogin.as_view()), - path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/', MyTokenView.as_view(), name='token_obtain_pair'), path('api/token2/', Login2View.as_view(), name='token_obtain_2'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/token/black/', LogoutView.as_view(), name='token_black'), @@ -64,6 +60,8 @@ urlpatterns = [ path('api/quality/', include('apps.quality.urls')), path('api/vod/', include('apps.vod.urls')), path('api/consulting/', include('apps.consulting.urls')), + path('', include('apps.ops.urls')), + path('', include('apps.exam.urls')), path('api/docs/', include_docs_urls(title="接口文档",authentication_classes=[], permission_classes=[])), url(r'^api/swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), diff --git a/server/utils/model.py b/server/utils/model.py index f8ded06..ff59a73 100644 --- a/server/utils/model.py +++ b/server/utils/model.py @@ -33,14 +33,15 @@ class SoftDeletableManagerMixin(object): ''' _queryset_class = SoftDeletableQuerySet - def get_queryset(self): + def get_queryset(self, all=False): ''' Return queryset limited to not deleted entries. ''' kwargs = {'model': self.model, 'using': self._db} if hasattr(self, '_hints'): kwargs['hints'] = self._hints - + if all: + return self._queryset_class(**kwargs) return self._queryset_class(**kwargs).filter(is_deleted=False) @@ -49,6 +50,9 @@ class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager): class BaseModel(models.Model): + """ + 自增ID,带有create_time, update_time, is_deleted + """ create_time = models.DateTimeField( default=timezone.now, verbose_name='创建时间', help_text='创建时间') update_time = models.DateTimeField( diff --git a/server/utils/pagination.py b/server/utils/pagination.py index e0fe3ea..68f60d6 100644 --- a/server/utils/pagination.py +++ b/server/utils/pagination.py @@ -6,12 +6,12 @@ class MyPagination(PageNumberPagination): page_size = 10 page_size_query_param = 'page_size' + def paginate_queryset(self, queryset, request, view=None): + if request.query_params.get('pageoff', None) or request.query_params.get('page', None) == '0': + if queryset.count() < 500: + return None + raise ParseError('单次请求数据量大,请分页获取') + return super().paginate_queryset(queryset, request, view=view) + class PageOrNot: - def paginate_queryset(self, queryset): - if (self.paginator is None): - return None - elif (self.request.query_params.get('pageoff', None)) and queryset.count()<500: - return None - elif (self.request.query_params.get('pageoff', None)) and queryset.count()>=500: - raise ParseError('单次请求数据量大,请求中止') - return self.paginator.paginate_queryset(queryset, self.request, view=self) \ No newline at end of file + pass \ No newline at end of file diff --git a/server/utils/sql.py b/server/utils/sql.py new file mode 100644 index 0000000..af94efd --- /dev/null +++ b/server/utils/sql.py @@ -0,0 +1,32 @@ +from django.db import connection + +def query_all_dict(sql, params=None): + ''' + 查询所有结果返回字典类型数据 + :param sql: + :param params: + :return: + ''' + with connection.cursor() as cursor: + if params: + cursor.execute(sql, params=params) + else: + cursor.execute(sql) + columns = [desc[0] for desc in cursor.description] + return [dict(zip(columns, row)) for row in cursor.fetchall()] + +def query_one_dict(sql, params=None): + """ + 查询一个结果返回字典类型数据 + :param sql: + :param params: + :return: + """ + with connection.cursor() as cursor: + if params: + cursor.execute(sql, params=params) + else: + cursor.execute(sql) + columns = [desc[0] for desc in cursor.description] + row = cursor.fetchone() + return dict(zip(columns, row)) \ No newline at end of file