From 6b17c5284036f45e7a5146b67734885a4baac6e9 Mon Sep 17 00:00:00 2001 From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de> Date: Tue, 3 Sep 2019 13:20:20 +0200 Subject: [PATCH] Implemented SolutionComment system Tutors and reviewers can now add comments to individual lines of the provided solution. The comments are short polled. --- Makefile | 2 +- core/migrations/0016_solutioncomment.py | 27 +++ core/migrations/0021_merge_20190902_1246.py | 14 ++ core/models/__init__.py | 3 +- core/models/submission_type.py | 18 ++ core/serializers/__init__.py | 2 + core/serializers/common_serializers.py | 68 ++++-- core/serializers/feedback.py | 54 +---- core/serializers/submission_type.py | 65 ++++++ core/urls.py | 1 + core/views/common_views.py | 25 ++- core/views/export.py | 2 +- frontend/src/api.ts | 22 +- .../submission_notes/SubmissionCorrection.vue | 2 +- .../submission_notes/base/SubmissionLine.vue | 20 +- .../{ => submission_type}/SubmissionType.vue | 43 +++- .../SubmissionTypesOverview.vue | 23 +- .../submission_type/solution/Solution.vue | 190 +++++++++++++++++ .../solution/SolutionComment.vue | 197 ++++++++++++++++++ frontend/src/models.ts | 11 + .../src/pages/StudentSubmissionSideView.vue | 2 +- frontend/src/pages/SubscriptionWorkPage.vue | 2 +- .../src/pages/reviewer/ReviewerStartPage.vue | 2 +- .../pages/student/StudentSubmissionPage.vue | 2 +- frontend/src/pages/tutor/TutorStartPage.vue | 2 +- frontend/src/store/actions.ts | 14 +- .../src/store/modules/submission-notes.ts | 2 +- frontend/src/store/mutations.ts | 2 +- functional_tests/test_auto_logout.py | 5 +- functional_tests/test_export_modal.py | 1 + functional_tests/test_feedback_creation.py | 7 +- functional_tests/test_feedback_update.py | 6 +- functional_tests/test_front_pages.py | 6 + functional_tests/test_solution_comments.py | 142 +++++++++++++ functional_tests/util.py | 11 +- 35 files changed, 875 insertions(+), 120 deletions(-) create mode 100644 core/migrations/0016_solutioncomment.py create mode 100644 core/migrations/0021_merge_20190902_1246.py create mode 100644 core/serializers/submission_type.py rename frontend/src/components/{ => submission_type}/SubmissionType.vue (71%) rename frontend/src/components/{ => submission_type}/SubmissionTypesOverview.vue (72%) create mode 100644 frontend/src/components/submission_type/solution/Solution.vue create mode 100644 frontend/src/components/submission_type/solution/SolutionComment.vue create mode 100644 functional_tests/test_solution_comments.py diff --git a/Makefile b/Makefile index 4610651a..a4760cf2 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ teste2e: cd frontend && yarn build && cp dist/index.html ../core/templates && cd .. && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html teste2e-nc: - cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest -n 4 --ds=grady.settings $(path); git checkout core/templates/index.html + cp frontend/dist/index.html ./core/templates && python util/format_index.py && python manage.py collectstatic --no-input && HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path); git checkout core/templates/index.html coverage: diff --git a/core/migrations/0016_solutioncomment.py b/core/migrations/0016_solutioncomment.py new file mode 100644 index 00000000..d76621d4 --- /dev/null +++ b/core/migrations/0016_solutioncomment.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.4 on 2019-05-14 15:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_feedbacklabel_colour'), + ] + + operations = [ + migrations.CreateModel( + name='SolutionComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('of_line', models.PositiveIntegerField()), + ('of_submission_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to='core.SubmissionType')), + ('of_user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/migrations/0021_merge_20190902_1246.py b/core/migrations/0021_merge_20190902_1246.py new file mode 100644 index 00000000..aba1adb9 --- /dev/null +++ b/core/migrations/0021_merge_20190902_1246.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.11 on 2019-09-02 12:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_solutioncomment'), + ('core', '0020_auto_20190831_1417'), + ] + + operations = [ + ] diff --git a/core/models/__init__.py b/core/models/__init__.py index 2b5cb566..47ab0c28 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1,5 +1,6 @@ from .exam_type import ExamType # noqa -from .submission_type import SubmissionType # noqa +from .submission_type import SubmissionType, SolutionComment # noqa +from .user_account import UserAccount, TutorReviewerManager # noqa from .user_account import UserAccount, TutorReviewerManager # noqa from .student_info import StudentInfo, random_matrikel_no # noqa from .test import Test # noqa diff --git a/core/models/submission_type.py b/core/models/submission_type.py index 7c2dc3c9..feb93e08 100644 --- a/core/models/submission_type.py +++ b/core/models/submission_type.py @@ -98,3 +98,21 @@ class SubmissionType(models.Model): ), submission_count=Count('submissions'), ).order_by('name') + + +class SolutionComment(models.Model): + text = models.TextField() + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + of_line = models.PositiveIntegerField() + of_user = models.ForeignKey( + 'UserAccount', + related_name="solution_comments", + on_delete=models.PROTECT + ) + of_submission_type = models.ForeignKey( + SubmissionType, + related_name="solution_comments", + on_delete=models.PROTECT, + ) diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py index f7e15a2a..2f69017b 100644 --- a/core/serializers/__init__.py +++ b/core/serializers/__init__.py @@ -1,4 +1,6 @@ from .common_serializers import * # noqa +from .submission_type import (SubmissionTypeListSerializer, SubmissionTypeSerializer, # noqa + SolutionCommentSerializer) # noqa from .feedback import (FeedbackSerializer, FeedbackCommentSerializer, # noqa VisibleCommentFeedbackSerializer) # noqa from .subscription import * # noqa diff --git a/core/serializers/common_serializers.py b/core/serializers/common_serializers.py index 305b2c6c..94e22592 100644 --- a/core/serializers/common_serializers.py +++ b/core/serializers/common_serializers.py @@ -1,8 +1,11 @@ import logging +from collections import defaultdict import django.contrib.auth.password_validation as validators from django.core import exceptions +from django.db.models.manager import Manager from rest_framework import serializers +from rest_framework.utils import html from core import models @@ -26,25 +29,6 @@ class TestSerializer(DynamicFieldsModelSerializer): fields = ('pk', 'name', 'label', 'annotation') -class SubmissionTypeListSerializer(DynamicFieldsModelSerializer): - - class Meta: - model = models.SubmissionType - fields = ('pk', 'name', 'full_score') - - -class SubmissionTypeSerializer(SubmissionTypeListSerializer): - - class Meta: - model = models.SubmissionType - fields = ('pk', - 'name', - 'full_score', - 'description', - 'solution', - 'programming_language') - - class UserAccountSerializer(DynamicFieldsModelSerializer): def validate(self, data): @@ -63,3 +47,49 @@ class UserAccountSerializer(DynamicFieldsModelSerializer): fields = ('pk', 'username', 'role', 'is_admin', 'password') read_only_fields = ('pk', 'username', 'role', 'is_admin') extra_kwargs = {'password': {'write_only': True}} + + +class CommentDictionarySerializer(serializers.ListSerializer): + + def to_internal_value(self, comment_dict): + """ Converts a line_no -> comment list dictionary back to a list + of comments. Currently we do not have any information about the + feedback since it is not available in this scope. Feedback is + responsible to add it later on update/creation """ + if html.is_html_input(comment_dict): + comment_dict = html.parse_html_list(comment_dict) + + if not isinstance(comment_dict, dict): + raise serializers.ValidationError( + 'Comments have to be provided as a dict' + 'with: line -> list of comments' + ) + + ret = [] + errors = [] + + for line, comment in comment_dict.items(): + try: + comment['of_line'] = line + validated = self.child.run_validation(comment) + except serializers.ValidationError as err: + errors.append(err.detail) + else: + ret.append(validated) + errors.append({}) + + if any(errors): + raise serializers.ValidationError(errors) + + return ret + + def to_representation(self, comments): + """ Provides a dict where all the keys correspond to lines and contain + a list of comments on that line. """ + if isinstance(comments, Manager): + comments = comments.all() + + ret = defaultdict(list) + for comment in comments: + ret[comment.of_line].append(self.child.to_representation(comment)) + return ret diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py index cccfa16d..6b0e1288 100644 --- a/core/serializers/feedback.py +++ b/core/serializers/feedback.py @@ -1,13 +1,11 @@ import logging -from collections import defaultdict from django.db import transaction -from django.db.models.manager import Manager from rest_framework import serializers -from rest_framework.utils import html from core import models from core.models import Feedback, UserAccount +from core.serializers import CommentDictionarySerializer from util.factories import GradyUserFactory from .generic import DynamicFieldsModelSerializer @@ -16,52 +14,6 @@ log = logging.getLogger(__name__) user_factory = GradyUserFactory() -class FeedbackCommentDictionarySerializer(serializers.ListSerializer): - - def to_internal_value(self, comment_dict): - """ Converts a line_no -> comment list dictionary back to a list - of comments. Currently we do not have any information about the - feedback since it is not availiable in this scope. Feedback is - responsible to add it later on update/creation """ - if html.is_html_input(comment_dict): - comment_dict = html.parse_html_list(comment_dict) - - if not isinstance(comment_dict, dict): - raise serializers.ValidationError( - 'Comments have to be provided as a dict' - 'with: line -> list of comments' - ) - - ret = [] - errors = [] - - for line, comment in comment_dict.items(): - try: - comment['of_line'] = line - validated = self.child.run_validation(comment) - except serializers.ValidationError as err: - errors.append(err.detail) - else: - ret.append(validated) - errors.append({}) - - if any(errors): - raise serializers.ValidationError(errors) - - return ret - - def to_representation(self, comments): - """ Provides a dict where all the keys correspond to lines and contain - a list of comments on that line. """ - if isinstance(comments, Manager): - comments = comments.all() - - ret = defaultdict(list) - for comment in comments: - ret[comment.of_line].append(self.child.to_representation(comment)) - return ret - - class FeedbackCommentSerializer(DynamicFieldsModelSerializer): of_tutor = serializers.StringRelatedField(source='of_tutor.username') labels = serializers.PrimaryKeyRelatedField(many=True, required=False, @@ -84,7 +36,7 @@ class FeedbackCommentSerializer(DynamicFieldsModelSerializer): 'of_feedback': {'write_only': True}, 'of_line': {'write_only': True}, } - list_serializer_class = FeedbackCommentDictionarySerializer + list_serializer_class = CommentDictionarySerializer class FeedbackSerializer(DynamicFieldsModelSerializer): @@ -99,7 +51,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): """ Search for the assignment of this feedback and report in which stage the tutor has worked on it. - Note: This method is unorthodox since it mingles the rather dump + TODO Note: This method is unorthodox since it mingles the rather dump feedback object with assignment logic. The reverse lookups in the method are not pre-fetched. Remove if possible. """ if 'request' not in self.context: diff --git a/core/serializers/submission_type.py b/core/serializers/submission_type.py new file mode 100644 index 00000000..b924c578 --- /dev/null +++ b/core/serializers/submission_type.py @@ -0,0 +1,65 @@ +import logging + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from core import models +from core.serializers import DynamicFieldsModelSerializer, CommentDictionarySerializer + +log = logging.getLogger(__name__) + + +class SolutionCommentSerializer(DynamicFieldsModelSerializer): + of_user = serializers.StringRelatedField(source='of_user.username') + + def validate(self, attrs): + super().validate(attrs) + submission_type = attrs.get('of_submission_type') + of_line = attrs.get('of_line') + if self.instance: + submission_type = self.instance.of_submission_type + of_line = self.instance.of_line + + max_line_number = len(submission_type.solution.split('\n')) + + if not (0 < of_line <= max_line_number): + raise ValidationError('Invalid line number for comment') + return attrs + + def create(self, validated_data): + validated_data['of_user'] = self.context['request'].user + return super().create(validated_data) + + class Meta: + model = models.SolutionComment + fields = ( + 'pk', + 'text', + 'created', + 'of_user', + 'of_line', + 'of_submission_type' + ) + read_only_fields = ('pk', 'created', 'of_user') + list_serializer_class = CommentDictionarySerializer + + +class SubmissionTypeListSerializer(DynamicFieldsModelSerializer): + + class Meta: + model = models.SubmissionType + fields = ('pk', 'name', 'full_score') + + +class SubmissionTypeSerializer(DynamicFieldsModelSerializer): + solution_comments = SolutionCommentSerializer(many=True, required=False) + + class Meta: + model = models.SubmissionType + fields = ('pk', + 'name', + 'full_score', + 'description', + 'solution', + 'programming_language', + 'solution_comments') diff --git a/core/urls.py b/core/urls.py index aee6e268..25fad50d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -25,6 +25,7 @@ router.register('statistics', views.StatisticsEndpoint, basename='statistics') router.register('user', views.UserAccountViewSet, basename='user') router.register('label', views.LabelApiViewSet, basename='label') router.register('label-statistics', views.LabelStatistics, basename='label-statistics') +router.register('solution-comment', views.SolutionCommentApiViewSet, basename='solution-comment') schema_view = get_schema_view( openapi.Info( diff --git a/core/views/common_views.py b/core/views/common_views.py index 99d3988b..3acd7245 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -24,7 +24,7 @@ from core.serializers import (ExamSerializer, StudentInfoSerializer, StudentInfoForListViewSerializer, SubmissionNoTypeSerializer, StudentSubmissionSerializer, SubmissionTypeSerializer, CorrectorSerializer, - UserAccountSerializer) + UserAccountSerializer, SolutionCommentSerializer) log = logging.getLogger(__name__) @@ -130,6 +130,29 @@ class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet): permission_classes = (IsTutorOrReviewer, ) +class SolutionCommentApiViewSet( + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet): + permission_classes = (IsTutorOrReviewer, ) + queryset = models.SolutionComment.objects.all() + serializer_class = SolutionCommentSerializer + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if not request.user.is_reviewer() and instance.of_user != request.user: + raise PermissionDenied(detail="You can only delete comments you made") + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + if instance.of_user != request.user: + raise PermissionDenied(detail="You can only update comments you made") + return super().update(request, *args, **kwargs) + + class StatisticsEndpoint(viewsets.ViewSet): permission_classes = (IsTutorOrReviewer, ) diff --git a/core/views/export.py b/core/views/export.py index 9e5f91a9..307fecbe 100644 --- a/core/views/export.py +++ b/core/views/export.py @@ -7,7 +7,7 @@ import xkcdpass.xkcd_password as xp from core.models import StudentInfo, UserAccount, ExamType, SubmissionType from core.permissions import IsReviewer -from core.serializers.common_serializers import SubmissionTypeSerializer, \ +from core.serializers import SubmissionTypeSerializer, \ ExamSerializer, UserAccountSerializer from core.serializers.student import StudentExportSerializer from core.serializers.tutor import CorrectorSerializer diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7d575c89..ec0687b7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,7 +12,7 @@ import { SubmissionNoType, SubmissionType, Subscription, Tutor, UserAccount, LabelStatisticsForSubType, - FeedbackLabel, + FeedbackLabel, SolutionComment, CreateUpdateFeedback } from '@/models' @@ -171,6 +171,26 @@ export async function fetchSubmissionTypes (): Promise<Array<SubmissionType>> { return (await ax.get(url)).data } +export async function fetchSubmissionType (pk: string): Promise<SubmissionType> { + const url = `/api/submissiontype/${pk}` + return (await ax.get(url)).data +} + +export async function deleteSolutionComment (pk: number): Promise<AxiosResponse<void>> { + const url = `/api/solution-comment/${pk}/` + return ax.delete(url) +} + +export async function createSolutionComment(comment: Partial<SolutionComment>): Promise<SolutionComment> { + const url = `/api/solution-comment/` + return (await ax.post(url, comment)).data +} + +export async function patchSolutionComment(comment: Partial<SolutionComment>): Promise<SolutionComment> { + const url = `/api/solution-comment/${comment.pk}/` + return (await ax.patch(url, comment)).data +} + export async function fetchAllAssignments (): Promise<Array<Assignment>> { const url = '/api/assignment/' return (await ax.get(url)).data diff --git a/frontend/src/components/submission_notes/SubmissionCorrection.vue b/frontend/src/components/submission_notes/SubmissionCorrection.vue index 150e8929..0ad5e275 100644 --- a/frontend/src/components/submission_notes/SubmissionCorrection.vue +++ b/frontend/src/components/submission_notes/SubmissionCorrection.vue @@ -186,7 +186,7 @@ export default { const hasUpdatedComment = this.updatedFeedback && this.updatedFeedback[lineNo] - return !this.showFeedback && (hasOrigComment ||Â hasUpdatedComment) + return !this.showFeedback && (hasOrigComment ||Â !!hasUpdatedComment) }, init () { SubmissionNotes.RESET_STATE() diff --git a/frontend/src/components/submission_notes/base/SubmissionLine.vue b/frontend/src/components/submission_notes/base/SubmissionLine.vue index 81c46f6d..463e0c9c 100644 --- a/frontend/src/components/submission_notes/base/SubmissionLine.vue +++ b/frontend/src/components/submission_notes/base/SubmissionLine.vue @@ -1,17 +1,9 @@ <template> <div> - <td class="line-number-cell"> - <v-btn v-if="hint" - block - depressed - class="line-number-btn" - color="error" - @click="toggleEditor" - > - {{ lineNo }} - </v-btn> + <td + :style="backgroundColor" + class="line-number-cell"> <v-btn - v-else flat block depressed @@ -20,6 +12,7 @@ > {{ lineNo }} </v-btn> + </v-btn> </td> <td class="code-cell-content pl-2"> <span v-html="code" class="code-line"></span> @@ -49,6 +42,11 @@ export default { default: false, }, }, + computed: { + backgroundColor() { + return this.hint ? 'background-color: #F44336;' : 'background-color: transparent;' + } + }, methods: { toggleEditor () { this.$emit('toggleEditor') diff --git a/frontend/src/components/SubmissionType.vue b/frontend/src/components/submission_type/SubmissionType.vue similarity index 71% rename from frontend/src/components/SubmissionType.vue rename to frontend/src/components/submission_type/SubmissionType.vue index 86388a33..699bdb58 100644 --- a/frontend/src/components/SubmissionType.vue +++ b/frontend/src/components/submission_type/SubmissionType.vue @@ -7,7 +7,16 @@ v-for="(item, i) in typeItems" :key="i" > - <div slot="header"><b>{{ item.title }}</b></div> + <div slot="header"> + <b>{{ item.title }}</b> + <v-btn + class="ml-5" + color="info" + flat + v-if="item.title == 'Solution'" + @click.stop="showSolutionComments = !showSolutionComments" + >Toggle Comments</v-btn> + </div> <v-card v-if="item.title === 'Description'" class="type-description" @@ -19,10 +28,14 @@ </v-card-text> </v-card> <div v-else-if="item.title === 'Solution'"> - <pre - class="elevation-2 solution-code pl-2" - :class="programmingLanguage" - ><span v-html="highlightedSolution"></span></pre> + <solution + :pk=pk + :solution=solution + :programmingLanguage=programmingLanguage + :solutionComments=solutionComments + :showSolutionComments=showSolutionComments + > + </solution> </div> </v-expansion-panel-content> </v-expansion-panel> @@ -36,9 +49,17 @@ import Component from 'vue-class-component' import { Prop } from 'vue-property-decorator' import { highlight } from 'highlight.js' import { UI } from '@/store/modules/ui' +import { SolutionComment } from '../../models'; +import Solution from '@/components/submission_type/solution/Solution.vue' -@Component +@Component({ + components: {Solution} +}) export default class SubmissionType extends Vue { + @Prop({ + type: String, + required: true, + }) pk!: string @Prop({ type: String, required: true @@ -64,6 +85,10 @@ export default class SubmissionType extends Vue { type: Boolean, default: false }) reverse!: boolean + @Prop({ + type: Object, + default: {}, + }) solutionComments!: {[ofLine: number]: SolutionComment[]} @Prop({ type: Object, default: function () { @@ -78,6 +103,8 @@ export default class SubmissionType extends Vue { ? [this.expandedByDefault.Description, this.expandedByDefault.Solution] : [this.expandedByDefault.Solution, this.expandedByDefault.Description] + showSolutionComments = true + get typeItems () { let items = [ { @@ -107,10 +134,6 @@ export default class SubmissionType extends Vue { </script> <style> - .solution-code { - border-width: 0px; - white-space: pre-wrap; - } .type-description code { background-color: lightgrey; } diff --git a/frontend/src/components/SubmissionTypesOverview.vue b/frontend/src/components/submission_type/SubmissionTypesOverview.vue similarity index 72% rename from frontend/src/components/SubmissionTypesOverview.vue rename to frontend/src/components/submission_type/SubmissionTypesOverview.vue index 88efc529..2e8bc31b 100644 --- a/frontend/src/components/SubmissionTypesOverview.vue +++ b/frontend/src/components/submission_type/SubmissionTypesOverview.vue @@ -3,7 +3,7 @@ <v-card-title class="title">Task types</v-card-title> <v-layout row wrap> <v-flex xs3> - <v-list> + <v-list id="submission-types-list"> <v-list-tile v-for="submissionType in sortedSubmissionTypes" :key="submissionType.pk" @click="selectedSubmissionType = submissionType" @@ -25,20 +25,29 @@ </template> <script> -import { mapState } from 'vuex' -import SubmissionType from '@/components/SubmissionType' +import SubmissionType from '@/components/submission_type/SubmissionType' +import store from '@/store/store'; export default { name: 'SubmissionTypesOverview', components: { SubmissionType }, data () { return { - selectedSubmissionType: null + selectedSubmissionTypePk: null } }, computed: { - ...mapState([ - 'submissionTypes' - ]), + submissionTypes () { + return store.state.submissionTypes + }, + // needed to keep selectedSubmissionType reactive + selectedSubmissionType: { + get: function () { + return store.state.submissionTypes[this.selectedSubmissionTypePk] + }, + set: function (newSubType) { + this.selectedSubmissionTypePk = newSubType.pk + } + }, sortedSubmissionTypes () { return Object.values(this.submissionTypes).sort((t1, t2) => { let lowerName1 = t1.name.toLowerCase() diff --git a/frontend/src/components/submission_type/solution/Solution.vue b/frontend/src/components/submission_type/solution/Solution.vue new file mode 100644 index 00000000..ae4f1bd9 --- /dev/null +++ b/frontend/src/components/submission_type/solution/Solution.vue @@ -0,0 +1,190 @@ +<template> + <table class="solution-table"> + <tr v-for="(code, lineNo) in highlightedSolution" :key="`${pk}:${lineNo}`" :id="`solution-line-${lineNo}`"> + <td class="line-number-cell" :style="backgroundColor(lineNo)"> + <v-btn + flat + block + depressed + class="line-number-btn" + @click="toggleEditor(lineNo)">{{lineNo}}</v-btn> + </td> + <td class="code-cell-content pl-2"> + <span v-html="code" class="code-line"></span> + <template + v-if="solutionComments[lineNo] && solutionComments[lineNo].length && showSolutionComments"> + <solution-comment + v-for="comment in solutionComments[lineNo]" + v-bind="comment" + :key="comment.pk" + @update-submission-type="updateSubmissionType" + @toggle-editor="toggleEditor(lineNo)" + @toggle-eidt-editor="toggleEditor(lineNo)" + /> + </template> + <template v-if="showEditorOnline[lineNo]"> + <v-textarea + name="solution-comment-input" + label="Here you can comment the solution. Other tutors will see those comments." + v-model="editedSolutionComments[lineNo]" + @keyup.enter.ctrl.exact="submitSolutionComment(lineNo)" + @keyup.esc="collapseTextField(lineNo)" + @focus="selectInput($event)" + rows="2" + outline + autofocus + auto-grow + hide-details + class="mx-2" + /> + <v-btn id="submit-comment" color="success" @click="submitSolutionComment(lineNo)"><v-icon>check</v-icon>Submit</v-btn> + <v-btn id="cancel-comment" @click="toggleEditor(lineNo)"><v-icon>cancel</v-icon>cancel</v-btn> + </template> + </td> + </tr> + </table> +</template> + +<script lang="ts"> + import { Vue, Component, Prop } from "vue-property-decorator" + import { SolutionComment, FeedbackComment } from "../../../models" + import { highlight } from "highlight.js" + import { syntaxPostProcess, objectifyArray } from "../../../util/helpers" + import SolutionCommentComponent from "@/components/submission_type/solution/SolutionComment.vue" + import * as api from '@/api' + import { actions } from '../../../store/actions'; + + @Component({ + components: {'SolutionComment': SolutionCommentComponent} + }) + export default class Solution extends Vue { + @Prop({ + type: String, + required: true + }) + pk!: string + @Prop({ + type: String, + required: false, + default: "" + }) + solution!: string + @Prop({ + type: String, + default: "c" + }) + programmingLanguage!: string + @Prop({ + type: Object, + default: {} + }) + solutionComments!: { [ofLine: number]: SolutionComment[] } + @Prop({ + type: Boolean, + default: true + }) + showSolutionComments!: boolean + + + timer = 0 + showEditorOnline: {[ofLine: number]: boolean} = {} + editedSolutionComments: {[ofLine: number]: string} = {} + + get highlightedSolution() { + const highlighted = highlight(this.programmingLanguage, this.solution, true) + .value + const postprocessed = syntaxPostProcess(highlighted) + return postprocessed + .split("\n") + .reduce((acc: { [k: number]: string }, curr, index) => { + acc[index + 1] = curr + return acc + }, {}) + } + + get lineNoHint() { + if (this.showSolutionComments) { + // will return a falsy value if indexed with a line number + // meaning no hint will be displayed + return {} + } else { + // returning the solutionComments will return a truthy value + // if indexed with the line number where comments are located + return this.solutionComments + } + } + + backgroundColor(lineNo: number) { + if (this.lineNoHint[lineNo]) { + return 'backgroundColor: #64B5F6;' + } else { + 'backgroundColor: transparent;' + } + } + + selectInput (event: Event) { + if (event !== null) { + const target = event.target as HTMLTextAreaElement + target.select() + } + } + + toggleEditor(lineNo: number) { + Vue.set(this.showEditorOnline, lineNo, !this.showEditorOnline[lineNo]) + } + + async submitSolutionComment(lineNo: number) { + const comment = { + text: this.editedSolutionComments[lineNo], + ofLine: lineNo, + ofSubmissionType: this.pk + } + await api.createSolutionComment(comment) + await actions.updateSubmissionType(this.pk) + this.toggleEditor(lineNo) + this.editedSolutionComments[lineNo] = '' + } + + updateSubmissionType() { + actions.updateSubmissionType(this.pk) + } + + mounted() { + this.timer = setInterval(() => { + actions.updateSubmissionType(this.pk) + }, 10 * 1e3) + } + + beforeDestroy() { + clearInterval(this.timer) + } + } +</script> + +<style scoped> + .solution-table { + table-layout: auto; + border-collapse: collapse; + width: 100%; + } + + .line-number-cell { + vertical-align: top + } + + .code-cell-content { + width: 100% + } + + .code-line { + white-space: pre-wrap; + font-family: monospace; + } + + .line-number-btn { + height: fit-content; + min-width: 50px; + margin: 0; + border-radius: 0px; + } +</style> diff --git a/frontend/src/components/submission_type/solution/SolutionComment.vue b/frontend/src/components/submission_type/solution/SolutionComment.vue new file mode 100644 index 00000000..ef5b9a9f --- /dev/null +++ b/frontend/src/components/submission_type/solution/SolutionComment.vue @@ -0,0 +1,197 @@ +<template> + <div> + <div class="dialog-box" @click="$emit('toggle-editor')"> + <div class="body elevation-1" :style="{borderColor: '#3D8FC1', backgroundColor}"> + <span class="tip tip-up" :style="{borderBottomColor: '#3D8FC1'}"></span> + <span v-if="ofUser" class="of-user">Of user: {{ofUser}}</span> + <span class="comment-created">{{parsedCreated}}</span> + <div class="message">{{text}}</div> + <v-btn + flat icon absolute + class="delete-button" + v-if="deletable" + @click.stop="deleteConfirmation = true" + > + <v-icon color="grey darken-1" size="20px">delete_forever</v-icon> + </v-btn> + <v-btn + flat icon absolute + class="edit-button" + v-if="editable" + @click.stop="toggleEditing()" + > + <v-icon color="grey darken-1" size="20px">edit</v-icon> + </v-btn> + </div> + </div> + <template v-if="editing"> + <v-textarea + name="solution-comment-edit" + label="Here you can edit your comment" + v-model="editedText" + @keyup.enter.ctrl.exact="submitEdit" + @keyup.esc="editing = false" + @focus="selectInput($event)" + rows="2" + outline + autofocus + auto-grow + hide-details + class="mx-2" + /> + <v-btn id="submit-comment" color="success" @click="submitEdit"><v-icon>check</v-icon>Submit</v-btn> + <v-btn id="cancel-comment" @click="editing = false"><v-icon>cancel</v-icon>cancel</v-btn> + </template> + + <v-dialog + v-model="deleteConfirmation" + max-width="max-content" + > + <v-card + class="text-xs-center pa-2" + > + <v-card-title class="title"> + Delete permanently? + </v-card-title> + <v-card-actions> + <v-btn :id="`confirm-delete-comment`" color="red lighten-1" @click="deleteComment">delete</v-btn> + <v-btn @click="deleteConfirmation = false">cancel</v-btn> + </v-card-actions> + </v-card> + </v-dialog> + </div> +</template> + +<script lang="ts"> +import {Vue, Component, Prop, Provide} from 'vue-property-decorator' +import { UI } from '@/store/modules/ui' +import { SubmissionNotes } from '@/store/modules/submission-notes' +import { Authentication } from '../../../store/modules/authentication'; +import * as api from "@/api" +import { actions } from '@/store/actions'; + +@Component +export default class SolutionComment extends Vue { + @Prop({ + type: Number, + required: true + }) pk!: number + @Prop({ + type: String, + required: true + }) text!: string + @Prop({ + type: String, + required: false + }) created?: string + @Prop({ + type: String, + required: true + }) ofUser!: string + @Prop({ + type: Number, + required: true + }) ofLine!: number + + editing: boolean = false + editedText: string = '' + deleteConfirmation: boolean = false + + get parsedCreated() { + if (this.created) { + return new Date(this.created).toLocaleString() + } else { + return 'Just now' + } + } + + get backgroundColor () { + return UI.state.darkMode ? 'grey' : '#F3F3F3' + } + + get deletable() { + return Authentication.state.user.username === this.ofUser || Authentication.isReviewer + } + + get editable() { + return Authentication.state.user.username === this.ofUser + } + + toggleEditing() { + console.log('adasd') + this.editing = !this.editing + this.editedText = this.text + } + + async deleteComment() { + await api.deleteSolutionComment(this.pk) + this.$emit('update-submission-type') + } + + async submitEdit() { + await api.patchSolutionComment({pk: this.pk, text: this.editedText}) + this.editing = false + this.$emit('update-submission-type') + } + + selectInput (event: Event) { + if (event !== null) { + const target = event.target as HTMLTextAreaElement + target.select() + } + } +} +</script> + +<style scoped> + .tip { + width: 0px; + height: 0px; + position: absolute; + background: transparent; + border: 10px solid; + } + .tip-up { + top: -22px; /* Same as body margin top + border */ + left: 10px; + border-right-color: transparent; + border-left-color: transparent; + border-top-color: transparent; + } + .dialog-box .body { + cursor: pointer; + position: relative; + height: auto; + margin: 20px 10px 10px 10px; + padding: 5px; + border-radius: 0px; + border: 2px solid; + } + .body .message { + min-height: 30px; + border-radius: 3px; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + } + .delete-button { + bottom: -12px; + left: -42px; + } + .edit-button { + bottom: 15px; + left: -42px; + } + .comment-created { + position: absolute; + font-size: 10px; + right: 4px; + top: -20px; + } + .of-user { + position: absolute; + font-size: 13px; + top: -20px; + left: 50px; + } +</style> diff --git a/frontend/src/models.ts b/frontend/src/models.ts index ffde4332..7a1ac897 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -595,6 +595,17 @@ export interface SubmissionType { * @memberof SubmissionType */ programmingLanguage?: SubmissionType.ProgrammingLanguageEnum + + solutionComments: {[ofLine: number]: SolutionComment[]} +} + +export interface SolutionComment { + pk: number, + created: string, + ofLine: number, + ofSubmissionType: string, + ofUser: string, + text: string } /** diff --git a/frontend/src/pages/StudentSubmissionSideView.vue b/frontend/src/pages/StudentSubmissionSideView.vue index d2859dad..6f1ee7b8 100644 --- a/frontend/src/pages/StudentSubmissionSideView.vue +++ b/frontend/src/pages/StudentSubmissionSideView.vue @@ -27,7 +27,7 @@ import store from '@/store/store' import VueInstance from '@/main' import SubmissionCorrection from '@/components/submission_notes/SubmissionCorrection' import SubmissionTests from '@/components/SubmissionTests' -import SubmissionType from '@/components/SubmissionType' +import SubmissionType from '@/components/submission_type/SubmissionType' import RouteChangeConfirmation from '@/components/submission_notes/RouteChangeConfirmation' import { actions } from '@/store/actions' diff --git a/frontend/src/pages/SubscriptionWorkPage.vue b/frontend/src/pages/SubscriptionWorkPage.vue index 7040ba24..fbfcc61b 100644 --- a/frontend/src/pages/SubscriptionWorkPage.vue +++ b/frontend/src/pages/SubscriptionWorkPage.vue @@ -36,7 +36,7 @@ import { Vue, Component} from 'vue-property-decorator' import { Route, NavigationGuard } from 'vue-router' import SubmissionCorrection from '@/components/submission_notes/SubmissionCorrection.vue' -import SubmissionType from '@/components/SubmissionType.vue' +import SubmissionType from '@/components/submission_type/SubmissionType.vue' import store from '@/store/store' import { SubmissionNotes } from '@/store/modules/submission-notes' import SubmissionTests from '@/components/SubmissionTests.vue' diff --git a/frontend/src/pages/reviewer/ReviewerStartPage.vue b/frontend/src/pages/reviewer/ReviewerStartPage.vue index 0f9fe4e5..860c602f 100644 --- a/frontend/src/pages/reviewer/ReviewerStartPage.vue +++ b/frontend/src/pages/reviewer/ReviewerStartPage.vue @@ -15,7 +15,7 @@ <script> import CorrectionStatistics from '@/components/CorrectionStatistics' import SubscriptionList from '@/components/subscriptions/SubscriptionList' -import SubmissionTypesOverview from '@/components/SubmissionTypesOverview' +import SubmissionTypesOverview from '@/components/submission_type/SubmissionTypesOverview' export default { components: { diff --git a/frontend/src/pages/student/StudentSubmissionPage.vue b/frontend/src/pages/student/StudentSubmissionPage.vue index d7827230..d5632bcd 100644 --- a/frontend/src/pages/student/StudentSubmissionPage.vue +++ b/frontend/src/pages/student/StudentSubmissionPage.vue @@ -55,7 +55,7 @@ <script> import { mapState, mapGetters } from 'vuex' import AnnotatedSubmission from '@/components/submission_notes/SubmissionCorrection' -import SubmissionType from '@/components/SubmissionType' +import SubmissionType from '@/components/submission_type/SubmissionType' import BaseAnnotatedSubmission from '@/components/submission_notes/base/BaseAnnotatedSubmission' import SubmissionLine from '@/components/submission_notes/base/SubmissionLine' import FeedbackComment from '@/components/submission_notes/base/FeedbackComment' diff --git a/frontend/src/pages/tutor/TutorStartPage.vue b/frontend/src/pages/tutor/TutorStartPage.vue index 94359ac7..ad127cf2 100644 --- a/frontend/src/pages/tutor/TutorStartPage.vue +++ b/frontend/src/pages/tutor/TutorStartPage.vue @@ -15,7 +15,7 @@ <script> import SubscriptionList from '@/components/subscriptions/SubscriptionList' import CorrectionStatistics from '@/components/CorrectionStatistics' -import SubmissionTypesOverview from '@/components/SubmissionTypesOverview' +import SubmissionTypesOverview from '@/components/submission_type/SubmissionTypesOverview' export default { components: { diff --git a/frontend/src/store/actions.ts b/frontend/src/store/actions.ts index ac0982b9..45942428 100644 --- a/frontend/src/store/actions.ts +++ b/frontend/src/store/actions.ts @@ -16,14 +16,21 @@ async function getExamTypes (context: BareActionContext<RootState, RootState>) { const examTypes = await api.fetchExamTypes() mut.SET_EXAM_TYPES(examTypes) } -async function updateSubmissionTypes ( - context: BareActionContext<RootState, RootState> -) { +async function updateSubmissionTypes (){ const submissionTypes = await api.fetchSubmissionTypes() submissionTypes.forEach(type => { mut.UPDATE_SUBMISSION_TYPE(type) }) } + +async function updateSubmissionType ( + context: BareActionContext<RootState, RootState>, + pk: string +) { + const submissionType = await api.fetchSubmissionType(pk) + mut.UPDATE_SUBMISSION_TYPE(submissionType) +} + async function getStudents ( context: BareActionContext<RootState, RootState>, opt: { studentPks: Array<string>} = { @@ -85,6 +92,7 @@ const mb = getStoreBuilder<RootState>() export const actions = { updateSubmissionTypes: mb.dispatch(updateSubmissionTypes), + updateSubmissionType: mb.dispatch(updateSubmissionType), getExamTypes: mb.dispatch(getExamTypes), getStudents: mb.dispatch(getStudents), getSubmissionFeedbackTest: mb.dispatch(getSubmissionFeedbackTest), diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts index e65f6794..9b2335c7 100644 --- a/frontend/src/store/modules/submission-notes.ts +++ b/frontend/src/store/modules/submission-notes.ts @@ -70,7 +70,7 @@ const submissionGetter = mb.read(function submission(state, getters) { ? getters.submissionType.programmingLanguage : 'c' const highlighted = hljs.highlight(language, state.submission.text || '', true).value - const postProcessed = syntaxPostProcess(highlighted); + const postProcessed = syntaxPostProcess(highlighted) const splitted = postProcessed.split('\n').reduce((acc: { [k: number]: string }, cur, index) => { acc[index + 1] = cur return acc diff --git a/frontend/src/store/mutations.ts b/frontend/src/store/mutations.ts index 24bea756..386a9df8 100644 --- a/frontend/src/store/mutations.ts +++ b/frontend/src/store/mutations.ts @@ -2,7 +2,7 @@ import Vue from 'vue' import { getStoreBuilder } from 'vuex-typex' import { initialState, RootState } from '@/store/store' -import { Exam, Feedback, Statistics, StudentInfoForListView, SubmissionNoType, SubmissionType, Tutor } from '@/models' +import { Exam, Statistics, StudentInfoForListView, SubmissionNoType, SubmissionType} from '@/models' export const mb = getStoreBuilder<RootState>() diff --git a/functional_tests/test_auto_logout.py b/functional_tests/test_auto_logout.py index ec268c73..ded3555b 100644 --- a/functional_tests/test_auto_logout.py +++ b/functional_tests/test_auto_logout.py @@ -7,7 +7,7 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ec from core.models import UserAccount -from functional_tests.util import (login, create_browser) +from functional_tests.util import (login, create_browser, reset_browser_after_test) from util import factory_boys as fact log = logging.getLogger(__name__) @@ -39,6 +39,9 @@ class TestAutoLogout(LiveServerTestCase): role=self.role ) + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + def _login(self): login(self.browser, self.live_server_url, self.username, self.password) diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py index 92d08bcc..5f82e207 100644 --- a/functional_tests/test_export_modal.py +++ b/functional_tests/test_export_modal.py @@ -112,6 +112,7 @@ class ExportTestModal(LiveServerTestCase): def test_export_student_scores_as_json(self): fact.StudentInfoFactory() + fact.SubmissionFactory() self._login() export_btn = self.browser.find_element_by_id('export-btn') export_btn.click() diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py index bc81158a..3c4ad161 100644 --- a/functional_tests/test_feedback_creation.py +++ b/functional_tests/test_feedback_creation.py @@ -9,7 +9,8 @@ from core.models import UserAccount, Submission, FeedbackComment from functional_tests.util import (login, create_browser, reset_browser_after_test, go_to_subscription, wait_until_code_changes, correct_some_submission, - reconstruct_submission_code, wait_until_element_count_equals) + reconstruct_submission_code, wait_until_element_count_equals, + reconstruct_solution_code) from util import factory_boys as fact @@ -71,8 +72,8 @@ class UntestedParent: f'{self.sub_type.name} - Full score: {self.sub_type.full_score}', title.text ) - solution = sub_type_el.find_element_by_class_name('solution-code') - self.assertEqual(self.sub_type.solution, solution.get_attribute('textContent')) + solution = reconstruct_solution_code(self) + self.assertEqual(self.sub_type.solution, solution) description = sub_type_el.find_element_by_class_name('type-description') html_el_in_desc = description.find_element_by_tag_name('h1') self.assertEqual('This', html_el_in_desc.text) diff --git a/functional_tests/test_feedback_update.py b/functional_tests/test_feedback_update.py index 30bf1a2d..a7fdbe9f 100644 --- a/functional_tests/test_feedback_update.py +++ b/functional_tests/test_feedback_update.py @@ -5,7 +5,8 @@ from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.ui import WebDriverWait from functional_tests.util import (login, create_browser, go_to_subscription, - reconstruct_submission_code, correct_some_submission) + reconstruct_submission_code, correct_some_submission, + reset_browser_after_test) from util import factory_boys as fact @@ -36,6 +37,9 @@ class TestFeedbackUpdate(LiveServerTestCase): self.sub_type = fact.SubmissionTypeFactory.create() fact.SubmissionFactory.create_batch(2, type=self.sub_type) + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + def _login(self): login(self.browser, self.live_server_url, self.username, self.password) diff --git a/functional_tests/test_front_pages.py b/functional_tests/test_front_pages.py index 742aacfd..a213fa00 100644 --- a/functional_tests/test_front_pages.py +++ b/functional_tests/test_front_pages.py @@ -74,6 +74,9 @@ class FrontPageTestsTutor(UntestedParent.FrontPageTestsTutorReviewer): password=self.password ) + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + def test_side_bar_contains_correct_items(self): self._login() drawer = self.browser.find_element_by_class_name('v-navigation-drawer') @@ -101,6 +104,9 @@ class FrontPageTestsReviewer(UntestedParent.FrontPageTestsTutorReviewer): role=self.role ) + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + def test_side_bar_contains_correct_items(self): self._login() drawer = self.browser.find_element_by_class_name('v-navigation-drawer') diff --git a/functional_tests/test_solution_comments.py b/functional_tests/test_solution_comments.py new file mode 100644 index 00000000..59358ece --- /dev/null +++ b/functional_tests/test_solution_comments.py @@ -0,0 +1,142 @@ +from django.test import LiveServerTestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support import expected_conditions as ec +from selenium.webdriver.support.ui import WebDriverWait + +from core import models +from functional_tests.util import (login, create_browser, query_returns_object, + reset_browser_after_test) +from util import factory_boys as fact + + +class TestSolutionComments(LiveServerTestCase): + browser: webdriver.Firefox = None + username = None + password = None + role = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.browser = create_browser() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.browser.quit() + + def setUp(self): + super().setUp() + self.username = 'tut' + self.password = 'p' + fact.UserAccountFactory( + username=self.username, + password=self.password, + ) + self.sub_type = fact.SubmissionTypeFactory.create() + + def tearDown(self): + reset_browser_after_test(self.browser, self.live_server_url) + + def _login(self): + login(self.browser, self.live_server_url, self.username, self.password) + + def _write_comment(self, text="A comment", line_no=1): + sub_types = self.browser.find_element_by_id('submission-types-list') + sub_types.find_element_by_tag_name('a').click() + solution_table = self.browser.find_element_by_class_name('solution-table') + tr_of_line = solution_table.find_element_by_id(f'solution-line-{line_no}') + tr_of_line.find_element_by_class_name('line-number-btn').click() + comment_input = tr_of_line.find_element_by_name('solution-comment-input') + comment_input.send_keys(text) + solution_table.find_element_by_id('submit-comment').click() + + def _edit_comment(self, old_text, new_text) -> WebElement: + solution_table = self.browser.find_element_by_class_name('solution-table') + comment = solution_table.find_element_by_xpath( + f"//div[@class='dialog-box' and .//*[contains(text(), '{old_text}')]]" + ) + comment.find_element_by_class_name('edit-button').click() + comment_input = solution_table.find_element_by_name('solution-comment-edit') + comment_input.send_keys(new_text) + solution_table.find_element_by_id('submit-comment').click() + return comment + + def test_tutor_can_add_comment(self): + self._login() + comment_text = 'A comment!' + self._write_comment(comment_text, 1) + solution_table = self.browser.find_element_by_class_name('solution-table') + displayed_text = solution_table.find_element_by_class_name('message').text + self.assertEqual(comment_text, displayed_text) + comment_obj = models.SolutionComment.objects.first() + self.assertEqual(comment_text, comment_obj.text) + self.assertEqual(1, comment_obj.of_line) + + def test_tutor_can_delete_own_comment(self): + self._login() + self._write_comment() + solution_table = self.browser.find_element_by_class_name('solution-table') + solution_table.find_element_by_class_name('delete-button').click() + self.browser.find_element_by_id('confirm-delete-comment').click() + WebDriverWait(self.browser, 10).until_not( + query_returns_object(models.SolutionComment), + "Solution comment not deleted." + ) + + def test_tutor_can_edit_own_comment(self): + self._login() + old_text = 'A comment' + new_text = 'A new text' + self._write_comment(old_text) + comment_obj = models.SolutionComment.objects.first() + self.assertEqual(old_text, comment_obj.text) + comment_el = self._edit_comment(old_text, new_text) + displayed_text = comment_el.find_element_by_class_name('message').text + self.assertEqual(new_text, displayed_text) + comment_obj.refresh_from_db() + self.assertEqual(new_text, comment_obj.text) + + def test_tutor_can_not_delete_edit_other_comment(self): + self._login() + self._write_comment() + username = 'tut2' + password = 'p' + fact.UserAccountFactory(username=username, password=password) + reset_browser_after_test(self.browser, self.live_server_url) + login(self.browser, self.live_server_url, username, password) + sub_types = self.browser.find_element_by_id('submission-types-list') + sub_types.find_element_by_tag_name('a').click() + solution_table = self.browser.find_element_by_class_name('solution-table') + # Set the implicit wait for those to shorter, to reduce test run time + self.browser.implicitly_wait(2) + edit_buttons = solution_table.find_elements_by_class_name('edit-button') + delete_buttons = solution_table.find_elements_by_class_name('delete-button') + self.browser.implicitly_wait(10) + self.assertEqual(0, len(edit_buttons)) + self.assertEqual(0, len(delete_buttons)) + + def test_reviewer_can_delete_tutor_comment(self): + self._login() + self._write_comment() + username = 'rev' + password = 'p' + fact.UserAccountFactory( + username=username, password=password, role=models.UserAccount.REVIEWER + ) + reset_browser_after_test(self.browser, self.live_server_url) + login(self.browser, self.live_server_url, username, password) + sub_types = self.browser.find_element_by_id('submission-types-list') + sub_types.find_element_by_tag_name('a').click() + solution_table = self.browser.find_element_by_class_name('solution-table') + solution_table.find_element_by_class_name('delete-button').click() + self.browser.find_element_by_id('confirm-delete-comment').click() + WebDriverWait(self.browser, 10).until_not( + ec.presence_of_element_located((By.CLASS_NAME, 'dialog-box')) + ) + WebDriverWait(self.browser, 10).until_not( + query_returns_object(models.SolutionComment), + "Solution comment not deleted." + ) diff --git a/functional_tests/util.py b/functional_tests/util.py index 7d10f0dc..d35addf2 100644 --- a/functional_tests/util.py +++ b/functional_tests/util.py @@ -120,7 +120,16 @@ def correct_some_submission(test_class_instance): def reconstruct_submission_code(test_class_instance): sub_table = test_class_instance.browser.find_element_by_class_name('submission-table') - lines = sub_table.find_elements_by_tag_name('tr') + return reconstruct_code_from_table(sub_table) + + +def reconstruct_solution_code(test_class_instance): + solution_table = test_class_instance.browser.find_element_by_class_name('solution-table') + return reconstruct_code_from_table(solution_table) + + +def reconstruct_code_from_table(table_el): + lines = table_el.find_elements_by_tag_name('tr') line_no_code_pairs = [ (line.get_attribute('id'), # call get_attribute here to get non normalized text -- GitLab