Merge branch 'feature/pw-reset' into develop
This commit is contained in:
commit
40a0c3ecbe
|
|
@ -0,0 +1,95 @@
|
|||
describe('Change Password Page', () => {
|
||||
|
||||
const validNewPassword = 'Abcd1234!';
|
||||
const validOldPassword = 'test';
|
||||
const validationTooShort = 'Das neue Passwort muss mindestens 8 Zeichen lang sein';
|
||||
const validationErrorMsg = 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten';
|
||||
const validationOldWrongMsg = 'Die Eingabe ist falsch';
|
||||
|
||||
after(function () {
|
||||
cy.exec("python ../server/manage.py reset_testuser_password rahel.cueni");
|
||||
});
|
||||
|
||||
it('shows an empty form', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.get('[data-cy=password-change-success]').should('not.exist');
|
||||
cy.get('[data-cy=old-password]').should('have.value', '');
|
||||
cy.get('[data-cy=new-password]').should('have.value', '');
|
||||
});
|
||||
|
||||
it('shows errors if old password is not entered', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword('', validNewPassword);
|
||||
cy.get('[data-cy=old-password-local-errors]').should('contain', 'Dein aktuelles Passwort fehlt')
|
||||
});
|
||||
|
||||
it('shows errors if new password is not entered', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, '');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', 'Dein neues Passwort fehlt')
|
||||
});
|
||||
|
||||
it('shows errors if new password is too short', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, 'Abc1!');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', validationTooShort)
|
||||
});
|
||||
|
||||
it('shows errors if new password has no uppercase letter', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, 'aabdddedddbc1!');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', validationErrorMsg)
|
||||
});
|
||||
|
||||
it('shows errors if new password has no lowercase letter', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, 'ABCDDD334551!');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', validationErrorMsg)
|
||||
});
|
||||
|
||||
it('shows errors if new password has no digit', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, 'AbcdEEDE!');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', validationErrorMsg)
|
||||
});
|
||||
|
||||
it('shows errors if new password has no special character', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, 'AbcdEEDE09877');
|
||||
cy.get('[data-cy=new-password-local-errors]').should('contain', validationErrorMsg)
|
||||
});
|
||||
|
||||
it('shows errors if old password does not match', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword('test12345', validNewPassword);
|
||||
cy.get('[data-cy=old-password-remote-errors]').should('contain', validationOldWrongMsg)
|
||||
});
|
||||
|
||||
it('shows success if change was successful', () => {
|
||||
cy.login('rahel.cueni', 'test');
|
||||
cy.visit('/me/password-change');
|
||||
|
||||
cy.changePassword(validOldPassword, validNewPassword);
|
||||
cy.get('[data-cy=password-change-success]').should('contain', 'Dein Password wurde erfolgreich geändert.');
|
||||
cy.get('[data-cy=old-password]').should('have.value', '');
|
||||
cy.get('[data-cy=new-password]').should('have.value', '');
|
||||
});
|
||||
});
|
||||
|
|
@ -73,3 +73,14 @@ Cypress.Commands.add('waitFor', operationName => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('changePassword', (oldPassword, newPassword) => {
|
||||
if (oldPassword) {
|
||||
cy.get('[data-cy=old-password]').type(oldPassword);
|
||||
}
|
||||
|
||||
if (newPassword) {
|
||||
cy.get('[data-cy=new-password]').type(newPassword);
|
||||
}
|
||||
cy.get('[data-cy=change-password-button]').click();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11825,6 +11825,11 @@
|
|||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
|
||||
},
|
||||
"vee-validate": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-2.2.0.tgz",
|
||||
"integrity": "sha512-s72VQcl1DWTNQKQyHtUDcU536dIx/GYDnCObDj4AXDZtWnqM3rXbgp7FCT3D2q9HFKw7IykW9bVrClhPBeQnrw=="
|
||||
},
|
||||
"vendors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"uploadcare-widget": "^3.6.0",
|
||||
"url-loader": "^1.0.1",
|
||||
"uuid": "^3.2.1",
|
||||
"vee-validate": "^2.2.0",
|
||||
"vue": "^2.5.17",
|
||||
"vue-analytics": "^5.16.2",
|
||||
"vue-apollo": "^3.0.0-beta.16",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div :class="{'no-scroll': showModal || showMobileNavigation}" class="app">
|
||||
<div :class="{'no-scroll': showModal || showMobileNavigation}" class="app" id="app">
|
||||
<component :is="showModal" v-if="showModal"></component>
|
||||
<component :is="layout"></component>
|
||||
<mobile-navigation v-if="showMobileNavigation"></mobile-navigation>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div class="pw-change">
|
||||
<form class="pw-change__form change-form" novalidate @submit.prevent="validateBeforeSubmit">
|
||||
<div class="change-form__field sbform-input">
|
||||
<label for="old-pw" class="sbform-input__label">Aktuelles Passwort</label>
|
||||
<input id="old-pw"
|
||||
name="oldPassword"
|
||||
type="text"
|
||||
v-model="oldPassword"
|
||||
v-validate="'required'"
|
||||
:class="{ 'sbform-input__input--error': errors.has('oldPassword') }"
|
||||
class="change-form__old skillbox-input sbform-input__input"
|
||||
autocomplete="off"
|
||||
data-cy="old-password">
|
||||
<small v-if="errors.has('oldPassword') && submitted" class="sbform-input__error" data-cy="old-password-local-errors">{{ errors.first('oldPassword') }}</small>
|
||||
<small v-for="error in oldPasswordErrors" :key="error" class=" sbform-input__error" data-cy="old-password-remote-errors">{{ error }}</small>
|
||||
</div>
|
||||
<div class="change-form__field sbform-input">
|
||||
<label for="new-pw" class="sbform-input__label">Neues Passwort</label>
|
||||
<input id="new-pw"
|
||||
name="newPassword"
|
||||
type="text"
|
||||
v-model="newPassword"
|
||||
v-validate="'required|min:8|strongPassword'"
|
||||
:class="{ 'sbform-input__input--error': errors.has('newPassword') }"
|
||||
class="change-form__new skillbox-input sbform-input__input"
|
||||
autocomplete="off"
|
||||
data-cy="new-password">
|
||||
<small v-if="errors.has('newPassword') && submitted" class=" sbform-input__error" data-cy="new-password-local-errors">{{ errors.first('newPassword') }}</small>
|
||||
<small v-for="error in newPasswordErrors" :key="error" class=" sbform-input__error" data-cy="new-password-remote-errors">{{ error }}</small>
|
||||
<p class="sbform-input__hint">Das Passwort muss mindestens 8 Zeichen lang sein und Grossbuchstaben, Zahlen und Sonderzeichen beinhalten.</p>
|
||||
</div>
|
||||
<button class="button button--primary change-form__submit" data-cy="change-password-button">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['newPasswordErrors', 'oldPasswordErrors'],
|
||||
data: () => ({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
submitted: false
|
||||
}),
|
||||
methods: {
|
||||
validateBeforeSubmit () {
|
||||
this.$validator.validate().then((result) => {
|
||||
this.submitted = true;
|
||||
if (result) {
|
||||
this.$emit('passwordSubmited', {
|
||||
oldPassword: this.oldPassword,
|
||||
newPassword: this.newPassword
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
resetForm () {
|
||||
this.oldPassword = '';
|
||||
this.newPassword = '';
|
||||
this.submitted = false;
|
||||
this.$validator.reset();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on('reset-password-form', () => {
|
||||
this.resetForm();
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
@import "@/styles/_buttons.scss";
|
||||
|
||||
.change-form {
|
||||
width: 50%;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.sbform-input {
|
||||
|
||||
margin-bottom: 20px;
|
||||
font-family: $sans-serif-font-family;
|
||||
|
||||
&__label {
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
|
||||
&--error {
|
||||
border-color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 10px;
|
||||
color: $color-error;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-top: $small-spacing;
|
||||
font-family: $sans-serif-font-family;
|
||||
color: $color-grey;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<template>
|
||||
<div class="user-widget">
|
||||
<user-icon class="user-widget__avatar" :src="avatar"></user-icon>
|
||||
<span class="user-widget__name">{{firstName}} {{lastName}}</span>
|
||||
<span class="user-widget__date" v-if="date">{{date}}</span>
|
||||
</div>
|
||||
<router-link to="/me/activity">
|
||||
<div class="user-widget" :class="{'user-widget--is-profile': isProfile}">
|
||||
<user-icon class="user-widget__avatar" :src="avatar"></user-icon>
|
||||
<span class="user-widget__name">{{firstName}} {{lastName}}</span>
|
||||
<span class="user-widget__date" v-if="date">{{date}}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
@ -14,6 +16,11 @@
|
|||
|
||||
components: {
|
||||
UserIcon
|
||||
},
|
||||
computed: {
|
||||
isProfile() {
|
||||
return this.$route.meta.isProfile;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -42,5 +49,15 @@
|
|||
border-radius: 15px;
|
||||
fill: $color-grey;
|
||||
}
|
||||
|
||||
&--is-profile {
|
||||
& > svg {
|
||||
fill: $color-brand;
|
||||
}
|
||||
|
||||
& > span {
|
||||
color: $color-brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="assignment">
|
||||
<h3 class="assignment__title">{{assignment.title}}</h3>
|
||||
<h3 class="assignment__title" :id="assignment.id.replace(/=/g, '')">{{assignment.title}}</h3>
|
||||
<p class="assignment__assignment-text">
|
||||
{{assignment.assignment}}
|
||||
</p>
|
||||
|
|
@ -67,6 +67,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import ASSIGNMENT_QUERY from '@/graphql/gql/assignmentQuery.gql';
|
||||
import ME_QUERY from '@/graphql/gql/meQuery.gql';
|
||||
import UPDATE_ASSIGNMENT_MUTATION from '@/graphql/gql/mutations/updateAssignmentMutation.gql';
|
||||
|
|
@ -92,6 +93,7 @@
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['scrollToAssignmentId']),
|
||||
final() {
|
||||
return !!this.submission && this.submission.final
|
||||
},
|
||||
|
|
@ -109,6 +111,7 @@
|
|||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(['scrollToAssignmentReady']),
|
||||
_save: debounce(function (submission) {
|
||||
this.saving++;
|
||||
this.$apollo.mutate({
|
||||
|
|
@ -193,9 +196,13 @@
|
|||
id: this.value.id
|
||||
}
|
||||
},
|
||||
result({data}) {
|
||||
result(response) {
|
||||
const data = response.data;
|
||||
this.assignment = cloneDeep(data.assignment);
|
||||
this.assignment.submission = Object.assign(this.initialSubmission(), this.assignment.submission);
|
||||
if (this.assignment.id === this.scrollToAssignmentId && 'stale' in response) {
|
||||
this.$nextTick(() => this.scrollToAssignmentReady(true));
|
||||
}
|
||||
}
|
||||
},
|
||||
me: {
|
||||
|
|
@ -215,7 +222,7 @@
|
|||
unsaved: false,
|
||||
saving: 0
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div class="schoolclass">
|
||||
<h2 class="schoolclass__name">{{name}}</h2>
|
||||
<div class="schoolclass__members schoolclass-members">
|
||||
<ul class="schoolclass-members__list members-list">
|
||||
<li v-for="user in users" :key="user.id" class="members-list__item">
|
||||
<p class="member-item"><span class="member-item__name">{{fullName(user)}}</span> <span class="member-item__role">{{role(user)}}</span></p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: ['users', 'name'],
|
||||
methods: {
|
||||
fullName (user) {
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
},
|
||||
role ({permissions}) {
|
||||
return permissions.indexOf('users.can_manage_school_class_content') > -1 ? 'Lehrperson' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
|
||||
$height: 52px;
|
||||
|
||||
.members-list {
|
||||
&__item {
|
||||
line-height: $height;
|
||||
height: $height;
|
||||
border-bottom: 1px solid $color-grey;
|
||||
}
|
||||
}
|
||||
|
||||
.member-item {
|
||||
|
||||
line-height: $height;
|
||||
height: $height;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
&__name {
|
||||
font-family: $sans-serif-font-family;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
&__role {
|
||||
padding-right: $medium-spacing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div class="module-activity">
|
||||
<h3 class="module-activity__module-name">{{moduleTitle}}</h3>
|
||||
<h2 class="module-activity__title">{{title}}</h2>
|
||||
<div class="module-activity__tasks activity-tasks">
|
||||
<h4 class="activity-tasks__title">Aufträge</h4>
|
||||
<ol class="activity-tasks__task-list task-list">
|
||||
<li v-for="activity in activities" :key="activity.key" class="task-list__item task-item">
|
||||
<h5 class="task-item__title">{{activity.assignmentTitle}}</h5>
|
||||
<p class="task-item__submission">{{activity.answer}}</p>
|
||||
<a href="#" @click="goToAssignment(activity)"><chevron-right class="task-item__chevron"></chevron-right></a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { mapActions } from 'vuex'
|
||||
import ChevronRight from '@/components/icons/ChevronRight';
|
||||
|
||||
export default {
|
||||
props: ['metaTitle', 'title', 'activities', 'topic', 'slug'],
|
||||
components: {ChevronRight},
|
||||
computed: {
|
||||
moduleTitle () {
|
||||
return `${this.topic.title} - ${this.metaTitle}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['scrollToAssignmentId']),
|
||||
goToAssignment (activity) {
|
||||
const url = `/module/${this.slug}/`;
|
||||
this.scrollToAssignmentId(activity.assignmentId);
|
||||
this.$router.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
@import "@/styles/_mixins.scss";
|
||||
|
||||
.module-activity {
|
||||
@include widget-shadow;
|
||||
padding: $medium-spacing;
|
||||
|
||||
/* used for text ellipis... somehow https://css-flexbox-text-ellipsis.dinhquangtrung.net/ just does not work */
|
||||
|
||||
max-width: 640px;
|
||||
|
||||
@include desktop {
|
||||
max-width: 1020px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 401px) {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
&__module-name {
|
||||
@include small-text;
|
||||
color: $color-grey;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-tasks {
|
||||
&__title {
|
||||
background-color: $color-brand-light;
|
||||
padding: $small-spacing $medium-spacing;
|
||||
border-radius: $default-border-radius;
|
||||
|
||||
&--alternative {
|
||||
background-color: $color-accent-1-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
$line-height: 50px;
|
||||
|
||||
&__title {
|
||||
&::after {
|
||||
content: ":"
|
||||
}
|
||||
margin-right: $medium-spacing;
|
||||
}
|
||||
|
||||
&__submission {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title, &__submission {
|
||||
line-height: $line-height;
|
||||
height: $line-height;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
line-height: $line-height;
|
||||
height: $medium-spacing;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: ($line-height - $medium-spacing) / 2;
|
||||
fill: $color-brand;
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
mutation UpdatePassword($input: UpdatePasswordInput!) {
|
||||
updatePassword(input: $input) {
|
||||
success
|
||||
errors {
|
||||
field
|
||||
errors {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
query {
|
||||
myActivity {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
text
|
||||
assignment {
|
||||
id
|
||||
title
|
||||
module {
|
||||
title
|
||||
metaTitle
|
||||
slug
|
||||
id
|
||||
topic {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
query {
|
||||
me {
|
||||
id
|
||||
schoolClasses {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
users {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
let cls = this.$store.state.specialContainerClass;
|
||||
return [cls ? `skillbox--${cls}` : '', {'skillbox--show-filter': this.showFilter}]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import router from './router'
|
|||
import store from '@/store/index'
|
||||
import VueScrollTo from 'vue-scrollto';
|
||||
import VueAnalytics from 'vue-analytics';
|
||||
import { Validator, install as VeeValidate } from 'vee-validate/dist/vee-validate.minimal.esm.js';
|
||||
import { required, min } from 'vee-validate/dist/rules.esm.js';
|
||||
import veeDe from 'vee-validate/dist/locale/de';
|
||||
import {dateFilter} from './filters/date-filter'
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
|
@ -74,6 +77,35 @@ const apolloProvider = new VueApollo({
|
|||
defaultClient: apolloClient
|
||||
});
|
||||
|
||||
Validator.extend('required', required);
|
||||
Validator.extend('min', min);
|
||||
|
||||
const dict = {
|
||||
custom: {
|
||||
oldPassword: {
|
||||
required: 'Dein aktuelles Passwort fehlt'
|
||||
},
|
||||
newPassword: {
|
||||
required: 'Dein neues Passwort fehlt',
|
||||
min: 'Das neue Passwort muss mindestens 8 Zeichen lang sein'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Validator.localize('de', veeDe)
|
||||
Validator.localize('de', dict)
|
||||
// https://github.com/baianat/vee-validate/issues/51
|
||||
Validator.extend('strongPassword', {
|
||||
getMessage: field => 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten',
|
||||
validate: value => {
|
||||
const strongRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*?(),.":{}|<>+])(?=.{8,})/;
|
||||
return strongRegex.test(value);
|
||||
}
|
||||
})
|
||||
Vue.use(VeeValidate, {
|
||||
locale: 'de'
|
||||
});
|
||||
|
||||
Vue.filter('date', dateFilter);
|
||||
|
||||
/* eslint-disable no-new */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="activity">
|
||||
<h1 class="activity__header">Meine Aktivität</h1>
|
||||
<div class="modules">
|
||||
<module-activity v-for="moduleId in Object.keys(modules)" v-bind="modules[moduleId]" :key="moduleId" class="activity"></module-activity>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import ModuleActivity from '@/components/profile/ModuleActivity';
|
||||
import MY_ACTIVITY_QUERY from '@/graphql/gql/myActivity.gql'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModuleActivity
|
||||
},
|
||||
|
||||
apollo: {
|
||||
submissions: {
|
||||
query: MY_ACTIVITY_QUERY,
|
||||
update(data) {
|
||||
return this.$getRidOfEdges(data).myActivity;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
submissions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
modules () {
|
||||
let modules = {};
|
||||
|
||||
this.submissions.map((submission) => {
|
||||
let activity = {
|
||||
assignmentId: submission.assignment.id,
|
||||
assignmentTitle: submission.assignment.title,
|
||||
answer: submission.text
|
||||
};
|
||||
|
||||
const module = submission.assignment.module
|
||||
if (!(module.id in modules)) {
|
||||
modules[module.id] = {
|
||||
...module,
|
||||
activities: []
|
||||
}
|
||||
}
|
||||
|
||||
modules[module.id].activities = [...modules[module.id].activities, activity];
|
||||
});
|
||||
return modules
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
|
||||
.module-activity {
|
||||
margin-bottom: $large-spacing;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import ASSIGNMENTS_QUERY from '@/graphql/gql/assignmentsQuery.gql';
|
||||
import {moduleQuery} from '@/graphql/queries';
|
||||
|
||||
|
|
@ -13,10 +14,23 @@
|
|||
Module
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(['scrollToAssignmentReady', 'scrollingToAssignment']),
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
scrollToAssignmentId: 'scrollToAssignmentId',
|
||||
isScrollingToAssignment: 'scrollingToAssignment'
|
||||
}),
|
||||
},
|
||||
|
||||
apollo: {
|
||||
module: moduleQuery,
|
||||
assignments: {
|
||||
query: ASSIGNMENTS_QUERY
|
||||
assignments() {
|
||||
return {
|
||||
query: ASSIGNMENTS_QUERY
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -25,6 +39,35 @@
|
|||
module: {},
|
||||
assignments: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$store.subscribe((mutation, state) => {
|
||||
if (mutation.type === 'setScrollToAssignmentReady' && mutation.payload && !this.isScrollingToAssignment) {
|
||||
this.scrollingToAssignment(true);
|
||||
let options = {
|
||||
container: '#app',
|
||||
easing: 'ease',
|
||||
offset: -60,
|
||||
onStart: (element) => {
|
||||
},
|
||||
onDone: (element) => {
|
||||
this.scrollToAssignmentReady(false);
|
||||
this.scrollingToAssignment(false);
|
||||
},
|
||||
onCancel: function() {
|
||||
// scrolling has been interrupted
|
||||
},
|
||||
x: false,
|
||||
y: true
|
||||
};
|
||||
setTimeout(() => {
|
||||
this.$scrollTo(`#${this.scrollToAssignmentId.replace(/=/g, '')}`, 1000, options);
|
||||
}, 250) // unfortunately this timeout is needed as it is hard to tell when everything is rendered
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeDestroy () {
|
||||
this.scrollToAssignmentReady(false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="myclasses">
|
||||
<h1 class="myclasses__header">Klassenliste</h1>
|
||||
<classlist v-for="schoolClass in schoolClasses" v-bind="schoolClass" :key="schoolClass.name" class="myclasses__class"></classlist>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import MY_SCHOOL_CLASSES_QUERY from '@/graphql/gql/mySchoolClasses.gql';
|
||||
import Classlist from '@/components/profile/Classlist';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Classlist
|
||||
},
|
||||
|
||||
apollo: {
|
||||
schoolClasses: {
|
||||
query: MY_SCHOOL_CLASSES_QUERY,
|
||||
update(data) {
|
||||
return this.$getRidOfEdges(data).me.schoolClasses
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
schoolClasses: []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
|
||||
.myclasses {
|
||||
&__class {
|
||||
margin-bottom: $large-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div class="password-reset">
|
||||
<h1 class="password-reset__header">Passwort ändern</h1>
|
||||
<div v-if="showSuccess" class="success-message">
|
||||
<p class="success-message__msg" data-cy="password-change-success">Dein Password wurde erfolgreich geändert.</p>
|
||||
</div>
|
||||
<password-change
|
||||
@passwordSubmited="resetPassword"
|
||||
:oldPasswordErrors="oldPasswordErrors"
|
||||
:newPasswordErrors="newPasswordErrors" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import UPDATE_PASSWORD_MUTATION from '@/graphql/gql/mutations/updatePassword.gql';
|
||||
import PasswordChange from '@/components/PasswordChange'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PasswordChange
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
oldPasswordErrors: [],
|
||||
newPasswordErrors: [],
|
||||
showSuccess: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetPassword (passwords) {
|
||||
this.$apollo.mutate({
|
||||
mutation: UPDATE_PASSWORD_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
passwordInput: passwords
|
||||
}
|
||||
}
|
||||
}).then(({ data }) => {
|
||||
if (data.updatePassword.success) {
|
||||
this.oldPasswordErrors = [];
|
||||
this.newPasswordErrors = [];
|
||||
this.showSuccess = true;
|
||||
this.$root.$emit('reset-password-form')
|
||||
} else {
|
||||
// currently we just have one error per field
|
||||
const error = data.updatePassword.errors[0]
|
||||
if (error.field === 'old_password') {
|
||||
this.handleOldPasswordError(error);
|
||||
} else {
|
||||
this.handleNewPasswordError(error)
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.log('fail', error)
|
||||
});
|
||||
},
|
||||
handleOldPasswordError (error) {
|
||||
this.oldPasswordErrors = error.errors.map((fieldError) => {
|
||||
if (fieldError.code === 'invalid') {
|
||||
return 'Die Eingabe ist falsch'
|
||||
}
|
||||
});
|
||||
},
|
||||
handleNewPasswordError (error) {
|
||||
this.newPasswordErrors = error.errors.map((fieldError) => {
|
||||
if (fieldError.code === 'invalid') {
|
||||
return 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten'
|
||||
} else if (fieldError.code === 'min_length') {
|
||||
return 'Das Passwort muss mindestens 8 Zeichen lang sein.'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
|
||||
.success-message {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__msg {
|
||||
color: $color-accent-4-dark;
|
||||
font-family: $sans-serif-font-family;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="profile">
|
||||
<nav class="top-navigation profile-submenu profile__submenu">
|
||||
<router-link to="/me/activity" active-class="top-navigation__link--active"
|
||||
class="top-navigation__link profile-submenu__item submenu-item">Aktivität
|
||||
</router-link>
|
||||
<router-link to="/me/myclasses" active-class="top-navigation__link--active"
|
||||
class="top-navigation__link profile-submenu__item submenu-item">Klassenliste
|
||||
</router-link>
|
||||
<router-link to="/me/password-change" active-class="top-navigation__link--active"
|
||||
class="top-navigation__link profile-submenu__item submenu-item">Passwort ändern
|
||||
</router-link>
|
||||
</nav>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {
|
||||
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
me: []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/styles/_variables.scss";
|
||||
@import "@/styles/_functions.scss";
|
||||
@import "@/styles/_mixins.scss";
|
||||
|
||||
.profile {
|
||||
padding: $medium-spacing;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
@include desktop {
|
||||
max-width: 1024px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__submenu {
|
||||
margin-bottom: $medium-spacing;
|
||||
margin-left: -$medium-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-submenu {
|
||||
&__item {
|
||||
font-family: $sans-serif-font-family;
|
||||
font-size: toRem(14px);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
// import index from '@/pages/index'
|
||||
import topic from '@/pages/topic'
|
||||
import book from '@/pages/book'
|
||||
|
|
@ -18,6 +17,11 @@ import start from '@/pages/start'
|
|||
import submission from '@/pages/studentSubmission'
|
||||
import portfolio from '@/pages/portfolio'
|
||||
import project from '@/pages/project'
|
||||
import profilePage from '@/pages/profile'
|
||||
import passwordChange from '@/pages/passwordChange'
|
||||
import myClasses from '@/pages/myClasses'
|
||||
import activity from '@/pages/activity'
|
||||
import Router from 'vue-router'
|
||||
import editProject from '@/pages/editProject'
|
||||
import newProject from '@/pages/newProject'
|
||||
|
||||
|
|
@ -73,6 +77,16 @@ const routes = [
|
|||
{path: 'topic/:topicSlug', component: topic, meta: {subnavigation: true}}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
component: profilePage,
|
||||
children: [
|
||||
{path: 'password-change', name: 'pw-change', component: passwordChange, meta: {isProfile: true}},
|
||||
{path: 'myclasses', name: 'my-classes', component: myClasses, meta: {isProfile: true}},
|
||||
{path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}},
|
||||
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},
|
||||
]
|
||||
},
|
||||
{path: '*', component: p404}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ export default new Vuex.Store({
|
|||
id: 0,
|
||||
type: ''
|
||||
},
|
||||
vimeoId: null
|
||||
vimeoId: null,
|
||||
scrollToAssignmentId: '',
|
||||
scrollToAssignmentReady: false,
|
||||
scrollingToAssignment: false
|
||||
},
|
||||
|
||||
getters: {
|
||||
|
|
@ -34,7 +37,10 @@ export default new Vuex.Store({
|
|||
},
|
||||
showMobileNavigation: state => {
|
||||
return state.showMobileNavigation
|
||||
}
|
||||
},
|
||||
scrollToAssignmentId: state => state.scrollToAssignmentId,
|
||||
scrollToAssignmentReady: state => state.scrollToAssignmentReady,
|
||||
scrollingToAssignment: state => state.scrollingToAssignment,
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
|
@ -118,6 +124,22 @@ export default new Vuex.Store({
|
|||
},
|
||||
showMobileNavigation({commit}, payload) {
|
||||
commit('setShowMobileNavigation', payload);
|
||||
},
|
||||
scrollToAssignmentId({commit}, payload) {
|
||||
commit('setScrollToAssignmentId', payload);
|
||||
},
|
||||
scrollToAssignmentReady({commit}, payload) {
|
||||
commit('setScrollToAssignmentReady', payload);
|
||||
},
|
||||
scrollingToAssignment({commit, state, dispatch}, payload) {
|
||||
if (payload && !state.scrollingToAssignment) {
|
||||
commit('setScrollingToAssignment', true);
|
||||
};
|
||||
|
||||
if (!payload && state.scrollingToAssignment) {
|
||||
commit('setScrollingToAssignment', false);
|
||||
dispatch('scrollToAssignmentId', '');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -172,6 +194,15 @@ export default new Vuex.Store({
|
|||
},
|
||||
setShowMobileNavigation(state, payload) {
|
||||
state.showMobileNavigation = payload;
|
||||
},
|
||||
setScrollToAssignmentId(state, payload) {
|
||||
state.scrollToAssignmentId = payload;
|
||||
},
|
||||
setScrollToAssignmentReady(state, payload) {
|
||||
state.scrollToAssignmentReady = payload;
|
||||
},
|
||||
setScrollingToAssignment(state, payload) {
|
||||
state.scrollingToAssignment = payload;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
.top-navigation {
|
||||
display: flex;
|
||||
|
||||
&__link {
|
||||
font-size: 1.0625rem;
|
||||
padding: 0 24px;
|
||||
font-family: $sans-serif-font-family;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $color-grey;
|
||||
|
||||
&--active {
|
||||
color: $color-brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,3 +13,4 @@
|
|||
@import "objective-group";
|
||||
@import "article";
|
||||
@import "actions";
|
||||
@import "top-navigation";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from graphene_django.debug import DjangoDebug
|
|||
# noinspection PyUnresolvedReferences
|
||||
from api import graphene_wagtail # Keep this import exactly here, it's necessary for StreamField conversion
|
||||
from assignments.schema.mutations import AssignmentMutations
|
||||
from assignments.schema.queries import AssignmentsQuery, StudentSubmissionQuery
|
||||
from assignments.schema.queries import AssignmentsQuery, StudentSubmissionQuery, MyActivityQuery
|
||||
from basicknowledge.queries import BasicKnowledgeQuery
|
||||
from books.schema.mutations.main import BookMutations
|
||||
from books.schema.queries import BookQuery
|
||||
|
|
@ -18,10 +18,11 @@ from portfolio.schema import PortfolioQuery
|
|||
from rooms.mutations import RoomMutations
|
||||
from rooms.schema import RoomsQuery
|
||||
from users.schema import UsersQuery
|
||||
from users.mutations import ProfileMutations
|
||||
|
||||
|
||||
class Query(UsersQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, StudentSubmissionQuery,
|
||||
BasicKnowledgeQuery, PortfolioQuery, graphene.ObjectType):
|
||||
BasicKnowledgeQuery, PortfolioQuery, MyActivityQuery, graphene.ObjectType):
|
||||
node = relay.Node.Field()
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
@ -29,7 +30,8 @@ class Query(UsersQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery
|
|||
|
||||
|
||||
class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations,
|
||||
graphene.ObjectType):
|
||||
ProfileMutations, graphene.ObjectType):
|
||||
|
||||
if settings.DEBUG:
|
||||
debug = graphene.Field(DjangoDebug, name='__debug')
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import random
|
|||
import factory
|
||||
|
||||
from books.factories import ModuleFactory
|
||||
from .models import Assignment
|
||||
from .models import Assignment, StudentSubmission
|
||||
|
||||
from core.factories import fake
|
||||
|
||||
|
||||
class AssignmentFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Assignment
|
||||
|
|
@ -14,3 +15,12 @@ class AssignmentFactory(factory.django.DjangoModelFactory):
|
|||
title = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8)))
|
||||
assignment = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8)))
|
||||
module = factory.SubFactory(ModuleFactory)
|
||||
|
||||
|
||||
class StudentSubmissionFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = StudentSubmission
|
||||
|
||||
text = factory.LazyAttribute(lambda x: fake.sentence(nb_words=random.randint(4, 8)))
|
||||
assignment = factory.SubFactory(AssignmentFactory)
|
||||
final = False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from graphene import relay
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
|
||||
from assignments.models import StudentSubmission
|
||||
from assignments.schema.types import AssignmentNode, StudentSubmissionNode
|
||||
|
||||
|
||||
|
|
@ -12,3 +13,10 @@ class AssignmentsQuery(object):
|
|||
class StudentSubmissionQuery(object):
|
||||
student_submission = relay.Node.Field(StudentSubmissionNode)
|
||||
|
||||
|
||||
class MyActivityQuery(object):
|
||||
my_activity = DjangoFilterConnectionField(StudentSubmissionNode)
|
||||
|
||||
def resolve_my_activity(self, info, **kwargs):
|
||||
user = info.context.user
|
||||
return StudentSubmission.objects.filter(student=user)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from books.utils import are_solutions_enabled_for
|
|||
class StudentSubmissionNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = StudentSubmission
|
||||
filter_fields = []
|
||||
interfaces = (relay.Node,)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# ITerativ GmbH
|
||||
# http://www.iterativ.ch/
|
||||
#
|
||||
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
|
||||
#
|
||||
# Created on 2019-04-11
|
||||
# @author: chrigu <christian.cueni@iterativ.ch>
|
||||
from django.conf import settings
|
||||
import json
|
||||
|
||||
from django.test import TestCase, RequestFactory
|
||||
from graphene.test import Client
|
||||
|
||||
from api import schema
|
||||
from api.schema import schema
|
||||
from api.test_utils import DefaultUserTestCase, create_client
|
||||
from assignments.factories import AssignmentFactory, StudentSubmissionFactory
|
||||
from assignments.models import Assignment
|
||||
from books.factories import ModuleFactory
|
||||
from books.models import ContentBlock, Chapter
|
||||
from core.factories import UserFactory
|
||||
from users.models import User
|
||||
from users.services import create_users
|
||||
|
||||
|
||||
class MyAssignemntsText(DefaultUserTestCase):
|
||||
def setUp(self):
|
||||
super(MyAssignemntsText, self).setUp()
|
||||
self.assignment = AssignmentFactory(
|
||||
owner=self.teacher
|
||||
)
|
||||
|
||||
self.submission1 = StudentSubmissionFactory(student=self.student1, assignment=self.assignment)
|
||||
self.submission2 = StudentSubmissionFactory(student=self.student2, assignment=self.assignment)
|
||||
|
||||
self.client = create_client(self.student1)
|
||||
|
||||
def query_my_assignments(self):
|
||||
query = '''
|
||||
query {
|
||||
myActivity {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
text
|
||||
assignment {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
result = self.client.execute(query)
|
||||
|
||||
self.assertIsNone(result.get('errors'))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_content(result):
|
||||
return result.get('data').get('myActivity').get('edges')
|
||||
|
||||
def test_my_assignment_query(self):
|
||||
result = self.query_my_assignments()
|
||||
contents = self.get_content(result)
|
||||
self.assertEqual(len(contents), 1)
|
||||
self.assertEquals(contents[0].get('node').get('text'), self.submission1.text)
|
||||
|
||||
|
|
@ -67,13 +67,14 @@ class ChapterNode(DjangoObjectType):
|
|||
class ModuleNode(DjangoObjectType):
|
||||
pk = graphene.Int()
|
||||
chapters = DjangoFilterConnectionField(ChapterNode)
|
||||
topic = graphene.Field('books.schema.queries.TopicNode')
|
||||
hero_image = graphene.String()
|
||||
solutions_enabled = graphene.Boolean()
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
only_fields = [
|
||||
'slug', 'title', 'meta_title', 'teaser', 'intro', 'objective_groups', 'assignments', 'hero_image'
|
||||
'slug', 'title', 'meta_title', 'teaser', 'intro', 'objective_groups', 'assignments', 'hero_image', 'topic'
|
||||
]
|
||||
filter_fields = {
|
||||
'slug': ['exact', 'icontains', 'in'],
|
||||
|
|
@ -91,6 +92,10 @@ class ModuleNode(DjangoObjectType):
|
|||
def resolve_chapters(self, info, **kwargs):
|
||||
return Chapter.get_by_parent(self)
|
||||
|
||||
def resolve_topic(self, info, **kwargs):
|
||||
some = self.get_parent().specific
|
||||
return self.get_parent().specific
|
||||
|
||||
def resolve_solutions_enabled(self, info, **kwargs):
|
||||
return self.solutions_enabled_by.filter(pk=info.context.user.pk).exists()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# ITerativ GmbH
|
||||
# http://www.iterativ.ch/
|
||||
#
|
||||
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
|
||||
#
|
||||
# Created on 2019-04-08
|
||||
# @author: chrigu <christian.cueni@iterativ.ch>
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from books.models import Module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional arguments
|
||||
parser.add_argument('username', nargs='+', type=str)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Reset Testuser Password")
|
||||
|
||||
email = "{}@skillbox.example".format(options['username'][0])
|
||||
|
||||
try:
|
||||
user = get_user_model().objects.get(email=email)
|
||||
user.set_password('test')
|
||||
user.save()
|
||||
self.stdout.write("Password reset successful")
|
||||
except get_user_model().DoesNotExist:
|
||||
self.stdout.write("No user found!")
|
||||
|
|
@ -8,6 +8,7 @@ from rooms.factories import RoomEntryFactory, RoomFactory
|
|||
from rooms.models import RoomEntry
|
||||
from users.factories import SchoolClassFactory
|
||||
|
||||
|
||||
class RoomEntryMutationsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(username='aschi')
|
||||
|
|
|
|||
|
|
@ -6,3 +6,8 @@ class SchoolClassInput(InputObjectType):
|
|||
id = graphene.ID()
|
||||
name = graphene.String()
|
||||
year = graphene.Int()
|
||||
|
||||
|
||||
class PasswordUpdateInput(InputObjectType):
|
||||
old_password = graphene.String()
|
||||
new_password = graphene.String()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import graphene
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from graphene import relay
|
||||
from users.inputs import PasswordUpdateInput
|
||||
from users.serializers import PasswordSerialzer
|
||||
|
||||
|
||||
class FieldError(graphene.ObjectType):
|
||||
code = graphene.String()
|
||||
|
||||
|
||||
class UpdateError(graphene.ObjectType):
|
||||
field = graphene.String()
|
||||
errors = graphene.List(FieldError)
|
||||
|
||||
|
||||
class UpdatePassword(relay.ClientIDMutation):
|
||||
class Input:
|
||||
password_input = graphene.Argument(PasswordUpdateInput)
|
||||
|
||||
success = graphene.Boolean()
|
||||
errors = graphene.List(UpdateError)
|
||||
|
||||
@classmethod
|
||||
def mutate_and_get_payload(cls, root, info, **kwargs):
|
||||
user = info.context.user
|
||||
password_data = kwargs.get('password_input')
|
||||
|
||||
serializer = PasswordSerialzer(data=password_data, context=user)
|
||||
|
||||
if serializer.is_valid():
|
||||
user.set_password(password_data['new_password'])
|
||||
user.save()
|
||||
update_session_auth_hash(info.context, user)
|
||||
return cls(success=True)
|
||||
|
||||
errors = []
|
||||
for key, value in serializer.errors.items():
|
||||
error = UpdateError(field=key, errors=[])
|
||||
for field_error in serializer.errors[key]:
|
||||
error.errors.append(FieldError(code=field_error.code))
|
||||
|
||||
errors.append(error)
|
||||
|
||||
return cls(success=False, errors=errors)
|
||||
|
||||
|
||||
class ProfileMutations:
|
||||
update_password = UpdatePassword.Field()
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# ITerativ GmbH
|
||||
# http://www.iterativ.ch/
|
||||
#
|
||||
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
|
||||
#
|
||||
# Created on 2019-04-02
|
||||
# @author: chrigu <christian.cueni@iterativ.ch>
|
||||
import re
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import CharField
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
# For future versions https://docs.djangoproject.com/en/2.1/topics/auth/passwords/#integrating-validation
|
||||
|
||||
|
||||
def validate_old_password(old_password, username):
|
||||
user = get_user_model().objects.get(username=username)
|
||||
if user.check_password(old_password):
|
||||
return old_password
|
||||
else:
|
||||
raise serializers.ValidationError(_(u'Das eingegebene Passwort ist falsch'))
|
||||
|
||||
|
||||
def validate_old_new_password(value):
|
||||
if value.get('old_password') == '' and value.get('new_password') == '':
|
||||
return value
|
||||
elif value.get('old_password') == '' and value.get('new_password') != '':
|
||||
raise serializers.ValidationError(_(u'Das neue Passwort muss gesetzt werden'))
|
||||
elif value.get('old_password') != '' and value.get('new_password') == '':
|
||||
raise serializers.ValidationError(_(u'Das alte Passwort muss angegeben werden'))
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate_strong_email(password):
|
||||
|
||||
has_number = re.search('\d', password)
|
||||
has_upper = re.search('[A-Z]', password)
|
||||
has_lower = re.search('[a-z]', password)
|
||||
has_special = re.search('[!@#$%^&*(),.?":{}|<>\+]', password)
|
||||
|
||||
if has_number and has_upper and has_lower and has_special:
|
||||
return password
|
||||
else:
|
||||
raise serializers.ValidationError(_(u'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten'))
|
||||
|
||||
|
||||
class PasswordSerialzer(serializers.Serializer):
|
||||
old_password = CharField(allow_blank=True)
|
||||
new_password = CharField(allow_blank=True, min_length=MIN_PASSWORD_LENGTH)
|
||||
|
||||
def validate_new_password(self, value):
|
||||
return validate_strong_email(value)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
return validate_old_password(value, self.context.username)
|
||||
|
||||
def validate(self, obj):
|
||||
return validate_old_new_password(obj)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# ITerativ GmbH
|
||||
# http://www.iterativ.ch/
|
||||
#
|
||||
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
|
||||
#
|
||||
# Created on 2019-04-04
|
||||
# @author: chrigu <christian.cueni@iterativ.ch>
|
||||
from django.conf import settings
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# ITerativ GmbH
|
||||
# http://www.iterativ.ch/
|
||||
#
|
||||
# Copyright (c) 2019 ITerativ GmbH. All rights reserved.
|
||||
#
|
||||
# Created on 2019-04-09
|
||||
# @author: chrigu <christian.cueni@iterativ.ch>
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, RequestFactory
|
||||
from graphene.test import Client
|
||||
from api.schema import schema
|
||||
from core.factories import UserFactory
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
from users.factories import SchoolClassFactory
|
||||
|
||||
|
||||
class PasswordUpdate(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(username='aschi')
|
||||
self.another_user = UserFactory(username='pesche')
|
||||
self.class1 = SchoolClassFactory(users=[self.user, self.another_user])
|
||||
self.class2 = SchoolClassFactory(users=[self.user])
|
||||
self.class3 = SchoolClassFactory(users=[self.another_user])
|
||||
|
||||
request = RequestFactory().get('/')
|
||||
request.user = self.user
|
||||
self.client = Client(schema=schema, context_value=request)
|
||||
|
||||
def make_query(self):
|
||||
|
||||
query = '''
|
||||
query {
|
||||
me {
|
||||
schoolClasses {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
users {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
return self.client.execute(query)
|
||||
|
||||
def test_user_sees_her_classes(self):
|
||||
|
||||
result = self.make_query()
|
||||
|
||||
classes = result.get('data').get('me').get('schoolClasses').get('edges')
|
||||
self.assertEqual(len(classes), 2)
|
||||
|
||||
for school_class in classes:
|
||||
if school_class.get('name') == self.class1:
|
||||
self.assertEqual(len(school_class.get('node')), 2)
|
||||
elif school_class.get('name') == self.class2:
|
||||
self.assertEqual(len(school_class.get('node')), 1)
|
||||
elif school_class.get('name') == self.class2:
|
||||
self.fail('MySchoolClassTest:test_user_sees_her_classes: Class should not be in response')
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test import TestCase, RequestFactory
|
||||
from graphene.test import Client
|
||||
from api.schema import schema
|
||||
from core.factories import UserFactory
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
|
||||
class PasswordUpdate(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(username='aschi')
|
||||
|
||||
request = RequestFactory().get('/')
|
||||
request.user = self.user
|
||||
|
||||
# adding session
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
self.client = Client(schema=schema, context_value=request)
|
||||
|
||||
def make_request(self, new_password, old_password='test'):
|
||||
|
||||
mutation = '''
|
||||
mutation UpdatePassword($input: UpdatePasswordInput!) {
|
||||
updatePassword(input: $input) {
|
||||
success
|
||||
errors {
|
||||
field
|
||||
errors {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
return self.client.execute(mutation, variables={
|
||||
'input': {
|
||||
'passwordInput': {
|
||||
'oldPassword': old_password,
|
||||
'newPassword': new_password
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
def test_update_password(self):
|
||||
|
||||
new_password = 'Abcd123!'
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertTrue(result.get('data').get('updatePassword').get('success'))
|
||||
|
||||
user = authenticate(username=self.user.username, password=new_password)
|
||||
self.assertIsNotNone(user)
|
||||
|
||||
def test_update_fails_with_short_password(self):
|
||||
|
||||
new_password = 'Ab!d123'
|
||||
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'new_password',
|
||||
'errors': [
|
||||
{'code': 'min_length'}
|
||||
]
|
||||
})
|
||||
|
||||
def test_update_fails_with_no_special_character(self):
|
||||
|
||||
new_password = 'Abcd1239'
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'new_password',
|
||||
'errors': [
|
||||
{'code': 'invalid'}
|
||||
]
|
||||
})
|
||||
|
||||
def test_update_fails_with_no_digit(self):
|
||||
|
||||
new_password = 'Abcd!asddfg'
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'new_password',
|
||||
'errors': [
|
||||
{'code': 'invalid'}
|
||||
]
|
||||
})
|
||||
|
||||
def test_update_fails_with_no_lowercase_char(self):
|
||||
|
||||
new_password = '45ABDC!AWSWS'
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'new_password',
|
||||
'errors': [
|
||||
{'code': 'invalid'}
|
||||
]
|
||||
})
|
||||
|
||||
def test_update_fails_with_no_uppercase_char(self):
|
||||
|
||||
new_password = '45aswed!aswdef'
|
||||
result = self.make_request(new_password)
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'new_password',
|
||||
'errors': [
|
||||
{'code': 'invalid'}
|
||||
]
|
||||
})
|
||||
|
||||
def test_update_fails_with_wrong_old_password(self):
|
||||
|
||||
new_password = 'Abcd123!'
|
||||
result = self.make_request(new_password, 'testttt')
|
||||
|
||||
self.assertFalse(result.get('data').get('updatePassword').get('success'))
|
||||
self.assertEquals(result.get('data').get('updatePassword').get('errors')[0], {
|
||||
'field': 'old_password',
|
||||
'errors': [
|
||||
{'code': 'invalid'}
|
||||
]
|
||||
})
|
||||
Loading…
Reference in New Issue