From 9d07705d213e877e9e696c383326709f990cc097 Mon Sep 17 00:00:00 2001 From: John Hsu Date: Tue, 21 Jun 2022 15:54:35 -0700 Subject: [PATCH 01/22] FIX lti launch to assignment not working There's two causes for this: - assignment needs to be allowed as a parameter - Canvas doesn't include the url query part when generating signatures Looks like one of the updates we pulled for the lti library added parameter verification and we need to add assignment to the allowed list. Assignment uid is specified as a query parameter after the launch url. The lti library takes the entire launch url, including this assignment parameter, when validating the signature. However, it looks like Canvas only uses the original launch url (without the assignment uid query) to generate the signature. So as a quick fix, we'll pass in only the url without queries to the signature validator. This fix wouldn't work if our original launch url contains other queries, but afaik, ComPAIR shouldn't encounter this issue. --- lti/launch_params.py | 3 ++- lti/tool_provider.py | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lti/launch_params.py b/lti/launch_params.py index a55ef2dc4..65509d54f 100644 --- a/lti/launch_params.py +++ b/lti/launch_params.py @@ -88,7 +88,8 @@ def touni(s, enc='utf8', err='strict'): LAUNCH_PARAMS_CANVAS = [ 'selection_directive', - 'text' + 'text', + 'assignment' ] CONTENT_PARAMS_REQUEST = [ diff --git a/lti/tool_provider.py b/lti/tool_provider.py index 4eaf48036..b7b9f4bc4 100644 --- a/lti/tool_provider.py +++ b/lti/tool_provider.py @@ -51,8 +51,16 @@ def is_valid_request(self, validator): validator = ProxyValidator(validator) endpoint = SignatureOnlyEndpoint(validator) + # hack to fix lti launch to assignment not working + # as far as I can figure, looks like Canvas doesn't use the modified + # url with the assignment query as part of the signature, so we need + # to use the url without the assignment query for signature validation + launchUrlParts = urlsplit(self.launch_url) + validateUrl = launchUrlParts.scheme + '://' + launchUrlParts.netloc + \ + launchUrlParts.path + valid, request = endpoint.validate_request( - self.launch_url, + validateUrl, 'POST', self.to_params(), self.launch_headers From ed8580fc735523e203ad8b2593ee7377cbb6a145 Mon Sep 17 00:00:00 2001 From: TueHoang Date: Wed, 22 Jun 2022 09:18:35 -0700 Subject: [PATCH 02/22] PlanReleaseDate updated with Course Name --- compair/api/assignment_search_enddate.py | 7 +------ compair/static/script-assignment-search.js | 3 ++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/compair/api/assignment_search_enddate.py b/compair/api/assignment_search_enddate.py index 48c4f9509..e571533d4 100644 --- a/compair/api/assignment_search_enddate.py +++ b/compair/api/assignment_search_enddate.py @@ -60,13 +60,8 @@ def get(self): db_url = str(current_app.config['SQLALCHEMY_DATABASE_URI']) engine = create_engine(db_url, pool_size=5, pool_recycle=3600) conn = engine.connect() - ##sql_text = str("SELECT JSON_OBJECT('uuid', uuid,'name', name,'compare_start', compare_start, 'compare_end', compare_end) FROM assignment;"); - ##sql_text = str("SELECT JSON_OBJECT('uuid', uuid,'name', name,'compare_start', compare_start, 'compare_end', compare_end) FROM assignment WHERE compare_end >= '" + end_date + "';"); - ##sql_text = str("SELECT JSON_OBJECT('uuid', uuid,'name', name,'answer_start', answer_start,'answer_end', answer_end,'compare_start', compare_start, 'compare_end', compare_end) FROM assignment WHERE compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "';"); - sql_text = str("SELECT JSON_OBJECT('uuid', uuid,'name', name,'answer_start', date_format(answer_start, '%%M %%d, %%Y'),'answer_end', date_format(answer_end, '%%M %%d, %%Y'),'compare_start', date_format(compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(compare_end, '%%M %%d, %%Y')) FROM assignment WHERE compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "';"); - + sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "');"); ##print(sql_text) - result = conn.execute(sql_text) final_result = [list(i) for i in result] diff --git a/compair/static/script-assignment-search.js b/compair/static/script-assignment-search.js index 8b06fd6a8..7e66747c4 100644 --- a/compair/static/script-assignment-search.js +++ b/compair/static/script-assignment-search.js @@ -38,6 +38,7 @@ function hideloadersearch() { function showsearchapi(search_data) { let tab = ` + Course Name Assignment Name Answering Begins Answering Ends @@ -50,7 +51,7 @@ function showsearchapi(search_data) { for (let key in search_data) { //tab += `${search_data[key]}`; let obj = JSON.parse(search_data[key]) - tab += `${JSON.stringify(obj.name).replace(/\"/g, "")}${JSON.stringify(obj.answer_start).replace(/\"/g, "")}${JSON.stringify(obj.answer_end).replace(/\"/g, "")}${JSON.stringify(obj.compare_start).replace(/\"/g, "")}${JSON.stringify(obj.compare_end).replace(/\"/g, "")}`; + tab += `${JSON.stringify(obj.course_name).replace(/\"/g, "")}${JSON.stringify(obj.name).replace(/\"/g, "")}${JSON.stringify(obj.answer_start).replace(/\"/g, "")}${JSON.stringify(obj.answer_end).replace(/\"/g, "")}${JSON.stringify(obj.compare_start).replace(/\"/g, "")}${JSON.stringify(obj.compare_end).replace(/\"/g, "")}`; iKey++; } From fb407c1d7dcaf3d7e8c25c70cde461af5aeb538a Mon Sep 17 00:00:00 2001 From: John Hsu Date: Wed, 22 Jun 2022 16:55:55 -0700 Subject: [PATCH 03/22] EDIT dl all only students, name as answer author Changes to the "Download All" attachments button in the "Participation" tab of an assignment. Instructor submitted answers were being included as part of this. However, it seems that the "Participation" tab only lists student answers. It's probably better to be consistent with the "Participation" tab and also only include student answer attachments. As such, I've modified the API so that it's downloading student answer attachments only. I didn't know that instructors can submit answers on a student's behalf. This caused an interesting issue where the file is linked to the instructor but the answer is linked to the student. Since the file name comes from the file user, the file gets the instructor's name. This is different from the UI, where the answer user is used. It makes more sense to use the answer user like the UI, so I've switched it to use the answer user in the filename. Tests were updated. Interestingly, the tests actually assumes usage of the answer user, so we didn't have to do much there. --- compair/api/assignment_attachment.py | 39 ++++++--- .../modules/gradebook/gradebook-module.js | 2 +- .../tests/api/test_assignment_attachment.py | 85 +++++++++++-------- 3 files changed, 77 insertions(+), 49 deletions(-) diff --git a/compair/api/assignment_attachment.py b/compair/api/assignment_attachment.py index 51bc209d3..80b0e791b 100644 --- a/compair/api/assignment_attachment.py +++ b/compair/api/assignment_attachment.py @@ -23,9 +23,13 @@ # differs completely from that used by file.py, I've had to split it out. -# given an assignment, download all attachments in that assignment. -# /api/courses//assignments//attachments/download -class DownloadAllAttachmentsAPI(Resource): +# given an assignment, download all student attachments in that assignment. +# This is restricted to only student answers to match the behaviour of the +# "Participation" tab in the UI, where it only lists students. +# /api/courses//assignments//attachments/download_students +class DownloadAllStudentAttachmentsAPI(Resource): + DELIM = ' - ' + @login_required def get(self, course_uuid, assignment_uuid): # course unused, but we need to call it to check if it's a valid course @@ -38,13 +42,21 @@ def get(self, course_uuid, assignment_uuid): message="Sorry, your system role does not allow downloading all attachments") # grab answers so we can see how many has files - answers = self.getAnswersByAssignment(assignment) + answers = self.getStudentAnswersByAssignment(assignment) fileIds = [] + fileAuthors = {} for answer in answers: if not answer.file_id: continue # answer has an attachment fileIds.append(answer.file_id) + # the user who uploaded the file can be different from the answer + # author (e.g. instructor can upload on behalf of student), so + # we need to use the answer author instead of file uploader + author = answer.user_fullname + if answer.user_student_number: + author += self.DELIM + answer.user_student_number + fileAuthors[answer.file_id] = author if not fileIds: return {'msg': 'Assignment has no attachments'} @@ -64,13 +76,9 @@ def get(self, course_uuid, assignment_uuid): current_app.config['ATTACHMENT_UPLOAD_FOLDER'], srcFile.name ) - # set filename to 'full name - student number - uuid.ext' - # omit student number or extension if not exist - delim = ' - ' - srcFileName = srcFile.user.fullname - if srcFile.user.student_number: - srcFileName += delim + srcFile.user.student_number - srcFileName += delim + srcFile.name + # filename should be 'full name - student number - uuid.ext' + # student number is omitted if user doesn't have one + srcFileName = fileAuthors[srcFile.id] + self.DELIM + srcFile.name #current_app.logger.debug("writing " + srcFileName) zipFile.write(srcFilePath, srcFileName) #current_app.logger.debug("Writing zip file") @@ -79,7 +87,7 @@ def get(self, course_uuid, assignment_uuid): # this really should be abstracted out into the Answer model, but I wasn't # able to get the join with UserCourse to work inside the Answer model. - def getAnswersByAssignment(self, assignment): + def getStudentAnswersByAssignment(self, assignment): return Answer.query \ .outerjoin(UserCourse, and_( Answer.user_id == UserCourse.user_id, @@ -91,7 +99,10 @@ def getAnswersByAssignment(self, assignment): Answer.practice == False, Answer.draft == False, or_( - and_(UserCourse.course_role != CourseRole.dropped, Answer.user_id != None), + and_( + UserCourse.course_role == CourseRole.student, + Answer.user_id != None + ), Answer.group_id != None ) )) \ @@ -102,4 +113,4 @@ def getFilesByIds(self, fileIds): filter(File.id.in_(fileIds)).all() -api.add_resource(DownloadAllAttachmentsAPI, '/download') +api.add_resource(DownloadAllStudentAttachmentsAPI, '/download_students') diff --git a/compair/static/modules/gradebook/gradebook-module.js b/compair/static/modules/gradebook/gradebook-module.js index 7dcdf2813..e1fd9d1ea 100644 --- a/compair/static/modules/gradebook/gradebook-module.js +++ b/compair/static/modules/gradebook/gradebook-module.js @@ -31,7 +31,7 @@ module.factory( ['$resource', function($resource) { - var ret = $resource('/api/courses/:courseId/assignments/:assignmentId/attachments/download'); + var ret = $resource('/api/courses/:courseId/assignments/:assignmentId/attachments/download_students'); return ret; } ]); diff --git a/compair/tests/api/test_assignment_attachment.py b/compair/tests/api/test_assignment_attachment.py index 3c9d117a8..230bc25ea 100644 --- a/compair/tests/api/test_assignment_attachment.py +++ b/compair/tests/api/test_assignment_attachment.py @@ -37,14 +37,24 @@ def _getUrl(self, courseUuid=None, assignmentUuid=None): if assignmentUuid is None: assignmentUuid = self.fixtures.assignment.uuid return "/api/courses/" + courseUuid + "/assignments/" + assignmentUuid \ - + "/attachments/download" + + "/attachments/download_students" # we need to create actual files - def _createAttachmentsInAssignment(self, assignment): + # isFileUploadedByInstructor simulates when instructors submit answers on + # behalf of students, the file would be owned by the instructor instead of + # the student + def _createAttachmentsInAssignment( + self, + assignment, + isFileUploadedByInstructor=False + ): uploadDir = current_app.config['ATTACHMENT_UPLOAD_FOLDER'] for answer in assignment.answers: - attachFile = self.fixtures.add_file(answer.user) + uploader = answer.user + if isFileUploadedByInstructor: + uploader = self.fixtures.instructor + attachFile = self.fixtures.add_file(uploader) attachFilename = attachFile.uuid + '.png' attachFile.name = attachFilename attachFile.uuid = attachFile.uuid @@ -58,35 +68,7 @@ def _createAttachmentsInAssignment(self, assignment): db.session.add(answer) db.session.commit() - def test_download_all_attachments_block_unauthorized_users(self): - # test login required - rv = self.client.get(self._getUrl()) - self.assert401(rv) - - # test unauthorized user - with self.login(self.fixtures.unauthorized_instructor.username): - rv = self.client.get(self._getUrl()) - self.assert403(rv) - - def test_download_all_attachments_require_valid_course_and_assignment(self): - with self.login(self.fixtures.instructor.username): - # invalid course - url = self._getUrl("invalidUuid") - rv = self.client.get(url) - self.assert404(rv) - # invalid assignment - url = self._getUrl(None, "invalidUuid") - rv = self.client.get(url) - self.assert404(rv) - - def test_download_all_attachments_return_msg_if_no_attachments(self): - with self.login(self.fixtures.instructor.username): - rv = self.client.get(self._getUrl()) - self.assert200(rv) - self.assertEqual('Assignment has no attachments', rv.json['msg']) - - def test_download_all_attachments(self): - self._createAttachmentsInAssignment(self.fixtures.assignment) + def _downloadAndCheckFiles(self): with self.login(self.fixtures.instructor.username): rv = self.client.get(self._getUrl()) self.assert200(rv) @@ -110,8 +92,8 @@ def test_download_all_attachments(self): # we don't include inactive, draft, or practice answers if not answer.active or answer.draft or answer.practice: continue - # we don't include dropped students - if answer.user in self.fixtures.dropped_students: + # we only want current active students + if answer.user not in self.fixtures.students: continue expectedAttachment = '{} - {} - {}'.format( answer.user.fullname, @@ -120,3 +102,38 @@ def test_download_all_attachments(self): ) self.assertTrue(expectedAttachment in actualAttachments) + def test_download_all_attachments_block_unauthorized_users(self): + # test login required + rv = self.client.get(self._getUrl()) + self.assert401(rv) + + # test unauthorized user + with self.login(self.fixtures.unauthorized_instructor.username): + rv = self.client.get(self._getUrl()) + self.assert403(rv) + + def test_download_all_attachments_require_valid_course_and_assignment(self): + with self.login(self.fixtures.instructor.username): + # invalid course + url = self._getUrl("invalidUuid") + rv = self.client.get(url) + self.assert404(rv) + # invalid assignment + url = self._getUrl(None, "invalidUuid") + rv = self.client.get(url) + self.assert404(rv) + + def test_download_all_attachments_return_msg_if_no_attachments(self): + with self.login(self.fixtures.instructor.username): + rv = self.client.get(self._getUrl()) + self.assert200(rv) + self.assertEqual('Assignment has no attachments', rv.json['msg']) + + def test_download_all_attachments(self): + self._createAttachmentsInAssignment(self.fixtures.assignment) + self._downloadAndCheckFiles() + + def test_files_named_for_answer_author_not_uploader(self): + self._createAttachmentsInAssignment(self.fixtures.assignment, True) + self._downloadAndCheckFiles() + From 35552a8e3f6d65e16b02a5959d5e03861840f879 Mon Sep 17 00:00:00 2001 From: TueHoang Date: Thu, 23 Jun 2022 09:35:09 -0700 Subject: [PATCH 04/22] UPDATE to Plan Release Date --- compair/api/assignment_search_enddate.py | 4 ++-- compair/static/script-assignment-search.js | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/compair/api/assignment_search_enddate.py b/compair/api/assignment_search_enddate.py index e571533d4..94da5412d 100644 --- a/compair/api/assignment_search_enddate.py +++ b/compair/api/assignment_search_enddate.py @@ -60,8 +60,8 @@ def get(self): db_url = str(current_app.config['SQLALCHEMY_DATABASE_URI']) engine = create_engine(db_url, pool_size=5, pool_recycle=3600) conn = engine.connect() - sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "');"); - ##print(sql_text) + + sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=1) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "');"); result = conn.execute(sql_text) final_result = [list(i) for i in result] diff --git a/compair/static/script-assignment-search.js b/compair/static/script-assignment-search.js index 7e66747c4..7a6ba322d 100644 --- a/compair/static/script-assignment-search.js +++ b/compair/static/script-assignment-search.js @@ -51,6 +51,17 @@ function showsearchapi(search_data) { for (let key in search_data) { //tab += `${search_data[key]}`; let obj = JSON.parse(search_data[key]) + + obj.compare_start = null; + if (obj.compare_start == null){ + obj.compare_start = 'After answering ends'; + } + + obj.compare_end = null; + if (obj.compare_end == null){ + obj.compare_end = 'No end date'; + } + tab += `${JSON.stringify(obj.course_name).replace(/\"/g, "")}${JSON.stringify(obj.name).replace(/\"/g, "")}${JSON.stringify(obj.answer_start).replace(/\"/g, "")}${JSON.stringify(obj.answer_end).replace(/\"/g, "")}${JSON.stringify(obj.compare_start).replace(/\"/g, "")}${JSON.stringify(obj.compare_end).replace(/\"/g, "")}`; iKey++; } From e3b49c05f773c7e3e709a4377d9a70aed6e3f384 Mon Sep 17 00:00:00 2001 From: TueHoang Date: Thu, 23 Jun 2022 09:43:42 -0700 Subject: [PATCH 05/22] UPDATE to Plan Release Date --- compair/static/script-assignment-search.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compair/static/script-assignment-search.js b/compair/static/script-assignment-search.js index 7a6ba322d..2c0c18396 100644 --- a/compair/static/script-assignment-search.js +++ b/compair/static/script-assignment-search.js @@ -51,13 +51,11 @@ function showsearchapi(search_data) { for (let key in search_data) { //tab += `${search_data[key]}`; let obj = JSON.parse(search_data[key]) - - obj.compare_start = null; + if (obj.compare_start == null){ obj.compare_start = 'After answering ends'; } - obj.compare_end = null; if (obj.compare_end == null){ obj.compare_end = 'No end date'; } From 180402cf033637f8480fa9ff123d021f3c186743 Mon Sep 17 00:00:00 2001 From: TueHoang Date: Fri, 24 Jun 2022 13:51:22 -0700 Subject: [PATCH 06/22] Fixed PlanReleaseDate local to UTC conversion --- compair/api/assignment.py | 1 + compair/api/assignment_search_enddate.py | 22 +++++++++++++++++++--- compair/static/script-assignment-search.js | 13 ++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/compair/api/assignment.py b/compair/api/assignment.py index 40d375694..2aa93a0f2 100644 --- a/compair/api/assignment.py +++ b/compair/api/assignment.py @@ -36,6 +36,7 @@ def non_blank_text(value): new_assignment_parser.add_argument('answer_end', required=True, nullable=False) new_assignment_parser.add_argument('compare_start', default=None) new_assignment_parser.add_argument('compare_end', default=None) +new_assignment_parser.add_argument('compare_localTimeZone', default='UTC') new_assignment_parser.add_argument('self_eval_start', default=None) new_assignment_parser.add_argument('self_eval_end', default=None) new_assignment_parser.add_argument('self_eval_instructions', type=non_blank_text, default=None) diff --git a/compair/api/assignment_search_enddate.py b/compair/api/assignment_search_enddate.py index 94da5412d..eec619628 100644 --- a/compair/api/assignment_search_enddate.py +++ b/compair/api/assignment_search_enddate.py @@ -4,6 +4,8 @@ from sqlalchemy import create_engine import json from flask import jsonify +import pytz + from bouncer.constants import READ, EDIT, CREATE, DELETE, MANAGE from flask import Blueprint, current_app @@ -46,14 +48,27 @@ def get(self): search_date_assignment_parser = RequestParser() search_date_assignment_parser.add_argument('compare_start', default=datetime.now().strftime("%Y-%m-%d")) search_date_assignment_parser.add_argument('compare_end', default=datetime.now().strftime("%Y-%m-%d")) + search_date_assignment_parser.add_argument('compare_localTimeZone', default='UTC') + args = search_date_assignment_parser.parse_args() - end_date = datetime.now().strftime("%Y-%m-%d") + end_date = datetime.now().strftime("%Y-%m-%d 00:00:00") start_date = datetime.now().strftime("%Y-%m-%d") + compare_localTimeZone = 'UTC' + if (args['compare_localTimeZone']): + compare_localTimeZone = str(args['compare_localTimeZone']) if validate(args['compare_end']): - end_date = str(args['compare_end']) + end_date = str(args['compare_end']) + ' 00:00:00' + + ##convert this to UTC + local = pytz.timezone(compare_localTimeZone) + naive = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S") + local_dt = local.localize(naive, is_dst=None) + utc_dt = local_dt.astimezone(pytz.utc) + end_date = str(utc_dt) + if validate(args['compare_start']): start_date = str(args['compare_start']) @@ -61,7 +76,8 @@ def get(self): engine = create_engine(db_url, pool_size=5, pool_recycle=3600) conn = engine.connect() - sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=1) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "');"); + sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y'), 'self_eval_end', date_format(t2.self_eval_end, '%%M %%d, %%Y'), 'self_eval_start', date_format(t2.self_eval_start, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=TRUE) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "' OR self_eval_end >= '" + end_date + "');"); + result = conn.execute(sql_text) final_result = [list(i) for i in result] diff --git a/compair/static/script-assignment-search.js b/compair/static/script-assignment-search.js index 2c0c18396..24b25be9d 100644 --- a/compair/static/script-assignment-search.js +++ b/compair/static/script-assignment-search.js @@ -3,6 +3,10 @@ let api_url = "/api/assignment/search/enddate"; const options = { year: 'numeric', month: 'short', day: 'numeric' }; var searchDay = new Date().toLocaleDateString('en-us', options); +const d = new Date(); +let diff = d.getTimezoneOffset(); +let localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + function formatDate(date) { var d = (new Date(date.toString().replace(/-/g, '\/')) ); return d.toLocaleDateString('en-ca', options); @@ -11,7 +15,9 @@ function formatDate(date) { function getObjectDate(object) { searchDay = formatDate(object); - strURL = api_url.concat('?compare_end=').concat(object); + strURL = api_url.concat('?compare_end=').concat(object).concat('&compare_localTimeZone=').concat(localTimeZone.toString()); + + console.log(localTimeZone); getsearchapi(strURL); } @@ -51,7 +57,7 @@ function showsearchapi(search_data) { for (let key in search_data) { //tab += `${search_data[key]}`; let obj = JSON.parse(search_data[key]) - + if (obj.compare_start == null){ obj.compare_start = 'After answering ends'; } @@ -59,7 +65,8 @@ function showsearchapi(search_data) { if (obj.compare_end == null){ obj.compare_end = 'No end date'; } - + //FOR NEXT RELEASE 2 DISPLAY SELF_EVAL_DATES + //tab += `${JSON.stringify(obj.course_name).replace(/\"/g, "")}${JSON.stringify(obj.name).replace(/\"/g, "")}${JSON.stringify(obj.answer_start).replace(/\"/g, "")}${JSON.stringify(obj.answer_end).replace(/\"/g, "")}${JSON.stringify(obj.compare_start).replace(/\"/g, "")}${JSON.stringify(obj.compare_end).replace(/\"/g, "")}${JSON.stringify(obj.self_eval_end).replace(/\"/g, "")}`; tab += `${JSON.stringify(obj.course_name).replace(/\"/g, "")}${JSON.stringify(obj.name).replace(/\"/g, "")}${JSON.stringify(obj.answer_start).replace(/\"/g, "")}${JSON.stringify(obj.answer_end).replace(/\"/g, "")}${JSON.stringify(obj.compare_start).replace(/\"/g, "")}${JSON.stringify(obj.compare_end).replace(/\"/g, "")}`; iKey++; } From 28b336560a05c2d6feb9959f4a7cd738f2ccc0ca Mon Sep 17 00:00:00 2001 From: TueHoang Date: Mon, 27 Jun 2022 13:05:18 -0700 Subject: [PATCH 07/22] Update query search for couse and assignment are active. --- compair/api/assignment_search_enddate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compair/api/assignment_search_enddate.py b/compair/api/assignment_search_enddate.py index eec619628..4c3222b16 100644 --- a/compair/api/assignment_search_enddate.py +++ b/compair/api/assignment_search_enddate.py @@ -76,7 +76,7 @@ def get(self): engine = create_engine(db_url, pool_size=5, pool_recycle=3600) conn = engine.connect() - sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y'), 'self_eval_end', date_format(t2.self_eval_end, '%%M %%d, %%Y'), 'self_eval_start', date_format(t2.self_eval_start, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=TRUE) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "' OR self_eval_end >= '" + end_date + "');"); + sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y'), 'self_eval_end', date_format(t2.self_eval_end, '%%M %%d, %%Y'), 'self_eval_start', date_format(t2.self_eval_start, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=TRUE AND t1.active=TRUE) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "' OR self_eval_end >= '" + end_date + "');"); result = conn.execute(sql_text) From ea0f6b2c98a5459f6ae1608177565bf49b173ef9 Mon Sep 17 00:00:00 2001 From: John Hsu Date: Tue, 28 Jun 2022 14:33:15 -0700 Subject: [PATCH 08/22] EDIT changelog, elaborate on v1.3 celery env vars Give more detail on why Celery's environment vars were changed. Fix defaults. Celery worker memory control options are not used by default. The standalone version executes locally and the docker-compose version does not use them. "Download All" is now limited to just student answers only. --- CHANGELOG.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c05b2dd7b..bc88299df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,30 @@ # v1.3 -## Notable changes - -* Upgrades: There is a high risk of regressions due to many upgrades to the libraries used by ComPAIR. +## Notable changes +* Many dependencies, both frontend and backend, were updated. ### New Features -* 1. "Download All" attachments button was added to generate and download a zip file of all submitted attachments in answers -* 2. The "Assignment End-Date" feature was added for admin users to query for the assignments end-date. - +* "Download All" attachments button was added to generate and download a zip file of all student submitted answer attachments. This can be found in an assignment's "Participation" tab under the "Attachments" column. +* The "Assignment End-Date" feature was added for admin users to query for the assignments end-date. -### Environment Variable Changes -* CELERY_ALWAYS_EAGER is now CELERY_TASK_ALWAYS_EAGER - * Default: false - -### New Environment Variables: For controlling memory leak growth in Kubernetes +### New Environment Variables: For controlling worker memory leak * CELERY_WORKER_MAX_TASKS_PER_CHILD - Kills a worker process and forks a new one when it has executed the given number of tasks. - * Default to 20 * CELERY_WORKER_MAX_MEMORY_PER_CHILD - Set to memory in kilobytes. Kills a worker process and forks a new one when it hits the given memory usage, the currently executing task will be allowed to complete before being killed. - * Default to 600MB +## Breaking Changes +Celery 4 introduced a new all lowercase environment variables system. ComPAIR +is now using this new system. To adapt a Celery environment variable to +ComPAIR, convert the original Celery variable to all uppercase and prefix it +"CELERY\_". ComPAIR will strip the prefix and lowercase the variable before +passing it to Celery. A few Celery environment variables were renamed in the +new system, the ones supported in ComPAIR are: + +* CELERY_ALWAYS_EAGER is now CELERY_TASK_ALWAYS_EAGER + * Set to true if running stock standalone, see `compair/settings.py`. + * Set to false if running via repo's docker-compose.yml +* BROKER_TRANSPORT_OPTIONS is now CELERY_BROKER_TRANSPORT_OPTIONS +* CELERYBEAT_SCHEDULE is now CELERY_BEAT_SCHEDULE # v1.2.12 From 4efaf4253db873b591e405071eceeb68ce83a2b4 Mon Sep 17 00:00:00 2001 From: ubc-tuehoang <86985864+ubc-tuehoang@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:57:17 -0700 Subject: [PATCH 09/22] Update some text to Plan Release Date tool. Update some text to Plan Release Date tool. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc88299df..7e23adb2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### New Features * "Download All" attachments button was added to generate and download a zip file of all student submitted answer attachments. This can be found in an assignment's "Participation" tab under the "Attachments" column. -* The "Assignment End-Date" feature was added for admin users to query for the assignments end-date. +* The "Assignment End-Date" tool was added for admin users to query for the assignments end-date. + * The purpose of this page is to search for ongoing or active assignments on a given date, to help plan potential schedules for testing, staging, and production environments. ### New Environment Variables: For controlling worker memory leak * CELERY_WORKER_MAX_TASKS_PER_CHILD - Kills a worker process and forks a new one when it has executed the given number of tasks. From 948a99a02a78a36df4a48a0b23bf691a5b7180df Mon Sep 17 00:00:00 2001 From: ubc-tuehoang <86985864+ubc-tuehoang@users.noreply.github.com> Date: Thu, 30 Jun 2022 17:25:29 -0700 Subject: [PATCH 10/22] PlanReleaseDate: clean and optimize code (#1035) * test README * PlanReleaseDate: updated UTC display * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * PlanReleaseDate: test and clean code * Update to use System TZ as default * Add env APP_TIMEZONE * Update and clean code Co-authored-by: TueHoang --- compair/api/assignment_search_enddate.py | 20 ++++++------- compair/configuration.py | 8 ++++- compair/settings.py | 6 +++- .../assignment/assignment-search-partial.html | 2 +- compair/static/script-assignment-search.js | 30 +++++++++++++++---- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/compair/api/assignment_search_enddate.py b/compair/api/assignment_search_enddate.py index 4c3222b16..e9b50f401 100644 --- a/compair/api/assignment_search_enddate.py +++ b/compair/api/assignment_search_enddate.py @@ -6,7 +6,6 @@ from flask import jsonify import pytz - from bouncer.constants import READ, EDIT, CREATE, DELETE, MANAGE from flask import Blueprint, current_app from flask_bouncer import can @@ -26,13 +25,11 @@ from .util import new_restful_api, get_model_changes, pagination_parser from datetime import datetime +import time assignment_search_enddate_api = Blueprint('assignment_search_enddate_api', __name__) api = new_restful_api(assignment_search_enddate_api) -##event -on_assignment_get = event.signal('ASSIGNMENT_GET') - def validate(date_text): try: if date_text != datetime.strptime(date_text, "%Y-%m-%d").strftime('%Y-%m-%d'): @@ -45,16 +42,19 @@ class AssignmentRootAPI1(Resource): @login_required def get(self): + # get app timezone in settings + appTimeZone = current_app.config.get('APP_TIMEZONE', time.strftime('%Z') ) + search_date_assignment_parser = RequestParser() search_date_assignment_parser.add_argument('compare_start', default=datetime.now().strftime("%Y-%m-%d")) search_date_assignment_parser.add_argument('compare_end', default=datetime.now().strftime("%Y-%m-%d")) - search_date_assignment_parser.add_argument('compare_localTimeZone', default='UTC') + search_date_assignment_parser.add_argument('compare_localTimeZone', default=appTimeZone) args = search_date_assignment_parser.parse_args() end_date = datetime.now().strftime("%Y-%m-%d 00:00:00") start_date = datetime.now().strftime("%Y-%m-%d") - compare_localTimeZone = 'UTC' + compare_localTimeZone = appTimeZone if (args['compare_localTimeZone']): compare_localTimeZone = str(args['compare_localTimeZone']) @@ -62,12 +62,12 @@ def get(self): if validate(args['compare_end']): end_date = str(args['compare_end']) + ' 00:00:00' - ##convert this to UTC + ##convert this to System TZ local = pytz.timezone(compare_localTimeZone) naive = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S") local_dt = local.localize(naive, is_dst=None) - utc_dt = local_dt.astimezone(pytz.utc) - end_date = str(utc_dt) + systemTZ_dt = local_dt.astimezone(pytz.timezone(appTimeZone)) + end_date = str(systemTZ_dt) if validate(args['compare_start']): start_date = str(args['compare_start']) @@ -76,7 +76,7 @@ def get(self): engine = create_engine(db_url, pool_size=5, pool_recycle=3600) conn = engine.connect() - sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(t2.answer_start, '%%M %%d, %%Y'),'answer_end', date_format(t2.answer_end, '%%M %%d, %%Y'),'compare_start', date_format(t2.compare_start, '%%M %%d, %%Y'), 'compare_end', date_format(t2.compare_end, '%%M %%d, %%Y'), 'self_eval_end', date_format(t2.self_eval_end, '%%M %%d, %%Y'), 'self_eval_start', date_format(t2.self_eval_start, '%%M %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=TRUE AND t1.active=TRUE) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "' OR self_eval_end >= '" + end_date + "');"); + sql_text = str("SELECT JSON_OBJECT('course_name', t1.name,'name', t2.name,'answer_start', date_format(CONVERT_TZ(t2.answer_start, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y'),'answer_end', date_format(CONVERT_TZ(t2.answer_end, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y'),'compare_start', date_format(CONVERT_TZ(t2.compare_start, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y'), 'compare_end', date_format(CONVERT_TZ(t2.compare_end, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y'), 'self_eval_end', date_format(CONVERT_TZ(t2.self_eval_end, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y'), 'self_eval_start', date_format(CONVERT_TZ(t2.self_eval_start, '" + appTimeZone + "','" + compare_localTimeZone + "'), '%%b %%d, %%Y')) FROM course as t1, assignment as t2 WHERE (t1.id = t2.course_id) AND (t2.active=TRUE AND t1.active=TRUE) AND (t2.compare_end >= '" + end_date + "' OR answer_end >= '" + end_date + "' OR self_eval_end >= '" + end_date + "');"); result = conn.execute(sql_text) diff --git a/compair/configuration.py b/compair/configuration.py index 41b471014..65071b0ef 100644 --- a/compair/configuration.py +++ b/compair/configuration.py @@ -23,6 +23,8 @@ import os import json import re +import pytz +import time from distutils.util import strtobool from flask import Config @@ -91,7 +93,7 @@ 'KALTURA_SECRET', 'KALTURA_PLAYER_ID', 'MAIL_SERVER', 'MAIL_DEBUG', 'MAIL_USERNAME', 'MAIL_PASSWORD', 'MAIL_DEFAULT_SENDER', 'MAIL_SUPPRESS_SEND', - 'GA_TRACKING_ID' + 'GA_TRACKING_ID', 'APP_TIMEZONE' ] env_bool_overridables = [ @@ -150,3 +152,7 @@ config['APP_LOGIN_ENABLED'] = True config['CAS_LOGIN_ENABLED'] = False config['SAML_LOGIN_ENABLED'] = False + +# configuring APP_TIMEZONE +if not(config['APP_TIMEZONE'] in pytz.all_timezones): + config['APP_TIMEZONE'] = time.strftime('%Z') diff --git a/compair/settings.py b/compair/settings.py index 8daf60594..3cd055ffb 100644 --- a/compair/settings.py +++ b/compair/settings.py @@ -1,5 +1,5 @@ import os - +import time """ Default settings, if no other settings is specified, values here are used. """ @@ -163,3 +163,7 @@ # Allow impersonation IMPERSONATION_ENABLED = True + +# when APP_TIMEZONE is empty or incorrect, it will default to system timezone +# example America/Vancouver or America/Montreal +APP_TIMEZONE = time.strftime('%Z') diff --git a/compair/static/modules/assignment/assignment-search-partial.html b/compair/static/modules/assignment/assignment-search-partial.html index e25b8d979..cd62d63ed 100644 --- a/compair/static/modules/assignment/assignment-search-partial.html +++ b/compair/static/modules/assignment/assignment-search-partial.html @@ -12,7 +12,7 @@

Plan Release Date

- +