Merge branch 'develop' into feature/licensing

# Conflicts:
#	Pipfile.lock
#	client/cypress/fixtures/schema.json
#	client/src/graphql/client.js
#	client/src/graphql/resolvers.js
#	client/src/graphql/typedefs.js
#	client/src/main.js
#	client/src/router/index.js
#	server/users/models.py
This commit is contained in:
Christian Cueni 2020-02-27 10:29:24 +01:00
commit d95e8ca492
57 changed files with 1265 additions and 251 deletions

View File

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

190
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "e7807798e8b5c39d7c1bc57d61520f2c888da08d2b6061f07758e00b490fdbd6" "sha256": "ca4f635dc983134e4569df2af8f0d73f488211bc2a97d11c194f76279f942977"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -48,18 +48,18 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:27e221d3868f35687807e5c920f7e8d4872f722f64196a7fd274a06ad65beec0", "sha256:629ce3be236b6e0aed52358146eea9ffa7679d6cd1cc9b3e12332226270d6499",
"sha256:8ff4e3d9e5d6a26dd7494afc68dc96afe6b7bda88130cca84cd58702d888ed27" "sha256:b1351e62136fae29be8fcbb1c4890f1d72017d57e33051d435a8bf9f71212fde"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.11.10" "version": "==1.11.14"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:cf3144994191847e30ef76781af867009bdc233b3f1f4736615e5330687a891e", "sha256:34ad4d73e6bef5c8ad956c66354611628cdebd431fe2927261ed9c068b9cfb7a",
"sha256:f11ff8616f46ca04697df031e622c9ed50931b9d649d4e719f961e9d80771e8d" "sha256:6570f2ba046956d5b548ab2346d32f713ecf8b8cb098a839d73fcf832ccfa223"
], ],
"version": "==1.14.10" "version": "==1.14.14"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
@ -535,10 +535,10 @@
}, },
"s3transfer": { "s3transfer": {
"hashes": [ "hashes": [
"sha256:2525bae2a530195576da53671bae8ca8c55ee8e33bc2225a65e804476611ea5a", "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
"sha256:4924e10451cc37901945806423d16c2c2040a6530645a614ed87e995ccec764c" "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
], ],
"version": "==0.3.2" "version": "==0.3.3"
}, },
"sendgrid": { "sendgrid": {
"hashes": [ "hashes": [
@ -603,6 +603,7 @@
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
], ],
"markers": "python_version != '3.4'",
"version": "==1.25.8" "version": "==1.25.8"
}, },
"wagtail": { "wagtail": {
@ -652,21 +653,26 @@
} }
}, },
"develop": { "develop": {
"asgiref": {
"hashes": [
"sha256:7e06d934a7718bf3975acbf87780ba678957b87c7adc056f13b6215d610695a0",
"sha256:ea448f92fc35a0ef4b1508f53a04c4670255a3f33d22a81c8fc9c872036adbe5"
],
"version": "==3.2.3"
},
"autopep8": {
"hashes": [
"sha256:0f592a0447acea0c2b0a9602be1e4e3d86db52badd2e3c84f0193bfd89fd3a43"
],
"version": "==1.5"
},
"awscli": { "awscli": {
"hashes": [ "hashes": [
<<<<<<< HEAD "sha256:2748af4e77728ced50d7d5bda0fa980449bd71eedff90ee643bee86ed4283d2f",
"sha256:4c49f085fb827ca1aeba5e6e5e39f6005110a0059b5c772aeb1d51c4f33c4028", "sha256:9118015f4bbab1c671d9c9927d07b6f7eadb7e1e8bbb2b06dc849c3de578d692"
"sha256:9459ac705c2a5d8724057492800c52084df714b624853eb3331087ecf8726a22"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.17.9" "version": "==1.17.14"
=======
"sha256:7ddb43a5423725adfabb752e21ac7d47c0b440a10128e9884f578848c2369555",
"sha256:e5617cb8244863566df1cb12564e439b224e88ea2270f27b28da82df093eba0a"
],
"index": "pypi",
"version": "==1.17.11"
>>>>>>> Add hello page, add local mutation
}, },
"backcall": { "backcall": {
"hashes": [ "hashes": [
@ -677,24 +683,32 @@
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
<<<<<<< HEAD "sha256:34ad4d73e6bef5c8ad956c66354611628cdebd431fe2927261ed9c068b9cfb7a",
"sha256:cf3144994191847e30ef76781af867009bdc233b3f1f4736615e5330687a891e", "sha256:6570f2ba046956d5b548ab2346d32f713ecf8b8cb098a839d73fcf832ccfa223"
"sha256:f11ff8616f46ca04697df031e622c9ed50931b9d649d4e719f961e9d80771e8d"
], ],
"version": "==1.14.10" "version": "==1.14.14"
======= },
"sha256:5ad6f4b80f3151fc5aa940f89fb6bf2db3064bf8d3f8919f5b60f5c741054ba5", "certifi": {
"sha256:ac783a87bd90be8a4d08101bfc0d29a4b35fe0ced387f5c8bc91d01cdaa7a168" "hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
], ],
"version": "==1.14.11" "version": "==2019.11.28"
>>>>>>> Add hello page, add local mutation },
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
}, },
"colorama": { "colorama": {
"hashes": [ "hashes": [
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
], ],
"version": "==0.4.1" "markers": "python_version != '3.4'",
"version": "==0.4.3"
}, },
"coverage": { "coverage": {
"hashes": [ "hashes": [
@ -740,6 +754,22 @@
], ],
"version": "==4.4.1" "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": { "docutils": {
"hashes": [ "hashes": [
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
@ -748,6 +778,19 @@
], ],
"version": "==0.15.2" "version": "==0.15.2"
}, },
"gprof2dot": {
"hashes": [
"sha256:b43fe04ebb3dfe181a612bbfc69e90555b8957022ad6a466f0308ed9c7f22e99"
],
"version": "==2019.11.30"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"ipdb": { "ipdb": {
"hashes": [ "hashes": [
"sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd" "sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd"
@ -777,6 +820,13 @@
], ],
"version": "==0.16.0" "version": "==0.16.0"
}, },
"jinja2": {
"hashes": [
"sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
"sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
],
"version": "==2.11.1"
},
"jmespath": { "jmespath": {
"hashes": [ "hashes": [
"sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6",
@ -784,6 +834,44 @@
], ],
"version": "==0.9.4" "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": { "parso": {
"hashes": [ "hashes": [
"sha256:56b2105a80e9c4df49de85e125feb6be69f49920e121406f15e7acde6c9dfc57", "sha256:56b2105a80e9c4df49de85e125feb6be69f49920e121406f15e7acde6c9dfc57",
@ -827,6 +915,13 @@
], ],
"version": "==0.4.8" "version": "==0.4.8"
}, },
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
@ -841,6 +936,13 @@
], ],
"version": "==2.8.1" "version": "==2.8.1"
}, },
"pytz": {
"hashes": [
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
],
"version": "==2019.3"
},
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
@ -857,6 +959,14 @@
], ],
"version": "==5.2" "version": "==5.2"
}, },
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"rsa": { "rsa": {
"hashes": [ "hashes": [
"sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5", "sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
@ -866,10 +976,10 @@
}, },
"s3transfer": { "s3transfer": {
"hashes": [ "hashes": [
"sha256:2525bae2a530195576da53671bae8ca8c55ee8e33bc2225a65e804476611ea5a", "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
"sha256:4924e10451cc37901945806423d16c2c2040a6530645a614ed87e995ccec764c" "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
], ],
"version": "==0.3.2" "version": "==0.3.3"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@ -878,6 +988,13 @@
], ],
"version": "==1.14.0" "version": "==1.14.0"
}, },
"sqlparse": {
"hashes": [
"sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177",
"sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873"
],
"version": "==0.3.0"
},
"traitlets": { "traitlets": {
"hashes": [ "hashes": [
"sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44", "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
@ -890,6 +1007,7 @@
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
], ],
"markers": "python_version != '3.4'",
"version": "==1.25.8" "version": "==1.25.8"
}, },
"wcwidth": { "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

@ -0,0 +1,19 @@
{
"me": {
"id": "VXNlck5vZGU6NQ==",
"pk": 5,
"username": "hansli",
"email": "hansli@skillbox.example",
"firstName": "Hansli",
"lastName": "Alleini",
"avatarUrl": "",
"lastModule": null,
"selectedClass": null,
"schoolClasses": {
"edges": [],
"__typename": "SchoolClassNodeConnection"
},
"__typename": "UserNode",
"permissions": []
}
}

View File

@ -1,4 +1,5 @@
{ {
"data": {
"__schema": { "__schema": {
"queryType": { "queryType": {
"name": "Query" "name": "Query"
@ -3125,6 +3126,18 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "expiryDate",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,
@ -3350,6 +3363,18 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "code",
"description": "",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "hiddenContentBlocks", "name": "hiddenContentBlocks",
"description": null, "description": null,
@ -9385,6 +9410,33 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "addProject",
"description": null, "description": null,
@ -10534,6 +10586,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "userIdInput",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "clientMutationId", "name": "clientMutationId",
"description": null, "description": null,
@ -11609,6 +11671,88 @@
"enumValues": null, "enumValues": null,
"possibleTypes": 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", "kind": "OBJECT",
"name": "AddProjectPayload", "name": "AddProjectPayload",
@ -15826,5 +15970,5 @@
} }
] ]
} }
}
} }

View File

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

View File

@ -0,0 +1,49 @@
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=user-widget-avatar]').click();
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

@ -0,0 +1,38 @@
const schema = require('../fixtures/schema.json');
const me = require('../fixtures/me.new-student.json');
describe('New student', () => {
it('shows "Enter Code" page and adds the user to a class', () => {
cy.server();
cy.mockGraphql({
schema: schema,
});
cy.apolloLogin('hansli', 'test');
cy.mockGraphqlOps({
operations: {
MeQuery: me,
JoinClass: {
joinClass: {
success: true,
schoolClass: {
id: "U2Nob29sQ2xhc3NOb2RlOjI=",
name: "KF1A",
__typename: "SchoolClassNode"
}
}
}
}
});
cy.visit('/');
cy.get('[data-cy=join-class-title]').should('contain', 'Zugangscode');
cy.get('[data-cy=input-class-code]').type('XXXX');
cy.get('[data-cy=join-class]').click();
cy.get('[data-cy=class-list-title]').should('contain', 'Klassenliste');
});
});

View File

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

View File

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

View File

@ -1,11 +1,12 @@
<template> <template>
<header class="header-bar"> <header class="header-bar">
<top-navigation></top-navigation> <content-navigation></content-navigation>
<router-link to="/" class="header-bar__logo" data-cy="home-link"> <router-link to="/" class="header-bar__logo" data-cy="home-link">
<logo></logo> <logo></logo>
</router-link> </router-link>
<div class="user-header"> <div class="user-header">
<class-selection-widget /> <a class="user-header__sidebar-link" @click="openSidebar()"><current-class class="user-header__current-class"/></a>
<user-widget v-bind="me"></user-widget> <user-widget v-bind="me"></user-widget>
</div> </div>
<book-navigation v-if="showSubnavigation"> <book-navigation v-if="showSubnavigation">
@ -14,23 +15,26 @@
</template> </template>
<script> <script>
import TopNavigation from '@/components/TopNavigation.vue'; import ContentNavigation from '@/components/ContentNavigation.vue';
import BookNavigation from '@/components/book-navigation/BookNavigation'; import BookNavigation from '@/components/book-navigation/BookNavigation';
import UserWidget from '@/components/UserWidget.vue'; import UserWidget from '@/components/UserWidget.vue';
import LogoutWidget from '@/components/LogoutWidget.vue'; import LogoutWidget from '@/components/LogoutWidget.vue';
import Logo from '@/components/icons/Logo'; import Logo from '@/components/icons/Logo';
import ClassSelectionWidget from '@/components/ClassSelectionWidget'; import CurrentClass from '@/components/school-class/CurrentClass';
import ME_QUERY from '@/graphql/gql/meQuery.gql'; import openSidebar from '@/mixins/open-sidebar';
import me from '@/mixins/me';
export default { export default {
mixins: [openSidebar, me],
components: { components: {
TopNavigation, ContentNavigation,
UserWidget, UserWidget,
LogoutWidget, LogoutWidget,
BookNavigation, BookNavigation,
Logo, Logo,
ClassSelectionWidget CurrentClass
}, },
computed: { computed: {
@ -38,18 +42,6 @@
return this.$route.meta.subnavigation; return this.$route.meta.subnavigation;
} }
}, },
data() {
return {
me: {}
}
},
apollo: {
me: {
query: ME_QUERY,
},
},
} }
</script> </script>
@ -127,5 +119,13 @@
.user-header { .user-header {
display: flex; display: flex;
&__current-class {
margin-right: $large-spacing;
}
&__sidebar-link {
cursor: pointer;
}
} }
</style> </style>

View File

@ -1,36 +1,17 @@
<template> <template>
<div class="user-widget" :class="{'user-widget--is-profile': isProfile}"> <div class="user-widget" :class="{'user-widget--is-profile': isProfile}">
<div class="user-widget__avatar" @click="toggleShowPopover()"> <div class="user-widget__avatar" data-cy="user-widget-avatar" @click="openSidebar()">
<avatar :avatar-url="avatarUrl" :icon-highlighted="isProfile"/> <avatar :avatar-url="avatarUrl" :icon-highlighted="isProfile"/>
</div> </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> </div>
</template> </template>
<script> <script>
import LOGOUT_MUTATION from '@/graphql/gql/mutations/logoutUser.gql';
import Avatar from '@/components/profile/Avatar'; import Avatar from '@/components/profile/Avatar';
import WidgetPopover from '@/components/WidgetPopover'; import openSidebar from '@/mixins/open-sidebar';
export default { export default {
// todo: clean up unneeded props
props: { props: {
firstName: { firstName: {
type: String type: String
@ -51,31 +32,10 @@
} }
}, },
data() { mixins: [openSidebar],
return {
showPopover: false
}
},
methods: {
toggleShowPopover() {
if (this.showMenu) {
this.showPopover = !this.showPopover;
}
},
logout() {
this.$apollo.mutate({
mutation: LOGOUT_MUTATION,
}).then(({data}) => {
if (data.logout.success) {
location.replace('/logout')
}
});
}
},
components: { components: {
Avatar, WidgetPopover Avatar
}, },
computed: { computed: {
isProfile() { isProfile() {

View File

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

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> <template>
<div class="avatar"> <div class="avatar">
<transition name="fade"> <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>
<transition name="show"> <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> </transition>
<img class="avatar__fake-image" :src="avatarUrl" ref="fakeImage"/> <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> </div>
</template> </template>
<script> <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 { export default {
props: ['avatarUrl', 'iconHighlighted'], props: {
components: {UserIcon}, avatarUrl: {
data () { type: String
},
iconHighlighted: {},
editable: {
default: false
}
},
components: {
DefaultAvatar,
PenIcon
},
data() {
return { return {
isAvatarLoaded: false isAvatarLoaded: false
} }
}, },
mounted () { mounted() {
if (this.avatarUrl !== '') { if (this.avatarUrl !== '') {
this.$refs.fakeImage.addEventListener('load', () => { this.$refs.fakeImage.addEventListener('load', () => {
if (this.$refs.fakeImage) { if (this.$refs.fakeImage) {
@ -30,7 +51,17 @@
this.isAvatarLoaded = true; this.isAvatarLoaded = true;
} }
}); });
}; }
},
methods: {
closeSidebar() {
this.$apollo.mutate({
mutation: TOGGLE_SIDEBAR,
variables: {
open: false
}
})
}
} }
} }
</script> </script>
@ -45,11 +76,11 @@
width: $max-width; width: $max-width;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
border-radius: 50%;
&__placeholder { &__placeholder {
height: $max-width; height: $max-width;
fill: $color-silver-dark; fill: $color-silver-dark;
border-radius: 50%;
&--highlighted { &--highlighted {
fill: $color-brand; fill: $color-brand;
@ -57,14 +88,14 @@
} }
&__image { &__image {
background-size: cover;
background-position: center center;
height: 100%;
width: 100%;
border: 0;
border-radius: 50%;
background-size: cover; &--landscape {
background-position: center center;
height: 100%;
width: 100%;
border: 0;
&--landscape {
width: auto; width: auto;
height: $max-width; height: $max-width;
} }
@ -75,10 +106,25 @@
height: 0; 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; transition: opacity .5s;
} }
.fade-leave-to, .show-enter { .fade-leave-to, .show-enter {
opacity: 0; opacity: 0;
} }

View File

@ -1,4 +1,5 @@
<template> <template>
<!-- Not currently in use, but keeping the file in case it's needed again -->
<div class="password-reset"> <div class="password-reset">
<h2 class="password-reset__header">Passwort ändern</h2> <h2 class="password-reset__header">Passwort ändern</h2>
<div v-if="showSuccess" class="success-message"> <div v-if="showSuccess" class="success-message">
@ -7,7 +8,7 @@
<password-change-form <password-change-form
@passwordSubmited="resetPassword" @passwordSubmited="resetPassword"
:oldPasswordErrors="oldPasswordErrors" :oldPasswordErrors="oldPasswordErrors"
:newPasswordErrors="newPasswordErrors" /> :newPasswordErrors="newPasswordErrors"/>
</div> </div>
</template> </template>
@ -29,7 +30,7 @@
} }
}, },
methods: { methods: {
resetPassword (passwords) { resetPassword(passwords) {
this.$apollo.mutate({ this.$apollo.mutate({
mutation: UPDATE_PASSWORD_MUTATION, mutation: UPDATE_PASSWORD_MUTATION,
variables: { variables: {
@ -37,7 +38,7 @@
passwordInput: passwords passwordInput: passwords
} }
} }
}).then(({ data }) => { }).then(({data}) => {
if (data.updatePassword.success) { if (data.updatePassword.success) {
this.oldPasswordErrors = []; this.oldPasswordErrors = [];
this.newPasswordErrors = []; this.newPasswordErrors = [];
@ -56,14 +57,14 @@
console.log('fail', error) console.log('fail', error)
}); });
}, },
handleOldPasswordError (error) { handleOldPasswordError(error) {
this.oldPasswordErrors = error.errors.map((fieldError) => { this.oldPasswordErrors = error.errors.map((fieldError) => {
if (fieldError.code === 'invalid') { if (fieldError.code === 'invalid') {
return 'Die Eingabe ist falsch' return 'Die Eingabe ist falsch'
} }
}); });
}, },
handleNewPasswordError (error) { handleNewPasswordError(error) {
this.newPasswordErrors = error.errors.map((fieldError) => { this.newPasswordErrors = error.errors.map((fieldError) => {
if (fieldError.code === 'invalid') { if (fieldError.code === 'invalid') {
return 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten' return 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten'

View File

@ -11,22 +11,18 @@
</a> </a>
</div> </div>
<avatar-upload-form v-else @avatarUpdate="updateAvatar"/> <avatar-upload-form v-else @avatarUpdate="updateAvatar"/>
<password-change />
</div> </div>
</template> </template>
<script> <script>
import UPDATE_AVATAR_QUERY from '@/graphql/gql/mutations/updateAvatarUrl.gql'; import UPDATE_AVATAR_QUERY from '@/graphql/gql/mutations/updateAvatarUrl.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql'; import ME_QUERY from '@/graphql/gql/meQuery.gql';
import PasswordChange from '@/components/profile/PasswordChange';
import AvatarUploadForm from '@/components/profile/AvatarUploadForm'; import AvatarUploadForm from '@/components/profile/AvatarUploadForm';
import Avatar from '@/components/profile/Avatar'; import Avatar from '@/components/profile/Avatar';
import TrashIcon from '@/components/icons/TrashIcon'; import TrashIcon from '@/components/icons/TrashIcon';
export default { export default {
components: { components: {
PasswordChange,
AvatarUploadForm, AvatarUploadForm,
Avatar, Avatar,
TrashIcon TrashIcon

View File

@ -0,0 +1,121 @@
<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">
<h3 class="profile-sidebar__subtitle">Klasse</h3>
<class-selection-widget></class-selection-widget>
<router-link :to="{name: 'my-classes'}" class="profile-sidebar__link">Klassenliste anzeigen</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';
import ClassSelectionWidget from '@/components/school-class/ClassSelectionWidget';
export default {
components: {
ClassSelectionWidget,
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;
padding: $large-spacing $medium-spacing;
}
&__subtitle {
@include small-text;
margin: 0;
margin-bottom: $small-spacing;
}
&__link {
@include default-link;
display: block;
}
&__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

@ -1,31 +1,37 @@
<template> <template>
<div class="class-selection" v-if="isTeacher"> <div class="class-selection" v-if="currentClassSelection">
<div class="class-selection__selected-class selected-class" @click="showPopover = !showPopover"> <div data-cy="class-selection" class="class-selection__selected-class selected-class"
<p class="selected-class__text">Klasse: {{currentClassSelection.name}}</p> @click="showPopover = !showPopover">
<current-class class="selected-class__text"></current-class> <chevron-down class="selected-class__dropdown-icon"></chevron-down>
</div> </div>
<widget-popover v-if="showPopover" <widget-popover v-if="showPopover"
@hide-me="showPopover = false" @hide-me="showPopover = false"
:mobile="mobile" :mobile="mobile"
class="class-selection__popover"> class="class-selection__popover">
<li class="popover-links__link popover-links__link--large" v-for="schoolClass in schoolClasses" <li data-cy="class-selection-entry" class="popover-links__link popover-links__link--large"
v-for="schoolClass in me.schoolClasses"
:key="schoolClass.id" :key="schoolClass.id"
:label="schoolClass.name" :label="schoolClass.name"
:item="schoolClass" :item="schoolClass"
@click="updateFilter(schoolClass)"> @click="updateFilter(schoolClass)">
{{schoolClass.name}} {{schoolClass.name}}
</li> </li>
</widget-popover> </widget-popover>
</div> </div>
</template> </template>
<script> <script>
import WidgetPopover from '@/components/WidgetPopover'; import WidgetPopover from '@/components/WidgetPopover';
import ChevronDown from '@/components/icons/ChevronDown';
import CurrentClass from '@/components/school-class/CurrentClass';
import ME_QUERY from '@/graphql/gql/meQuery.gql'; import ME_QUERY from '@/graphql/gql/meQuery.gql';
import UPDATE_USER_SETTING from '@/graphql/gql/mutations/updateUserSetting.gql'; import UPDATE_USER_SETTING from '@/graphql/gql/mutations/updateUserSetting.gql';
export default { export default {
components: { components: {
WidgetPopover WidgetPopover,
ChevronDown,
CurrentClass
}, },
props: { props: {
@ -37,7 +43,13 @@
apollo: { apollo: {
me: { me: {
query: ME_QUERY query: ME_QUERY,
manual: true,
result({data, loading, networkStatus}) {
if (!loading) {
this.me = this.$getRidOfEdges(data).me;
}
}
} }
}, },
@ -47,7 +59,8 @@
selectedClass: { selectedClass: {
id: '' id: ''
}, },
permissions: [] permissions: [],
schoolClasses: []
}, },
showPopover: false showPopover: false
} }
@ -64,7 +77,7 @@
}, },
update(store, data) { update(store, data) {
let meData = store.readQuery({query: ME_QUERY}); let meData = store.readQuery({query: ME_QUERY});
meData.me.selectedClass = selectedClass meData.me.selectedClass = selectedClass;
store.writeQuery({query: ME_QUERY, data: meData}); store.writeQuery({query: ME_QUERY, data: meData});
} }
}).catch((error) => { }).catch((error) => {
@ -76,16 +89,10 @@
computed: { computed: {
currentClassSelection() { currentClassSelection() {
let currentClass = this.schoolClasses.find(schoolClass => { let currentClass = this.me.schoolClasses.find(schoolClass => {
return schoolClass.id === this.me.selectedClass.id return schoolClass.id === this.me.selectedClass.id
}) });
return currentClass || this.schoolClasses[0]; return currentClass || this.me.schoolClasses[0];
},
schoolClasses() {
return this.$getRidOfEdges(this.me.schoolClasses);
},
isTeacher() {
return this.me.permissions.includes('users.can_manage_school_class_content');
} }
}, },
@ -97,25 +104,42 @@
@import "@/styles/_mixins.scss"; @import "@/styles/_mixins.scss";
.class-selection { .class-selection {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
margin-right: $large-spacing; margin-bottom: $medium-spacing;
border: 1px solid $color-silver;
border-radius: 4px;
padding: $small-spacing $medium-spacing;
/*justify-self: space-between;*/
&__popover { &__popover {
white-space: nowrap; white-space: nowrap;
top: 40px; top: 100%;
left: 0;
transform: translateY($small-spacing);
} }
} }
.selected-class { .selected-class {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
&__text { &__text {
line-height: $large-spacing; line-height: $large-spacing;
@include regular-text; @include regular-text;
color: $color-silver-dark; color: $color-silver-dark;
}
} }
&__dropdown-icon {
width: 20px;
height: 20px;
fill: $color-brand;
}
}
.popover-links__link { .popover-links__link {
cursor: pointer; cursor: pointer;
} }

View File

@ -0,0 +1,31 @@
<template>
<span class="current-class">Klasse: {{currentClassName}}</span>
</template>
<script>
import me from '@/mixins/me';
export default {
mixins: [me],
computed: {
currentClassName() {
let currentClass = this.me.schoolClasses.find(schoolClass => {
return schoolClass.id === this.me.selectedClass.id
});
return currentClass ? currentClass.name : this.me.schoolClasses.length ? this.me.schoolClasses[0].name : '';
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.current-class {
line-height: $large-spacing;
@include regular-text;
color: $color-silver-dark;
}
</style>

View File

@ -111,6 +111,10 @@ export default function (uri, networkErrorCallback) {
helloEmail: { helloEmail: {
__typename: 'HelloEmail', __typename: 'HelloEmail',
email: '' email: ''
},
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,5 +1,6 @@
import SCROLL_POSITION from '@/graphql/gql/local/scrollPosition.gql'; import SCROLL_POSITION from '@/graphql/gql/local/scrollPosition.gql';
import HELLO_EMAIL from '@/graphql/gql/local/helloEmail.gql'; import HELLO_EMAIL from '@/graphql/gql/local/helloEmail.gql';
import SIDEBAR from '@/graphql/gql/local/sidebar.gql';
export const resolvers = { export const resolvers = {
Mutation: { Mutation: {
@ -15,5 +16,11 @@ export const resolvers = {
cache.writeQuery({query: HELLO_EMAIL, data}); cache.writeQuery({query: HELLO_EMAIL, data});
return data.helloEmail; return data.helloEmail;
}, },
toggleSidebar: (_, {open}, {cache}) => {
const data = cache.readQuery({query: SIDEBAR});
data.sidebar.open = open;
cache.writeQuery({query: SIDEBAR, data});
return data.sidebar;
}
} }
}; };

View File

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

View File

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

View File

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

View File

@ -170,10 +170,9 @@ router.beforeEach(async (to, from, next) => {
return; return;
} }
// handle users students without class if ((to.name !== 'join-class' && to.name !== 'licenseActivation') && loginRequired(to) && await redirectStudentsWithoutClass()) {
if ((to.name !== 'noClass' && to.name !== 'licenseActivation') && loginRequired(to) && await redirectStudentsWithoutClass()) { next({name: 'join-class'})
next({name: 'noClass'}) return
return;
} }
next(); next();

28
client/src/mixins/me.js Normal file
View File

@ -0,0 +1,28 @@
import ME_QUERY from '@/graphql/gql/meQuery.gql';
export default {
data() {
return {
me: {
selectedClass: {
id: ''
},
permissions: [],
schoolClasses: []
},
showPopover: false
}
},
apollo: {
me: {
query: ME_QUERY,
manual: true,
result({data, loading, networkStatus}) {
if (!loading) {
this.me = this.$getRidOfEdges(data).me;
}
}
},
},
}

View File

@ -0,0 +1,14 @@
import TOGGLE_SIDEBAR from '@/graphql/gql/local/mutations/toggleSidebar.gql';
export default {
methods: {
openSidebar() {
this.$apollo.mutate({
mutation: TOGGLE_SIDEBAR,
variables: {
open: true
}
});
},
},
}

View File

@ -0,0 +1,84 @@
<template>
<div>
<h1 data-cy="join-class-title">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 = 'Sie sind dieser Klasse bereits beigetreten.';
} else {
this.error = 'Dieser Zugangscode ist nicht gültig.';
}
})
},
cancel() {
this.$router.go(-1);
}
}
}
</script>

View File

@ -25,7 +25,7 @@
<script> <script>
import MODULE_ROOM_ENTRIES_QUERY from '@/graphql/gql/moduleRoomEntryQuery.gql'; import MODULE_ROOM_ENTRIES_QUERY from '@/graphql/gql/moduleRoomEntryQuery.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql'; import ME_QUERY from '@/graphql/gql/meQuery.gql';
import roomMixin from '@/components/mixins/room' import roomMixin from '@/mixins/room'
export default { export default {
props: ['slug'], props: ['slug'],

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="myclasses"> <div class="myclasses">
<h1 class="myclasses__header">Klassenliste</h1> <h1 class="myclasses__header" data-cy="class-list-title">Klassenliste</h1>
<classlist v-for="schoolClass in schoolClasses" v-bind="schoolClass" :key="schoolClass.name" class="myclasses__class"></classlist> <classlist v-for="schoolClass in schoolClasses" v-bind="schoolClass" :key="schoolClass.name" class="myclasses__class"></classlist>
</div> </div>
</template> </template>

View File

@ -2,13 +2,16 @@
<div class="profile"> <div class="profile">
<nav class="top-navigation profile-submenu profile__submenu"> <nav class="top-navigation profile-submenu profile__submenu">
<router-link to="/me/activity" active-class="top-navigation__link--active" <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>
<router-link to="/me/myclasses" active-class="top-navigation__link--active" <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>
<router-link to="/me/profile" active-class="top-navigation__link--active" <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> </router-link>
</nav> </nav>
<router-view></router-view> <router-view></router-view>

View File

@ -25,7 +25,7 @@
<script> <script>
import ROOM_ENTRIES_QUERY from '@/graphql/gql/roomEntriesQuery.gql'; import ROOM_ENTRIES_QUERY from '@/graphql/gql/roomEntriesQuery.gql';
import roomMixin from '@/components/mixins/room' import roomMixin from '@/mixins/room'
export default { export default {
props: ['slug'], props: ['slug'],

View File

@ -39,9 +39,9 @@
<div class="start-page__news news"> <div class="start-page__news news">
<h2 class="news__title">News</h2> <h2 class="news__title">News</h2>
<news-teaser date="20. Dezember 2019" title="Blockchain" <news-teaser date="20. Dezember 2019" title="Blockchain"
url="http://abunews-blockchain.webflow.io/"></news-teaser> url="https://myskillbox-abu-news.webflow.io/blockchain"></news-teaser>
<news-teaser date="9. September 2019" title="Klima was sonst?" <news-teaser date="9. September 2019" title="Klima was sonst?"
url="https://abunews-1f178193a10edaabf-4caff67b27d10.webflow.io/"></news-teaser> url="https://myskillbox-abu-news.webflow.io/klima"></news-teaser>
<!--<news-teaser date="31. Oktober 2018" title="Sommerzeit - Festivalzeit"--> <!--<news-teaser date="31. Oktober 2018" title="Sommerzeit - Festivalzeit"-->
<!--url="https://abunews.webflow.io/"></news-teaser>--> <!--url="https://abunews.webflow.io/"></news-teaser>-->
<div class="news__more">Mehr...</div> <div class="news__more">Mehr...</div>

View File

@ -36,6 +36,7 @@ import checkEmail from '@/pages/check-email'
import emailVerification from '@/pages/email-verification' import emailVerification from '@/pages/email-verification'
import licenseActivation from '@/pages/license-activation' import licenseActivation from '@/pages/license-activation'
import forgotPassword from '@/pages/forgot-password' import forgotPassword from '@/pages/forgot-password'
import joinClass from '@/pages/joinClass'
import store from '@/store/index'; import store from '@/store/index';
@ -136,6 +137,7 @@ const routes = [
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}}, {path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},
] ]
}, },
{path: 'join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}},
{ {
path: '/survey/:id', path: '/survey/:id',
component: surveyPage, component: surveyPage,

View File

@ -141,6 +141,17 @@
font-size: toRem(18px); 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 { @mixin page-form-input-heading {
display: block; display: block;
margin-bottom: $medium-spacing; margin-bottom: $medium-spacing;

View File

@ -1,4 +1,5 @@
import graphene import graphene
from django.db.models import Q
from graphene import relay from graphene import relay
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
@ -85,24 +86,20 @@ class ChapterNode(DjangoObjectType):
def resolve_content_blocks(self, info, **kwargs): def resolve_content_blocks(self, info, **kwargs):
user = info.context.user user = info.context.user
school_classes = user.school_classes.values_list('pk') school_classes = user.school_classes.values_list('pk')
by_parent = ContentBlock.get_by_parent(self).prefetch_related(
'visible_for').prefetch_related( by_parent = ContentBlock.get_by_parent(self) \
'hidden_for') .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 if user.has_perm('users.can_manage_school_class_content'): # teacher
publisher_content_blocks = by_parent.filter(user_created=False) return by_parent.filter(default_blocks | owned_by_user)
user_created_content_blocks = by_parent.filter(user_created=True, owner=user)
else: # student else: # student
publisher_content_blocks = by_parent.filter(user_created=False).exclude( return by_parent.filter(default_blocks | teacher_created_and_visible)
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)
def resolve_bookmark(self, info, **kwags): def resolve_bookmark(self, info, **kwags):
return ChapterBookmark.objects.filter( return ChapterBookmark.objects.filter(
@ -234,24 +231,6 @@ class BookNode(DjangoObjectType):
return Topic.get_by_parent(self) 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): class BookQuery(object):
book = relay.Node.Field(BookNode) book = relay.Node.Field(BookNode)
topic = graphene.Field(TopicNode, slug=graphene.String()) topic = graphene.Field(TopicNode, slug=graphene.String())
@ -287,7 +266,6 @@ class BookQuery(object):
elif slug is not None: elif slug is not None:
module = Module.objects.get(slug=slug) module = Module.objects.get(slug=slug)
return module return module
def resolve_topic(self, info, **kwargs): def resolve_topic(self, info, **kwargs):

View File

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

View File

@ -12,7 +12,8 @@ from wagtail.core.models import Page
from books.factories import BookFactory, TopicFactory, ModuleFactory, ChapterFactory, ContentBlockFactory from books.factories import BookFactory, TopicFactory, ModuleFactory, ChapterFactory, ContentBlockFactory
from core.factories import UserFactory from core.factories import UserFactory
from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory
from users.services import create_users from users.models import Role
from users.services import create_users, create_student
from .data.module_data import data from .data.module_data import data
from .data.user_data import user_data from .data.user_data import user_data
@ -57,6 +58,13 @@ class Command(BaseCommand):
create_users(user_data) create_users(user_data)
# create student without class
create_student(
username='hansli',
first_name='Hansli',
last_name='Alleini'
)
for book_idx, book_data in enumerate(data): for book_idx, book_data in enumerate(data):
book = BookFactory.create(parent=site.root_page, **self.filter_data(book_data, 'topics')) book = BookFactory.create(parent=site.root_page, **self.filter_data(book_data, 'topics'))
@ -102,8 +110,6 @@ class Command(BaseCommand):
# ContentBlockFactory.create(parent=chapter, **self.filter_data(content_block_data, 'contents')) # ContentBlockFactory.create(parent=chapter, **self.filter_data(content_block_data, 'contents'))
ContentBlockFactory.create(parent=chapter, module=module, **content_block_data) ContentBlockFactory.create(parent=chapter, module=module, **content_block_data)
# now create all and rooms # now create all and rooms
management.call_command('dummy_rooms', verbosity=0) management.call_command('dummy_rooms', verbosity=0)

View File

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

View File

@ -1,9 +1,12 @@
{% load i18n %}{% autoescape off %} {% load i18n %}
{% load core_tags %}
{% autoescape off %}
{% blocktrans %}Du erhältst dieses E-Mail, weil dein Passwort auf {{ site_name }} zurückgesetzt wurde.{% endblocktrans %} {% blocktrans %}Du erhältst dieses E-Mail, weil dein Passwort auf {{ site_name }} zurückgesetzt wurde.{% endblocktrans %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %} {% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% block reset_link %} {% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} <a href="{% reset_link 'password_reset_confirm' protocol domain token uid %}">{% reset_link 'password_reset_confirm' protocol domain token uid %}</a>
{% endblock %} {% endblock %}
{% trans "Ihr Benutzername lautet:" %} {{ user.get_username }} {% trans "Ihr Benutzername lautet:" %} {{ user.get_username }}

View File

@ -1,9 +1,12 @@
{% load i18n %}{% autoescape off %} {% load i18n %}
{% load core_tags %}
{% autoescape off %}
{% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %} {% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %} {% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% block reset_link %} {% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'set_password_confirm' uidb64=uid token=token %} <a href="{% reset_link 'set_password_confirm' protocol domain token uid %}">{% reset_link 'set_password_confirm' protocol domain token uid %}</a>
{% endblock %} {% endblock %}
{% trans "Ihr Benutzername lautet:" %} {{ user.get_username }} {% trans "Ihr Benutzername lautet:" %} {{ user.get_username }}

View File

@ -1,9 +1,12 @@
{% load i18n %}{% autoescape off %} {% load i18n %}
{% load core_tags %}
{% autoescape off %}
{% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %} {% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %} {% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% block reset_link %} {% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'set_password_confirm' uidb64=uid token=token %} <a href="{% reset_link 'set_password_confirm' protocol domain token uid %}">{% reset_link 'set_password_confirm' protocol domain token uid %}</a>
{% endblock %} {% endblock %}
{% trans "Ihr Benutzername lautet:" %} {{ user.get_username }} {% trans "Ihr Benutzername lautet:" %} {{ user.get_username }}

View File

@ -1,6 +1,7 @@
import json import json
from django import template from django import template
from django.urls import reverse
from rest_framework import serializers from rest_framework import serializers
register = template.Library() register = template.Library()
@ -19,3 +20,8 @@ def json_dumps(obj):
@register.filter(name='class_name') @register.filter(name='class_name')
def class_name(obj): def class_name(obj):
return str(obj.__class__.__name__) return str(obj.__class__.__name__)
@register.simple_tag
def reset_link(name, protocol, domain, token, uid):
return '{}://{}{}'.format(protocol, domain, reverse(name, kwargs={'uidb64': uid, 'token': token}))

View File

@ -32,6 +32,9 @@ if settings.DEBUG and not settings.USE_AWS:
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 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 # 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)), ] urlpatterns += [url(r'pages/', include(wagtail_urls)), ]

View File

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

View File

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

@ -95,9 +95,14 @@ class SchoolClass(models.Model):
name = models.CharField(max_length=100, blank=False, null=False, unique=True) name = models.CharField(max_length=100, blank=False, null=False, unique=True)
is_deleted = models.BooleanField(blank=False, null=False, default=False) is_deleted = models.BooleanField(blank=False, null=False, default=False)
users = models.ManyToManyField(get_user_model(), related_name='school_classes', blank=True) 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): def __str__(self):
return 'SchoolClass {}-{}'.format(self.id, self.name) return '{}'.format(self.name)
@classmethod @classmethod
def generate_default_group_name(cls): def generate_default_group_name(cls):
@ -130,6 +135,11 @@ class SchoolClass(models.Model):
def get_teacher(self): def get_teacher(self):
return self.users.filter(user_roles__role__key='teacher').first() 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): class Role(models.Model):
key = models.CharField(_('Key'), max_length=100, blank=False, null=False, unique=True) key = models.CharField(_('Key'), max_length=100, blank=False, null=False, unique=True)

View File

@ -1,14 +1,20 @@
import graphene import graphene
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Q
from graphene import relay from graphene import relay
from api.utils import get_object from api.utils import get_object
from users.inputs import PasswordUpdateInput from users.inputs import PasswordUpdateInput
from users.models import SchoolClass, UserSetting from users.models import SchoolClass, UserSetting
from users.schema import SchoolClassNode
from users.serializers import PasswordSerialzer, AvatarUrlSerializer from users.serializers import PasswordSerialzer, AvatarUrlSerializer
class CodeNotFoundException(Exception):
pass
class FieldError(graphene.ObjectType): class FieldError(graphene.ObjectType):
code = graphene.String() code = graphene.String()
@ -102,8 +108,33 @@ class UpdateSetting(relay.ClientIDMutation):
errors = graphene.List(UpdateError) 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: class ProfileMutations:
update_password = UpdatePassword.Field() update_password = UpdatePassword.Field()
update_avatar = UpdateAvatar.Field() update_avatar = UpdateAvatar.Field()
update_setting = UpdateSetting.Field() update_setting = UpdateSetting.Field()
join_class = JoinClass.Field()

View File

@ -3,11 +3,23 @@ from users.factories import SchoolClassFactory
from users.models import Role, UserRole, DEFAULT_SCHOOL_ID from users.models import Role, UserRole, DEFAULT_SCHOOL_ID
def create_student(**kwargs):
try:
student_role = Role.objects.get_default_student_role()
except Role.DoesNotExist:
Role.objects.create_default_roles()
student_role = Role.objects.get_default_student_role()
user = UserFactory(
**kwargs
)
UserRole.objects.create(user=user, role=student_role)
return user
def create_users(data=None): def create_users(data=None):
Role.objects.create_default_roles() Role.objects.create_default_roles()
teacher_role = Role.objects.get_default_teacher_role() teacher_role = Role.objects.get_default_teacher_role()
student_role = Role.objects.get_default_student_role()
if data is None: if data is None:
teacher = UserFactory(username='teacher') teacher = UserFactory(username='teacher')
@ -15,18 +27,17 @@ def create_users(data=None):
students = [] students = []
for i in range(1, 7): for i in range(1, 7):
student = UserFactory(username='student{}'.format(i)) student = create_student(username='student{}'.format(i))
UserRole.objects.create(user=student, role=student_role)
students.append(student) students.append(student)
SchoolClassFactory( SchoolClassFactory(
users=[teacher] + students, users=[teacher] + students,
name='skillbox' name='skillbox',
) )
teacher2 = UserFactory(username='teacher2') teacher2 = UserFactory(username='teacher2')
UserRole.objects.create(user=teacher2, role=teacher_role) UserRole.objects.create(user=teacher2, role=teacher_role)
student_second_class = UserFactory(username='student_second_class')
UserRole.objects.create(user=student_second_class, role=student_role) student_second_class = create_student(username='student_second_class')
SchoolClassFactory( SchoolClassFactory(
users=[teacher2, student_second_class], users=[teacher2, student_second_class],
name='second_class' name='second_class'
@ -45,16 +56,16 @@ def create_users(data=None):
students = [] students = []
for first, last in school_class.get('students'): for first, last in school_class.get('students'):
student = UserFactory( student = create_student(
username='{}.{}'.format(first, last).lower(), username='{}.{}'.format(first, last).lower(),
first_name=first, first_name=first,
last_name=last, last_name=last,
email='{}.{}@skillbox.example'.format(first, last).lower() email='{}.{}@skillbox.example'.format(first, last).lower()
) )
UserRole.objects.create(user=student, role=student_role)
students.append(student) students.append(student)
SchoolClassFactory( SchoolClassFactory(
users=students + [teacher], users=students + [teacher],
name=school_class.get('class'), 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)