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
Select Git revision

Target

Select target project
  • j.michal/grady
1 result
Select Git revision
Show changes
Commits on Source (4)
......@@ -112,7 +112,7 @@ test_pytest:
<<: *test_definition_virtualenv
stage: test
services:
- postgres:9.6
- postgres:13
script:
- pytest --cov --ds=grady.settings.test core/tests
artifacts:
......@@ -152,7 +152,7 @@ test_frontend:
<<: *test_definition_frontend
stage: test
services:
- postgres:9.6
- postgres:13
script:
- cp frontend/dist/index.html core/templates
- python util/format_index.py
......
APP_LIST ?= core grady util
DB_NAME = postgres
.PHONY: run install migrations-check isort isort-check test
.ONESHELL:
.PHONY: run install migrations-check isort isort-check test teste2e
run:
python manage.py runserver 0.0.0.0:8000
......@@ -21,14 +23,19 @@ migrate:
test:
pytest --ds=grady.settings core/tests
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 --ds=grady.settings $(path); git checkout core/templates/index.html
frontend/dist: $(shell find frontend/src -type f)
yarn --cwd frontend build
teste2e: frontend/dist
set -e
cp frontend/dist/index.html core/templates
trap "git checkout core/templates/index.html" EXIT
python util/format_index.py
python manage.py collectstatic --no-input
HEADLESS_TESTS=$(headless) pytest --ds=grady.settings $(path)
coverage:
set -e
DJANGO_SETTINGS_MODULE=grady.settings pytest --cov
coverage html
......
......@@ -62,7 +62,7 @@ installed automatically during the installation process.
To set up a new development instance perform the following steps:
1. Create a virtual environment with a Python3.6 interpreter and install
1. Create a virtual environment with a Python3.6 interpreter and install
all relevant dependencies:
```shell script
......@@ -82,7 +82,7 @@ pipenv shell
4. Set up a Postgres 9.5 database. If you have docker installed the
easiest way is to just run it in a docker container, like this:
```shell script
docker run -d --rm --name postgres -p 5432:5432 postgres:9.5
docker run -d --rm --name postgres -p 5432:5432 postgres:13
```
......@@ -137,31 +137,31 @@ make teste2e path=functional_tests headless=True
for headless mode (Note: You might need to install additional dependencies).
make teste2e
Notice that this will always issue a complete rebuild of the frontend. If you want to run tests without building the
frontend anew, use
make teste2e-nc
## Production
In order to run the app in production, a server with
[Docker](https://www.docker.com/) is needed. To make routing to the
In order to run the app in production, a server with
[Docker](https://www.docker.com/) is needed. To make routing to the
respective instances easier, we recommend running [traefik](https://traefik.io/)
as a reverse proxy on the server. For easier configuration of the containers
we recommend using `docker-compose`. The following guide will assume both these
dependencies are available.
### Setting up a new instance
Simply copy the following `docker-compose.yml` onto your production server:
Simply copy the following `docker-compose.yml` onto your production server:
```yaml
version: "3"
services:
postgres:
image: postgres:9.6
image: postgres:13
labels:
traefik.enable: "false"
networks:
......@@ -198,14 +198,14 @@ networks:
external: false
```
and set the `INSTANCE`, `URLPATH`, `GRADY_HOST` variables either directly in the
and set the `INSTANCE`, `URLPATH`, `GRADY_HOST` variables either directly in the
compose file or within an `.env` file in the same directory as the `docker-compose.yml`
(it will be automatically loaded by `docker-compose`).
(it will be automatically loaded by `docker-compose`).
Login to gwdg gitlab docker registry by entering:
```commandline
docker login docker.gitlab.gwdg.de
```
Running
Running
```commandline
docker-compose pull
docker-compose up -d
......@@ -214,17 +214,17 @@ will download the latest postgres and grady images and run them in the backgroun
### Importing exam data
#### Exam data structure
In order to import the exam data it must be in a specific format.
In order to import the exam data it must be in a specific format.
You need the following:
1. A .json file file containing the output of the converted ILIAS export which is
generated by [hektor](https://gitlab.gwdg.de/j.michal/hektor)
2. A plain text file containing one username per line. A new **reviewer** account
2. A plain text file containing one username per line. A new **reviewer** account
will be created with the corresponding username and a randomly
generated password. The passwords are written to a `.importer_passwords` file.
This step should not be skipped because a reviewer account is necessary in order
generated password. The passwords are written to a `.importer_passwords` file.
This step should not be skipped because a reviewer account is necessary in order
to activate the tutor accounts.
#### Importing exam data
In order to create reviewer accounts, open an interactive shell session in the running container:
......
from rest_framework.test import APIClient, APITestCase
import pytest
import os
from rest_framework.test import APIClient, APITestCase
from constance.test import override_config
from core.models import UserAccount
......@@ -22,3 +25,47 @@ class AuthTests(APITestCase):
token = self.client.post('/api/get-token/', self.credentials).data
response = self.client.post('/api/refresh-token/', token)
self.assertContains(response, 'token')
@override_config(REGISTRATION_PASSWORD='pw')
def test_registration_correct_password(self):
credentials = {
'username': 'john-doe',
'password': 'safeandsound',
'registration_password': 'pw',
}
response = self.client.post('/api/corrector/register/', credentials)
self.assertEqual(201, response.status_code)
@override_config(REGISTRATION_PASSWORD='wrong_pw')
def test_registration_wrong_password(self):
credentials = {
'username': 'john-doe',
'password': 'safeandsound',
'registration_password': 'pw',
}
response = self.client.post('/api/corrector/register/', credentials)
self.assertEqual(403, response.status_code)
@pytest.mark.skipif(os.environ.get('DJANGO_DEV', False),
reason="No password strengths checks in dev")
@override_config(REGISTRATION_PASSWORD='pw')
def test_password_is_strong_enough(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'weak',
'registration_password': 'pw',
})
self.assertEqual(400, response.status_code)
self.assertIn('password', response.data)
@override_config(REGISTRATION_PASSWORD='pw')
def test_cannot_register_active(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'safeandsound',
'registration_password': 'pw',
'is_active': True
})
self.assertEqual(403, response.status_code)
......@@ -5,12 +5,11 @@
* GET /tutorlist list of all tutors with their scores
"""
from django.contrib.auth import get_user_model
import pytest
from constance.test import override_config
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import (APIClient, APIRequestFactory, APITestCase,
force_authenticate)
import os
from core.models import Feedback, TutorSubmissionAssignment
from core.views import CorrectorApiViewSet
......@@ -221,38 +220,12 @@ class TutorRegisterTests(APITestCase):
self.reviewer = self.user_factory.make_reviewer()
self.client = APIClient()
@pytest.mark.skipif(os.environ.get('DJANGO_DEV', False),
reason="No password strengths checks in dev")
def test_password_is_strong_enough(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'weak'
})
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
self.assertIn('password', response.data)
def test_anonymous_can_request_access(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'safeandsound'
})
self.assertEqual(status.HTTP_201_CREATED, response.status_code)
def test_cannot_register_active(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'safeandsound',
'is_active': True
})
self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code)
@override_config(REGISTRATION_PASSWORD='pw')
def test_reviewer_can_activate_tutor(self):
response = self.client.post('/api/corrector/register/', {
'username': 'hans',
'password': 'safeandsound'
'password': 'safeandsound',
'registration_password': 'pw',
})
self.assertEqual(status.HTTP_201_CREATED, response.status_code)
......
......@@ -131,9 +131,14 @@ class CorrectorApiViewSet(
def register(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if serializer.validated_data.get('is_active', False):
raise PermissionDenied(detail='Cannot be created active')
registration_password = request.data.get('registration_password', None)
if registration_password is None or registration_password != config.REGISTRATION_PASSWORD:
raise PermissionDenied(detail='Invalid registration password')
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
......
......@@ -3,7 +3,7 @@ version: '3'
services:
postgres:
image: postgres:9.6
image: postgres:13
restart: always
networks:
- default
......
......@@ -40,6 +40,14 @@
autofocus
@input="usernameErrors = null"
/>
<v-text-field
id="input-register-instance-password"
v-model="credentials.registrationPassword"
label="Instance-Password"
required
:rules="[ required ]"
type="password"
/>
<v-text-field
id="input-register-password"
v-model="credentials.password"
......@@ -48,6 +56,14 @@
:rules="[ required ]"
type="password"
/>
<v-text-field
id="input-register-password-repeat"
v-model="credentials.passwordRepeat"
label="Repeat Password"
required
:rules="[ required, checkPasswordsMatch ]"
type="password"
/>
<v-alert
type="error"
:value="errorAlert"
......@@ -82,16 +98,28 @@ export default {
return {
credentials: {
username: '',
password: ''
password: '',
passwordRepeat: '',
registrationPassword: ''
},
loading: false,
acceptedGDPR: false,
registrationFormIsValid: false,
required: required,
checkPasswordsMatch: v => v === this.credentials.password || "Passwords do not match.",
errorAlert: null,
usernameErrors: null,
}
},
watch: {
credentials: {
handler() {
if (this.credentials.passwordRepeat !== '')
this.$refs.registrationForm.validate()
},
deep: true
}
},
methods: {
register () {
if (!this.$refs.registrationForm.validate())
......
<template>
<v-container class="bottom-container">
<v-layout wrap>
<v-flex
sm4
md4
lg2
<v-card>
<v-card-text>
<v-form
ref="scoreForm"
v-model="scoreIsValid"
lazy-validation
>
<v-tooltip
v-if="skippable"
top
>
<v-btn
id="skip-submission"
slot="activator"
<v-text-field
id="score-input"
v-model="score"
v-shortkey="'numeric'"
type="number"
step="0.5"
label="Score"
:suffix="`/${fullScore}`"
:rules="scoreRules"
min="0"
:max="fullScore"
@shortkey="handleKeypress"
/>
<div class="suggestion-chips">
<v-chip
id="score-zero"
small
color="error"
outline
round
color="grey darken-2"
@click="skipSubmission"
>
Skip
</v-btn>
<span>Skip this submission</span>
</v-tooltip>
<v-spacer />
</v-flex>
<v-flex>
<v-layout
wrap
class="score-submit-container"
>
<v-flex
xs5
class="score-flex"
@click="score=0"
>
<span class="mr-2">Score:</span>
<input
id="score-input"
v-model="score"
v-shortkey="'numeric'"
class="score-text-field"
type="number"
step="0.5"
@shortkey="handleKeypress"
@input="validateScore"
@change="validateScore"
>
<span>&nbsp;/ {{ fullScore }}</span>
<v-btn
id="score-zero"
outline
round
flat
class="score-button"
color="red lighten-1"
@click="score=0"
>
0
</v-btn>
<v-btn
id="score-full"
outline
round
flat
color="blue darken-3"
class="score-button"
@click="score=fullScore"
>
{{ fullScore }}
</v-btn>
</v-flex>
<v-flex
class="submit-flex"
xs3
sm3
0
</v-chip>
<v-chip
id="score-full"
small
color="success"
outline
@click="score=fullScore"
>
<v-layout>
<v-flex xs4>
<v-tooltip
v-if="showFinalCheckbox"
top
>
<v-toolbar-items
slot="activator"
class="final-container"
>
<label>Final</label>
<v-checkbox
slot="activator"
v-model="isFinal"
class="final-checkbox"
/>
</v-toolbar-items>
<span>If unchecked this submission will be marked for review by the lecturer</span>
</v-tooltip>
</v-flex>
<v-flex xs>
<v-tooltip top>
<v-btn
id="submit-feedback"
slot="activator"
color="success"
:loading="loading"
@click="submit"
>
Submit
<v-icon>chevron_right</v-icon>
</v-btn>
<span>Submit and continue</span>
</v-tooltip>
</v-flex>
</v-layout>
</v-flex>
</v-layout>
</v-flex>
<v-flex v-if="scoreError">
<v-alert
class="score-alert ma-3"
color="error"
icon="warning"
:value="scoreError"
{{ fullScore }}
</v-chip>
</div>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions>
<v-tooltip
v-if="showFinalCheckbox"
top
>
<v-checkbox
slot="activator"
v-model="isFinal"
label="Final"
class="final-checkbox"
hide-details
/>
<span>If unchecked this submission will be marked for review by the lecturer</span>
</v-tooltip>
<v-spacer />
<v-tooltip
v-if="skippable"
top
>
<v-btn
id="skip-submission"
slot="activator"
flat
@click="skipSubmission"
>
{{ scoreError }}
</v-alert>
</v-flex>
</v-layout>
</v-container>
Skip
</v-btn>
<span>Skip this submission</span>
</v-tooltip>
<v-tooltip top>
<v-btn
id="submit-feedback"
slot="activator"
color="primary"
:loading="loading"
block
:disabled="!scoreIsValid"
@click="submit"
>
Submit
</v-btn>
<span>Submit and continue</span>
</v-tooltip>
</v-card-actions>
</v-card>
</template>
<script>
import { SubmissionNotes } from '@/store/modules/submission-notes'
import { Authentication } from '@/store/modules/authentication'
import { Assignments } from '@/store/modules/assignments'
import { mapState } from 'vuex'
export default {
name: 'AnnotatedSubmissionBottomToolbar',
......@@ -154,11 +117,27 @@ export default {
},
data () {
return {
scoreError: '',
isFinal: this.initialFinalStatus()
scoreIsValid: true,
isFinal: this.initialFinalStatus(),
scoreRules: [
score => score !== undefined ||
'Score is required.',
score => !isNaN(parseFloat(score)) ||
'Score must be a number.',
score => parseFloat(score) >= 0 && parseFloat(score) <= this.fullScore ||
`Score must be between 0 and ${this.fullScore}.`,
score => parseFloat(score) === this.fullScore || this.hasFeedbackOrLabel ||
'Add a comment or label explaining why this submission doesn\'t get full score.'
]
}
},
computed: {
hasFeedbackOrLabel: function() {
return Object.keys(SubmissionNotes.state.updatedFeedback.feedbackLines).length > 0 ||
SubmissionNotes.state.updatedFeedback.labels.length > 0 ||
Object.keys(SubmissionNotes.state.origFeedback.feedbackLines).length > 0 ||
SubmissionNotes.state.origFeedback.labels.length > 0
},
score: {
get: function () {
return SubmissionNotes.score
......@@ -183,6 +162,10 @@ export default {
this.isFinal = this.initialFinalStatus()
},
deep: true
},
hasFeedbackOrLabel: function (newValue) {
if (this.score !== undefined)
this.$refs.scoreForm.validate()
}
},
methods: {
......@@ -197,24 +180,9 @@ export default {
}
}
},
emitScoreError (error, duration) {
this.scoreError = error
setTimeout(() => { this.scoreError = '' }, duration)
},
validateScore () {
if (this.score < 0) {
this.score = 0
this.emitScoreError('Score must be 0 or greater.', 2000)
} else if (this.score > this.fullScore) {
this.score = this.fullScore
this.emitScoreError(`Score must be less or equal to ${this.fullScore}`, 2000)
} else {
return true
}
return false
},
submit () {
this.$emit('submitFeedback', { isFinal: this.isFinal })
if (this.$refs.scoreForm.validate())
this.$emit('submitFeedback', { isFinal: this.isFinal })
},
skipSubmission () {
if (this.skippable) {
......@@ -242,44 +210,11 @@ export default {
</script>
<style scoped>
.bottom-container {
padding: 0px;
}
.bottom-toolbar {
font-size: large;
}
.score-text-field {
max-width: 50px;
box-sizing: border-box;
border: 1px solid grey;
border-radius: 2px;
padding: 3px;
}
.score-alert {
max-height: 40px;
}
.score-button {
min-width: 0px;
}
.final-container {
margin-top: 15px;
height: 10px;
.suggestion-chips {
margin: 0 -4px;
}
.final-checkbox {
margin-left: 10px;
padding-top: 0;
margin-top: 0;
}
.submit-flex {
min-width: 190px;
margin-left: 10px;
}
.score-flex {
margin-left: 10px;
min-width: 250px;
}
.score-submit-container {
justify-content: space-between;
margin: 0;
padding: 0;
}
</style>
......@@ -196,27 +196,18 @@ Promise<AxiosResponse<void>[]> {
delete feedback.labels
}
if (state.origFeedback.score === undefined && state.updatedFeedback.score === undefined) {
throw new Error('You need to give a score.')
} else if (state.updatedFeedback.score !== undefined) {
if (state.updatedFeedback.score !== undefined) {
feedback.score = state.updatedFeedback.score
} else {
feedback.score = state.origFeedback.score
}
const hasFeedbackOrLabel = Object.keys(state.updatedFeedback.feedbackLines).length > 0 ||
state.updatedFeedback.labels.length > 0
// set the comments for the feedback lines accordingly
for (const key of Object.keys(state.updatedFeedback.feedbackLines)) {
const numKey = Number(key)
if (hasFeedbackOrLabel) {
// set the comments for the feedback lines accordingly
for (const key of Object.keys(state.updatedFeedback.feedbackLines)) {
const numKey = Number(key)
numKey && feedback.feedbackLines
&& (feedback.feedbackLines[numKey] = state.updatedFeedback.feedbackLines[numKey])
}
} else if (feedback.score! < SubmissionNotes.submissionType.fullScore! && !state.hasOrigFeedback) {
throw new Error('You need to add or change a comment or a feedback label when setting a non full score.')
numKey && feedback.feedbackLines
&& (feedback.feedbackLines[numKey] = state.updatedFeedback.feedbackLines[numKey])
}
const assignment = Assignments.state.currentAssignment
......
......@@ -94,9 +94,7 @@ class UntestedParent:
go_to_subscription(self)
self.browser.find_element_by_id('score-zero').click()
submit_btn = self.browser.find_element_by_id('submit-feedback')
submit_btn.click()
notification = self.browser.find_element_by_class_name('notification-content')
self.assertIn('comment', notification.text)
assert submit_btn.get_attribute('disabled')
def test_can_give_zero_score(self):
self._login()
......
......@@ -2,6 +2,7 @@ from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from constance.test import override_config
from core.models import UserAccount
from util.factories import make_test_data
from functional_tests.util import GradyTestCase, reset_browser_after_test
......@@ -120,17 +121,25 @@ class LoginPageTest(GradyTestCase):
self._login(student)
self.assertTrue(self.browser.current_url.endswith('#/home'))
@override_config(REGISTRATION_PASSWORD='pw')
def test_can_register_account(self):
username = 'danny'
password = 'redrum-is-murder-reversed'
instance_password = 'pw'
self.browser.get(self.live_server_url)
self.browser.find_element_by_id('register').click()
self.browser.find_element_by_id('gdpr-notice')
self.browser.find_element_by_id('accept-gdpr-notice').click()
username_input = self.browser.find_element_by_id('input-register-username')
username_input.send_keys(username)
instance_password_input = self.browser.find_element_by_id(
'input-register-instance-password'
)
instance_password_input.send_keys(instance_password)
password_input = self.browser.find_element_by_id('input-register-password')
password_input.send_keys(password)
password_repeat_input = self.browser.find_element_by_id('input-register-password-repeat')
password_repeat_input.send_keys(password)
register_submit_el = self.browser.find_element_by_id('register-submit')
register_submit_el.click()
WebDriverWait(self.browser, 10).until_not(ec.visibility_of(register_submit_el))
......
......@@ -216,5 +216,6 @@ CONSTANCE_CONFIG = {
'SINGLE_CORRECTION': (False, "Set submitted feedback immediately to final and skip validation"),
'EXERCISE_MODE': (False, "Whether the application runs in exercise mode. "
"Gives tutors access to options normally reserved to reviewers"),
"SHOW_SOLUTION_TO_STUDENTS": (False, "Whether or not the students should be allowed to see the solutions")
"SHOW_SOLUTION_TO_STUDENTS": (False, "Whether or not the students should be allowed to see the solutions"),
'REGISTRATION_PASSWORD': ("", "The registration password to use.")
}
......@@ -10,6 +10,7 @@ from core.models import ExamType, Feedback, Submission, SubmissionType, Test, Fe
from core.models import UserAccount as User
from util.factories import GradyUserFactory
import xkcdpass.xkcd_password as xp
import semver
log = logging.getLogger(__name__)
......@@ -36,6 +37,7 @@ RUSTY_HEKTOR_MAX_VER = "<7.0.0"
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
user_factory = GradyUserFactory()
words = xp.generate_wordlist(wordfile=xp.locate_wordfile(), min_length=5, max_length=8)
def start():
......@@ -151,6 +153,12 @@ def load_reviewers():
store_pw=True)
def set_registration_password():
pw = xp.generate_xkcdpassword(words, numwords=4, delimiter='-')
setattr(config, 'REGISTRATION_PASSWORD', pw)
print('The password will be set to', pw)
def add_submission(student_obj, code, tests, type=None, source_code=None):
submission_type_obj = SubmissionType.objects.get(name=type)
......@@ -212,5 +220,6 @@ def add_label_to_feedback_if_test_recommends_it(test_obj):
call_order = [
load_hektor_json,
load_reviewers
load_reviewers,
set_registration_password,
]