Merged in feature/multiple-classes-for-everyone (pull request #46)

Feature/multiple classes for everyone

Approved-by: Christian Cueni
This commit is contained in:
Ramon Wenger 2020-02-25 08:42:21 +00:00
commit a7413c6ce0
40 changed files with 1061 additions and 168 deletions

View File

@ -10,6 +10,7 @@ python_version = '3.6'
awscli = "*"
ipdb = "*"
coverage = "*"
django-silk = "*"
[packages]
factory-boy = "==2.11.0"

177
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "e7807798e8b5c39d7c1bc57d61520f2c888da08d2b6061f07758e00b490fdbd6"
"sha256": "ca4f635dc983134e4569df2af8f0d73f488211bc2a97d11c194f76279f942977"
},
"pipfile-spec": 6,
"requires": {
@ -48,18 +48,18 @@
},
"boto3": {
"hashes": [
"sha256:27e221d3868f35687807e5c920f7e8d4872f722f64196a7fd274a06ad65beec0",
"sha256:8ff4e3d9e5d6a26dd7494afc68dc96afe6b7bda88130cca84cd58702d888ed27"
"sha256:629ce3be236b6e0aed52358146eea9ffa7679d6cd1cc9b3e12332226270d6499",
"sha256:b1351e62136fae29be8fcbb1c4890f1d72017d57e33051d435a8bf9f71212fde"
],
"index": "pypi",
"version": "==1.11.10"
"version": "==1.11.14"
},
"botocore": {
"hashes": [
"sha256:cf3144994191847e30ef76781af867009bdc233b3f1f4736615e5330687a891e",
"sha256:f11ff8616f46ca04697df031e622c9ed50931b9d649d4e719f961e9d80771e8d"
"sha256:34ad4d73e6bef5c8ad956c66354611628cdebd431fe2927261ed9c068b9cfb7a",
"sha256:6570f2ba046956d5b548ab2346d32f713ecf8b8cb098a839d73fcf832ccfa223"
],
"version": "==1.14.10"
"version": "==1.14.14"
},
"certifi": {
"hashes": [
@ -535,10 +535,10 @@
},
"s3transfer": {
"hashes": [
"sha256:2525bae2a530195576da53671bae8ca8c55ee8e33bc2225a65e804476611ea5a",
"sha256:4924e10451cc37901945806423d16c2c2040a6530645a614ed87e995ccec764c"
"sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
"sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
],
"version": "==0.3.2"
"version": "==0.3.3"
},
"sendgrid": {
"hashes": [
@ -603,6 +603,7 @@
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
],
"markers": "python_version != '3.4'",
"version": "==1.25.8"
},
"wagtail": {
@ -652,13 +653,26 @@
}
},
"develop": {
"asgiref": {
"hashes": [
"sha256:7e06d934a7718bf3975acbf87780ba678957b87c7adc056f13b6215d610695a0",
"sha256:ea448f92fc35a0ef4b1508f53a04c4670255a3f33d22a81c8fc9c872036adbe5"
],
"version": "==3.2.3"
},
"autopep8": {
"hashes": [
"sha256:0f592a0447acea0c2b0a9602be1e4e3d86db52badd2e3c84f0193bfd89fd3a43"
],
"version": "==1.5"
},
"awscli": {
"hashes": [
"sha256:4c49f085fb827ca1aeba5e6e5e39f6005110a0059b5c772aeb1d51c4f33c4028",
"sha256:9459ac705c2a5d8724057492800c52084df714b624853eb3331087ecf8726a22"
"sha256:2748af4e77728ced50d7d5bda0fa980449bd71eedff90ee643bee86ed4283d2f",
"sha256:9118015f4bbab1c671d9c9927d07b6f7eadb7e1e8bbb2b06dc849c3de578d692"
],
"index": "pypi",
"version": "==1.17.9"
"version": "==1.17.14"
},
"backcall": {
"hashes": [
@ -669,17 +683,32 @@
},
"botocore": {
"hashes": [
"sha256:cf3144994191847e30ef76781af867009bdc233b3f1f4736615e5330687a891e",
"sha256:f11ff8616f46ca04697df031e622c9ed50931b9d649d4e719f961e9d80771e8d"
"sha256:34ad4d73e6bef5c8ad956c66354611628cdebd431fe2927261ed9c068b9cfb7a",
"sha256:6570f2ba046956d5b548ab2346d32f713ecf8b8cb098a839d73fcf832ccfa223"
],
"version": "==1.14.10"
"version": "==1.14.14"
},
"certifi": {
"hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
"version": "==2019.11.28"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"colorama": {
"hashes": [
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
"sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
"sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
],
"version": "==0.4.1"
"markers": "python_version != '3.4'",
"version": "==0.4.3"
},
"coverage": {
"hashes": [
@ -725,6 +754,22 @@
],
"version": "==4.4.1"
},
"django": {
"hashes": [
"sha256:48522428f4a285cf265af969f4744c5ebb027c7f41958ba48b639ace2068ffe7",
"sha256:a794f7a2f4b7c928eecfbc4ebad03712ff27fb545abe269bf01aa8500781eb1c"
],
"index": "pypi",
"version": "==2.1.15"
},
"django-silk": {
"hashes": [
"sha256:9d5d66628689230288d1020de186b86e6f38583f611b5dd796ec988bb6a6333e",
"sha256:b0033ec3713882a5abb8b3db2b4392a746b83ce164ab7be568f0eeec4ba78a98"
],
"index": "pypi",
"version": "==4.0.0"
},
"docutils": {
"hashes": [
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
@ -733,6 +778,19 @@
],
"version": "==0.15.2"
},
"gprof2dot": {
"hashes": [
"sha256:b43fe04ebb3dfe181a612bbfc69e90555b8957022ad6a466f0308ed9c7f22e99"
],
"version": "==2019.11.30"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"ipdb": {
"hashes": [
"sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd"
@ -762,6 +820,13 @@
],
"version": "==0.16.0"
},
"jinja2": {
"hashes": [
"sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
"sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
],
"version": "==2.11.1"
},
"jmespath": {
"hashes": [
"sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6",
@ -769,6 +834,44 @@
],
"version": "==0.9.4"
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
],
"version": "==1.1.1"
},
"parso": {
"hashes": [
"sha256:56b2105a80e9c4df49de85e125feb6be69f49920e121406f15e7acde6c9dfc57",
@ -812,6 +915,13 @@
],
"version": "==0.4.8"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pygments": {
"hashes": [
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
@ -826,6 +936,13 @@
],
"version": "==2.8.1"
},
"pytz": {
"hashes": [
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
],
"version": "==2019.3"
},
"pyyaml": {
"hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
@ -842,6 +959,14 @@
],
"version": "==5.2"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"rsa": {
"hashes": [
"sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
@ -851,10 +976,10 @@
},
"s3transfer": {
"hashes": [
"sha256:2525bae2a530195576da53671bae8ca8c55ee8e33bc2225a65e804476611ea5a",
"sha256:4924e10451cc37901945806423d16c2c2040a6530645a614ed87e995ccec764c"
"sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
"sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
],
"version": "==0.3.2"
"version": "==0.3.3"
},
"six": {
"hashes": [
@ -863,6 +988,13 @@
],
"version": "==1.14.0"
},
"sqlparse": {
"hashes": [
"sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177",
"sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873"
],
"version": "==0.3.0"
},
"traitlets": {
"hashes": [
"sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
@ -875,6 +1007,7 @@
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
],
"markers": "python_version != '3.4'",
"version": "==1.25.8"
},
"wcwidth": {

View File

@ -0,0 +1,35 @@
{
"me": {
"id": "VXNlck5vZGU6NQ==",
"pk": 5,
"username": "rahel.cueni",
"email": "rahel.cueni@skillbox.example",
"firstName": "Rahel",
"lastName": "Cueni",
"avatarUrl": "",
"lastModule": {
"id": "TW9kdWxlTm9kZToxNw==",
"slug": "lohn-und-budget",
"__typename": "ModuleNode"
},
"selectedClass": {
"id": "U2Nob29sQ2xhc3NOb2RlOjI=",
"__typename": "SchoolClassNode"
},
"schoolClasses": {
"edges": [
{
"node": {
"id": "U2Nob29sQ2xhc3NOb2RlOjE=",
"name": "FLID2018a",
"__typename": "SchoolClassNode"
},
"__typename": "SchoolClassNodeEdge"
}
],
"__typename": "SchoolClassNodeConnection"
},
"__typename": "UserNode",
"permissions": []
}
}

View File

@ -1500,6 +1500,79 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "myInstrumentActivity",
"description": null,
"args": [
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "slug",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "type",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "InstrumentNodeConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The ID of the object",
@ -3277,6 +3350,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "code",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "hiddenContentBlocks",
"description": null,
@ -9285,6 +9370,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "joinClass",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "JoinClassInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "JoinClassPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "addProject",
"description": null,
@ -11434,6 +11546,88 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "JoinClassPayload",
"description": null,
"fields": [
{
"name": "success",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "schoolClass",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "SchoolClassNode",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clientMutationId",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "JoinClassInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "code",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AddProjectPayload",

View File

@ -1,4 +1,4 @@
describe('Survey', () => {
describe('Bookmarks', () => {
beforeEach(() => {
// todo: mock all the graphql queries and mutations
cy.exec("python ../server/manage.py prepare_bookmarks_for_cypress");
@ -27,7 +27,6 @@ describe('Survey', () => {
cy.get('@interviewContent').within(() => {
cy.get('.bookmark-actions__edit-note').click();
});
cy.get('[data-cy=bookmark-note]').within(() => {

View File

@ -0,0 +1,48 @@
const schema = require('../fixtures/schema.json');
const me = require('../fixtures/me.join-class.json');
describe('Join Class', () => {
beforeEach(() => {
cy.server();
cy.mockGraphql({
schema: schema,
});
cy.viewport('macbook-15');
cy.apolloLogin('rahel.cueni', 'test');
});
it('should join class', () => {
cy.mockGraphqlOps({
operations: {
MeQuery: me,
JoinClass: {
joinClass: {
success: true,
schoolClass: {
id: "U2Nob29sQ2xhc3NOb2RlOjI=",
name: "KF1A",
__typename: "SchoolClassNode"
}
}
}
}
});
cy.visit('/me/profile');
cy.get('[data-cy=class-selection]').click();
cy.get('[data-cy=class-selection-entry]').should('have.length', 1);
cy.get('[data-cy=join-class-link]').click();
cy.get('[data-cy=input-class-code]').type('XXXX');
cy.get('[data-cy=join-class]').click();
cy.get('[data-cy=class-selection]').click();
cy.get('[data-cy=class-selection-entry]').should('have.length', 2);
})
})

View File

@ -10,7 +10,6 @@ describe('Survey', () => {
});
cy.viewport('macbook-15');
cy.apolloLogin('rahel.cueni', 'test');
});

View File

@ -1,19 +1,21 @@
<template>
<div class="class-selection" v-if="isTeacher">
<div class="class-selection__selected-class selected-class" @click="showPopover = !showPopover">
<div class="class-selection" v-if="currentClassSelection">
<div data-cy="class-selection" class="class-selection__selected-class selected-class"
@click="showPopover = !showPopover">
<p class="selected-class__text">Klasse: {{currentClassSelection.name}}</p>
</div>
<widget-popover v-if="showPopover"
@hide-me="showPopover = false"
:mobile="mobile"
class="class-selection__popover">
<li class="popover-links__link popover-links__link--large" v-for="schoolClass in schoolClasses"
<widget-popover v-if="showPopover"
@hide-me="showPopover = false"
:mobile="mobile"
class="class-selection__popover">
<li data-cy="class-selection-entry" class="popover-links__link popover-links__link--large"
v-for="schoolClass in schoolClasses"
:key="schoolClass.id"
:label="schoolClass.name"
:item="schoolClass"
@click="updateFilter(schoolClass)">
{{schoolClass.name}}
</li>
{{schoolClass.name}}
</li>
</widget-popover>
</div>
</template>
@ -47,7 +49,8 @@
selectedClass: {
id: ''
},
permissions: []
permissions: [],
schoolClasses: []
},
showPopover: false
}
@ -78,14 +81,11 @@
currentClassSelection() {
let currentClass = this.schoolClasses.find(schoolClass => {
return schoolClass.id === this.me.selectedClass.id
})
});
return currentClass || this.schoolClasses[0];
},
schoolClasses() {
return this.$getRidOfEdges(this.me.schoolClasses);
},
isTeacher() {
return this.me.permissions.includes('users.can_manage_school_class_content');
}
},
@ -113,8 +113,8 @@
line-height: $large-spacing;
@include regular-text;
color: $color-silver-dark;
}
}
}
.popover-links__link {
cursor: pointer;

View File

@ -59,15 +59,8 @@
display: flex;
&__link {
font-size: 1.0625rem;
padding: 0 24px;
font-family: $sans-serif-font-family;
font-weight: $font-weight-regular;
color: $color-silver-dark;
&--active {
color: $color-brand;
}
@include default-link;
}
$parent: &;

View File

@ -1,6 +1,6 @@
<template>
<header class="header-bar">
<top-navigation></top-navigation>
<content-navigation></content-navigation>
<router-link to="/" class="header-bar__logo" data-cy="home-link">
<logo></logo>
</router-link>
@ -14,7 +14,7 @@
</template>
<script>
import TopNavigation from '@/components/TopNavigation.vue';
import ContentNavigation from '@/components/ContentNavigation.vue';
import BookNavigation from '@/components/book-navigation/BookNavigation';
import UserWidget from '@/components/UserWidget.vue';
import LogoutWidget from '@/components/LogoutWidget.vue';
@ -25,7 +25,7 @@
export default {
components: {
TopNavigation,
ContentNavigation,
UserWidget,
LogoutWidget,
BookNavigation,

View File

@ -1,36 +1,17 @@
<template>
<div class="user-widget" :class="{'user-widget--is-profile': isProfile}">
<div class="user-widget__avatar" @click="toggleShowPopover()">
<div class="user-widget__avatar" @click="openSidebar()">
<avatar :avatar-url="avatarUrl" :icon-highlighted="isProfile"/>
</div>
<widget-popover v-if="showPopover && showMenu"
@hide-me="showPopover = false"
:mobile="mobile"
class="user-widget__popover ">
<li class="popover-links__link popover-links__link--large popover-links__link--emph">{{firstName}} {{lastName}}
</li>
<li class="popover-links__link popover-links__link--large">
<router-link to="/me/activity" @click="toggleShowPopover()">Aktivität</router-link>
</li>
<li class="popover-links__link popover-links__link--large" @click="toggleShowPopover()">
<router-link to="/me/profile">Profil</router-link>
</li>
<li class="popover-links__link popover-links__link--large" @click="toggleShowPopover()">
<router-link to="/me/myclasses">Klassenliste</router-link>
</li>
<li class="popover-links__link popover-links__link--large" data-cy="logout" @click="logout()">
<a>Logout</a>
</li>
</widget-popover>
</div>
</template>
<script>
import LOGOUT_MUTATION from '@/graphql/gql/mutations/logoutUser.gql';
import TOGGLE_SIDEBAR from '@/graphql/gql/local/mutations/toggleSidebar.gql';
import Avatar from '@/components/profile/Avatar';
import WidgetPopover from '@/components/WidgetPopover';
export default {
// todo: clean up unneeded props
props: {
firstName: {
type: String
@ -51,31 +32,19 @@
}
},
data() {
return {
showPopover: false
}
},
methods: {
toggleShowPopover() {
if (this.showMenu) {
this.showPopover = !this.showPopover;
}
},
logout() {
openSidebar() {
this.$apollo.mutate({
mutation: LOGOUT_MUTATION,
}).then(({data}) => {
if (data.logout.success) {
location.replace('/logout')
mutation: TOGGLE_SIDEBAR,
variables: {
open: true
}
});
}
},
},
components: {
Avatar, WidgetPopover
Avatar
},
computed: {
isProfile() {

View File

@ -1,6 +1,6 @@
<template>
<div class="mobile-navigation">
<top-navigation class="mobile-navigation__main" :mobile="true"></top-navigation>
<content-navigation class="mobile-navigation__main" :mobile="true"></content-navigation>
<div class="mobile-navigation__close-button" @click="hideMobileNavigation">
<cross class="mobile-navigation__close-icon"></cross>
</div>
@ -16,14 +16,14 @@
import Cross from '@/components/icons/Cross';
import UserWidget from '@/components/UserWidget';
import LogoutWidget from '@/components/LogoutWidget';
import TopNavigation from '@/components/TopNavigation';
import ContentNavigation from '@/components/ContentNavigation';
import ClassSelectionWidget from '@/components/ClassSelectionWidget';
import {meQuery} from '@/graphql/queries';
export default {
components: {
TopNavigation,
ContentNavigation,
Cross,
UserWidget,
LogoutWidget,

View File

@ -89,7 +89,6 @@
spellcheckText() {
if (!this.spellcheckLoading) {
return 'Rechtschreibung prüfen'
} else {
return 'Wird geprüft...'
}

View File

@ -0,0 +1,63 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400">
<defs>
<clipPath id="clip-path">
<circle class="cls-1" cx="200" cy="200" r="197"/>
</clipPath>
</defs>
<g id="bg">
<circle class="cls-2" cx="200" cy="200" r="197"/>
</g>
<g id="objects">
<g class="cls-3">
<path class="cls-4"
d="M380.03229,346.82485l-99.89952-29.68554-75.91247-7.58045h-2.40707l-78.31955,7.58045-103.526,30.44984s75.62737,95.55272,183.04906,95.55272S380.03229,346.82485,380.03229,346.82485Z"/>
<path class="cls-5"
d="M272.15765,65.53589a86.01591,86.01591,0,0,0-20.524-14.33295,103.61294,103.61294,0,0,0-49.8204-11.92284,103.61271,103.61271,0,0,0-49.8198,11.92255A86.01706,86.01706,0,0,0,131.4688,65.53589c-47.89186,45.957-59.684,136.053-53.17653,195.51559,22.30221,28.20465,70.43168,35.98046,103.97675,29.59277-1.699-28.98109-18.28465-55.36789-18.09824-84.40807,16.82179-9.95623,9.42329-59.11938,10.702-81.59916l26.94039-6.97556,26.9404,6.97556c1.27875,22.47978-6.11974,71.64293,10.70211,81.59916.18642,29.04018-16.39928,55.427-18.09824,84.40807,33.545,6.38769,81.67447-1.38812,103.97668-29.59277C331.84167,201.58886,320.04951,111.49293,272.15765,65.53589Z"/>
<path class="cls-6"
d="M237.057,266.23162H166.56944c0,34.9025-27.34,45.73431-43.07576,50.90769,15.73577,67.03868,142.10688,67.03868,156.63909,0C260.78642,311.96593,241.52987,305.94826,237.057,266.23162Z"/>
<ellipse class="cls-7" cx="202.35227" cy="179.47219" rx="79.23803" ry="97.02616"/>
<path class="cls-8"
d="M117.61519,154.166c10.8764-3.25836,22.30778,5.29762,25.51206,19.09451,3.20424,13.7965-3.02464,27.64251-13.90108,30.90087-10.87644,3.25678-22.30919-5.29877-25.51339-19.09389-3.20425-13.79708,3.026-27.64273,13.90241-30.90149Z"/>
<path class="cls-5"
d="M158.77973,88.35379c34.35007,53.44875,83.71361,66.59188,126.44529,82.83956C322.20435,145.19484,244.7734,11.96688,158.77973,88.35379Z"/>
<path class="cls-5"
d="M181.50651,94.88021c-10.83147,33.057-30.31262,48.8307-55.51284,58.59347C109.4863,115.38906,134.25941,72.625,181.50651,94.88021Z"/>
</g>
</g>
</svg>
</template>
<style scoped>
.cls-1 {
fill: none;
}
.cls-2 {
fill: #d1eee7;
}
.cls-3 {
clip-path: url(#clip-path);
}
.cls-4 {
fill: #17a887;
}
.cls-4, .cls-5, .cls-6, .cls-8 {
fill-rule: evenodd;
}
.cls-5 {
fill: #102d24;
}
.cls-6 {
fill: #e5f5f2;
}
.cls-7, .cls-8 {
fill: #fff;
}
</style>

View File

@ -1,28 +1,49 @@
<template>
<div class="avatar">
<transition name="fade">
<user-icon v-show="!isAvatarLoaded" class="avatar__placeholder" :class="{'avatar__placeholder--highlighted': iconHighlighted}"></user-icon>
<default-avatar v-show="!isAvatarLoaded" class="avatar__placeholder"
:class="{'avatar__placeholder--highlighted': iconHighlighted}"></default-avatar>
</transition>
<transition name="show">
<div v-show="isAvatarLoaded" class="avatar__image" ref="avatarImage" :style="{'background-image': `url(${this.avatarUrl})`}"></div>
<div v-show="isAvatarLoaded" class="avatar__image" ref="avatarImage"
:style="{'background-image': `url(${this.avatarUrl})`}"></div>
</transition>
<img class="avatar__fake-image" :src="avatarUrl" ref="fakeImage"/>
<div class="avatar__edit" v-if="editable" @click="closeSidebar">
<router-link :to="{name: 'profile'}">
<pen-icon></pen-icon>
</router-link>
</div>
</div>
</template>
<script>
import DefaultAvatar from '@/components/icons/DefaultAvatar';
import PenIcon from '@/components/icons/PenIcon';
import UserIcon from '@/components/icons/UserIcon';
import TOGGLE_SIDEBAR from '@/graphql/gql/local/mutations/toggleSidebar.gql';
export default {
props: ['avatarUrl', 'iconHighlighted'],
components: {UserIcon},
data () {
props: {
avatarUrl: {
type: String
},
iconHighlighted: {},
editable: {
default: false
}
},
components: {
DefaultAvatar,
PenIcon
},
data() {
return {
isAvatarLoaded: false
}
},
mounted () {
mounted() {
if (this.avatarUrl !== '') {
this.$refs.fakeImage.addEventListener('load', () => {
if (this.$refs.fakeImage) {
@ -30,7 +51,17 @@
this.isAvatarLoaded = true;
}
});
};
}
},
methods: {
closeSidebar() {
this.$apollo.mutate({
mutation: TOGGLE_SIDEBAR,
variables: {
open: false
}
})
}
}
}
</script>
@ -45,11 +76,11 @@
width: $max-width;
overflow: hidden;
text-align: center;
border-radius: 50%;
&__placeholder {
height: $max-width;
fill: $color-silver-dark;
border-radius: 50%;
&--highlighted {
fill: $color-brand;
@ -57,14 +88,14 @@
}
&__image {
background-size: cover;
background-position: center center;
height: 100%;
width: 100%;
border: 0;
border-radius: 50%;
background-size: cover;
background-position: center center;
height: 100%;
width: 100%;
border: 0;
&--landscape {
&--landscape {
width: auto;
height: $max-width;
}
@ -75,10 +106,25 @@
height: 0;
}
.fade-leave-active, .show-enter-active {
&__edit {
position: absolute;
box-sizing: border-box;
width: 34px;
height: 34px;
display: block;
left: 50%;
bottom: -7px;
transform: translateX(50%);
background-color: $color-white;
border-radius: 50%;
padding: 6px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.12);
}
.fade-leave-active, .show-enter-active {
transition: opacity .5s;
}
.fade-leave-to, .show-enter {
.fade-leave-to, .show-enter {
opacity: 0;
}

View File

@ -0,0 +1,111 @@
<template>
<div class="profile-sidebar" v-if="sidebar.open">
<a class="profile-sidebar__close-link" @click="closeSidebar">
<cross class="profile-sidebar__close-icon"></cross>
</a>
<profile-widget class="profile-sidebar__item"></profile-widget>
<div class="profile-sidebar__item" @click="closeSidebar">
<router-link to="/me/activity" class="profile-sidebar__link">Meine Aktivitäten</router-link>
</div>
<div class="profile-sidebar__item" @click="closeSidebar">
<router-link :to="{name: 'my-classes'}" class="profile-sidebar__link">Klassenliste</router-link>
</div>
<div class="profile-sidebar__item" @click="closeSidebar">
<router-link :to="{name:'join-class'}" class="profile-sidebar__link">Zugangscode eingeben</router-link>
</div>
<div class="profile-sidebar__item" @click="logout">
<a class="profile-sidebar__link">Logout</a>
</div>
</div>
</template>
<script>
import ProfileWidget from '@/components/profile/ProfileWidget';
import Cross from '@/components/icons/Cross';
import SIDEBAR from '@/graphql/gql/local/sidebar.gql';
import TOGGLE_SIDEBAR from '@/graphql/gql/local/mutations/toggleSidebar.gql';
import LOGOUT_MUTATION from '@/graphql/gql/mutations/logoutUser.gql';
export default {
components: {
ProfileWidget,
Cross
},
methods: {
closeSidebar() {
this.$apollo.mutate({
mutation: TOGGLE_SIDEBAR,
variables: {
open: false
}
});
},
logout() {
this.$apollo.mutate({
mutation: LOGOUT_MUTATION,
}).then(({data}) => {
if (data.logout.success) {
location.replace('/logout')
}
});
}
},
apollo: {
sidebar: {
query: SIDEBAR
}
},
data: () => ({
sidebar: {
open: false
}
})
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.profile-sidebar {
padding: $large-spacing 0;
box-sizing: border-box;
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 333px;
background-color: $color-white;
z-index: 10;
box-shadow: 0 3px 9px 0 rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
&__item {
border-bottom: 1px solid $color-silver-light;
}
&__link {
@include default-link;
displaY: block;
padding: $large-spacing $medium-spacing;
}
&__close-link {
position: absolute;
right: $small-spacing;
top: $small-spacing;
cursor: pointer;
}
&__close-icon {
width: 40px;
height: 40px;
}
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="profile-widget">
<div class="profile-widget__avatar">
<avatar :avatar-url="me.avatarUrl" :editable="true"></avatar>
</div>
<h3 class="profile-widget__name">{{me.firstName}} {{me.lastName}}</h3>
</div>
</template>
<script>
import Avatar from '@/components/profile/Avatar';
import {meQuery} from '@/graphql/queries';
export default {
components: {
Avatar
},
apollo: {
me: meQuery
},
data: () => ({
me: {}
})
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.profile-widget {
display: flex;
flex-direction: column;
align-items: center;
&__name {
@include heading-3;
text-align: center;
}
&__avatar {
display: flex;
justify-content: center;
margin-bottom: $medium-spacing;
width: 80px;
height: 80px;
position: relative;
}
}
</style>

View File

@ -102,6 +102,10 @@ export default function (uri) {
scrollPosition: {
__typename: 'ScrollPosition',
scrollTo: ''
},
sidebar: {
__typename: 'Sidebar',
open: false
}
}
});

View File

@ -0,0 +1,3 @@
mutation($open: Boolean!) {
toggleSidebar(open: $open) @client
}

View File

@ -0,0 +1,5 @@
query Sidebar {
sidebar @client {
open
}
}

View File

@ -0,0 +1,9 @@
mutation JoinClass($input: JoinClassInput!) {
joinClass(input: $input) {
success
schoolClass {
id
name
}
}
}

View File

@ -1,4 +1,5 @@
import SCROLL_POSITION from '@/graphql/gql/local/scrollPosition.gql';
import SIDEBAR from '@/graphql/gql/local/sidebar.gql';
export const resolvers = {
Mutation: {
@ -7,6 +8,12 @@ export const resolvers = {
data.scrollPosition.scrollTo = scrollTo;
cache.writeQuery({query: SCROLL_POSITION, data});
return data.scrollPosition;
},
toggleSidebar: (_, {open}, {cache}) => {
const data = cache.readQuery({query: SIDEBAR});
data.sidebar.open = open;
cache.writeQuery({query: SIDEBAR, data});
return data.sidebar;
}
}
};

View File

@ -5,6 +5,10 @@ export const typeDefs = gql`
scrollTo: String!
}
type Sidebar {
open: Boolean!
}
type Mutation {
scrollTo(scrollTo: String!): ScrollPosition
}

View File

@ -1,5 +1,6 @@
<template>
<div class="blank-layout">
<profile-sidebar></profile-sidebar>
<router-view></router-view>
</div>
</template>
@ -14,9 +15,9 @@
</style>
<script>
import ProfileSidebar from '@/components/profile/ProfileSidebar';
export default {
components: {},
components: {ProfileSidebar},
}
</script>

View File

@ -1,5 +1,6 @@
<template>
<div class="container skillbox" :class="specialContainerClass">
<profile-sidebar></profile-sidebar>
<header-bar class="header skillbox__header">
</header-bar>
@ -13,11 +14,13 @@
<script>
import HeaderBar from '@/components/HeaderBar';
import MobileHeader from '@/components/MobileHeader';
import ProfileSidebar from '@/components/profile/ProfileSidebar';
export default {
components: {
HeaderBar,
MobileHeader
MobileHeader,
ProfileSidebar
},
computed: {

View File

@ -0,0 +1,84 @@
<template>
<div>
<h1>Zugangscode eingeben</h1>
<div>
<div class="skillboxform-input">
<label for="join-code" class="skillboxform-input__label">Zugangscode</label>
<input
id="join-code"
class="skillbox-input skillboxform-input__input"
:class="{'skillboxform-input__input--error': error}"
data-cy="input-class-code"
:value="code"
@input="updateCode">
<small
v-if="error"
class="skillboxform-input__error"
data-cy="email-local-errors"
>{{ error }}
</small>
</div>
<div>
<a class="button button--primary button--big" data-cy="join-class" @click="joinClass(code)">Klasse beitreten</a>
<a class="button button--big" data-cy="join-class-cancel" @click="cancel">Abbrechen</a>
</div>
</div>
</div>
</template>
<script>
import JOIN_CLASS_MUTATION from '@/graphql/gql/mutations/joinClass.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
export default {
data: () => ({
code: '',
error: ''
}),
methods: {
updateCode(event) {
this.code = event.target.value;
this.error = '';
},
joinClass(code) {
this.$apollo.mutate({
mutation: JOIN_CLASS_MUTATION,
variables: {
input: {
code
}
},
update(store, {data: {joinClass: {schoolClass}}}) {
if (schoolClass) {
const data = store.readQuery({query: ME_QUERY});
if (data) {
data.me.schoolClasses.edges.push({
node: schoolClass,
__typename: 'SchoolClassNode'
});
store.writeQuery({query: ME_QUERY, data});
}
}
}
})
.then(() => {
this.$router.push({name: 'my-classes'});
})
.catch(e => {
console.debug(e);
if (e.message.indexOf('[CAJ]') > -1) {
this.error = 'Klasse bereits beigetreten';
} else {
this.error = 'Code ist nicht gültig';
}
})
},
cancel() {
this.$router.go(-1);
}
}
}
</script>

View File

@ -2,13 +2,16 @@
<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
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
class="top-navigation__link profile-submenu__item submenu-item">Klassenliste
</router-link>
<router-link to="/me/profile" active-class="top-navigation__link--active"
class="top-navigation__link profile-submenu__item submenu-item">Profil
class="top-navigation__link profile-submenu__item submenu-item">Profil
</router-link>
<router-link :to="{name:'join-class'}" active-class="top-navigation__link--active" data-cy="join-class-link"
class="top-navigation__link profile-submenu__item submenu-item">Klasse beitreten
</router-link>
</nav>
<router-view></router-view>

View File

@ -30,6 +30,7 @@ import moduleRoom from '@/pages/moduleRoom'
import login from '@/pages/login'
import registration from '@/pages/registration'
import waitForClass from '@/pages/waitForClass'
import joinClass from '@/pages/joinClass'
import store from '@/store/index';
@ -112,6 +113,7 @@ const routes = [
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},
]
},
{path: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},
{
path: '/survey/:id',
component: surveyPage,

View File

@ -141,6 +141,17 @@
font-size: toRem(18px);
}
@mixin default-link {
font-size: toRem(18px);
font-family: $sans-serif-font-family;
font-weight: $font-weight-regular;
color: $color-silver-dark;
&--active {
color: $color-brand;
}
}
@mixin page-form-input-heading {
display: block;
margin-bottom: $medium-spacing;

View File

@ -1,4 +1,5 @@
import graphene
from django.db.models import Q
from graphene import relay
from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField
@ -85,24 +86,20 @@ class ChapterNode(DjangoObjectType):
def resolve_content_blocks(self, info, **kwargs):
user = info.context.user
school_classes = user.school_classes.values_list('pk')
by_parent = ContentBlock.get_by_parent(self).prefetch_related(
'visible_for').prefetch_related(
'hidden_for')
by_parent = ContentBlock.get_by_parent(self) \
.prefetch_related('visible_for') \
.prefetch_related('hidden_for')
# don't filter the hidden blocks on the server any more, we do this on the client now, as they are not secret
default_blocks = Q(user_created=False)
owned_by_user = Q(user_created=True, owner=user)
teacher_created_and_visible = Q(Q(user_created=True) & Q(visible_for__in=school_classes))
if user.has_perm('users.can_manage_school_class_content'): # teacher
publisher_content_blocks = by_parent.filter(user_created=False)
user_created_content_blocks = by_parent.filter(user_created=True, owner=user)
return by_parent.filter(default_blocks | owned_by_user)
else: # student
publisher_content_blocks = by_parent.filter(user_created=False).exclude(
hidden_for__in=school_classes)
self_created_content_blocks = by_parent.filter(user_created=True, owner=user)
user_created_content_blocks = by_parent.filter(user_created=True,
visible_for__in=school_classes).union(
self_created_content_blocks)
return publisher_content_blocks.union(user_created_content_blocks)
return by_parent.filter(default_blocks | teacher_created_and_visible)
def resolve_bookmark(self, info, **kwags):
return ChapterBookmark.objects.filter(
@ -234,24 +231,6 @@ class BookNode(DjangoObjectType):
return Topic.get_by_parent(self)
# todo: do we need this?
# class FilteredChapterNode(DjangoObjectType):
# content_blocks = DjangoFilterConnectionField(ContentBlockNode)
#
# class Meta:
# model = Chapter
# only_fields = [
# 'slug', 'title', 'description',
# ]
# filter_fields = [
# 'slug', 'title',
# ]
# interfaces = (relay.Node,)
#
# def resolve_content_blocks(self, *args, **kwargs):
# return ContentBlock.get_by_parent(self)
class BookQuery(object):
book = relay.Node.Field(BookNode)
topic = graphene.Field(TopicNode, slug=graphene.String())
@ -287,7 +266,6 @@ class BookQuery(object):
elif slug is not None:
module = Module.objects.get(slug=slug)
return module
def resolve_topic(self, info, **kwargs):

View File

@ -2,6 +2,7 @@ user_data = [
{
'teacher': ('Nico', 'Zickgraf',),
'class': 'FLID2018a',
'code': 'XXXX',
'students': [
('Robin', 'Abbühl'),
('Zeynep', 'Catal'),
@ -15,12 +16,13 @@ user_data = [
('Kelly', 'To'),
('Deborah', 'Waldmeier'),
('Rahel', 'Weiss'),
('Nora', 'Zimmermann'),
('Cindy', 'Zimmermann'),
]
},
{
'teacher': ('Michael', 'Gurtner',),
'class': 'KF1A',
'code': 'YYYY',
'students': [
('Lisa', 'Arn'),
('Machado', 'Fernandes'),

View File

@ -33,6 +33,7 @@ SIGNING_SECRET = os.environ.get('SIGNING_SECRET')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool_value(os.environ.get('DEBUG', ''))
TEST = 'test' in sys.argv
ENABLE_SILKY = bool_value(os.environ.get('ENABLE_SILKY', ''))
ALLOWED_HOSTS = ['*']
@ -107,6 +108,11 @@ if DEBUG:
)
CORS_ALLOW_CREDENTIALS = True
# enable silk for performance measuring
if ENABLE_SILKY:
INSTALLED_APPS += ['silk']
MIDDLEWARE += ['silk.middleware.SilkyMiddleware', ]
MIDDLEWARE += [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',

View File

@ -48,6 +48,9 @@ if settings.DEBUG and not settings.USE_AWS:
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.ENABLE_SILKY:
urlpatterns += [url(r'^silk/', include('silk.urls', namespace='silk'))]
# actually we use the cms in headless mode but need the url pattern to get the wagtail_serve function
urlpatterns += [url(r'pages/', include(wagtail_urls)), ]

View File

@ -1,4 +1,5 @@
import graphene
from django.db.models import Q
from graphene import relay
from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField
@ -29,17 +30,14 @@ class ObjectiveGroupNode(DjangoObjectType):
user = info.context.user
school_classes = user.school_classes.values_list('pk')
objectives_from_publisher = Q(owner=None)
objectives_from_user = Q(owner=user)
objectives_from_teacher = Q(owner__isnull=False, visible_for__in=school_classes)
if user.has_perm('users.can_manage_school_class_content'): # teacher
publisher_objectives = self.objectives.filter(owner=None)
user_created_objectives = self.objectives.filter(owner=user)
return self.objectives.filter(objectives_from_publisher | objectives_from_user)
else: # student
publisher_objectives = self.objectives.filter(owner=None).exclude(
hidden_for__in=school_classes)
user_created_objectives = self.objectives.filter(owner__isnull=False,
visible_for__in=school_classes)
return publisher_objectives.union(user_created_objectives)
return self.objectives.filter(objectives_from_publisher | objectives_from_teacher)
class ObjectiveNode(DjangoObjectType):

View File

@ -15,7 +15,7 @@ class RoleInline(admin.TabularInline):
@admin.register(SchoolClass)
class SchoolClassAdmin(admin.ModelAdmin):
list_display = ('id', 'name')
list_display = ('name', 'code', 'is_deleted')
@admin.register(Role)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.1.15 on 2020-02-11 10:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0009_auto_20191009_0905'),
]
operations = [
migrations.AddField(
model_name='schoolclass',
name='code',
field=models.CharField(blank=True, default=None, max_length=10, null=True, unique=True, verbose_name='Code zum Beitreten'),
),
]

View File

@ -70,9 +70,14 @@ class SchoolClass(models.Model):
name = models.CharField(max_length=100, blank=False, null=False, unique=True)
is_deleted = models.BooleanField(blank=False, null=False, default=False)
users = models.ManyToManyField(get_user_model(), related_name='school_classes', blank=True)
code = models.CharField('Code zum Beitreten', blank=True, null=True, max_length=10, unique=True, default=None)
class Meta:
verbose_name = 'Schulklasse'
verbose_name_plural = 'Schulklassen'
def __str__(self):
return 'SchoolClass {}-{}'.format(self.id, self.name)
return '{}'.format(self.name)
@classmethod
def generate_default_group_name(cls):
@ -99,6 +104,11 @@ class SchoolClass(models.Model):
def get_teacher(self):
return self.users.filter(user_roles__role__key='teacher').first()
def save(self, *args, **kwargs):
if self.code == '': # '' can't be unique, so we null it
self.code = None
super().save(*args, **kwargs)
class Role(models.Model):
key = models.CharField(_('Key'), max_length=100, blank=False, null=False, unique=True)
@ -165,4 +175,3 @@ class UserRole(models.Model):
class UserSetting(models.Model):
user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE, related_name='user_setting')
selected_class = models.ForeignKey(SchoolClass, blank=True, null=True, on_delete=models.CASCADE)

View File

@ -1,14 +1,20 @@
import graphene
from django.contrib.auth import update_session_auth_hash
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from graphene import relay
from api.utils import get_object
from users.inputs import PasswordUpdateInput
from users.models import SchoolClass, UserSetting
from users.schema import SchoolClassNode
from users.serializers import PasswordSerialzer, AvatarUrlSerializer
class CodeNotFoundException(Exception):
pass
class FieldError(graphene.ObjectType):
code = graphene.String()
@ -102,8 +108,33 @@ class UpdateSetting(relay.ClientIDMutation):
errors = graphene.List(UpdateError)
class JoinClass(relay.ClientIDMutation):
class Input:
code = graphene.String(required=True)
success = graphene.Boolean()
school_class = graphene.Field(SchoolClassNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
user = info.context.user
code = kwargs.get('code')
try:
school_class = SchoolClass.objects.get(Q(code__iexact=code))
if user not in list(school_class.users.all()):
school_class.users.add(user)
else:
raise CodeNotFoundException('[CAJ] Schüler ist bereits in Klasse') # CAJ = Class Already Joined
return cls(success=True, school_class=school_class)
except SchoolClass.DoesNotExist:
raise CodeNotFoundException('[CNV] Code ist nicht gültig') # CAV = Code Not Valid
class ProfileMutations:
update_password = UpdatePassword.Field()
update_avatar = UpdateAvatar.Field()
update_setting = UpdateSetting.Field()
join_class = JoinClass.Field()

View File

@ -21,7 +21,7 @@ def create_users(data=None):
SchoolClassFactory(
users=[teacher] + students,
name='skillbox'
name='skillbox',
)
teacher2 = UserFactory(username='teacher2')
UserRole.objects.create(user=teacher2, role=teacher_role)
@ -57,4 +57,5 @@ def create_users(data=None):
SchoolClassFactory(
users=students + [teacher],
name=school_class.get('class'),
code=school_class.get('code', '')
)

View File

@ -0,0 +1,68 @@
from django.test import TestCase
from graphene import Context
from graphene.test import Client
from core.factories import UserFactory
from users.factories import SchoolClassFactory
from users.models import SchoolClass
from api.schema import schema
class JoinSchoolClassTest(TestCase):
def setUp(self):
self.client = Client(schema=schema)
self.user = UserFactory(username='ueli')
SchoolClassFactory(users=[self.user], name='Klasse 1A', code='XXXX')
SchoolClassFactory(name='Klasse 2B', code='YYYY')
self.mutation = """
mutation JoinClass($input: JoinClassInput!) {
joinClass(input: $input) {
success
schoolClass {
id
name
}
}
}
"""
self.variables = {
'input': {
'code': 'YYYY'
}
}
self.context = Context(user=self.user)
def test_join_class(self):
self.assertEqual(self.user.school_classes.count(), 1)
executed = self.client.execute(self.mutation, variables=self.variables, context=self.context)
self.assertIsNone(executed.get('errors', None))
result = executed['data']['joinClass']
self.assertEqual(result['success'], True)
self.assertEqual(result['schoolClass']['name'], 'Klasse 2B')
self.assertEqual(self.user.school_classes.count(), 2)
def test_class_already_joined(self):
code = 'YYYY'
school_class = SchoolClass.objects.get(code=code)
school_class.users.add(self.user)
self.assertEqual(self.user.school_classes.count(), 2)
executed = self.client.execute(self.mutation, variables=self.variables, context=self.context)
self.assertIsNotNone(executed['errors'])
self.assertEqual(executed['errors'][0]['message'], '[CAJ] Schüler ist bereits in Klasse')
self.assertEqual(self.user.school_classes.count(), 2)
def test_code_not_valid(self):
code = '1234'
self.assertEqual(self.user.school_classes.count(), 1)
executed = self.client.execute(self.mutation, variables={
'input': {
'code': code
}
}, context=self.context)
self.assertIsNotNone(executed['errors'])
self.assertEqual(executed['errors'][0]['message'], '[CNV] Code ist nicht gültig')
self.assertEqual(self.user.school_classes.count(), 1)