Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • j.michal/grady
1 result
Show changes
Commits on Source (6)
Showing
with 343 additions and 43 deletions
...@@ -2,7 +2,6 @@ stages: ...@@ -2,7 +2,6 @@ stages:
- build - build
- test - test
- build_image - build_image
- test_build
- pages - pages
- staging - staging
...@@ -32,6 +31,8 @@ build_test_env: ...@@ -32,6 +31,8 @@ build_test_env:
- .venv - .venv
tags: tags:
- docker - docker
interruptible: true
build_frontend: build_frontend:
image: node:carbon image: node:carbon
...@@ -54,6 +55,29 @@ build_frontend: ...@@ -54,6 +55,29 @@ build_frontend:
- frontend/node_modules/ - frontend/node_modules/
tags: tags:
- docker - docker
interruptible: true
build_test_image:
image: docker:latest
stage: build
only:
- branches
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_DRIVER: overlay2
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker pull $DEV_IMAGE_BASE || true
- docker build --cache-from $DEV_IMAGE_BASE -t $DEV_IMAGE_BASE --target node .
- docker pull $DEV_IMAGE || true
- docker build --cache-from $DEV_IMAGE --cache-from $DEV_IMAGE_BASE -t $DEV_IMAGE .
- docker push $DEV_IMAGE_BASE
- docker push $DEV_IMAGE
tags:
- docker
interruptible: true
# ============================== Testing section ============================= # # ============================== Testing section ============================= #
# ----------------------------- Backend subsection --------------------------- # # ----------------------------- Backend subsection --------------------------- #
...@@ -65,6 +89,9 @@ build_frontend: ...@@ -65,6 +89,9 @@ build_frontend:
- build_test_env - build_test_env
tags: tags:
- docker - docker
interruptible: true
needs:
- build_test_env
test_pytest: test_pytest:
<<: *test_definition_virtualenv <<: *test_definition_virtualenv
...@@ -97,6 +124,10 @@ test_flake8: ...@@ -97,6 +124,10 @@ test_flake8:
- build_frontend - build_frontend
tags: tags:
- docker - docker
interruptible: true
needs:
- build_frontend
- build_test_env
test_frontend: test_frontend:
<<: *test_definition_frontend <<: *test_definition_frontend
...@@ -124,37 +155,17 @@ test_frontend_unit: ...@@ -124,37 +155,17 @@ test_frontend_unit:
script: script:
- cd frontend/ - cd frontend/
- yarn test:unit - yarn test:unit
interruptible: true
needs:
- build_frontend
# =========================== Build Image section ============================ # # =========================== Build Image section ============================ #
build_dev_image:
image: docker:latest
stage: build_image
only:
- branches
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_DRIVER: overlay2
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker pull $DEV_IMAGE_BASE || true
- docker build --cache-from $DEV_IMAGE_BASE -t $DEV_IMAGE_BASE --target node .
- docker pull $DEV_IMAGE || true
- docker build --cache-from $DEV_IMAGE --cache-from $DEV_IMAGE_BASE -t $DEV_IMAGE .
- docker push $DEV_IMAGE_BASE
- docker push $DEV_IMAGE
tags:
- docker
build_release_image: build_release_image:
image: docker:latest image: docker:latest
stage: build_image stage: build_image
only: only:
- tags - tags
except:
- branches
services: services:
- docker:dind - docker:dind
variables: variables:
...@@ -184,7 +195,10 @@ pages: ...@@ -184,7 +195,10 @@ pages:
- public - public
only: only:
- master - master
interruptible: true
needs:
- test_pytest
- build_test_env
# ============================== Staging section ============================= # # ============================== Staging section ============================= #
.staging_template: &staging_definition .staging_template: &staging_definition
......
from .common_serializers import * # noqa from .common_serializers import * # noqa
from .submission_type import (SubmissionTypeListSerializer, SubmissionTypeSerializer, # noqa from .submission_type import (SubmissionTypeListSerializer, SubmissionTypeSerializer, # noqa
SolutionCommentSerializer) # noqa SolutionCommentSerializer) # noqa
from .feedback import (FeedbackSerializer, FeedbackCommentSerializer, # noqa from .feedback import (FeedbackSerializer, # noqa
FeedbackCommentSerializer, # noqa
VisibleCommentFeedbackSerializer) # noqa VisibleCommentFeedbackSerializer) # noqa
from .subscription import * # noqa from .subscription import * # noqa
from .student import * # noqa from .student import * # noqa
......
...@@ -153,7 +153,11 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): ...@@ -153,7 +153,11 @@ class FeedbackSerializer(DynamicFieldsModelSerializer):
self.instance is not None and self.instance is not None and
self.instance.feedback_lines.count() > 0) self.instance.feedback_lines.count() > 0)
if not has_full_score and not has_feedback_lines: has_label_attached = 'labels' in data and len(data['labels']) > 0
# a non-full scored feedback is considered valid if there is
# at least one comment line or a label attached to the feedback
if not (has_full_score or has_feedback_lines or has_label_attached):
raise serializers.ValidationError( raise serializers.ValidationError(
'Sorry, you have to explain why this does not get full score') 'Sorry, you have to explain why this does not get full score')
......
...@@ -43,6 +43,14 @@ class SubmissionNoTypeSerializer(DynamicFieldsModelSerializer): ...@@ -43,6 +43,14 @@ class SubmissionNoTypeSerializer(DynamicFieldsModelSerializer):
fields = ('pk', 'type', 'full_score', 'text', 'feedback', 'tests') fields = ('pk', 'type', 'full_score', 'text', 'feedback', 'tests')
class SubmissionNoTypeWithStudentSerializer(SubmissionNoTypeSerializer):
of_student = serializers.ReadOnlyField(source='student.user.username')
class Meta:
model = Submission
fields = ('pk', 'type', 'full_score', 'text', 'feedback', 'tests', 'of_student')
class SubmissionListSerializer(DynamicFieldsModelSerializer): class SubmissionListSerializer(DynamicFieldsModelSerializer):
type = SubmissionTypeListSerializer(fields=('pk', 'name', 'full_score')) type = SubmissionTypeListSerializer(fields=('pk', 'name', 'full_score'))
feedback = FeedbackSerializer() feedback = FeedbackSerializer()
......
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from core.models import UserAccount, SubmissionType
from util.factories import GradyUserFactory
test_data = {
"meta": {
"version": "4.0.0"
},
"module": {
"module_reference": "test",
"pass_only": True,
"pass_score": 1,
"total_score": 99
},
"students": [
{
"fullname": "test",
"identifier": "test-test",
"submissions": [
{
"code": "some messy, perhaps incorrect stuff",
"tests": {},
"type": "[a0] coding stuff"
},
{
"code": "i don't know man",
"tests": {},
"type": "[a1] improvise"
}
],
}
],
"submission_types": [
{
"description": "code some 1337 stuff",
"full_score": 99,
"name": "[a0] coding stuff",
"programming_language": "c",
"solution": "how dare u"
},
{
"description": "now this one's hard",
"full_score": 1,
"name": "[a1] improvise",
"programming_language": "haskell",
"solution": "nope"
},
]
}
class ImportViewTest(APITestCase):
factory = GradyUserFactory()
def setUp(self):
self.url = '/api/import/'
self.client = APIClient()
self.client.force_login(user=self.factory.make_reviewer())
def test_can_not_submit_nothing(self):
res = self.client.post(self.url)
self.assertEqual(status.HTTP_400_BAD_REQUEST, res.status_code)
def test_will_fail_on_wrong_importer_version(self):
data = {"meta": {"version": "0.0.0"}}
res = self.client.post(self.url, data)
self.assertEqual(status.HTTP_409_CONFLICT, res.status_code)
def test_data_is_imported_correctly(self):
res = self.client.post(self.url, test_data)
sub_types = SubmissionType.objects.all()
students = UserAccount.objects.all().filter(role='Student')
self.assertEqual(2, len(sub_types))
self.assertEqual(1, len(students))
self.assertEqual(status.HTTP_201_CREATED, res.status_code)
...@@ -52,6 +52,7 @@ regular_views_urlpatterns = [ ...@@ -52,6 +52,7 @@ regular_views_urlpatterns = [
name='jwt-time-delta'), name='jwt-time-delta'),
path('instance/export/', views.InstanceExport.as_view(), name="instance-export"), path('instance/export/', views.InstanceExport.as_view(), name="instance-export"),
path('export/json/', views.StudentJSONExport.as_view(), name='export-json'), path('export/json/', views.StudentJSONExport.as_view(), name='export-json'),
path('import/', views.ImportApiViewSet.as_view(), name='import-json'),
re_path(r'swagger(?P<format>\.json|\.yaml)$', re_path(r'swagger(?P<format>\.json|\.yaml)$',
schema_view.without_ui(cache_timeout=0), name='schema-json'), schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'swagger/$', schema_view.with_ui('swagger', cache_timeout=0), re_path(r'swagger/$', schema_view.with_ui('swagger', cache_timeout=0),
......
...@@ -3,3 +3,4 @@ from .subscription import SubscriptionApiViewSet, AssignmentApiViewSet # noqa ...@@ -3,3 +3,4 @@ from .subscription import SubscriptionApiViewSet, AssignmentApiViewSet # noqa
from .common_views import * # noqa from .common_views import * # noqa
from .export import StudentJSONExport, InstanceExport # noqa from .export import StudentJSONExport, InstanceExport # noqa
from .label import LabelApiViewSet, LabelStatistics # noqa from .label import LabelApiViewSet, LabelStatistics # noqa
from .importer import ImportApiViewSet # noqa
...@@ -24,7 +24,8 @@ from core.serializers import (ExamSerializer, StudentInfoSerializer, ...@@ -24,7 +24,8 @@ from core.serializers import (ExamSerializer, StudentInfoSerializer,
StudentInfoForListViewSerializer, StudentInfoForListViewSerializer,
SubmissionNoTypeSerializer, StudentSubmissionSerializer, SubmissionNoTypeSerializer, StudentSubmissionSerializer,
SubmissionTypeSerializer, CorrectorSerializer, SubmissionTypeSerializer, CorrectorSerializer,
UserAccountSerializer, SolutionCommentSerializer) UserAccountSerializer, SolutionCommentSerializer,
SubmissionNoTypeWithStudentSerializer)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -183,7 +184,6 @@ class StatisticsEndpoint(viewsets.ViewSet): ...@@ -183,7 +184,6 @@ class StatisticsEndpoint(viewsets.ViewSet):
class SubmissionViewSet(viewsets.ReadOnlyModelViewSet): class SubmissionViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (IsTutorOrReviewer, ) permission_classes = (IsTutorOrReviewer, )
serializer_class = SubmissionNoTypeSerializer
queryset = models.Submission.objects\ queryset = models.Submission.objects\
.select_related('type')\ .select_related('type')\
.select_related('feedback')\ .select_related('feedback')\
...@@ -192,6 +192,14 @@ class SubmissionViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -192,6 +192,14 @@ class SubmissionViewSet(viewsets.ReadOnlyModelViewSet):
.prefetch_related('feedback__feedback_lines__of_tutor')\ .prefetch_related('feedback__feedback_lines__of_tutor')\
.all() .all()
def get_serializer_class(self):
if self.request.user.is_reviewer():
# this contains student username
# in most cases a pseudonym, but useful for
# tracking students across views in the frontend
return SubmissionNoTypeWithStudentSerializer
return SubmissionNoTypeSerializer
def get_queryset(self): def get_queryset(self):
if self.request.user.is_reviewer(): if self.request.user.is_reviewer():
return self.queryset return self.queryset
......
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import ValidationError
from core.permissions import IsReviewer
from util.importer import parse_and_import_hektor_json
class ImportApiViewSet(APIView):
permission_classes = (IsReviewer, )
def post(self, request):
exam_data = request.data
if not exam_data:
return Response({"Error": "You need to submit the exam data to be imported"},
status.HTTP_400_BAD_REQUEST)
try:
parse_and_import_hektor_json(exam_data)
except ValidationError as err:
return Response({"ValidationError": err.detail},
status.HTTP_409_CONFLICT)
return Response({}, status.HTTP_201_CREATED)
...@@ -256,6 +256,10 @@ export async function fetchStudentExportData (options: StudentExportOptions): Pr ...@@ -256,6 +256,10 @@ export async function fetchStudentExportData (options: StudentExportOptions): Pr
return (await ax.post('/api/export/json/', options)).data return (await ax.post('/api/export/json/', options)).data
} }
export async function importData (data: Object): Promise<AxiosResponse<void>> {
return ax.post('/api/import/', data)
}
// Note, this interface does not represent all of the returned data, // Note, this interface does not represent all of the returned data,
// but only the fields which have to be transformed for deanonymisation // but only the fields which have to be transformed for deanonymisation
export interface InstanceExportData { export interface InstanceExportData {
......
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
<slot name="toolbar-center"/> <slot name="toolbar-center"/>
<div class="toolbar-content"> <div class="toolbar-content">
<v-menu bottom offset-y v-if="!isStudent"> <v-menu bottom offset-y v-if="!isStudent">
<v-btn slot="activator" color="cyan" style="text-transform: none"> <v-btn id="user-options" slot="activator" color="cyan" style="text-transform: none">
{{ userRole }} | {{ username }} <v-icon>arrow_drop_down</v-icon> {{ userRole }} | {{ username }} <v-icon>arrow_drop_down</v-icon>
</v-btn> </v-btn>
<user-options class="mt-1" v-if="!isStudent"/> <user-options class="mt-1" v-if="!isStudent"/>
......
<template>
<v-dialog v-model="show" width="30%">
<v-card>
<v-card-title class="title">Import data</v-card-title>
<v-card-text>
<p>
You can use this component to import data into Grady.
You can use
<a
href="https://gitlab.gwdg.de/grady-corp/rusty-hektor"
target="_blank"
>rusty-hektor</a> to convert
and pseudonomize ILIAS output.
</p>
<file-select v-model="hektorFile" display-text="Select json file" />
</v-card-text>
<v-card-actions>
<v-btn @click="submitData" :loading="loading" id="submit-import">Import</v-btn>
<v-btn @click="$emit('hide')" color="red">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import FileSelect from "@/components/util/FileSelect.vue";
import { importData } from "@/api";
export default {
name: "ImportDialog",
components: {
FileSelect
},
data: () => {
return {
show: true,
loading: false,
hektorFile: null
};
},
methods: {
async submitData() {
this.loading = true
let data;
try {
data = await this.readFile();
data = JSON.parse(data)
} catch (error) {
this.$notify({
type: 'error',
title: 'Error reading import file',
text: error.message
})
this.loading = false
return
}
try {
await importData(data)
this.$emit('imported')
this.$notify({
title: 'Successfully imported data. Please log out and in again.',
type: 'success'
})
} finally {
this.loading = false
}
},
readFile() {
const fileReader = new FileReader();
return new Promise((resolve, reject) => {
fileReader.onload = event => {
resolve(event.target.result);
};
fileReader.onerror = () => {
fileReader.abort();
reject(new Error("Problem parsing input file."));
};
fileReader.readAsText(this.hektorFile)
});
}
},
watch: {
show(val) {
if (!val) {
this.$emit("hide");
}
}
}
};
</script>
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
<v-list-tile <v-list-tile
v-if="opt.condition()" v-if="opt.condition()"
@click="opt.action" @click="opt.action"
:id="opt.id"
:key="i" :key="i"
> >
{{opt.display}} {{opt.display}}
...@@ -17,11 +18,12 @@ ...@@ -17,11 +18,12 @@
<script> <script>
import PasswordChangeDialog from '@/components/PasswordChangeDialog' import PasswordChangeDialog from '@/components/PasswordChangeDialog'
import ImportDialog from '@/components/ImportDialog'
import { Authentication } from '@/store/modules/authentication' import { Authentication } from '@/store/modules/authentication'
import { deleteAllActiveAssignments } from '@/api' import { deleteAllActiveAssignments } from '@/api'
export default { export default {
name: 'UserOptions', name: 'UserOptions',
components: { PasswordChangeDialog }, components: { PasswordChangeDialog, ImportDialog },
data () { data () {
return { return {
displayComponent: null, displayComponent: null,
...@@ -29,12 +31,20 @@ export default { ...@@ -29,12 +31,20 @@ export default {
{ {
display: 'Change password', display: 'Change password',
action: () => { this.displayComponent = PasswordChangeDialog }, action: () => { this.displayComponent = PasswordChangeDialog },
condition: () => !Authentication.isStudent condition: () => !Authentication.isStudent,
id: "change-password-list-tile"
}, },
{ {
display: 'Free all reserved submissions', display: 'Free all reserved submissions',
action: deleteAllActiveAssignments, action: deleteAllActiveAssignments,
condition: () => Authentication.isReviewer condition: () => Authentication.isReviewer,
id: "free-assignments-list-tile"
},
{
display: 'Import data',
action: () => { this.displayComponent = ImportDialog },
condition: () => Authentication.isReviewer,
id: "import-data-list-tile"
} }
] ]
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
<annotated-submission-top-toolbar <annotated-submission-top-toolbar
class="mb-1 elevation-1" class="mb-1 elevation-1"
slot="header" slot="header"
:ofStudent="submissionObj && submissionObj.ofStudent"
/> />
<template slot="table-content" id='sub-lines'> <template slot="table-content" id='sub-lines'>
<tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`" :id="`sub-line-${lineNo}`"> <tr v-for="(code, lineNo) in submission" :key="`${submissionObj.pk}${lineNo}`" :id="`sub-line-${lineNo}`">
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
> >
<correction-help-card/> <correction-help-card/>
</v-dialog> </v-dialog>
<span class="title">Participant submission</span> <span class="title">Submission of {{ofStudent}}</span>
<toggle-feedback-visibility-button/> <toggle-feedback-visibility-button/>
<v-spacer/> <v-spacer/>
<v-tooltip top> <v-tooltip top>
...@@ -35,6 +35,12 @@ export default { ...@@ -35,6 +35,12 @@ export default {
ToggleFeedbackVisibilityButton, ToggleFeedbackVisibilityButton,
CorrectionHelpCard }, CorrectionHelpCard },
name: 'annotated-submission-top-toolbar', name: 'annotated-submission-top-toolbar',
props: {
ofStudent: {
type: String,
default: "Participant"
}
},
data () { data () {
return { return {
helpDialog: false, helpDialog: false,
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<span v-if="value">Selected: {{value.name}}</span> <span v-if="value">Selected: {{value.name}}</span>
<span v-else>{{displayText}}</span> <span v-else>{{displayText}}</span>
</div> </div>
<input type="file" @change="handleFileChange"/> <input id="file-input" type="file" @change="handleFileChange"/>
</label> </label>
</template> </template>
...@@ -17,7 +17,6 @@ export default { ...@@ -17,7 +17,6 @@ export default {
default: 'Select File' default: 'Select File'
} }
}, },
methods: { methods: {
handleFileChange (e) { handleFileChange (e) {
this.$emit('input', e.target.files[0]) this.$emit('input', e.target.files[0])
......
...@@ -177,7 +177,7 @@ export interface CreateUpdateFeedback { ...@@ -177,7 +177,7 @@ export interface CreateUpdateFeedback {
* @type {Array<FeedbackComment>} * @type {Array<FeedbackComment>}
* @memberof Feedback * @memberof Feedback
*/ */
feedbackLines?: {[lineNo: number]: FeedbackComment} feedbackLines: {[lineNo: number]: FeedbackComment}
/** /**
* *
* @type {Date} * @type {Date}
...@@ -545,6 +545,7 @@ export interface SubmissionNoType { ...@@ -545,6 +545,7 @@ export interface SubmissionNoType {
* @memberof SubmissionNoType * @memberof SubmissionNoType
*/ */
feedback?: Feedback feedback?: Feedback
ofStudent?:string
/** /**
* *
* @type {Array<Test>} * @type {Array<Test>}
......
<template> <template>
<component :is="startPage"> <component :is="startPage"/>
</component>
</template> </template>
<script> <script>
......
<template> <template>
<div> <div v-if="dataLoaded">
<v-layout row wrap> <v-layout row wrap>
<v-flex lg5 md9 xs12> <v-flex lg5 md9 xs12>
<correction-statistics class="ma-4"></correction-statistics> <correction-statistics class="ma-4"></correction-statistics>
...@@ -10,22 +10,66 @@ ...@@ -10,22 +10,66 @@
</v-layout> </v-layout>
<SubmissionTypesOverview class="ma-4"/> <SubmissionTypesOverview class="ma-4"/>
</div> </div>
<v-layout v-else justify-center class="mt-4 pt-4">
<import-dialog
v-if="showImportDialog"
@hide="showImportDialog = false"
@imported="importDone"
/>
<v-card class="import-card">
<v-card-title class="title">
Import data
</v-card-title>
<v-card-text>
It looks like this instance doesn't contain any data.
Would you like to import some?
</v-card-text>
<v-card-actions class="justify-center">
<v-btn @click="showImportDialog = true" class="info">Import data</v-btn>
</v-card-actions>
</v-card>
</v-layout>
</template> </template>
<script> <script>
import CorrectionStatistics from '@/components/CorrectionStatistics' import CorrectionStatistics from '@/components/CorrectionStatistics'
import ImportDialog from '@/components/ImportDialog'
import SubscriptionList from '@/components/subscriptions/SubscriptionList' import SubscriptionList from '@/components/subscriptions/SubscriptionList'
import SubmissionTypesOverview from '@/components/submission_type/SubmissionTypesOverview' import SubmissionTypesOverview from '@/components/submission_type/SubmissionTypesOverview'
import { getters } from '../../store/getters'
import { Subscriptions } from '../../store/modules/subscriptions'
export default { export default {
components: { components: {
ImportDialog,
SubmissionTypesOverview, SubmissionTypesOverview,
SubscriptionList, SubscriptionList,
CorrectionStatistics }, CorrectionStatistics },
name: 'reviewer-start-page' name: 'reviewer-start-page',
data: () => {
return {
showImportDialog: false,
dataImported: false
}
},
computed: {
dataLoaded () {
return Object.keys(getters.state.submissionTypes).length !== 0 || this.dataImported
}
},
methods: {
importDone() {
this.dataImported = true
Subscriptions.RESET_STATE()
}
}
} }
</script> </script>
<style scoped> <style scoped>
.import-card {
width: 30%;
}
</style> </style>
...@@ -187,7 +187,10 @@ Promise<AxiosResponse<void>[]> { ...@@ -187,7 +187,10 @@ Promise<AxiosResponse<void>[]> {
feedback.score = state.origFeedback.score feedback.score = state.origFeedback.score
} }
if (state.updatedFeedback.feedbackLines && Object.keys(state.updatedFeedback.feedbackLines).length > 0) { const hasFeedbackOrLabel = Object.keys(state.updatedFeedback.feedbackLines).length > 0 ||
state.updatedFeedback.labels.length > 0
if (hasFeedbackOrLabel) {
// set the comments for the feedback lines accordingly // set the comments for the feedback lines accordingly
for (const key of Object.keys(state.updatedFeedback.feedbackLines)) { for (const key of Object.keys(state.updatedFeedback.feedbackLines)) {
const numKey = Number(key) const numKey = Number(key)
...@@ -196,7 +199,7 @@ Promise<AxiosResponse<void>[]> { ...@@ -196,7 +199,7 @@ Promise<AxiosResponse<void>[]> {
&& (feedback.feedbackLines[numKey] = state.updatedFeedback.feedbackLines[numKey]) && (feedback.feedbackLines[numKey] = state.updatedFeedback.feedbackLines[numKey])
} }
} else if (feedback.score! < SubmissionNotes.submissionType.fullScore! && !state.hasOrigFeedback) { } else if (feedback.score! < SubmissionNotes.submissionType.fullScore! && !state.hasOrigFeedback) {
throw new Error('You need to add or change a comment when setting a non full score.') throw new Error('You need to add or change a comment or a feedback label when setting a non full score.')
} }
const assignment = Subscriptions.state.currentAssignment const assignment = Subscriptions.state.currentAssignment
......