Skip to content

Commit 15a682c

Browse files
authored
AWS: Implement diffing of solutions (#1462)
1 parent 800a0fc commit 15a682c

File tree

8 files changed

+206
-25
lines changed

8 files changed

+206
-25
lines changed

cms/server/admin/handlers/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@
7979
SubmissionHandler, \
8080
SubmissionCommentHandler, \
8181
SubmissionOfficialStatusHandler, \
82-
SubmissionFileHandler
82+
SubmissionFileHandler, \
83+
SubmissionDiffHandler
8384
from .task import \
8485
AddTaskHandler, \
8586
TaskHandler, \
@@ -215,12 +216,13 @@
215216
(r"/submission/([0-9]+)(?:/([0-9]+))?/comment", SubmissionCommentHandler),
216217
(r"/submission/([0-9]+)(?:/([0-9]+))?/official", SubmissionOfficialStatusHandler),
217218
(r"/submission_file/([0-9]+)", SubmissionFileHandler),
219+
(r"/submission_diff/([0-9]+)/([0-9]+)", SubmissionDiffHandler),
218220

219221
# User tests
220222

221223
(r"/user_test/([0-9]+)(?:/([0-9]+))?", UserTestHandler),
222224
(r"/user_test_file/([0-9]+)", UserTestFileHandler),
223-
225+
224226
# The following prefixes are handled by WSGI middlewares:
225227
# * /rpc, defined in cms/io/web_service.py
226228
# * /static, defined in cms/io/web_service.py

cms/server/admin/handlers/submission.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
2727
"""
2828

29+
import json
2930
import logging
31+
import difflib
3032

3133
from cms.db import Dataset, File, Submission
3234
from cms.grading.languagemanager import get_language
@@ -88,6 +90,89 @@ def get(self, file_id):
8890
self.fetch(digest, "text/plain", real_filename)
8991

9092

93+
class SubmissionDiffHandler(BaseHandler):
94+
"""Shows a diff between two submissions.
95+
"""
96+
@require_permission(BaseHandler.AUTHENTICATED)
97+
def get(self, old_id, new_id):
98+
sub_old = Submission.get_from_id(old_id, self.sql_session)
99+
sub_new = Submission.get_from_id(new_id, self.sql_session)
100+
101+
self.set_header("Content-type", "application/json; charset=utf-8")
102+
resp = {
103+
'message': None,
104+
'files': []
105+
}
106+
107+
if sub_old is None or sub_new is None:
108+
missing_id = old_id if sub_old is None else new_id
109+
resp['message'] = f"Submission ID {missing_id} not found."
110+
self.write(json.dumps(resp))
111+
return
112+
113+
if sub_old.task_id == sub_new.task_id:
114+
files_to_compare = sub_old.task.submission_format
115+
old_files = sub_old.files
116+
new_files = sub_new.files
117+
elif len(sub_old.files) == 1 and len(sub_new.files) == 1:
118+
old_file = list(sub_old.files.values())[0]
119+
old_files = {"submission.%l": old_file}
120+
new_file = list(sub_new.files.values())[0]
121+
new_files = {"submission.%l": new_file}
122+
files_to_compare = ["submission.%l"]
123+
else:
124+
resp['message'] = "Cannot compare submissions: they are for " \
125+
"different tasks and have more than 1 file."
126+
self.write(json.dumps(resp))
127+
return
128+
129+
result_files = []
130+
for fname in files_to_compare:
131+
if ".%l" in fname:
132+
if sub_old.language == sub_new.language and sub_old.language is not None:
133+
ext = get_language(sub_old.language).source_extension
134+
else:
135+
ext = ".txt"
136+
real_fname = fname.replace(".%l", ext)
137+
else:
138+
real_fname = fname
139+
140+
def get_file(x, which):
141+
if fname not in x:
142+
return None, f"File not present in {which} submission"
143+
digest = x[fname].digest
144+
file_bin = self.service.file_cacher.get_file_content(digest)
145+
if len(file_bin) > 1000000:
146+
return None, f"{which} file is too big to diff".capitalize()
147+
file_lines = file_bin.decode(errors='replace').splitlines()
148+
if len(file_lines) > 5000:
149+
return None, f"{which} file has too many lines to diff".capitalize()
150+
return file_lines, None
151+
152+
old_content, old_status = get_file(old_files, "old")
153+
if old_status:
154+
result_files.append({"fname": real_fname, "status": old_status})
155+
continue
156+
new_content, new_status = get_file(new_files, "new")
157+
if new_status:
158+
result_files.append({"fname": real_fname, "status": new_status})
159+
continue
160+
161+
if old_content == new_content:
162+
result_files.append({"fname": real_fname, "status": "No changes"})
163+
else:
164+
diff_iter = difflib.unified_diff(old_content, new_content, lineterm='')
165+
# skip the "---" and "+++" lines.
166+
next(diff_iter)
167+
next(diff_iter)
168+
diff = '\n'.join(diff_iter)
169+
170+
result_files.append({"fname": real_fname, "diff": diff})
171+
172+
resp['files'] = result_files
173+
self.write(json.dumps(resp))
174+
175+
91176
class SubmissionCommentHandler(BaseHandler):
92177
"""Called when the admin comments on a submission.
93178

cms/server/admin/static/aws_style.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,3 +742,10 @@ a.button-link {
742742
a.button-link:hover {
743743
background-color: #EEF3EA;
744744
}
745+
746+
th.diff-only, td.diff-only {
747+
display: none;
748+
}
749+
table.diff-open th.diff-only, table.diff-open td.diff-only {
750+
display: table-cell;
751+
}

cms/server/admin/static/aws_utils.js

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ document.addEventListener('keydown', function(event) {
9191
}
9292
});
9393

94+
CMS.AWSUtils.filename_to_lang = function(file_name) {
95+
// TODO: update if adding a new language to cms
96+
// (need to also update the prism bundle then)
97+
var extension_to_lang = {
98+
'cs': 'csharp',
99+
'cpp': 'cpp',
100+
'c': 'c',
101+
'h': 'c',
102+
'go': 'go',
103+
'hs': 'haskell',
104+
'java': 'java',
105+
'js': 'javascript',
106+
'php': 'php',
107+
'py': 'python',
108+
'rs': 'rust',
109+
}
110+
var file_ext = file_name.split('.').pop();
111+
return extension_to_lang[file_ext] || file_ext;
112+
}
94113

95114
/**
96115
* This is called when we receive file content, or an error message.
@@ -114,23 +133,7 @@ CMS.AWSUtils.prototype.file_received = function(response, error) {
114133
this.display_subpage(elements);
115134
return;
116135
}
117-
// TODO: update if adding a new language to cms
118-
// (need to also update the prism bundle then)
119-
var extension_to_lang = {
120-
'cs': 'csharp',
121-
'cpp': 'cpp',
122-
'c': 'c',
123-
'h': 'c',
124-
'go': 'go',
125-
'hs': 'haskell',
126-
'java': 'java',
127-
'js': 'javascript',
128-
'php': 'php',
129-
'py': 'python',
130-
'rs': 'rust',
131-
}
132-
var file_ext = file_name.split('.').pop();
133-
var lang_name = extension_to_lang[file_ext] || file_ext;
136+
var lang_name = CMS.AWSUtils.filename_to_lang(file_name);
134137

135138
elements.push($('<h1>').text(file_name));
136139
elements.push($('<a>').text("Download").prop("href", url));
@@ -903,3 +906,70 @@ CMS.AWSUtils.prototype.render_markdown_preview = function(target) {
903906
},
904907
});
905908
}
909+
910+
/**
911+
* Handlers for diffing submissions.
912+
*/
913+
914+
/**
915+
* Shows/hides the diff radio buttons when opening/closing the diff section.
916+
*/
917+
CMS.AWSUtils.prototype.update_diffchooser = function() {
918+
var el = document.getElementById("diffchooser");
919+
if(el.open) {
920+
$("#submissions_table").addClass("diff-open");
921+
} else {
922+
$("#submissions_table").removeClass("diff-open");
923+
}
924+
}
925+
926+
/**
927+
* Updates the submission ID inputs when clicking diff radio buttons.
928+
*/
929+
CMS.AWSUtils.prototype.update_diff_ids = function(ev) {
930+
var name = ev.target.name;
931+
var sub_id = ev.target.dataset.submission;
932+
if(name == "diff-radio-old") {
933+
$("#diff-old-input").val(sub_id);
934+
} else {
935+
$("#diff-new-input").val(sub_id);
936+
}
937+
}
938+
939+
/**
940+
* Renders a diff that was received from the server.
941+
*/
942+
CMS.AWSUtils.prototype.show_diff = function(response, error) {
943+
if(error !== null) {
944+
this.display_subpage([$('<p>').text('Error: ' + error)]);
945+
return;
946+
}
947+
var elements = [];
948+
if(response.message !== null) {
949+
elements.push($('<p>').text(response.message));
950+
}
951+
for(let x of response.files) {
952+
elements.push($('<h2>').text(x.fname));
953+
if('status' in x) {
954+
elements.push($('<p>').text(x.status));
955+
continue;
956+
}
957+
var lang_name = CMS.AWSUtils.filename_to_lang(x.fname);
958+
var codearea = $('<code>').text(x.diff)
959+
.addClass('language-diff-' + lang_name)
960+
.addClass('diff-highlight');
961+
elements.push($('<pre>').append(codearea));
962+
}
963+
this.display_subpage(elements);
964+
Prism.highlightAllUnder(document.getElementById('subpage_content'));
965+
}
966+
967+
/**
968+
* Called when "Diff" button is clicked, requests the diff from the server.
969+
*/
970+
CMS.AWSUtils.prototype.do_diff = function() {
971+
var old_id = $("#diff-old-input").val();
972+
var new_id = $("#diff-new-input").val();
973+
var show_diff = this.bind_func(this, this.show_diff);
974+
this.ajax_request(this.url("submission_diff", old_id, new_id), null, show_diff);
975+
}

cms/server/admin/static/prism.css

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)