Skip to content

Commit 19e1c67

Browse files
authored
feat: Allow users to comment (#193)
* feat: Allow users to comment - Users can now comment to their own solutions - Users can now delete their own comments - Add unittests
1 parent 558747d commit 19e1c67

File tree

7 files changed

+160
-9
lines changed

7 files changed

+160
-9
lines changed

lms/lmsdb/models.py

+5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def get_student_role(cls) -> 'Role':
7474
def get_staff_role(cls) -> 'Role':
7575
return cls.get(Role.name == RoleOptions.STAFF.value)
7676

77+
@classmethod
78+
def get_admin_role(cls) -> 'Role':
79+
return cls.get(Role.name == RoleOptions.ADMINISTRATOR.value)
80+
7781
@classmethod
7882
def by_name(cls, name) -> 'Role':
7983
if name.startswith('_'):
@@ -755,6 +759,7 @@ def _by_file(cls, file_id: int):
755759
CommentText.id.alias('comment_id'), CommentText.text,
756760
SolutionFile.id.alias('file_id'),
757761
User.fullname.alias('author_name'),
762+
User.role.alias('author_role'),
758763
]
759764
return (
760765
cls

lms/lmsweb/config.py.example

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ ERRORS_CSV = 'errors.csv'
2929
# Shareable option
3030
SHAREABLE_SOLUTIONS = True
3131

32+
# Users comments option
33+
USERS_COMMENTS = True
34+
3235
# Babel config
3336
LANGUAGES = {
3437
'he': 'Hebrew',

lms/lmsweb/views.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from werkzeug.utils import redirect
2020

2121
from lms.lmsdb.models import (
22-
ALL_MODELS, Comment, CommentText, Exercise, RoleOptions,
22+
ALL_MODELS, Comment, CommentText, Exercise, Role, RoleOptions,
2323
SharedSolution, Solution, SolutionFile, User, database,
2424
)
2525
from lms.lmsweb import babel, routes, webapp
@@ -215,8 +215,8 @@ def _create_comment(
215215

216216
return jsonify({
217217
'success': 'true', 'text': comment_.comment.text,
218-
'author_name': user.fullname, 'is_auto': False, 'id': comment_.id,
219-
'line_number': line_number,
218+
'author_name': user.fullname, 'author_role': user.role.id,
219+
'is_auto': False, 'id': comment_.id, 'line_number': line_number,
220220
})
221221

222222

@@ -281,9 +281,20 @@ def comment():
281281
if act == 'fetch':
282282
return jsonify(Comment.by_file(file_id))
283283

284+
if (
285+
not webapp.config.get('USERS_COMMENTS', False)
286+
and not current_user.role.is_manager
287+
):
288+
return fail(403, "You aren't allowed to access this page.")
289+
284290
if act == 'delete':
285291
comment_id = int(request.args.get('commentId'))
286292
comment_ = Comment.get_or_none(Comment.id == comment_id)
293+
if (
294+
comment_.commenter.id != current_user.id
295+
and not current_user.role.is_manager
296+
):
297+
return fail(403, "You aren't allowed to access this page.")
287298
if comment_ is not None:
288299
comment_.delete_instance()
289300
return jsonify({'success': 'true'})
@@ -508,9 +519,17 @@ def _common_comments(exercise_id=None, user_id=None):
508519
Most common comments throughout all exercises.
509520
Filter by exercise id when specified.
510521
"""
511-
query = CommentText.filter(**{
512-
CommentText.flake8_key.name: None,
513-
}).select(CommentText.id, CommentText.text).join(Comment)
522+
is_moderator_comments = (
523+
(Comment.commenter.role == Role.get_staff_role().id)
524+
| (Comment.commenter.role == Role.get_admin_role().id),
525+
)
526+
query = (
527+
CommentText.select(CommentText.id, CommentText.text)
528+
.join(Comment).join(User).join(Role).where(
529+
CommentText.flake8_key.is_null(True),
530+
is_moderator_comments,
531+
).switch(Comment)
532+
)
514533

515534
if exercise_id is not None:
516535
query = (

lms/static/comments.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,15 @@ function isUserGrader() {
2727
return ['staff', 'administrator'].includes(sessionStorage.getItem('role'));
2828
}
2929

30+
function isSolverComment(commentData) {
31+
const authorIsSolver = commentData.author_name === sessionStorage.getItem('solver');
32+
const allowedComment = sessionStorage.getItem('allowedComment') === 'true';
33+
return (authorIsSolver && allowedComment);
34+
}
35+
3036
function formatCommentData(commentData) {
3137
let changedCommentText = `<span class="comment-author">${commentData.author_name}:</span> ${commentData.text}`;
32-
if (isUserGrader()) {
38+
if (isUserGrader() || isSolverComment(commentData)) {
3339
const deleteButton = `<i class="fa fa-trash grader-delete" aria-hidden="true" data-commentid="${commentData.id}" onclick="deleteComment(${window.fileId}, ${commentData.id});"></i>`;
3440
changedCommentText = `${deleteButton} ${changedCommentText}`;
3541
}
@@ -139,6 +145,8 @@ window.addEventListener('load', () => {
139145
window.solutionId = codeElement.id;
140146
window.fileId = codeElement.file;
141147
sessionStorage.setItem('role', codeElement.role);
148+
sessionStorage.setItem('solver', codeElement.solver);
149+
sessionStorage.setItem('allowedComment', codeElement.allowedComment);
142150
addLineSpansToPre(document.getElementsByTagName('code'));
143151
pullComments(window.fileId, treatComments);
144152
});

lms/templates/view.html

+4-2
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ <h1>{{ _('תצוגת תרגיל') }} {{ solution['exercise']['id'] }}: {{ soluti
6767
</div>
6868
</div>
6969
</div>
70-
<div id="code-view" data-id="{{ solution['id'] }}" data-file="{{ current_file.id }}" data-exercise="{{ solution['exercise']['id'] }}" data-role="{{ role }}">
70+
<div id="code-view" data-id="{{ solution['id'] }}" data-file="{{ current_file.id }}" data-exercise="{{ solution['exercise']['id'] }}" data-role="{{ role }}" data-solver="{{ solution['solver']['fullname'] }}" data-allowed-comment="{{ config['USERS_COMMENTS'] | lower }}">
7171
<pre><code id="user-code" class="language-{{ current_file | language_name }} line-numbers highlight">{{- current_file.code | trim(chars=' ') | e -}}</code></pre>
7272
</div>
7373
{% if test_results and not shared_url %}
@@ -137,8 +137,10 @@ <h2>{{ _('הערות בודק') }}</h2>
137137
<script src="{{ url_for('static', filename='prism.js') }}"></script>
138138
{% if not shared_url %}
139139
<script src="{{ url_for('static', filename='comments.js') }}"></script>
140-
{%- if is_manager %}
140+
{%- if is_manager or config.USERS_COMMENTS %}
141141
<script src="{{ url_for('static', filename='grader.js') }}"></script>
142+
{% endif -%}
143+
{%- if is_manager %}
142144
<script src="{{ url_for('static', filename='keyboard.js') }}"></script>
143145
{% endif -%}
144146
{% endif %}

lms/tests/conftest.py

+9
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def celery_eager():
5656
@pytest.fixture(autouse=True, scope='session')
5757
def webapp_configurations():
5858
webapp.config['SHAREABLE_SOLUTIONS'] = True
59+
webapp.config['USERS_COMMENTS'] = True
5960
webapp.secret_key = ''.join(
6061
random.choices(string.ascii_letters + string.digits, k=64),
6162
)
@@ -65,6 +66,14 @@ def disable_shareable_solutions():
6566
webapp.config['SHAREABLE_SOLUTIONS'] = False
6667

6768

69+
def disable_users_comments():
70+
webapp.config['USERS_COMMENTS'] = False
71+
72+
73+
def enable_users_comments():
74+
webapp.config['USERS_COMMENTS'] = True
75+
76+
6877
def get_logged_user(username: str) -> FlaskClient:
6978
client = webapp.test_client()
7079
client.post(

lms/tests/test_solutions.py

+105
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,111 @@ def test_start_checking(
188188
the_solution = Solution.get_by_id(solution.id)
189189
assert the_solution.state == Solution.STATES.IN_CHECKING.name
190190

191+
@staticmethod
192+
def test_user_comments(
193+
exercise: Exercise,
194+
student_user: User,
195+
):
196+
solution = conftest.create_solution(exercise, student_user)
197+
198+
client = conftest.get_logged_user(student_user.username)
199+
# Creating a comment
200+
comment_response = client.post('/comments', data=json.dumps(dict(
201+
fileId=solution.files[0].id, act='create', kind='text',
202+
comment='hey', line=1,
203+
)), content_type='application/json')
204+
assert comment_response.status_code == 200
205+
206+
# Creating another comment
207+
another_comment_response = client.post(
208+
'/comments', data=json.dumps(dict(
209+
fileId=solution.files[0].id, act='create', kind='text',
210+
comment='noice', line=2,
211+
)), content_type='application/json',
212+
)
213+
assert another_comment_response.status_code == 200
214+
215+
# Removing the second comment
216+
json_response_another_comment = json.loads(
217+
another_comment_response.get_data(as_text=True),
218+
)
219+
delete_response = client.get('/comments', query_string=dict(
220+
fileId=solution.files[0].id, act='delete',
221+
commentId=json_response_another_comment['id'],
222+
), content_type='application/json')
223+
assert delete_response.status_code == 200
224+
225+
# Disabling users comments option
226+
conftest.disable_users_comments()
227+
228+
# Trying to remove a comment
229+
json_response_comment = json.loads(
230+
comment_response.get_data(as_text=True),
231+
)
232+
delete_response = client.get('/comments', query_string=dict(
233+
fileId=solution.files[0].id, act='delete',
234+
commentId=json_response_comment['id'],
235+
), content_type='application/json')
236+
assert delete_response.status_code == 403
237+
238+
# Trying to create a comment
239+
disable_comment_response = client.post(
240+
'/comments', data=json.dumps(dict(
241+
fileId=solution.files[0].id, act='create', kind='text',
242+
comment='well well well', line=2,
243+
)), content_type='application/json',
244+
)
245+
assert disable_comment_response.status_code == 403
246+
247+
@staticmethod
248+
def test_staff_and_user_comments(
249+
exercise: Exercise,
250+
student_user: User,
251+
staff_user: User,
252+
):
253+
solution = conftest.create_solution(exercise, student_user)
254+
255+
client = conftest.get_logged_user(staff_user.username)
256+
# Enabling user comments option
257+
conftest.enable_users_comments()
258+
# Creating a comment
259+
comment_response = client.post('/comments', data=json.dumps(dict(
260+
fileId=solution.files[0].id, act='create', kind='text',
261+
comment='try again', line=1,
262+
)), content_type='application/json')
263+
assert comment_response.status_code == 200
264+
265+
# Creating another comment
266+
another_comment_response = client.post(
267+
'/comments', data=json.dumps(dict(
268+
fileId=solution.files[0].id, act='create', kind='text',
269+
comment='hey', line=1,
270+
)), content_type='application/json',
271+
)
272+
assert another_comment_response.status_code == 200
273+
274+
# Removing the second comment
275+
json_response_another_comment = json.loads(
276+
another_comment_response.get_data(as_text=True),
277+
)
278+
delete_response = client.get('/comments', query_string=dict(
279+
fileId=solution.files[0].id, act='delete',
280+
commentId=json_response_another_comment['id'],
281+
), content_type='application/json')
282+
assert delete_response.status_code == 200
283+
284+
conftest.logout_user(client)
285+
client2 = conftest.get_logged_user(student_user.username)
286+
# Trying to remove a comment
287+
json_response_comment = json.loads(
288+
comment_response.get_data(as_text=True),
289+
)
290+
delete_response = client2.get('/comments', query_string=dict(
291+
fileId=solution.files[0].id, act='delete',
292+
commentId=json_response_comment['id'],
293+
), content_type='application/json')
294+
assert delete_response.status_code == 403
295+
191296
@staticmethod
192297
def test_share_solution_by_another_user(
193298
exercise: Exercise,

0 commit comments

Comments
 (0)