diff --git a/client/cypress/integration/survey.spec.js b/client/cypress/integration/survey.spec.js new file mode 100644 index 00000000..08d3ec96 --- /dev/null +++ b/client/cypress/integration/survey.spec.js @@ -0,0 +1,34 @@ +describe('Survey', () => { + beforeEach(() => { + cy.exec("python ../server/manage.py prepare_surveys_for_cypress"); + + cy.viewport('macbook-15'); + cy.startGraphQLCapture(); + cy.login('rahel.cueni', 'test'); + }); + + it('should display and fill out the survey', () => { + cy.visit('/survey/U3VydmV5Tm9kZTox'); + + cy.get('.survey__panel-title').should('contain', 'Fall 1') + + cy.get('#sq_100i').type('Wohlwollen'); + cy.get('#sq_101i').type('Demut'); + + cy.get('[value=Next]').click(); + //cy.get('.button--primary').click() + + cy.get('#sq_102i').type('Keuschheit'); + cy.get('#sq_103i').type('Geduld'); + + cy.get('[value=Complete]').click(); + + cy.waitFor('UpdateAnswer'); + + cy.visit('/survey/U3VydmV5Tm9kZTox'); + + cy.get('#sq_100i').should('have.value', 'Wohlwollen') + + }); + +}); diff --git a/client/index.html b/client/index.html index b94913b0..45c9fa05 100644 --- a/client/index.html +++ b/client/index.html @@ -1,13 +1,16 @@ - - - skillbox + + + skillbox + + + + + + - - - + + diff --git a/client/src/router/index.js b/client/src/router/index.js index 9f01eeda..652f6780 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -24,6 +24,8 @@ import activity from '@/pages/activity' import Router from 'vue-router' import editProject from '@/pages/editProject' import newProject from '@/pages/newProject' +import surveyPage from '@/pages/survey' +import styleGuidePage from '@/pages/styleguide' import store from '@/store/index'; @@ -45,9 +47,7 @@ const routes = [ component: submissions, meta: {filter: true} }, - ] - }, {path: '/rooms', name: 'rooms', component: rooms, meta: {filter: true}}, {path: '/new-room/', name: 'new-room', component: newRoom}, @@ -87,6 +87,12 @@ const routes = [ {path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}}, ] }, + { + path: '/survey/:id', + component: surveyPage, + props: true + }, + {path: '/styleguide', component: styleGuidePage}, {path: '*', component: p404} ]; diff --git a/client/src/styles/_survey.scss b/client/src/styles/_survey.scss new file mode 100644 index 00000000..c854eb23 --- /dev/null +++ b/client/src/styles/_survey.scss @@ -0,0 +1,28 @@ +.survey { + &__panel-title { + @include main-title; + margin-bottom: $large-spacing*2; + span { + @include main-title; + } + } + + &__panel-description { + @include regular-paragraph; + line-height: 1.5; + margin-bottom: $large-spacing; + } + + &__question-title { + @include heading-4; + margin-bottom: $medium-spacing; + span { + @include heading-4; + } + } + + &__input { + width: 100%; + margin-bottom: $medium-spacing; + } +} diff --git a/client/src/styles/main.scss b/client/src/styles/main.scss index 87f9760f..7315029b 100644 --- a/client/src/styles/main.scss +++ b/client/src/styles/main.scss @@ -14,3 +14,4 @@ @import "article"; @import "actions"; @import "top-navigation"; +@import "survey"; diff --git a/client/src/survey.config.js b/client/src/survey.config.js new file mode 100644 index 00000000..a6b2213b --- /dev/null +++ b/client/src/survey.config.js @@ -0,0 +1,161 @@ +export const css = { + 'root': 'survey', + 'header': '', + 'body': '', + 'bodyEmpty': '', + 'footer': '', + 'navigationButton': 'button button--primary', + 'completedPage': '', + 'navigation': { + 'complete': 'button button--primary', + 'prev': 'button button--primary', + 'next': 'button button--primary', + 'start': 'button button--primary' + }, + 'progress': 'progress center-block mx-auto mb-4', + 'progressBar': 'progress-bar', + 'page': { + 'root': '', + 'title': '', + 'survey__page-description': '' + }, + 'pageTitle': '', + 'pageDescription': 'small', + 'row': 'sv_row', + 'question': { + 'mainRoot': 'survey__question question', + 'flowRoot': 'sv_q_flow sv_qstn', + 'titleLeftRoot': 'sv_qstn_left', + 'title': 'survey__question-title', + 'number': 'sv_q_num', + 'survey__question-description': 'small', + 'comment': 'survey__question-input skillbox-input', + 'required': '', + 'titleRequired': '', + 'hasError': 'has-error', + 'indent': 20 + }, + 'panel': { + 'title': 'survey__panel-title', + 'description': 'small survey__panel-description', + 'container': 'sv_p_container' + }, + 'error': { + 'root': 'alert alert-danger', + 'icon': 'glyphicon glyphicon-exclamation-sign', + 'item': '', + 'locationTop': 'sv_qstn_error_top', + 'locationBottom': 'sv_qstn_error_bottom' + }, + 'boolean': { + 'root': 'sv_qbln form-inline checkbox', + 'item': '', + 'label': '', + 'materialDecorator': 'checkbox-material' + }, + 'checkbox': { + 'root': 'sv_qcbc sv_qcbx form-inline', + 'item': 'checkbox', + 'itemControl': '', + 'controlLabel': '', + 'materialDecorator': 'checkbox-material', + 'other': 'sv_q_checkbox_other skillbox-input', + 'column': 'sv_q_select_column' + }, + 'comment': 'survey__input skillbox-input', + 'dropdown': { + 'root': '', + 'control': 'skillbox-input', + 'other': 'sv_q_dd_other skillbox-input' + }, + 'html': { + 'root': '' + }, + 'matrix': { + 'root': 'table table-striped', + 'label': 'sv_q_m_label', + 'cellText': 'sv_q_m_cell_text', + 'cellTextSelected': 'sv_q_m_cell_selected bg-primary', + 'cellLabel': 'sv_q_m_cell_label' + }, + 'matrixdropdown': { + 'root': 'table' + }, + 'matrixdynamic': { + 'root': 'table', + 'button': 'button', + 'buttonAdd': '', + 'buttonRemove': '', + 'iconAdd': '', + 'iconRemove': '' + }, + 'paneldynamic': { + 'root': '', + 'button': 'button', + 'buttonPrev': '', + 'buttonNext': '', + 'buttonAdd': '', + 'buttonRemove': '' + }, + 'multipletext': { + 'root': 'table', + 'itemTitle': '', + 'itemValue': 'sv_q_mt_item_value skillbox-input' + }, + 'radiogroup': { + 'root': 'sv_qcbc form-inline', + 'item': 'radio', + 'label': '', + 'itemControl': '', + 'controlLabel': '', + 'materialDecorator': 'circle', + 'other': 'sv_q_radiogroup_other skillbox-input', + 'clearButton': 'sv_q_radiogroup_clear button', + 'column': 'sv_q_select_column' + }, + 'imagepicker': { + 'root': 'sv_imgsel', + 'item': 'sv_q_imgsel', + 'label': 'sv_q_imgsel_label', + 'itemControl': 'sv_q_imgsel_control_item', + 'image': 'sv_q_imgsel_image', + 'itemText': 'sv_q_imgsel_text', + 'clearButton': 'sv_q_radiogroup_clear' + }, + 'rating': { + 'root': 'btn-group', + 'item': 'btn btn-default btn-secondary', + 'selected': 'active', + 'minText': 'sv_q_rating_min_text', + 'itemText': 'sv_q_rating_item_text', + 'maxText': 'sv_q_rating_max_text' + }, + 'text': 'survey__input skillbox-input', + 'expression': 'survey__input skillbox-input', + 'file': { + 'root': 'sv_q_file', + 'placeholderInput': 'sv_q_file_placeholder', + 'preview': 'sv_q_file_preview', + 'removeButton': 'sv_q_file_remove_button', + 'fileInput': 'sv_q_file_input', + 'removeFile': 'sv_q_file_remove' + }, + 'saveData': { + 'root': '', + 'saving': 'alert alert-info', + 'error': 'alert alert-danger', + 'success': 'alert alert-success', + 'saveAgainButton': '' + }, + 'window': { + 'root': 'modal-content', + 'body': 'modal-body', + 'header': { + 'root': 'modal-header panel-title', + 'title': 'pull-left', + 'button': 'glyphicon pull-right', + 'buttonExpanded': 'glyphicon pull-right glyphicon-chevron-up', + 'buttonCollapsed': 'glyphicon pull-right glyphicon-chevron-down' + } + } +}; diff --git a/server/api/schema.py b/server/api/schema.py index 961cdf35..9c39b4db 100644 --- a/server/api/schema.py +++ b/server/api/schema.py @@ -15,6 +15,8 @@ from objectives.mutations import ObjectiveMutations from objectives.schema import ObjectivesQuery from portfolio.mutations import PortfolioMutations from portfolio.schema import PortfolioQuery +from surveys.schema import SurveysQuery +from surveys.mutations import SurveysMutations from rooms.mutations import RoomMutations from rooms.schema import RoomsQuery from users.schema import UsersQuery @@ -22,7 +24,7 @@ from users.mutations import ProfileMutations class Query(UsersQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, StudentSubmissionQuery, - BasicKnowledgeQuery, PortfolioQuery, MyActivityQuery, graphene.ObjectType): + BasicKnowledgeQuery, PortfolioQuery, MyActivityQuery, SurveysQuery, graphene.ObjectType): node = relay.Node.Field() if settings.DEBUG: @@ -30,7 +32,7 @@ class Query(UsersQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations, - ProfileMutations, graphene.ObjectType): + ProfileMutations, SurveysMutations, graphene.ObjectType): if settings.DEBUG: debug = graphene.Field(DjangoDebug, name='__debug') diff --git a/server/api/utils.py b/server/api/utils.py index 6cd70ac4..3b3e76f1 100644 --- a/server/api/utils.py +++ b/server/api/utils.py @@ -56,6 +56,12 @@ def get_graphql_mutation(filename): return mutation +def get_by_id(model, **kwargs): + id = kwargs.get('id') + + return get_object(model, id) if id is not None else None + + def get_by_id_or_slug(model, **kwargs): slug = kwargs.get('slug') id = kwargs.get('id') diff --git a/server/core/management/commands/prepare_surveys_for_cypress.py b/server/core/management/commands/prepare_surveys_for_cypress.py new file mode 100644 index 00000000..1d0b21a2 --- /dev/null +++ b/server/core/management/commands/prepare_surveys_for_cypress.py @@ -0,0 +1,75 @@ +from django.core.management import BaseCommand + +from portfolio.factories import ProjectFactory +from portfolio.models import ProjectEntry +from surveys.models import Survey +from users.models import User + +survey_data = { + "pages": [ + { + "elements": [ + { + "description": "Max hat Ende Monat noch Fr. 20.\u2013 \u00fcbrig, die er gespart hat, um mit seinem besten Kumpel, der ein halbes Jahr im Ausland verweilte, Billard spielen zu gehen. Doch dann bittet ihn seine j\u00fcngere Schwester um Geld. Sie hat ein unverhofftes Date mit einem jungen Mann, in den sie sich bereits vor Monaten unsterblich verliebt hat. Leider ist ihr Kontostand aber bereits auf Null.", + "elements": [ + { + "name": "A: Max gibt ihr das Geld und muss das Billardspiel absagen.", + "placeHolder": "Passende Tugenden erfassen...", + "type": "text" + }, + { + "name": "question2", + "placeHolder": "Passende Tugenden erfassen...", + "title": "B: Max gibt ihr das Geld nicht und geht Billard spielen.", + "type": "text" + } + ], + "name": "Fall 1", + "title": "Fall 1", + "type": "panel" + } + ], + "name": "Seite 1" + }, + { + "elements": [ + { + "description": "Auf der Autobahn brennt ein Lastwagen, der jederzeit explodieren kann. Silvio, dem Fahrer, bleiben nur noch wenige Minuten: Entweder bringt er seinen ohnm\u00e4chtig gewordenen Mitfahrer in Sicherheit oder er sperrt die Strasse ab, die nach wie vor dicht befahren wird.", + "elements": [ + { + "name": "question1", + "placeHolder": "Passende Tugenden erfassen...", + "title": "A: Silvio bringt seinen Mitfahrer in Sicherheit.", + "type": "text", + "useDisplayValuesInTitle": False + }, + { + "name": "question3", + "placeHolder": "Passende Tugenden erfassen...", + "title": "B: Silvio sperrt die Strasse ab.", + "type": "text" + } + ], + "name": "panel1", + "title": "Fall 2", + "type": "panel" + } + ], + "name": "Seite 2" + } + ], + "showQuestionNumbers": "off" +} + + +class Command(BaseCommand): + def handle(self, *args, **options): + self.stdout.write("Clearing surveys") + Survey.objects.all().delete() + self.stdout.write("Creating survey") + + Survey.objects.create( + title='Test', + data=survey_data, + pk=1 + ) diff --git a/server/core/settings.py b/server/core/settings.py index 12964ab8..b0720a86 100644 --- a/server/core/settings.py +++ b/server/core/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ 'basicknowledge', 'portfolio', 'statistics', + 'surveys', 'wagtail.contrib.forms', 'wagtail.contrib.redirects', diff --git a/server/rooms/migrations/0005_auto_20190617_1115.py b/server/rooms/migrations/0005_auto_20190617_1115.py new file mode 100644 index 00000000..d5d48c75 --- /dev/null +++ b/server/rooms/migrations/0005_auto_20190617_1115.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.6 on 2019-06-17 11:15 + +from django.db import migrations +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('rooms', '0004_auto_20190210_2125'), + ] + + operations = [ + migrations.AlterField( + model_name='roomentry', + name='contents', + field=wagtail.core.fields.StreamField([('text_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.RichTextBlock())])), ('image_url_block', wagtail.core.blocks.StructBlock([('title', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('link_block', wagtail.core.blocks.StructBlock([('text', wagtail.core.blocks.TextBlock()), ('url', wagtail.core.blocks.URLBlock())])), ('document_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())])), ('video_block', wagtail.core.blocks.StructBlock([('url', wagtail.core.blocks.URLBlock())]))], blank=True, null=True), + ), + ] diff --git a/server/surveys/__init__.py b/server/surveys/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/surveys/admin.py b/server/surveys/admin.py new file mode 100644 index 00000000..b7873b3a --- /dev/null +++ b/server/surveys/admin.py @@ -0,0 +1,40 @@ +import json +import logging + +from django.contrib import admin +from django.contrib.postgres.fields import JSONField +from django.forms import widgets + +logger = logging.getLogger(__name__) + +# Register your models here. +from surveys.models import Survey, Answer + + +class PrettyJSONWidget(widgets.Textarea): + + def format_value(self, value): + try: + value = json.dumps(json.loads(value), indent=2, sort_keys=True) + # these lines will try to adjust size of TextArea to fit to content + row_lengths = [len(r) for r in value.split('\n')] + self.attrs['rows'] = min(max(len(row_lengths) + 2, 10), 30) + self.attrs['cols'] = min(max(max(row_lengths) + 2, 40), 120) + return value + except Exception as e: + logger.warning("Error while formatting JSON: {}".format(e)) + return super(PrettyJSONWidget, self).format_value(value) + + +class JSONAdmin(admin.ModelAdmin): + formfield_overrides = { + JSONField: {'widget': PrettyJSONWidget} + } + +@admin.register(Survey) +class SurveyAdmin(JSONAdmin): + pass + +@admin.register(Answer) +class AnswerAdmin(JSONAdmin): + pass diff --git a/server/surveys/apps.py b/server/surveys/apps.py new file mode 100644 index 00000000..861dd6e2 --- /dev/null +++ b/server/surveys/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SurveysConfig(AppConfig): + name = 'surveys' diff --git a/server/surveys/inputs.py b/server/surveys/inputs.py new file mode 100644 index 00000000..11a45b9c --- /dev/null +++ b/server/surveys/inputs.py @@ -0,0 +1,6 @@ +import graphene +from graphene import InputObjectType + +class UpdateAnswerArgument(InputObjectType): + survey_id = graphene.ID(required=True) + data = graphene.String(required=True) diff --git a/server/surveys/migrations/0001_initial.py b/server/surveys/migrations/0001_initial.py new file mode 100644 index 00000000..35a3e25f --- /dev/null +++ b/server/surveys/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.6 on 2019-06-17 11:15 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Survey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ], + ), + ] diff --git a/server/surveys/migrations/0002_answer.py b/server/surveys/migrations/0002_answer.py new file mode 100644 index 00000000..21bdf24b --- /dev/null +++ b/server/surveys/migrations/0002_answer.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.6 on 2019-06-27 14:35 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('surveys', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='surveys.Survey')), + ], + ), + ] diff --git a/server/surveys/migrations/__init__.py b/server/surveys/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/surveys/models.py b/server/surveys/models.py new file mode 100644 index 00000000..1f137f71 --- /dev/null +++ b/server/surveys/models.py @@ -0,0 +1,19 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.contrib.postgres.fields import JSONField + + +class Survey(models.Model): + title = models.CharField(max_length=255) + data = JSONField() + + def __str__(self): + return self.title + +class Answer(models.Model): + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='answers') + data = JSONField() + survey = models.ForeignKey(Survey, on_delete=models.CASCADE, related_name='answers') + + def __str__(self): + return '{} - {}'.format(self.owner.username, self.survey.title) diff --git a/server/surveys/mutations.py b/server/surveys/mutations.py new file mode 100644 index 00000000..0fb962e8 --- /dev/null +++ b/server/surveys/mutations.py @@ -0,0 +1,37 @@ +import graphene +import json +from graphene import relay + +from api.utils import get_object +from surveys.inputs import UpdateAnswerArgument +from surveys.models import Survey, Answer +from surveys.schema import AnswerNode + + +class UpdateAnswer(relay.ClientIDMutation): + class Input: + answer = graphene.Argument(UpdateAnswerArgument) + + answer = graphene.Field(AnswerNode) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + user = info.context.user + + answer = kwargs.get('answer') + survey_id = answer.get('survey_id') + data = json.loads(answer.get('data')) + survey = get_object(Survey, survey_id) + + try: + answer = survey.answers.get(owner=user) + answer.data = data + answer.save() + except Answer.DoesNotExist: + answer = Answer.objects.create(owner=user, survey=survey, data=data) + + return cls(answer=answer) + + +class SurveysMutations: + update_answer = UpdateAnswer.Field() diff --git a/server/surveys/schema.py b/server/surveys/schema.py new file mode 100644 index 00000000..701e76cd --- /dev/null +++ b/server/surveys/schema.py @@ -0,0 +1,49 @@ +import graphene +from graphene import relay +from graphene_django import DjangoObjectType +from graphene_django.filter import DjangoFilterConnectionField + +from api.utils import get_by_id +from surveys.models import Answer +from .models import Survey + + +class AnswerNode(DjangoObjectType): + pk = graphene.Int() + + class Meta: + model = Answer + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + +class SurveyNode(DjangoObjectType): + pk = graphene.Int() + answer = graphene.Field(AnswerNode) + + class Meta: + model = Survey + filter_fields = [] + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + def resolve_answer(self, info, **kwargs): + user = info.context.user + try: + return Answer.objects.get(owner=user, survey=self) + except Answer.DoesNotExist: + return None + +class SurveysQuery(object): + survey = graphene.Field(SurveyNode, id=graphene.ID()) + surveys = DjangoFilterConnectionField(SurveyNode) + + def resolve_surveys(self, info, **kwargs): + return Survey.objects.all() + + def resolve_survey(self, info, **kwargs): + return get_by_id(Survey, **kwargs) diff --git a/server/surveys/tests.py b/server/surveys/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/server/surveys/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/surveys/views.py b/server/surveys/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/server/surveys/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.