Merge branch 'feature/pw-reset' into develop

This commit is contained in:
Christian Cueni 2019-04-15 10:31:14 +02:00
commit 40a0c3ecbe
38 changed files with 1382 additions and 20 deletions

View File

@ -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', '');
});
});

View File

@ -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();
});

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,11 @@
mutation UpdatePassword($input: UpdatePasswordInput!) {
updatePassword(input: $input) {
success
errors {
field
errors {
code
}
}
}
}

View File

@ -0,0 +1,23 @@
query {
myActivity {
edges {
node {
id
text
assignment {
id
title
module {
title
metaTitle
slug
id
topic {
title
}
}
}
}
}
}
}

View File

@ -0,0 +1,23 @@
query {
me {
id
schoolClasses {
edges {
node {
id
name
users {
edges {
node {
id
firstName
lastName
permissions
}
}
}
}
}
}
}
}

View File

@ -32,7 +32,7 @@
let cls = this.$store.state.specialContainerClass;
return [cls ? `skillbox--${cls}` : '', {'skillbox--show-filter': this.showFilter}]
}
},
}
}
</script>

View File

@ -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 */

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}
];

View File

@ -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;
}
}
})

View File

@ -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;
}
}
}

View File

@ -13,3 +13,4 @@
@import "objective-group";
@import "article";
@import "actions";
@import "top-navigation";

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -9,6 +9,7 @@ from books.utils import are_solutions_enabled_for
class StudentSubmissionNode(DjangoObjectType):
class Meta:
model = StudentSubmission
filter_fields = []
interfaces = (relay.Node,)

View File

@ -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)

View File

@ -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()

View File

@ -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!")

View File

@ -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')

View File

@ -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()

49
server/users/mutations.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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'}
]
})