Gitlab Community Edition Instance

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 (11)
Showing
with 628 additions and 344 deletions
......@@ -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
......
This diff is collapsed.
......@@ -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)
......
......@@ -7,6 +7,7 @@ fi
gunicorn \
--bind 0.0.0.0:8000 \
--workers=5 \
--timeout=120 \
--worker-class=sync \
--log-level debug \
grady.wsgi:application
......@@ -3,7 +3,7 @@ version: '3'
services:
postgres:
image: postgres:9.6
image: postgres:13
restart: always
networks:
- default
......
......@@ -43,29 +43,37 @@
<v-switch
slot="activator"
v-model="darkMode"
class="ml-3"
class="dark-mode-switch"
:disabled="!darkModeUnlocked"
label="dark mode"
:label="mini ? '' : 'dark mode'"
/>
<span v-if="darkModeUnlocked">Experimental: styling issues may occur!</span>
<span v-else>You need to visit the feedback site below first!</span>
</v-tooltip>
<v-footer
v-if="!mini"
<v-btn
id="feedback-btn"
href="https://gitlab.gwdg.de/j.michal/grady/issues"
target="_blank"
:block="!mini"
:fab="mini"
:flat="mini"
:tile="!mini"
depressed
@click.native="logFeedbackClick"
v-bind:class="{ 'fab-button': mini, 'fab-button-white': !darkMode }"
>
<v-spacer />
<a
href="https://gitlab.gwdg.de/j.michal/grady/issues"
target="_blank"
class="feedback-link"
>Give us Feedback!</a>
<v-spacer />
</v-footer>
<v-icon :left="!mini">
feedback
</v-icon>
<div v-show="!mini">
Feedback
</div>
</v-btn>
</div>
</v-navigation-drawer>
<v-toolbar
app
dense
clipped-left
fixed
dark
......@@ -76,41 +84,28 @@
<span class="pl-2 grady-speak">{{ gradySpeak }}</span>
</router-link>
<v-spacer />
<slot name="toolbar-center" />
<div class="toolbar-content">
<instance-actions />
<v-divider vertical />
<v-toolbar-items class="user-menu">
<v-menu
v-if="!isStudent"
bottom
offset-y
left
>
<v-btn
id="user-options"
slot="activator"
color="cyan"
flat
style="text-transform: none"
>
{{ userRole }} | {{ username }} <v-icon>arrow_drop_down</v-icon>
<v-icon left>
account_circle
</v-icon>
{{ username }} ({{ userRole }})<v-icon>arrow_drop_down</v-icon>
</v-btn>
<user-options
v-if="!isStudent"
class="mt-1"
/>
<user-options />
</v-menu>
<span
v-else
style="color:#595959"
>
{{ username }}
</span>
</div>
<slot name="toolbar-right" />
<v-btn
id="logout"
color="blue darken-1"
@click.native="logout"
>
Logout
</v-btn>
</v-toolbar-items>
</v-toolbar>
</div>
</template>
......@@ -120,12 +115,12 @@ import { mapGetters, mapState } from 'vuex'
import { UI } from '@/store/modules/ui'
import { mapStateToComputedGetterSetter } from '@/util/helpers'
import UserOptions from '@/components/UserOptions'
import InstanceActions from '@/components/InstanceActions'
import { Authentication } from '@/store/modules/authentication'
import { actions } from '@/store/actions'
export default {
name: 'BaseLayout',
components: { UserOptions },
components: { InstanceActions, UserOptions },
computed: {
username () { return Authentication.state.user.username },
userRole () { return Authentication.state.user.role },
......@@ -151,9 +146,6 @@ export default {
})
},
methods: {
logout () {
actions.logout()
},
logFeedbackClick () {
this.darkModeUnlocked = true
}
......@@ -167,20 +159,30 @@ export default {
bottom: 0px;
}
.feedback-link {
text-decoration: none;
color: grey;
}
.toolbar-content {
margin-left: auto;
}
.grady-toolbar {
font-weight: bold;
}
.title {
color: gray;
white-space: nowrap;
}
.fab-button {
margin: 0 12px;
}
.fab-button-white {
color: grey !important;
}
.dark-mode-switch {
margin-left: 22px;
}
</style>
<style>
.grady-toolbar > div {
padding-right: 0;
}
</style>
<template>
<v-tooltip bottom>
<v-btn
slot="activator"
flat
icon
:disabled="!activeAssignmentsExist"
:loading="loading"
@click="freeLocks"
>
<v-icon>vpn_key</v-icon>
</v-btn>
<span>Free all locked Submissions</span>
</v-tooltip>
</template>
<script>
import { deleteAllActiveAssignments, fetchActiveAssignments } from '@/api'
import { TutorOverview } from '@/store/modules/tutor-overview'
export default {
name: 'FreeLocksButton',
data () {
return {
activeAssignmentsExist: false,
loading: false,
shortPollInterval: null
}
},
async created () {
this.activeAssignmentsExist = await this.checkForActiveAssignments()
this.shortPollInterval = setInterval(async () => {
this.activeAssignmentsExist = await this.checkForActiveAssignments()
} , 5e3)
},
beforeDestroy () {
clearInterval(this.shortPollInterval)
},
methods: {
async checkForActiveAssignments () {
return (await fetchActiveAssignments()).length > 0
},
async freeLocks () {
this.loading = true
await deleteAllActiveAssignments()
this.loading = false
// Just lie to the user for now. The actual value will be fetched by the timeout soon.
this.activeAssignmentsExist = false
}
}
}
</script>
<template>
<div>
<export-dialog v-if="isReviewer" />
<template v-for="(a, i) in actions">
<v-tooltip
v-if="a.condition()"
:id="a.id"
:key="i"
bottom
>
<v-btn
slot="activator"
flat
icon
@click="a.action"
>
<v-icon>{{ a.icon }}</v-icon>
</v-btn>
{{ a.text }}
</v-tooltip>
</template>
<free-locks-button v-if="isReviewer" />
<component
:is="displayComponent"
v-if="displayComponent"
@hide="hideComponent"
/>
</div>
</template>
<script>
import ExportDialog from '@/components/export/ExportDialog'
import ImportDialog from '@/components/ImportDialog'
import ConfigDialog from '@/components/instance_config/ConfigDialog'
import FreeLocksButton from '@/components/FreeLocksButton'
import { Authentication } from '@/store/modules/authentication'
export default {
name: 'InstanceActions',
components: { ExportDialog, ImportDialog, FreeLocksButton },
data () {
return {
displayComponent: null,
actions: [
{
icon: 'publish',
text: 'Import exam data',
action: () => { this.displayComponent = ImportDialog },
condition: () => Authentication.isReviewer,
id: 'import-data-list-tile'
},
{
icon: 'settings',
text: 'Instance settings',
action: () => { this.displayComponent = ConfigDialog },
condition: () => Authentication.isReviewer,
id: 'configure-instance-tile',
},
]
}
},
computed: {
isReviewer: () => Authentication.isReviewer,
},
methods: {
hideComponent () {
this.displayComponent = null
},
logout () {
actions.logout()
}
}
}
</script>
......@@ -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>
<div>
<v-list>
<template v-for="(opt, i) in userOptions">
<v-list-tile
v-if="opt.condition()"
:id="opt.id"
:key="i"
@click="opt.action"
>
{{ opt.display }}
</v-list-tile>
</template>
<v-list-tile
v-if="!isStudent"
@click="showPasswordChangeDialog"
>
Change password
</v-list-tile>
<v-divider class="my-2" />
<v-list-tile @click="logout">
<v-icon left>
exit_to_app
</v-icon>
Logout
</v-list-tile>
</v-list>
<component
:is="displayComponent"
......@@ -22,47 +25,28 @@
<script>
import PasswordChangeDialog from '@/components/PasswordChangeDialog'
import ImportDialog from '@/components/ImportDialog'
import ConfigDialog from '@/components/instance_config/ConfigDialog'
import { Authentication } from '@/store/modules/authentication'
import { deleteAllActiveAssignments } from '@/api'
import { actions } from '@/store/actions'
export default {
name: 'UserOptions',
components: { PasswordChangeDialog, ImportDialog },
components: { PasswordChangeDialog },
data () {
return {
displayComponent: null,
userOptions: [
{
display: 'Change password',
action: () => { this.displayComponent = PasswordChangeDialog },
condition: () => !Authentication.isStudent,
id: 'change-password-list-tile'
},
{
display: 'Free all locked submissions',
action: deleteAllActiveAssignments,
condition: () => Authentication.isReviewer,
id: 'free-assignments-list-tile'
},
{
display: 'Import data',
action: () => { this.displayComponent = ImportDialog },
condition: () => Authentication.isReviewer,
id: 'import-data-list-tile'
},
{
display: 'Configure current instance',
action: () => { this.displayComponent = ConfigDialog },
condition: () => Authentication.isReviewer,
id: 'configure-instance-tile',
}
]
}
},
computed: {
isStudent: () => Authentication.isStudent,
},
methods: {
hideComponent () {
this.displayComponent = null
},
logout () {
actions.logout()
},
showPasswordChangeDialog () {
this.displayComponent = PasswordChangeDialog
}
}
}
......
<template>
<div>
<v-menu offset-y>
<v-tooltip
<v-menu offset-y>
<v-tooltip
slot="activator"
bottom
>
<v-btn
id="export-btn"
slot="activator"
left
:icon="!corrected"
:flat="!corrected"
:color="corrected ? 'success' : undefined"
style="text-transform: none;"
>
<v-btn
id="export-btn"
slot="activator"
:color="exportColor"
>
export
<v-icon>file_download</v-icon>
</v-btn>
<span
v-if="corrected"
id="corrected-tooltip"
>All submissions have been corrected!</span>
<span
v-else
id="uncorrected-tooltip"
>UNCORRECTED submissions left! Export will be incomplete.</span>
</v-tooltip>
<v-list>
<v-list-tile
v-for="(item, i) in menuItems"
:id="'export-list' + i"
:key="i"
@click="item.action"
>
{{ item.display }}
</v-list-tile>
</v-list>
</v-menu>
<v-icon :left="corrected">
file_download
</v-icon>
<span v-if="corrected">
Export
</span>
</v-btn>
Export
<span
v-if="corrected"
id="corrected-tooltip"
>(All submissions have been corrected!)</span>
<span
v-else
id="uncorrected-tooltip"
>(UNCORRECTED submissions left! Export will be incomplete.)</span>
</v-tooltip>
<v-list>
<v-list-tile
v-for="(item, i) in menuItems"
:id="'export-list' + i"
:key="i"
@click="item.action"
>
{{ item.display }}
</v-list-tile>
</v-list>
<component
:is="displayComponent"
v-if="displayComponent"
@hide="displayComponent = null"
/>
</div>
</v-menu>
</template>
<script lang="ts">
......@@ -70,9 +76,6 @@ export default class ExportDialog extends Vue {
get corrected () {
return getters.corrected
}
get exportColor () {
return this.corrected ? 'green darken-1' : 'red lighten-1'
}
// apparently `this` is not the same when used within a
// closure when defining data and within a method
......
......@@ -55,10 +55,18 @@ export default {
loading: true,
show: true,
selected: [],
// config fields that should be ignored entirely go here
ignoredFields: ['registrationPassword']
}
},
computed: {
instanceSettings: () => { return ConfigModule.state.config.instanceSettings },
instanceSettings: function () {
const filtered = Object.entries(ConfigModule.state.config.instanceSettings)
.filter(([key,]) => !this.ignoredFields.includes(key))
console.log(filtered)
return Object.fromEntries(filtered)
},
},
watch: {
show (val) {
......@@ -100,4 +108,4 @@ export default {
}
}
</script>
\ No newline at end of file
</script>
......@@ -31,7 +31,7 @@
color="info"
value="true"
>
You reached <b>{{ sumScore }}</b> of <b>{{ sumFullScore }}</b> possible points ( {{ pointRatio }}% ).
You reached <b>{{ sumScore }}</b> of <b>{{ sumFullScore }}</b> possible points ({{ pointRatio }}%).
</v-alert>
</div>
</template>
......@@ -74,7 +74,7 @@ export default {
},
computed: {
sumScore () {
return this.submissions.map(a => a.feedback && a.feedback.score).reduce((a, b) => a + b)
return this.submissions.map(a => a.feedback && a.feedback.score).reduce((a, b) => a + b) || 0
},
sumFullScore () {
return this.submissions.map(a => a.type.fullScore).reduce((a, b) => a + b)
......
......@@ -38,7 +38,7 @@
v-if="item.title === 'Description'"
class="type-description"
>
<v-card-text class="ml-2">
<v-card-text>
<!-- eslint-disable-next-line -->
<span v-html="item.text"/>
</v-card-text>
......@@ -180,4 +180,9 @@ export default class SubmissionType extends Vue {
.type-description code {
background-color: lightgrey;
}
.v-expansion-panel {
/* Vuetify limits the height by default. We don't want that.*/
max-height: initial;
}
</style>
......@@ -44,6 +44,9 @@ export default class SubscriptionForList extends Vue {
}
get correctionRoute() {
if (!this.active)
return undefined
const group = Assignments.state.assignmentCreation.group
const group_pk = group !== undefined ? group.pk : undefined
......
......@@ -2,7 +2,7 @@ export interface Config {
timeDelta: number
version: string,
instanceSettings: {
[config: string]: boolean,
[config: string]: boolean,
}
}
......@@ -898,4 +898,4 @@ export interface GitlabRelease {
description_html: string
}
\ No newline at end of file
}
......@@ -23,17 +23,15 @@
</v-list>
<v-divider />
<slot name="above-subscriptions" />
<subscription-list :sidebar="true" />
<feedback-label-tab />
<subscription-list :sidebar="true" v-if="!mini" />
<feedback-label-tab v-if="!mini" />
<slot name="below-subscriptions" />
</template>
<template slot="toolbar-right">
<slot name="toolbar-right" />
</template>
</base-layout>
</template>
<script>
import { UI } from '@/store/modules/ui'
import BaseLayout from '@/components/BaseLayout'
import SubscriptionList from '@/components/subscriptions/SubscriptionList'
import FeedbackLabelTab from '@/components/feedback_labels/FeedbackLabelTab.vue'
......@@ -67,6 +65,9 @@ export default {
}
]
}
},
computed: {
mini () { return UI.state.sideBarCollapsed },
}
}
</script>
......