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 = "*"
ipdb = "*"
coverage = "*"
django-silk = "*"
[packages]
factory-boy = "==2.11.0"

190
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,21 +653,26 @@
}
},
"develop": {
"asgiref": {
"hashes": [
"sha256:7e06d934a7718bf3975acbf87780ba678957b87c7adc056f13b6215d610695a0",
"sha256:ea448f92fc35a0ef4b1508f53a04c4670255a3f33d22a81c8fc9c872036adbe5"
],
"version": "==3.2.3"
},
"autopep8": {
"hashes": [
"sha256:0f592a0447acea0c2b0a9602be1e4e3d86db52badd2e3c84f0193bfd89fd3a43"
],
"version": "==1.5"
},
"awscli": {
"hashes": [
<<<<<<< HEAD
"sha256:4c49f085fb827ca1aeba5e6e5e39f6005110a0059b5c772aeb1d51c4f33c4028",
"sha256:9459ac705c2a5d8724057492800c52084df714b624853eb3331087ecf8726a22"
"sha256:2748af4e77728ced50d7d5bda0fa980449bd71eedff90ee643bee86ed4283d2f",
"sha256:9118015f4bbab1c671d9c9927d07b6f7eadb7e1e8bbb2b06dc849c3de578d692"
],
"index": "pypi",
"version": "==1.17.9"
=======
"sha256:7ddb43a5423725adfabb752e21ac7d47c0b440a10128e9884f578848c2369555",
"sha256:e5617cb8244863566df1cb12564e439b224e88ea2270f27b28da82df093eba0a"
],
"index": "pypi",
"version": "==1.17.11"
>>>>>>> Add hello page, add local mutation
"version": "==1.17.14"
},
"backcall": {
"hashes": [
@ -677,24 +683,32 @@
},
"botocore": {
"hashes": [
<<<<<<< HEAD
"sha256:cf3144994191847e30ef76781af867009bdc233b3f1f4736615e5330687a891e",
"sha256:f11ff8616f46ca04697df031e622c9ed50931b9d649d4e719f961e9d80771e8d"
"sha256:34ad4d73e6bef5c8ad956c66354611628cdebd431fe2927261ed9c068b9cfb7a",
"sha256:6570f2ba046956d5b548ab2346d32f713ecf8b8cb098a839d73fcf832ccfa223"
],
"version": "==1.14.10"
=======
"sha256:5ad6f4b80f3151fc5aa940f89fb6bf2db3064bf8d3f8919f5b60f5c741054ba5",
"sha256:ac783a87bd90be8a4d08101bfc0d29a4b35fe0ced387f5c8bc91d01cdaa7a168"
"version": "==1.14.14"
},
"certifi": {
"hashes": [
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
"version": "==1.14.11"
>>>>>>> Add hello page, add local mutation
"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": [
@ -740,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",
@ -748,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"
@ -777,6 +820,13 @@
],
"version": "==0.16.0"
},
"jinja2": {
"hashes": [
"sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
"sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
],
"version": "==2.11.1"
},
"jmespath": {
"hashes": [
"sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6",
@ -784,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",
@ -827,6 +915,13 @@
],
"version": "==0.4.8"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pygments": {
"hashes": [
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
@ -841,6 +936,13 @@
],
"version": "==2.8.1"
},
"pytz": {
"hashes": [
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
],
"version": "==2019.3"
},
"pyyaml": {
"hashes": [
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
@ -857,6 +959,14 @@
],
"version": "==5.2"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"rsa": {
"hashes": [
"sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
@ -866,10 +976,10 @@
},
"s3transfer": {
"hashes": [
"sha256:2525bae2a530195576da53671bae8ca8c55ee8e33bc2225a65e804476611ea5a",
"sha256:4924e10451cc37901945806423d16c2c2040a6530645a614ed87e995ccec764c"
"sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13",
"sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"
],
"version": "==0.3.2"
"version": "==0.3.3"
},
"six": {
"hashes": [
@ -878,6 +988,13 @@
],
"version": "==1.14.0"
},
"sqlparse": {
"hashes": [
"sha256:40afe6b8d4b1117e7dff5504d7a8ce07d9a1b15aeeade8a2d10f130a834f8177",
"sha256:7c3dca29c022744e95b547e867cee89f4fce4373f3549ccd8797d8eb52cdb873"
],
"version": "==0.3.0"
},
"traitlets": {
"hashes": [
"sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
@ -890,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

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

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,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.apolloLogin('rahel.cueni', 'test');
});

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,11 +1,12 @@
<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>
<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>
</div>
<book-navigation v-if="showSubnavigation">
@ -14,23 +15,26 @@
</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';
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 {
mixins: [openSidebar, me],
components: {
TopNavigation,
ContentNavigation,
UserWidget,
LogoutWidget,
BookNavigation,
Logo,
ClassSelectionWidget
CurrentClass
},
computed: {
@ -38,18 +42,6 @@
return this.$route.meta.subnavigation;
}
},
data() {
return {
me: {}
}
},
apollo: {
me: {
query: ME_QUERY,
},
},
}
</script>
@ -127,5 +119,13 @@
.user-header {
display: flex;
&__current-class {
margin-right: $large-spacing;
}
&__sidebar-link {
cursor: pointer;
}
}
</style>

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" data-cy="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 Avatar from '@/components/profile/Avatar';
import WidgetPopover from '@/components/WidgetPopover';
import openSidebar from '@/mixins/open-sidebar';
export default {
// todo: clean up unneeded props
props: {
firstName: {
type: String
@ -51,31 +32,10 @@
}
},
data() {
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')
}
});
}
},
mixins: [openSidebar],
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 ClassSelectionWidget from '@/components/ClassSelectionWidget';
import ContentNavigation from '@/components/ContentNavigation';
import ClassSelectionWidget from '@/components/school-class/ClassSelectionWidget';
import {meQuery} from '@/graphql/queries';
export default {
components: {
TopNavigation,
ContentNavigation,
Cross,
UserWidget,
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>
<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

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

View File

@ -11,22 +11,18 @@
</a>
</div>
<avatar-upload-form v-else @avatarUpdate="updateAvatar"/>
<password-change />
</div>
</template>
<script>
import UPDATE_AVATAR_QUERY from '@/graphql/gql/mutations/updateAvatarUrl.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import PasswordChange from '@/components/profile/PasswordChange';
import AvatarUploadForm from '@/components/profile/AvatarUploadForm';
import Avatar from '@/components/profile/Avatar';
import TrashIcon from '@/components/icons/TrashIcon';
export default {
components: {
PasswordChange,
AvatarUploadForm,
Avatar,
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>
<div class="class-selection" v-if="isTeacher">
<div class="class-selection__selected-class selected-class" @click="showPopover = !showPopover">
<p class="selected-class__text">Klasse: {{currentClassSelection.name}}</p>
<div class="class-selection" v-if="currentClassSelection">
<div data-cy="class-selection" class="class-selection__selected-class selected-class"
@click="showPopover = !showPopover">
<current-class class="selected-class__text"></current-class> <chevron-down class="selected-class__dropdown-icon"></chevron-down>
</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 me.schoolClasses"
:key="schoolClass.id"
:label="schoolClass.name"
:item="schoolClass"
@click="updateFilter(schoolClass)">
{{schoolClass.name}}
</li>
{{schoolClass.name}}
</li>
</widget-popover>
</div>
</template>
<script>
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 UPDATE_USER_SETTING from '@/graphql/gql/mutations/updateUserSetting.gql';
export default {
components: {
WidgetPopover
WidgetPopover,
ChevronDown,
CurrentClass
},
props: {
@ -37,7 +43,13 @@
apollo: {
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: {
id: ''
},
permissions: []
permissions: [],
schoolClasses: []
},
showPopover: false
}
@ -64,7 +77,7 @@
},
update(store, data) {
let meData = store.readQuery({query: ME_QUERY});
meData.me.selectedClass = selectedClass
meData.me.selectedClass = selectedClass;
store.writeQuery({query: ME_QUERY, data: meData});
}
}).catch((error) => {
@ -76,16 +89,10 @@
computed: {
currentClassSelection() {
let currentClass = this.schoolClasses.find(schoolClass => {
let currentClass = this.me.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');
});
return currentClass || this.me.schoolClasses[0];
}
},
@ -97,25 +104,42 @@
@import "@/styles/_mixins.scss";
.class-selection {
position: relative;
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 {
white-space: nowrap;
top: 40px;
top: 100%;
left: 0;
transform: translateY($small-spacing);
}
}
.selected-class {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
&__text {
line-height: $large-spacing;
@include regular-text;
color: $color-silver-dark;
}
}
&__dropdown-icon {
width: 20px;
height: 20px;
fill: $color-brand;
}
}
.popover-links__link {
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: {
__typename: 'HelloEmail',
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 HELLO_EMAIL from '@/graphql/gql/local/helloEmail.gql';
import SIDEBAR from '@/graphql/gql/local/sidebar.gql';
export const resolvers = {
Mutation: {
@ -15,5 +16,11 @@ export const resolvers = {
cache.writeQuery({query: HELLO_EMAIL, data});
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!
}
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

@ -170,10 +170,9 @@ router.beforeEach(async (to, from, next) => {
return;
}
// handle users students without class
if ((to.name !== 'noClass' && to.name !== 'licenseActivation') && loginRequired(to) && await redirectStudentsWithoutClass()) {
next({name: 'noClass'})
return;
if ((to.name !== 'join-class' && to.name !== 'licenseActivation') && loginRequired(to) && await redirectStudentsWithoutClass()) {
next({name: 'join-class'})
return
}
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>
import MODULE_ROOM_ENTRIES_QUERY from '@/graphql/gql/moduleRoomEntryQuery.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import roomMixin from '@/components/mixins/room'
import roomMixin from '@/mixins/room'
export default {
props: ['slug'],

View File

@ -1,6 +1,6 @@
<template>
<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>
</div>
</template>

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

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

View File

@ -39,9 +39,9 @@
<div class="start-page__news news">
<h2 class="news__title">News</h2>
<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?"
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"-->
<!--url="https://abunews.webflow.io/"></news-teaser>-->
<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 licenseActivation from '@/pages/license-activation'
import forgotPassword from '@/pages/forgot-password'
import joinClass from '@/pages/joinClass'
import store from '@/store/index';
@ -136,6 +137,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

@ -12,7 +12,8 @@ from wagtail.core.models import Page
from books.factories import BookFactory, TopicFactory, ModuleFactory, ChapterFactory, ContentBlockFactory
from core.factories import UserFactory
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.user_data import user_data
@ -57,6 +58,13 @@ class Command(BaseCommand):
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):
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, module=module, **content_block_data)
# now create all and rooms
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!
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

@ -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 %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% 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 %}
{% 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 %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% 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 %}
{% 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 %}
{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %}
{% 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 %}
{% trans "Ihr Benutzername lautet:" %} {{ user.get_username }}

View File

@ -1,6 +1,7 @@
import json
from django import template
from django.urls import reverse
from rest_framework import serializers
register = template.Library()
@ -19,3 +20,8 @@ def json_dumps(obj):
@register.filter(name='class_name')
def class_name(obj):
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:
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

@ -95,9 +95,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):
@ -130,6 +135,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)

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

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