diff --git a/.graphqlconfig b/.graphqlconfig index 8ecbad1a..1c92ceb8 100644 --- a/.graphqlconfig +++ b/.graphqlconfig @@ -1,19 +1,3 @@ { - "name": "skillbox", - "projects": { - "private": { - "schemaPath": "schema.graphql" - } - }, - "extensions": { - "endpoints": { - "localhost": { - "url": "http://localhost:8000/api/graphql", - "headers": { - "user-agent": "JS GraphQL" - }, - "introspect": false - } - } - } + "schemaPath": "server/schema.graphql" } diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..593085d3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +nodejs 12.22.1 +python 3.8.5 diff --git a/Pipfile.lock b/Pipfile.lock index 8e56e4e2..e7972165 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -25,10 +25,10 @@ }, "autopep8": { "hashes": [ - "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea", - "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443" + "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0", + "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9" ], - "version": "==1.5.5" + "version": "==1.5.7" }, "backcall": { "hashes": [ @@ -55,18 +55,19 @@ }, "boto3": { "hashes": [ - "sha256:1ca39de26205439832fa740fcc200de85f107b615dae445f869a16b76672e663", - "sha256:d6716f6701aae771f6ff2386a1d641855a99a9fedd64bd25476b83bacdd50c94" + "sha256:56f1766f1271b6b4e979c7b56225377f8912050e5935adc5c1c9e3a0338b949e", + "sha256:c61c809d288e88b9a0d926f56f803d0128b498aa9b45a42a6e03cd9a83e5c124" ], "index": "pypi", - "version": "==1.17.23" + "version": "==1.17.68" }, "botocore": { "hashes": [ - "sha256:d5ea913331e93cecde25773841397c5d2abbc04f38b1c96c636444e8bb3478f8", - "sha256:f3ae5cae250c3fb200d33abacbee3f0c6513fc6f1d17a851b04a3998de68c829" + "sha256:0f693f5ad6348ec1a62b3a66fee2840d3b722d66b44896022d644275ff8b143d", + "sha256:eb3544911cb0316a33b328a27d137130af278a9c0006be0c95e5e402b01d9865" ], - "version": "==1.20.23" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.20.68" }, "certifi": { "hashes": [ @@ -80,14 +81,16 @@ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, "decorator": { "hashes": [ - "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", - "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" + "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060", + "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98" ], - "version": "==4.4.2" + "markers": "python_version >= '3.5'", + "version": "==5.0.7" }, "dj-database-url": { "hashes": [ @@ -98,11 +101,11 @@ }, "django": { "hashes": [ - "sha256:30c235dec87e05667597e339f194c9fed6c855bda637266ceee891bf9093da43", - "sha256:e319a7164d6d30cb177b3fd74d02c52f1185c37304057bb76d74047889c605d9" + "sha256:db2214db1c99017cbd971e58824e6f424375154fe358afc30e976f5b99fc6060", + "sha256:e831105edb153af1324de44d06091ca75520a227456387dda4a47d2f1cc2731a" ], "index": "pypi", - "version": "==2.2.19" + "version": "==2.2.22" }, "django-appconf": { "hashes": [ @@ -113,11 +116,11 @@ }, "django-compressor": { "hashes": [ - "sha256:57ac0a696d061e5fc6fbc55381d2050f353b973fb97eee5593f39247bc0f30af", - "sha256:d2ed1c6137ddaac5536233ec0a819e14009553fee0a869bea65d03e5285ba74f" + "sha256:3358077605c146fdcca5f9eaffb50aa5dbe15f238f8854679115ebf31c0415e0", + "sha256:f8313f59d5e65712fc28787d084fe834997c9dfa92d064a1a3ec3d3366594d04" ], "index": "pypi", - "version": "==2.4" + "version": "==2.4.1" }, "django-cors-headers": { "hashes": [ @@ -186,6 +189,7 @@ "sha256:710b4d15ec1996550cc68a0abbc41903ca7d832540e52b1336e6858737e410d8", "sha256:bb8f27684814cd1414b2af75b857b5e26a40912631904038a7ecacd2bfafc3ac" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.24.0" }, "django-treebeard": { @@ -193,6 +197,7 @@ "sha256:7c2b1cdb1e9b46d595825186064a1228bc4d00dbbc186db5b0b9412357fba91c", "sha256:80150017725239702054e5fa64dc66e383dc13ac262c8d47ee5a82cb005969da" ], + "markers": "python_version >= '3.6'", "version": "==4.5.1" }, "djangorestframework": { @@ -220,15 +225,17 @@ }, "faker": { "hashes": [ - "sha256:97fb6748031569a65265d38d85370b3c3970f4333b40430d52a60b37dea97877", - "sha256:ee86c3140e1d1651b76a7aab637c7da068660f33937143e7063c663f4cec45ab" + "sha256:156854f36d4086bb21ff85a79b4d6a6403a240cd2c17a33a44b8ea4ff4e957c2", + "sha256:a2ed065342e91a7672407325848cd5728d5e5eb4928d0a1c478fd4f0dd97d1f7" ], - "version": "==6.5.2" + "markers": "python_version >= '3.6'", + "version": "==8.1.2" }, "future": { "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "gprof2dot": { @@ -279,6 +286,7 @@ "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.1" }, "idna": { @@ -286,15 +294,16 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "ipython": { "hashes": [ - "sha256:04323f72d5b85b606330b6d7e2dc8d2683ad46c3905e955aa96ecc7a99388e70", - "sha256:34207ffb2f653bced2bc8e3756c1db86e7d93e44ed049daae9814fed66d408ec" + "sha256:714810a5c74f512b69d5f3b944c86e592cee0a5fb9c728e582f074610f6cf038", + "sha256:f78c6a3972dde1cc9e4041cbf4de583546314ba52d3c97208e5b6b2221a9cb7d" ], "index": "pypi", - "version": "==7.21.0" + "version": "==7.23.1" }, "ipython-genutils": { "hashes": [ @@ -316,6 +325,7 @@ "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, "jmespath": { @@ -323,6 +333,7 @@ "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.0" }, "libsass": { @@ -398,8 +409,17 @@ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, + "matplotlib-inline": { + "hashes": [ + "sha256:5cf1176f554abb4fa98cb362aa2b55c500147e4bdbb07e3fda359143e1da0811", + "sha256:f41d5ff73c9f5385775d5c0bc13b424535c8402fe70ea8210f93e11f3683993e" + ], + "markers": "python_version >= '3.5'", + "version": "==0.1.2" + }, "newrelic": { "hashes": [ "sha256:242a5e901d684f7ffdd621bc58da8fe9a85d5545b4b63e1070589f5ab45c9e1e", @@ -422,6 +442,7 @@ "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.9" }, "parso": { @@ -429,6 +450,7 @@ "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea", "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.7.1" }, "pexpect": { @@ -501,10 +523,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974", - "sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd" + "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", + "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" ], - "version": "==3.0.16" + "markers": "python_full_version >= '3.6.1'", + "version": "==3.0.18" }, "psycopg2": { "hashes": [ @@ -536,23 +559,26 @@ }, "pycodestyle": { "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", + "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" ], - "version": "==2.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.7.0" }, "pygments": { "hashes": [ - "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94", - "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8" + "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", + "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" ], - "version": "==2.8.1" + "markers": "python_version >= '3.5'", + "version": "==2.9.0" }, "pyparsing": { "hashes": [ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "python-dateutil": { @@ -560,6 +586,7 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "python-dotenv": { @@ -635,17 +662,18 @@ }, "s3transfer": { "hashes": [ - "sha256:1e28620e5b444652ed752cf87c7e0cb15b0e578972568c6609f0f18212f259ed", - "sha256:7fdddb4f22275cf1d32129e21f056337fd2a80b6ccef1664528145b72c49e6d2" + "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", + "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2" ], - "version": "==0.3.4" + "version": "==0.4.2" }, "sendgrid": { "hashes": [ - "sha256:2eb1dcb1f7d8656eed4db586e428c2c86f347590b8511d7f92993882d0e4fab9", - "sha256:e422c8263563ac7d664066d2f87b90bcb005b067eb7c33a9b1396442b2ed285b" + "sha256:273bdc0abec649bf6319df7b6267980f79e53ab64e92906d65eea6d4330d00b4", + "sha256:74b0dcf9a79188948f61f456bd1bf67ffa676a5d388aba1c76bff516566d7084" ], - "version": "==6.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==6.7.0" }, "sentry-sdk": { "hashes": [ @@ -660,20 +688,23 @@ "sha256:58b46ce1cc4d43af0aac3ac9a047bdb0f44e05f0b2fa2eec755863331700c865", "sha256:85c97f94c8957fa4e6dab113156c182fb346d56d059af78aad710bced15f16fb" ], + "markers": "python_version >= '2.6'", "version": "==3.6.1" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "version": "==1.15.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" }, "sqlparse": { "hashes": [ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "starkbank-ecdsa": { @@ -694,6 +725,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "traitlets": { @@ -701,6 +733,7 @@ "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" ], + "markers": "python_version >= '3.7'", "version": "==5.0.5" }, "typing": { @@ -708,6 +741,7 @@ "sha256:1187fb9c82fd670d10aa07bbb6cfcfe4bdda42d6fab8d5134f04e8c4d0b71cc9", "sha256:283d868f5071ab9ad873e5e52268d611e851c870a2ba354193026f2dfb29d8b5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.7.4.3" }, "unidecode": { @@ -727,10 +761,11 @@ }, "urllib3": { "hashes": [ - "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", - "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" + "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", + "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" ], - "version": "==1.26.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.4" }, "wagtail": { "hashes": [ @@ -742,10 +777,10 @@ }, "wagtail-autocomplete": { "hashes": [ - "sha256:5f8ddf16d3ca365af4ad0f09e8f45d9e982ca854dc1b084a8a30f66b00a7739d" + "sha256:73602db77eb3eee27f17a0b22aa88badf9fb5079fb1822c921f06a17224ce4db" ], "index": "pypi", - "version": "==0.6" + "version": "==0.6.3" }, "wagtail-factories": { "hashes": [ @@ -788,25 +823,26 @@ "develop": { "asgiref": { "hashes": [ - "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", - "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" + "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", + "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" ], - "version": "==3.3.1" + "markers": "python_version >= '3.6'", + "version": "==3.3.4" }, "autopep8": { "hashes": [ - "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea", - "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443" + "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0", + "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9" ], - "version": "==1.5.5" + "version": "==1.5.7" }, "awscli": { "hashes": [ - "sha256:191c07061bf227775437bf0b6e6209e0db50affcd8d5a37ba9fe5d87b6b80585", - "sha256:426e2eb8412b59e677ac28603cb6a038bea50ae8fab60ee9edf3756b28d4f419" + "sha256:57ae60a3f59cac265a9e5321c618b8768fdee89565089ada271e24489be5110d", + "sha256:a26b5e24f70cb2c542128ccc11e9d38e43cded687d60cd2ca18b3d28cd902509" ], "index": "pypi", - "version": "==1.19.23" + "version": "==1.19.68" }, "backcall": { "hashes": [ @@ -817,10 +853,11 @@ }, "botocore": { "hashes": [ - "sha256:d5ea913331e93cecde25773841397c5d2abbc04f38b1c96c636444e8bb3478f8", - "sha256:f3ae5cae250c3fb200d33abacbee3f0c6513fc6f1d17a851b04a3998de68c829" + "sha256:0f693f5ad6348ec1a62b3a66fee2840d3b722d66b44896022d644275ff8b143d", + "sha256:eb3544911cb0316a33b328a27d137130af278a9c0006be0c95e5e402b01d9865" ], - "version": "==1.20.23" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.20.68" }, "certifi": { "hashes": [ @@ -834,6 +871,7 @@ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==4.0.0" }, "colorama": { @@ -841,6 +879,7 @@ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.4.3" }, "coverage": { @@ -903,18 +942,19 @@ }, "decorator": { "hashes": [ - "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", - "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" + "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060", + "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98" ], - "version": "==4.4.2" + "markers": "python_version >= '3.5'", + "version": "==5.0.7" }, "django": { "hashes": [ - "sha256:30c235dec87e05667597e339f194c9fed6c855bda637266ceee891bf9093da43", - "sha256:e319a7164d6d30cb177b3fd74d02c52f1185c37304057bb76d74047889c605d9" + "sha256:db2214db1c99017cbd971e58824e6f424375154fe358afc30e976f5b99fc6060", + "sha256:e831105edb153af1324de44d06091ca75520a227456387dda4a47d2f1cc2731a" ], "index": "pypi", - "version": "==2.2.19" + "version": "==2.2.22" }, "django-silk": { "hashes": [ @@ -929,6 +969,7 @@ "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.15.2" }, "gprof2dot": { @@ -942,22 +983,23 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "ipdb": { "hashes": [ - "sha256:1aa37e19e5b3b96c930fe7fe97193a54202c23a8d1b1c7763f76553f5b275731" + "sha256:178c367a61c1039e44e17c56fcc4a6e7dc11b33561261382d419b6ddb4401810" ], "index": "pypi", - "version": "==0.13.6" + "version": "==0.13.7" }, "ipython": { "hashes": [ - "sha256:04323f72d5b85b606330b6d7e2dc8d2683ad46c3905e955aa96ecc7a99388e70", - "sha256:34207ffb2f653bced2bc8e3756c1db86e7d93e44ed049daae9814fed66d408ec" + "sha256:714810a5c74f512b69d5f3b944c86e592cee0a5fb9c728e582f074610f6cf038", + "sha256:f78c6a3972dde1cc9e4041cbf4de583546314ba52d3c97208e5b6b2221a9cb7d" ], "index": "pypi", - "version": "==7.21.0" + "version": "==7.23.1" }, "ipython-genutils": { "hashes": [ @@ -979,6 +1021,7 @@ "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, "jmespath": { @@ -986,6 +1029,7 @@ "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.0" }, "markupsafe": { @@ -1043,13 +1087,23 @@ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, + "matplotlib-inline": { + "hashes": [ + "sha256:5cf1176f554abb4fa98cb362aa2b55c500147e4bdbb07e3fda359143e1da0811", + "sha256:f41d5ff73c9f5385775d5c0bc13b424535c8402fe70ea8210f93e11f3683993e" + ], + "markers": "python_version >= '3.5'", + "version": "==0.1.2" + }, "parso": { "hashes": [ "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea", "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.7.1" }, "pexpect": { @@ -1069,10 +1123,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974", - "sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd" + "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", + "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" ], - "version": "==3.0.16" + "markers": "python_full_version >= '3.6.1'", + "version": "==3.0.18" }, "ptyprocess": { "hashes": [ @@ -1101,23 +1156,26 @@ }, "pycodestyle": { "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", + "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" ], - "version": "==2.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.7.0" }, "pygments": { "hashes": [ - "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94", - "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8" + "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", + "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" ], - "version": "==2.8.1" + "markers": "python_version >= '3.5'", + "version": "==2.9.0" }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "pytz": { @@ -1159,6 +1217,7 @@ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==5.4.1" }, "requests": { @@ -1174,27 +1233,30 @@ "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2", "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9" ], + "markers": "python_version >= '3.5' and python_version < '4'", "version": "==4.7.2" }, "s3transfer": { "hashes": [ - "sha256:1e28620e5b444652ed752cf87c7e0cb15b0e578972568c6609f0f18212f259ed", - "sha256:7fdddb4f22275cf1d32129e21f056337fd2a80b6ccef1664528145b72c49e6d2" + "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", + "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2" ], - "version": "==0.3.4" + "version": "==0.4.2" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "version": "==1.15.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" }, "sqlparse": { "hashes": [ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "toml": { @@ -1202,6 +1264,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "traitlets": { @@ -1209,14 +1272,16 @@ "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" ], + "markers": "python_version >= '3.7'", "version": "==5.0.5" }, "urllib3": { "hashes": [ - "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", - "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" + "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", + "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" ], - "version": "==1.26.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.4" }, "wcwidth": { "hashes": [ diff --git a/client/build/webpack.base.conf.js b/client/build/webpack.base.conf.js index 3e770a5a..b978cf7b 100644 --- a/client/build/webpack.base.conf.js +++ b/client/build/webpack.base.conf.js @@ -42,6 +42,7 @@ module.exports = { alias: { '@': resolve('src'), styles: resolve('src/styles'), + gql: resolve('src/graphql/gql') }, }, module: { diff --git a/client/cypress/fixtures/me.minimal.js b/client/cypress/fixtures/me.minimal.js new file mode 100644 index 00000000..21bd49d7 --- /dev/null +++ b/client/cypress/fixtures/me.minimal.js @@ -0,0 +1,10 @@ +export default { + username: '', + firstName: '', + lastName: '', + avatarUrl: '', + email: '', + onboardingVisited: true, + schoolClasses: {}, + id: '', +}; diff --git a/client/cypress/fixtures/mocks.js b/client/cypress/fixtures/mocks.js new file mode 100644 index 00000000..ceaf95a5 --- /dev/null +++ b/client/cypress/fixtures/mocks.js @@ -0,0 +1,4 @@ +export default { + UUID: () => '123-456-789', + GenericStreamFieldType: () => 'GenericStreamFieldType', +}; diff --git a/client/cypress/fixtures/module.minimal.js b/client/cypress/fixtures/module.minimal.js new file mode 100644 index 00000000..f04a353e --- /dev/null +++ b/client/cypress/fixtures/module.minimal.js @@ -0,0 +1,11 @@ +export default { + title: 'title', + metaTitle: 'metaTitle', + heroImage: 'heroImage', + teaser: 'teaser', + intro: 'intro', + assignments: {}, + objectiveGroups: [], + id: '', + chapters: [], +}; diff --git a/client/cypress/fixtures/spell-check.json b/client/cypress/fixtures/spell-check.json index c1075aa5..609c4f51 100644 --- a/client/cypress/fixtures/spell-check.json +++ b/client/cypress/fixtures/spell-check.json @@ -7,8 +7,7 @@ "length": 3, "sentenceOffset": 0, "affected": "Hir", - "corrected": "Dir", - "__typename": "SpellCheckStepNode" + "corrected": "Dir" }, { "sentence": "Hir ist ein Feler gewesen", @@ -16,8 +15,7 @@ "length": 5, "sentenceOffset": 0, "affected": "Feler", - "corrected": "Fehler", - "__typename": "SpellCheckStepNode" + "corrected": "Fehler" }, { "sentence": "Hir ist ein Feler gewesen", @@ -25,9 +23,7 @@ "length": 7, "sentenceOffset": 0, "affected": "gewesen", - "corrected": "gewesen.", - "__typename": "SpellCheckStepNode" + "corrected": "gewesen." } - ], - "__typename": "SpellCheckPayload" + ] } diff --git a/client/cypress/integration/e2e/new-student.spec.js b/client/cypress/integration/e2e/new-student.spec.js index 6af340a4..7bb93106 100644 --- a/client/cypress/integration/e2e/new-student.spec.js +++ b/client/cypress/integration/e2e/new-student.spec.js @@ -1,6 +1,5 @@ import {mockUpdateOnboardingProgress} from '../../support/helpers'; -const schema = require('../../fixtures/schema.json'); const me = require('../../fixtures/me.new-student.json'); describe('New student', () => { diff --git a/client/cypress/integration/frontend/sync-module-visibility.spec.js b/client/cypress/integration/frontend/apply-module-visibility.spec.js similarity index 95% rename from client/cypress/integration/frontend/sync-module-visibility.spec.js rename to client/cypress/integration/frontend/apply-module-visibility.spec.js index eb660e2e..c7492f0b 100644 --- a/client/cypress/integration/frontend/sync-module-visibility.spec.js +++ b/client/cypress/integration/frontend/apply-module-visibility.spec.js @@ -1,10 +1,6 @@ // import * as schema from '../fixtures/schema.json'; import {getModules, getMe} from '../../support/helpers'; - -const mocks = { - UUID: () => 'Whatever', - GenericStreamFieldType: () => [], -}; +import mocks from '../../fixtures/mocks'; const operations = { MeQuery() { diff --git a/client/cypress/integration/frontend/snapshots.spec.js b/client/cypress/integration/frontend/snapshots.spec.js new file mode 100644 index 00000000..051f43d4 --- /dev/null +++ b/client/cypress/integration/frontend/snapshots.spec.js @@ -0,0 +1,59 @@ +import minimalMe from '../../fixtures/me.minimal'; +import module from '../../fixtures/module.minimal'; +import mocks from '../../fixtures/mocks'; + +const me = isTeacher => ({ + ...minimalMe, + isTeacher, +}); + +const operations = isTeacher => ({ + operations: { + MeQuery: { + me: me(isTeacher), + }, + ModuleDetailsQuery: { + module, + }, + }, +}); + +describe('Snapshot', () => { + beforeEach(() => { + cy.server(); + + cy.task('getSchema').then(schema => { + cy.mockGraphql({ + schema, + mocks, + }); + }); + + cy.viewport('macbook-15'); + }); + + it('Menu is visible for teacher', () => { + cy.fakeLogin('nico.zickgraf', 'test'); + + cy.mockGraphqlOps(operations(true)); + cy.visit('module/miteinander-reden/'); + cy.getByDataCy('snapshot-menu').should('be.visible'); + }); + + it('Menu is not visible for student', () => { + cy.fakeLogin('rahel.cueni', 'test'); + cy.mockGraphqlOps(operations(false)); + cy.visit('module/miteinander-reden/'); + + cy.getByDataCy('module-title').should('be.visible'); + cy.getByDataCy('snapshot-menu').should('not.exist'); + }); + + it('Applies Snapshot', () => { + cy.fakeLogin('nico.zickgraf', 'test'); + + cy.mockGraphqlOps(operations(true)); + cy.visit('module/miteinander-reden/snapshots'); + cy.get('Not Implemented'); + }); +}); diff --git a/client/cypress/integration/frontend/spellcheck.spec.js b/client/cypress/integration/frontend/spellcheck.spec.js index adeb0495..bbaa6ccb 100644 --- a/client/cypress/integration/frontend/spellcheck.spec.js +++ b/client/cypress/integration/frontend/spellcheck.spec.js @@ -1,7 +1,6 @@ import {mockUpdateLastModule} from '../../support/helpers'; +import module from '../../fixtures/module.minimal'; -const assignments = require('../../fixtures/assignments.json'); -const module = require('../../fixtures/module.json'); const spellCheck = require('../../fixtures/spell-check.json'); const operations = { @@ -11,22 +10,128 @@ const operations = { onboardingVisited: true, }, }, - AssignmentsQuery: { - assignments, + AssignmentQuery: { + assignment: { + id: '', + title: 'Ein Auftragstitel', + assignment: 'Ein Auftrag', + submission: { + id: 'U3R1ZGVudFN1Ym1pc3Npb25Ob2RlOjE=', + text: 'Hir ist ein Feler gewesen', + final: false, + document: '', + submissionFeedback: { + 'id': 'U3VibWlzc2lvbkZlZWRiYWNrTm9kZTox', + 'text': '\ud83d\ude42\ud83d\ude10\ud83e\udd2c\ud83d\udc4d\ud83e\udd22\ud83e\udd22\ud83e\udd22\ud83e\udd22\ud83d\ude2e\ud83e\udd17', + 'teacher': { + 'firstName': 'Nico', + 'lastName': 'Zickgraf', + '__typename': 'UserNode', + }, + }, + }, + }, }, - ModulesQuery: { - module, + ModuleDetailsQuery: { + module: { + ...module, + assignments: [ + { + 'id': 'QXNzaWdubWVudE5vZGU6MQ==', + 'title': 'Ein Auftragstitel', + 'assignment': 'Ein Auftrag', + 'solution': null, + 'submission': { + 'id': 'U3R1ZGVudFN1Ym1pc3Npb25Ob2RlOjE=', + 'text': 'Hir ist ein Feler gewesen', + 'final': false, + 'document': '', + 'submissionFeedback': { + 'id': 'U3VibWlzc2lvbkZlZWRiYWNrTm9kZTox', + 'text': '🙂😐🤬👍🤢🤢🤢🤢😮🤗', + 'teacher': { + 'firstName': 'Nico', + 'lastName': 'Zickgraf', + '__typename': 'UserNode', + }, + '__typename': 'SubmissionFeedbackNode', + }, + '__typename': 'StudentSubmissionNode', + }, + '__typename': 'AssignmentNode', + }, + ], + chapters: [ + { + 'id': 'Q2hhcHRlck5vZGU6MTg=', + 'title': '1.1 Lehrbeginn', + 'description': 'Wie sieht Ihr Konsumverhalten aus?', + 'bookmark': { + 'note': { + 'id': 'Tm90ZU5vZGU6Mg==', + 'text': 'Chapter Chapter', + '__typename': 'NoteNode', + }, + '__typename': 'ChapterBookmarkNode', + }, + contentBlocks: [ + { + 'id': 'Q29udGVudEJsb2NrTm9kZToxOQ==', + 'slug': 'assignment', + 'title': 'Assignment', + 'type': 'NORMAL', + 'contents': [ + { + 'type': 'assignment', + 'value': { + 'title': 'Ein Auftragstitel', + 'assignment': 'Ein Auftrag', + 'id': 'QXNzaWdubWVudE5vZGU6MQ==', + }, + 'id': 'df8212ee-3e82-49fa-977e-c4b60789163e', + }, + ], + 'userCreated': false, + 'mine': false, + 'bookmarks': [ + { + 'uuid': 'df8212ee-3e82-49fa-977e-c4b60789163e', + 'note': { + 'id': 'Tm90ZU5vZGU6Mw==', + 'text': 'Noch eine Notiz', + '__typename': 'NoteNode', + }, + '__typename': 'ContentBlockBookmarkNode', + }, + ], + 'hiddenFor': [], + 'visibleFor': [], + '__typename': 'ContentBlockNode', + }, + ], + }, + ], + }, }, SpellCheck: { spellCheck, }, + UpdateAssignment: { + updateAssignment: { + assignment: { + id: '', + title: 'title', + assignment: '', + solution: '', + }, + }, + }, ...mockUpdateLastModule(), }; describe('Spellcheck', () => { before(() => { cy.server(); - cy.task('getSchema').then(schema => { cy.mockGraphql({ schema, @@ -40,9 +145,7 @@ describe('Spellcheck', () => { cy.visit('/module/lohn-und-budget/'); cy.get('.spellcheck__correction').should('have.length', 0); - cy.get('.submission-form-container__spellcheck').click(); - cy.get('.spellcheck__correction').should('have.length', 3); }); }); diff --git a/client/cypress/plugins/index.js b/client/cypress/plugins/index.js index 4117cf88..00a3e8ae 100644 --- a/client/cypress/plugins/index.js +++ b/client/cypress/plugins/index.js @@ -20,7 +20,7 @@ module.exports = (on, config) => { on('task', { getSchema() { return readFileSync( - resolve(__dirname, '../../../schema.graphql'), + resolve(__dirname, '../../../server/schema.graphql'), 'utf8' ); } diff --git a/client/cypress/support/helpers.js b/client/cypress/support/helpers.js index bfb2b43a..f704d728 100644 --- a/client/cypress/support/helpers.js +++ b/client/cypress/support/helpers.js @@ -22,7 +22,7 @@ export const getMe = ({schoolClasses, teacher}) => { 'firstName': 'Rahel', 'lastName': 'Cueni', 'avatarUrl': '', - 'isTeacher': false, + 'isTeacher': teacher, 'lastModule': { 'id': 'TW9kdWxlTm9kZToxNw==', 'slug': 'lohn-und-budget', diff --git a/client/jest.config.js b/client/jest.config.js index b0ad431e..664b42ed 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -19,7 +19,8 @@ module.exports = { '/node_modules/' ], moduleNameMapper: { - '^@/(.*)$': '/src/$1' + '^@/(.*)$': '/src/$1', + '^gql/(.*)$': '/src/graphql/gql/$1', }, snapshotSerializers: [ '/node_modules/jest-serializer-vue' diff --git a/client/package-lock.json b/client/package-lock.json index 652b72fc..9992f461 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8336,6 +8336,11 @@ "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", "dev": true }, + "dayjs": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz", + "integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==" + }, "de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/client/package.json b/client/package.json index 3ecc0653..99fca5e1 100644 --- a/client/package.json +++ b/client/package.json @@ -41,6 +41,7 @@ "chalk": "^2.0.1", "copy-webpack-plugin": "^4.0.1", "css-loader": "^0.28.0", + "dayjs": "^1.10.4", "debounce": "^1.2.0", "eslint": "^4.15.0", "eslint-config-standard": "^10.2.1", diff --git a/client/src/App.vue b/client/src/App.vue index 2680ec21..0f4623c9 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -36,6 +36,7 @@ import FullscreenInfographic from '@/components/FullscreenInfographic'; import FullscreenVideo from '@/components/FullscreenVideo'; import DeactivatePerson from '@/components/profile/DeactivatePerson'; + import SnapshotCreated from '@/components/modules/SnapshotCreated'; import {mapGetters} from 'vuex'; import ScrollUp from '@/components/ScrollUp'; @@ -64,7 +65,8 @@ FullscreenImage, FullscreenInfographic, FullscreenVideo, - DeactivatePerson + DeactivatePerson, + SnapshotCreated }, computed: { diff --git a/client/src/components/AssignmentWithSubmissions.vue b/client/src/components/AssignmentWithSubmissions.vue index 9198ddf0..821b1776 100644 --- a/client/src/components/AssignmentWithSubmissions.vue +++ b/client/src/components/AssignmentWithSubmissions.vue @@ -90,8 +90,7 @@ diff --git a/client/src/components/ContentBlock.vue b/client/src/components/ContentBlock.vue index c0490b65..637f3171 100644 --- a/client/src/components/ContentBlock.vue +++ b/client/src/components/ContentBlock.vue @@ -57,7 +57,7 @@ import VisibilityAction from '@/components/visibility/VisibilityAction'; import ContentComponent from '@/components/content-blocks/ContentComponent'; - import CHAPTER_QUERY from '@/graphql/gql/chapterQuery.gql'; + import CHAPTER_QUERY from '@/graphql/gql/queries/chapterQuery.gql'; import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql'; import {mapState} from 'vuex'; diff --git a/client/src/components/StudentSubmission.vue b/client/src/components/StudentSubmission.vue index c33cc7ab..0a6dd5d5 100644 --- a/client/src/components/StudentSubmission.vue +++ b/client/src/components/StudentSubmission.vue @@ -57,10 +57,10 @@ diff --git a/client/src/components/modules/ModuleNavigation.vue b/client/src/components/modules/ModuleNavigation.vue index a7961669..c1dbae30 100644 --- a/client/src/components/modules/ModuleNavigation.vue +++ b/client/src/components/modules/ModuleNavigation.vue @@ -44,10 +44,7 @@ class="module-navigation__toggle-menu" v-if="canManageContent" > - - - - + + +
+

Ein neuer Snapshot wurde erstellt

+
+
+
+ {{ snapshot.title }} + {{ created }} - {{ snapshot.creator }} +
+
+ +
+ + + + + diff --git a/client/src/components/modules/SnapshotHeader.vue b/client/src/components/modules/SnapshotHeader.vue new file mode 100644 index 00000000..6f108b5b --- /dev/null +++ b/client/src/components/modules/SnapshotHeader.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/client/src/components/modules/SnapshotListItem.vue b/client/src/components/modules/SnapshotListItem.vue new file mode 100644 index 00000000..c198b292 --- /dev/null +++ b/client/src/components/modules/SnapshotListItem.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/client/src/components/modules/SnapshotMenu.vue b/client/src/components/modules/SnapshotMenu.vue new file mode 100644 index 00000000..d6bde185 --- /dev/null +++ b/client/src/components/modules/SnapshotMenu.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/client/src/components/modules/SnapshotPreview.vue b/client/src/components/modules/SnapshotPreview.vue new file mode 100644 index 00000000..e69de29b diff --git a/client/src/components/modules/SnapshotTeamMenu.vue b/client/src/components/modules/SnapshotTeamMenu.vue new file mode 100644 index 00000000..c0d176bc --- /dev/null +++ b/client/src/components/modules/SnapshotTeamMenu.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/client/src/components/notes/EditNoteWizard.vue b/client/src/components/notes/EditNoteWizard.vue index f0d61e13..8ee7f1c1 100644 --- a/client/src/components/notes/EditNoteWizard.vue +++ b/client/src/components/notes/EditNoteWizard.vue @@ -9,8 +9,8 @@ import NoteForm from '@/components/notes/NoteForm'; import UPDATE_NOTE_MUTATION from '@/graphql/gql/mutations/updateNote.gql'; - import MODULE_DETAILS_QUERY from '@/graphql/gql/moduleDetailsQuery.gql'; - import INSTRUMENT_QUERY from '@/graphql/gql/instrumentQuery.gql'; + import MODULE_DETAILS_QUERY from '@/graphql/gql/queries/modules/moduleDetailsQuery.gql'; + import INSTRUMENT_QUERY from '@/graphql/gql/queries/instrumentQuery.gql'; import {mapGetters} from 'vuex'; diff --git a/client/src/components/objective-groups/NewObjectiveWizard.vue b/client/src/components/objective-groups/NewObjectiveWizard.vue index 33dc0b83..e70fbb5f 100644 --- a/client/src/components/objective-groups/NewObjectiveWizard.vue +++ b/client/src/components/objective-groups/NewObjectiveWizard.vue @@ -24,7 +24,7 @@ import ModalInput from '@/components/ModalInput'; import NEW_OBJECTIVE_MUTATION from '@/graphql/gql/mutations/addObjective.gql'; - import OBJECTIVE_GROUP_QUERY from '@/graphql/gql/objectiveGroupQuery.gql'; + import OBJECTIVE_GROUP_QUERY from '@/graphql/gql/queries/objectiveGroupQuery.gql'; import {mapGetters} from 'vuex'; diff --git a/client/src/components/objective-groups/Objective.vue b/client/src/components/objective-groups/Objective.vue index c0032716..f7723399 100644 --- a/client/src/components/objective-groups/Objective.vue +++ b/client/src/components/objective-groups/Objective.vue @@ -30,7 +30,7 @@ import MoreOptionsWidget from '@/components/MoreOptionsWidget'; import DELETE_OBJECTIVE_MUTATION from '@/graphql/gql/mutations/deleteObjective.gql'; - import MODULE_DETAILS_QUERY from '@/graphql/gql/moduleDetailsQuery.gql'; + import MODULE_DETAILS_QUERY from '@/graphql/gql/queries/modules/moduleDetailsQuery.gql'; import {hidden} from '@/helpers/visibility'; import {OBJECTIVE_TYPE} from '@/consts/types'; diff --git a/client/src/components/objective-groups/ObjectiveGroup.vue b/client/src/components/objective-groups/ObjectiveGroup.vue index 070fdcaf..d8c9aa92 100644 --- a/client/src/components/objective-groups/ObjectiveGroup.vue +++ b/client/src/components/objective-groups/ObjectiveGroup.vue @@ -62,7 +62,7 @@ return hidden({ block: this.group, schoolClass: this.schoolClass, - type: this.type + type: this.type, }); }, }, @@ -70,7 +70,7 @@ diff --git a/client/src/layouts/SimpleLayout.vue b/client/src/layouts/SimpleLayout.vue index 1fefdb80..36c3e896 100644 --- a/client/src/layouts/SimpleLayout.vue +++ b/client/src/layouts/SimpleLayout.vue @@ -1,11 +1,13 @@ + + diff --git a/client/src/pages/snapshot/snapshots.vue b/client/src/pages/snapshot/snapshots.vue new file mode 100644 index 00000000..10d90b51 --- /dev/null +++ b/client/src/pages/snapshot/snapshots.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/client/src/pages/studentSubmission.vue b/client/src/pages/studentSubmission.vue index 9c9adb06..f14b8d76 100644 --- a/client/src/pages/studentSubmission.vue +++ b/client/src/pages/studentSubmission.vue @@ -50,7 +50,7 @@ import cloneDeep from 'lodash/cloneDeep'; import StudentSubmissionDocument from '@/components/StudentSubmissionDocument'; - import STUDENT_SUBMISSIONS_QUERY from '@/graphql/gql/studentSubmissionQuery.gql'; + import STUDENT_SUBMISSIONS_QUERY from '@/graphql/gql/queries/studentSubmissionQuery.gql'; import UPDATE_FEEDBACK_MUTATION from '@/graphql/gql/mutations/updateFeedback.gql'; import UPDATE_FEEDBACK_WITH_TEXT_MUTATION from '@/graphql/gql/mutations/updateFeedbackWithText.gql'; import SubmissionForm from '@/components/content-blocks/assignment/SubmissionForm'; diff --git a/client/src/pages/submissions.vue b/client/src/pages/submissions.vue index 8553f305..db031eab 100644 --- a/client/src/pages/submissions.vue +++ b/client/src/pages/submissions.vue @@ -11,7 +11,7 @@ diff --git a/client/src/pages/test.vue b/client/src/pages/test.vue new file mode 100644 index 00000000..06c80e73 --- /dev/null +++ b/client/src/pages/test.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/client/src/pages/topic.vue b/client/src/pages/topic.vue index a48f1dfc..6ccbd8bf 100644 --- a/client/src/pages/topic.vue +++ b/client/src/pages/topic.vue @@ -43,12 +43,12 @@ import ModuleTeaser from '@/components/modules/ModuleTeaser.vue'; import PlayIcon from '@/components/icons/Play'; import BulbIcon from '@/components/icons/BulbIcon'; - import TOPIC_QUERY from '@/graphql/gql/topicQuery.gql'; + import TOPIC_QUERY from '@/graphql/gql/queries/topicQuery.gql'; import me from '@/mixins/me'; import BookTopicNavigation from '@/components/book-navigation/BookTopicNavigation'; import UPDATE_LAST_TOPIC_MUTATION from '@/graphql/gql/mutations/updateLastTopic.gql'; - import ME_QUERY from '@/graphql/gql/meQuery.gql'; + import ME_QUERY from '@/graphql/gql/queries/meQuery.gql'; export default { diff --git a/client/src/router/core.constants.js b/client/src/router/core.constants.js new file mode 100644 index 00000000..5c5264c3 --- /dev/null +++ b/client/src/router/core.constants.js @@ -0,0 +1 @@ +export const LAYOUT_SIMPLE = 'simple'; diff --git a/client/src/router/index.js b/client/src/router/index.js index 86ece71d..96a32a54 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -24,6 +24,7 @@ import authRoutes from './auth.routes'; import roomRoutes from './room.routes'; import store from '@/store/index'; +import {LAYOUT_SIMPLE} from '@/router/core.constants'; const routes = [ { @@ -34,24 +35,25 @@ const routes = [ ...moduleRoutes, ...authRoutes, ...roomRoutes, - {path: '/article/:slug', name: 'article', component: article, meta: {layout: 'simple'}}, + ...onboardingRoutes, + ...portfolioRoutes, + ...meRoutes, + {path: '/article/:slug', name: 'article', component: article, meta: {layout: LAYOUT_SIMPLE}}, { path: '/instruments/', name: 'instrument-overview', component: instrumentOverview, }, - {path: '/instrument/:slug', name: 'instrument', component: instrument, meta: {layout: 'simple'}}, - {path: '/submission/:id', name: 'submission', component: submission, meta: {layout: 'simple'}}, - ...portfolioRoutes, + {path: '/instrument/:slug', name: 'instrument', component: instrument, meta: {layout: LAYOUT_SIMPLE}}, + {path: '/submission/:id', name: 'submission', component: submission, meta: {layout: LAYOUT_SIMPLE}}, {path: '/topic/:topicSlug', name: 'topic', component: topic, alias: '/book/topic/:topicSlug'}, - ...meRoutes, - {path: '/join-class', name: 'join-class', component: joinClass, meta: {layout: 'simple'}}, + {path: '/join-class', name: 'join-class', component: joinClass, meta: {layout: LAYOUT_SIMPLE}}, { path: '/survey/:id', component: surveyPage, name: 'survey', props: true, - meta: {layout: 'simple'}, + meta: {layout: LAYOUT_SIMPLE}, }, { path: '/check-email', @@ -93,7 +95,6 @@ const routes = [ component: news, name: 'news', }, - ...onboardingRoutes, {path: '/styleguide', component: styleGuidePage}, { path: '*', @@ -118,6 +119,7 @@ const router = new Router({ }); router.afterEach((to, from) => { + store.commit('setEditModule', false); store.dispatch('showMobileNavigation', false); }); export default router; diff --git a/client/src/router/me.routes.js b/client/src/router/me.routes.js index b78197b4..93fa1c26 100644 --- a/client/src/router/me.routes.js +++ b/client/src/router/me.routes.js @@ -11,6 +11,7 @@ import joinTeam from '@/pages/me/joinTeam'; import createTeam from '@/pages/me/createTeam'; import {CREATE_TEAM, JOIN_TEAM, MY_TEAM, SHOW_SCHOOL_CLASS_CODE, SHOW_TEAM_CODE} from './me.names'; +import {LAYOUT_SIMPLE} from '@/router/core.constants'; export default [ { @@ -32,23 +33,23 @@ export default [ alias: 'create-class', name: 'create-class', component: createClass, - meta: {layout: 'simple'}, + meta: {layout: LAYOUT_SIMPLE}, }, { path: 'class/code', alias: 'show-code', name: SHOW_SCHOOL_CLASS_CODE, component: showSchoolClassCode, - meta: {layout: 'simple'}, + meta: {layout: LAYOUT_SIMPLE}, }, {path: 'team', name: MY_TEAM, component: myTeam, meta: {isProfile: true}}, - {path: 'team/join', name: JOIN_TEAM, component: joinTeam, meta: {isProfile: true, layout: 'simple'}}, - {path: 'team/create', name: CREATE_TEAM, component: createTeam, meta: {isProfile: true, layout: 'simple'}}, + {path: 'team/join', name: JOIN_TEAM, component: joinTeam, meta: {isProfile: true, layout: LAYOUT_SIMPLE}}, + {path: 'team/create', name: CREATE_TEAM, component: createTeam, meta: {isProfile: true, layout: LAYOUT_SIMPLE}}, { path: 'team/code', name: SHOW_TEAM_CODE, component: showTeamCode, - meta: {layout: 'simple'}, + meta: {layout: LAYOUT_SIMPLE}, }, ], }, diff --git a/client/src/router/module.names.js b/client/src/router/module.names.js index 793e0b65..eb05bd6b 100644 --- a/client/src/router/module.names.js +++ b/client/src/router/module.names.js @@ -2,3 +2,5 @@ export const SUBMISSIONS_PAGE = 'submissions'; export const MODULE_PAGE = 'module'; export const MODULE_SETTINGS_PAGE = 'module-settings'; export const VISIBILITY_PAGE = 'visibility'; +export const SNAPSHOT_LIST = 'snapshot-list'; +export const SNAPSHOT_DETAIL = 'snapshot-detail'; diff --git a/client/src/router/module.routes.js b/client/src/router/module.routes.js index 1f0ac4bd..cf740b7d 100644 --- a/client/src/router/module.routes.js +++ b/client/src/router/module.routes.js @@ -2,8 +2,18 @@ import moduleBase from '@/pages/module/module-base'; import module from '@/pages/module/module'; import submissions from '@/pages/submissions'; import moduleVisibility from '@/pages/module/moduleVisibility'; -import {MODULE_PAGE, MODULE_SETTINGS_PAGE, SUBMISSIONS_PAGE, VISIBILITY_PAGE} from '@/router/module.names'; +import { + MODULE_PAGE, + MODULE_SETTINGS_PAGE, + SNAPSHOT_DETAIL, + SNAPSHOT_LIST, + SUBMISSIONS_PAGE, + VISIBILITY_PAGE, +} from '@/router/module.names'; import settingsPage from '@/pages/module/moduleSettings'; +import snapshots from '@/pages/snapshot/snapshots'; +import snapshot from '@/pages/snapshot/snapshot'; +import {LAYOUT_SIMPLE} from '@/router/core.constants'; export default [ { @@ -38,10 +48,29 @@ export default [ name: VISIBILITY_PAGE, component: moduleVisibility, meta: { - layout: 'simple', + layout: LAYOUT_SIMPLE, hideNavigation: true, }, }, + { + path: 'snapshots', + component: snapshots, + name: SNAPSHOT_LIST, + meta: { + showSubNavigation: true, + }, + }, + { + path: 'snapshot/:id', + component: snapshot, + name: SNAPSHOT_DETAIL, + props: true, + meta: { + layout: LAYOUT_SIMPLE, + hideNavigation: true, + fullWidth: true + }, + }, ], }, ]; diff --git a/client/src/styles/_buttons.scss b/client/src/styles/_buttons.scss index 3245ecab..0610e307 100644 --- a/client/src/styles/_buttons.scss +++ b/client/src/styles/_buttons.scss @@ -1,29 +1,28 @@ .button { background: transparent; - border: 2px solid $color-silver-dark; padding: 5px 15px; - border-radius: 3px; font-family: $sans-serif-font-family; font-weight: $font-weight-regular; display: inline-flex; cursor: pointer; + @include small-text; - - &--active { - border-color: $color-brand; - } - - &--primary { - border-color: $color-brand; - } + @include button-border; &--white-bg { background-color: $color-white; } + @mixin disabled { + cursor: default; + } &--disabled { - border: 2px solid $color-silver-light; + @include disabled; background-color: $color-silver-light; } + &--disabled-alt { + @include disabled; + opacity: 0.3; + } &--big { padding: 15px; } diff --git a/client/src/styles/_forms.scss b/client/src/styles/_forms.scss index 80b35650..8058fd4b 100644 --- a/client/src/styles/_forms.scss +++ b/client/src/styles/_forms.scss @@ -73,6 +73,7 @@ $icon-size: 20px; &__icon { width: $icon-size; height: $icon-size; + overflow: hidden; display: flex; border: 2px solid $color-silver-dark; justify-content: center; diff --git a/client/src/styles/_mixins.scss b/client/src/styles/_mixins.scss index bf627f81..6cb79683 100644 --- a/client/src/styles/_mixins.scss +++ b/client/src/styles/_mixins.scss @@ -145,6 +145,12 @@ font-weight: 600; } +@mixin large-link { + font-family: $sans-serif-font-family; + font-size: toRem(18px); + font-weight: 400; +} + @mixin meta-title { color: $color-silver-dark; font-size: toRem(36px); @@ -240,3 +246,38 @@ margin-bottom: $section-spacing; } } + + +@mixin button-border { + border: 2px solid $color-silver-dark; + border-radius: 3px; + + &--active { + border-color: $color-brand; + } + + &--primary { + border-color: $color-brand; + } + + &--disabled { + border-color: $color-silver-light; + } +} + + +@mixin centered($width: 800px) { + width: $width; + max-width: 100%; + justify-self: center; +} + +@mixin table-row($color: $color-silver-dark) { + border-top: 1px solid $color; + padding: 15px 0; + width: 100%; + + &:last-of-type { + border-bottom: 1px solid $color; + } +} diff --git a/client/src/styles/_student-submission.scss b/client/src/styles/_student-submission.scss index c7b2f713..a4edc392 100644 --- a/client/src/styles/_student-submission.scss +++ b/client/src/styles/_student-submission.scss @@ -1,5 +1,4 @@ -@import "@/styles/_variables.scss"; -@import "@/styles/_mixins.scss"; +@import "~styles/helpers"; .student-submission-row { display: grid; @@ -12,6 +11,5 @@ } align-items: center; - border-bottom: 1px solid $color-silver-dark; padding: 15px 0; } diff --git a/server/assignments/tests/test_custom_assignments.py b/server/assignments/tests/test_custom_assignments.py index 94b739d8..b8905c1d 100644 --- a/server/assignments/tests/test_custom_assignments.py +++ b/server/assignments/tests/test_custom_assignments.py @@ -53,16 +53,8 @@ class CustomAssignmentTestCase(TestCase): module(slug: $slug) { id chapters { - edges { - node { - contentBlocks { - edges { - node { - contents - } - } - } - } + contentBlocks { + contents } } } @@ -78,8 +70,8 @@ class CustomAssignmentTestCase(TestCase): @staticmethod def get_first_contents(result): - return result.get('data').get('module').get('chapters').get('edges')[0].get('node').get('contentBlocks').get( - 'edges')[0].get('node').get('contents') + return result.get('data').get('module').get('chapters')[0].get('contentBlocks')[0].get( + 'contents') def test_module_query(self): result = self.query_module() diff --git a/server/books/migrations/0026_auto_20210427_1814.py b/server/books/migrations/0026_auto_20210427_1814.py new file mode 100644 index 00000000..7ad377a7 --- /dev/null +++ b/server/books/migrations/0026_auto_20210427_1814.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.20 on 2021-04-27 18:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0025_auto_20210414_2116'), + ] + + operations = [ + migrations.AlterField( + model_name='snapshot', + name='module', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='snapshots', to='books.Module'), + ), + ] diff --git a/server/books/migrations/0027_auto_20210429_1444.py b/server/books/migrations/0027_auto_20210429_1444.py new file mode 100644 index 00000000..dd1cac57 --- /dev/null +++ b/server/books/migrations/0027_auto_20210429_1444.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.20 on 2021-04-29 14:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('books', '0026_auto_20210427_1814'), + ] + + operations = [ + migrations.AddField( + model_name='snapshot', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='snapshot', + name='creator', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/server/books/migrations/0028_snapshot_shared.py b/server/books/migrations/0028_snapshot_shared.py new file mode 100644 index 00000000..b574b85a --- /dev/null +++ b/server/books/migrations/0028_snapshot_shared.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.22 on 2021-05-08 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0027_auto_20210429_1444'), + ] + + operations = [ + migrations.AddField( + model_name='snapshot', + name='shared', + field=models.BooleanField(default=False), + ), + ] diff --git a/server/books/migrations/0029_auto_20210511_1301.py b/server/books/migrations/0029_auto_20210511_1301.py new file mode 100644 index 00000000..4173cb52 --- /dev/null +++ b/server/books/migrations/0029_auto_20210511_1301.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.22 on 2021-05-11 13:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('objectives', '0014_delete_objectiveprogressstatus'), + ('books', '0028_snapshot_shared'), + ] + + operations = [ + migrations.AddField( + model_name='snapshot', + name='hidden_objectives', + field=models.ManyToManyField(related_name='hidden_for_snapshots', to='objectives.Objective'), + ), + migrations.CreateModel( + name='ObjectiveGroupSnapshot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hidden', models.BooleanField(default=False)), + ('objective_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='objectives.ObjectiveGroup')), + ('snapshot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='objective_group_snapshots', to='books.Snapshot')), + ], + ), + migrations.AddField( + model_name='snapshot', + name='objective_groups', + field=models.ManyToManyField(through='books.ObjectiveGroupSnapshot', to='objectives.ObjectiveGroup'), + ), + ] diff --git a/server/books/models/contentblock.py b/server/books/models/contentblock.py index eebe0ff9..da4b0db8 100644 --- a/server/books/models/contentblock.py +++ b/server/books/models/contentblock.py @@ -97,6 +97,13 @@ class ContentBlock(StrictHierarchyPage): def module(self): return self.get_parent().get_parent().specific + def is_hidden_for_class(self, school_class): + return ( + not self.user_created and self.hidden_for.filter(id=school_class.id).exists() + ) or ( + self.user_created and not self.visible_for.filter(id=school_class.id).exists() + ) + def save(self, *args, **kwargs): for data in self.contents.stream_data: block_type, value = get_type_and_value(data) diff --git a/server/books/models/snapshot.py b/server/books/models/snapshot.py index 34b982fd..e8cd4a52 100644 --- a/server/books/models/snapshot.py +++ b/server/books/models/snapshot.py @@ -1,7 +1,9 @@ +from django.contrib.auth import get_user_model from django.db import models from django.db.models import Q from books.models import Chapter, ContentBlock, ContentBlockSnapshot +from objectives.models import ObjectiveSnapshot class ChapterSnapshot(models.Model): @@ -23,14 +25,24 @@ class ChapterSnapshot(models.Model): description_hidden = models.BooleanField(default=False) +class ObjectiveGroupSnapshot(models.Model): + objective_group = models.ForeignKey( + 'objectives.ObjectiveGroup', + on_delete=models.CASCADE + ) + snapshot = models.ForeignKey( + 'books.Snapshot', + related_name='objective_group_snapshots', + on_delete=models.CASCADE + ) + hidden = models.BooleanField(default=False) + + class SnapshotManager(models.Manager): def create_snapshot(self, module, school_class, user, *args, **kwargs): - snapshot = self.create(module=module, *args, **kwargs) - chapters_with_hidden_properties = Chapter.get_by_parent(module).filter( - Q(description_hidden_for=school_class) - | Q(title_hidden_for=school_class) - ) - for chapter in chapters_with_hidden_properties: + snapshot = self.create(module=module, creator=user, *args, **kwargs) + chapters = Chapter.get_by_parent(module) + for chapter in chapters: ChapterSnapshot.objects.create( chapter=chapter, snapshot=snapshot, @@ -43,7 +55,7 @@ class SnapshotManager(models.Manager): snapshot.hidden_content_blocks.add(content_block) for content_block in base_qs.filter(Q(user_created=True) & Q(owner=user)): new_content_block = ContentBlockSnapshot( - hidden=False, + hidden=content_block.is_hidden_for_class(school_class), snapshot=snapshot, contents=content_block.contents, type=content_block.type, @@ -54,13 +66,34 @@ class SnapshotManager(models.Manager): revision.publish() new_content_block.save() + for objective_group in module.objective_groups.all(): + ObjectiveGroupSnapshot.objects.create( + objective_group=objective_group, + snapshot=snapshot, + hidden=objective_group.hidden_for.filter(id=school_class.id).exists(), + ) + base_qs = objective_group.objectives.filter(objectivesnapshot__isnull=True) + for objective in base_qs.filter(owner__isnull=True): + if objective.hidden_for.filter(id=school_class.id).exists(): + snapshot.hidden_objectives.add(objective) + for objective in base_qs.filter(owner=user): + ObjectiveSnapshot.objects.create( + hidden=objective.is_hidden_for_class(school_class=school_class), + snapshot=snapshot, + text=objective.text, + group=objective_group, + order=objective.order + ) + + return snapshot class Snapshot(models.Model): module = models.ForeignKey( 'books.Module', - on_delete=models.PROTECT + on_delete=models.PROTECT, + related_name='snapshots' ) chapters = models.ManyToManyField( 'books.Chapter', @@ -70,4 +103,20 @@ class Snapshot(models.Model): 'books.ContentBlock', related_name='hidden_for_snapshots' ) + created = models.DateTimeField(auto_now_add=True) + creator = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, null=True) + shared = models.BooleanField(default=False) + objective_groups = models.ManyToManyField( + 'objectives.ObjectiveGroup', + through=ObjectiveGroupSnapshot, + related_name='+' + ) + hidden_objectives = models.ManyToManyField( + 'objectives.Objective', + related_name='hidden_for_snapshots' + ) + objects = SnapshotManager() + + def __str__(self): + return f'Snapshot {self.id}' diff --git a/server/books/schema/interfaces/chapter.py b/server/books/schema/interfaces/chapter.py index 6fcd3deb..6fc855bf 100644 --- a/server/books/schema/interfaces/chapter.py +++ b/server/books/schema/interfaces/chapter.py @@ -1,5 +1,7 @@ import graphene +from graphene import relay from graphene_django.filter import DjangoFilterConnectionField class ChapterInterface(graphene.Interface): - content_blocks = DjangoFilterConnectionField('books.schema.nodes.ContentBlockNode') + description = graphene.String() + title = graphene.String() diff --git a/server/books/schema/interfaces/contentblock.py b/server/books/schema/interfaces/contentblock.py new file mode 100644 index 00000000..a2f1d41a --- /dev/null +++ b/server/books/schema/interfaces/contentblock.py @@ -0,0 +1,14 @@ +import graphene +from graphene import relay + +from api.graphene_wagtail import GenericStreamFieldType + + +class ContentBlockInterface(graphene.Interface): + title = graphene.String() + contents = GenericStreamFieldType() + type = graphene.String() + + @staticmethod + def resolve_type(parent, info, **kwargs): + return parent.type diff --git a/server/books/schema/interfaces/module.py b/server/books/schema/interfaces/module.py index ff885054..87d7919e 100644 --- a/server/books/schema/interfaces/module.py +++ b/server/books/schema/interfaces/module.py @@ -1,8 +1,17 @@ import graphene +from graphene import relay -class ModuleInterface(graphene.Interface): +class ModuleInterface(relay.Node): pk = graphene.Int() + hero_image = graphene.String(required=True) + topic = graphene.Field('books.schema.nodes.TopicNode') - def resolve_pk(self, info, **kwargs): - return self.id + @staticmethod + def resolve_pk(parent, info, **kwargs): + return parent.id + + @staticmethod + def resolve_hero_image(parent, info, **kwargs): + if parent.hero_image: + return parent.hero_image.file.url diff --git a/server/books/schema/mutations/__init__.py b/server/books/schema/mutations/__init__.py index 92cd8087..fab3c1e9 100644 --- a/server/books/schema/mutations/__init__.py +++ b/server/books/schema/mutations/__init__.py @@ -1,7 +1,7 @@ from books.schema.mutations.chapter import UpdateChapterVisibility from books.schema.mutations.contentblock import MutateContentBlock, AddContentBlock, DeleteContentBlock from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility -from books.schema.mutations.snapshot import CreateSnapshot, ApplySnapshot +from books.schema.mutations.snapshot import CreateSnapshot, ApplySnapshot, ShareSnapshot from books.schema.mutations.topic import UpdateLastTopic @@ -16,3 +16,4 @@ class BookMutations(object): sync_module_visibility = SyncModuleVisibility.Field() create_snapshot = CreateSnapshot.Field() apply_snapshot = ApplySnapshot.Field() + share_snapshot = ShareSnapshot.Field() diff --git a/server/books/schema/mutations/snapshot.py b/server/books/schema/mutations/snapshot.py index f0b9e374..6d88c5b8 100644 --- a/server/books/schema/mutations/snapshot.py +++ b/server/books/schema/mutations/snapshot.py @@ -1,16 +1,17 @@ import graphene +from django.db.models import Q from graphene import relay from api.utils import get_object -from books.models import Module, ContentBlock +from books.models import Module, ContentBlock, Chapter from books.models.snapshot import Snapshot -from books.schema.nodes import SnapshotNode +from books.schema.nodes import SnapshotNode, ModuleNode from users.models import SchoolClass class CreateSnapshot(relay.ClientIDMutation): class Input: - module = graphene.ID(required=True) + module = graphene.String(required=True) selected_class = graphene.ID(required=True) snapshot = graphene.Field(SnapshotNode) @@ -18,11 +19,11 @@ class CreateSnapshot(relay.ClientIDMutation): @classmethod def mutate_and_get_payload(cls, root, info, **args): - module_id = args.get('module') - module = get_object(Module, module_id) + module_slug = args.get('module') + module = Module.objects.get(slug=module_slug) user = info.context.user # todo: check user - raise NotImplementedError('Permissions') + # raise NotImplementedError('Permissions') selected_class_id = args.get('selected_class') selected_class = get_object(SchoolClass, selected_class_id) snapshot = Snapshot.objects.create_snapshot(module, selected_class, user) @@ -35,6 +36,7 @@ class ApplySnapshot(relay.ClientIDMutation): selected_class = graphene.ID(required=True) success = graphene.Boolean() + module = graphene.Field(ModuleNode) @classmethod def mutate_and_get_payload(cls, root, info, **args): @@ -43,16 +45,55 @@ class ApplySnapshot(relay.ClientIDMutation): user = info.context.user selected_class_id = args.get('selected_class') selected_class = get_object(SchoolClass, selected_class_id) + # reset everything + for chapter in Chapter.get_by_parent(snapshot.module): + cb_qs = ContentBlock.get_by_parent(chapter) + without_owner = Q(owner__isnull=True) + no_snapshot = Q(contentblocksnapshot__isnull=True) + owner_user = Q(owner=user) + for cb in cb_qs.filter(without_owner & no_snapshot): + cb.hidden_for.remove(selected_class) + for cb in cb_qs.filter(owner_user): + cb.visible_for.remove(selected_class) + # apply snapshot if not selected_class.users.filter(username=user.username).exists() or not user.is_teacher(): raise PermissionError('Not allowed') for content_block in snapshot.hidden_content_blocks.all(): content_block.hidden_for.add(selected_class) for custom_content_block in snapshot.custom_content_blocks.all(): - custom_content_block.to_regular_content_block(user, selected_class) + custom_content_block.to_regular_content_block(owner=user, school_class=selected_class) for chapter_snapshot in snapshot.chapters.through.objects.all(): chapter = chapter_snapshot.chapter if chapter_snapshot.title_hidden: chapter.title_hidden_for.add(selected_class) if chapter_snapshot.description_hidden: chapter.description_hidden_for.add(selected_class) - return cls(success=True) + for objective_group_snapshot in snapshot.objective_groups.through.objects.all(): + if objective_group_snapshot.hidden: + objective_group_snapshot.objective_group.hidden_for.add(selected_class) + for objective in snapshot.hidden_objectives.all(): + objective.hidden_for.add(selected_class) + for custom_objective in snapshot.custom_objectives.all(): + custom_objective.to_regular_objective(owner=user, school_class=selected_class) + return cls(success=True, module=snapshot.module) + + +class ShareSnapshot(relay.ClientIDMutation): + class Input: + snapshot = graphene.ID(required=True) + shared = graphene.Boolean(required=True) + + success = graphene.Boolean(required=True) + snapshot = graphene.Field(SnapshotNode) + + @classmethod + def mutate_and_get_payload(cls, root, info, **args): + snapshot_id = args.get('snapshot') + shared = args.get('shared') + user = info.context.user + snapshot = get_object(Snapshot, snapshot_id) + if snapshot.creator != user: + raise PermissionError('Not permitted') + snapshot.shared = shared + snapshot.save() + return cls(success=True, snapshot=snapshot) diff --git a/server/books/schema/nodes/chapter.py b/server/books/schema/nodes/chapter.py index 941a7264..20bb6af0 100644 --- a/server/books/schema/nodes/chapter.py +++ b/server/books/schema/nodes/chapter.py @@ -2,10 +2,8 @@ import graphene from django.db.models import Q from graphene import relay from graphene_django import DjangoObjectType -from graphql_relay import to_global_id from books.models import Chapter, ContentBlock -from books.models.snapshot import ChapterSnapshot from books.schema.interfaces import ChapterInterface from notes.models import ChapterBookmark from notes.schema import ChapterBookmarkNode @@ -13,6 +11,9 @@ from notes.schema import ChapterBookmarkNode class ChapterNode(DjangoObjectType): bookmark = graphene.Field(ChapterBookmarkNode) + content_blocks = graphene.List('books.schema.nodes.ContentBlockNode') + title_hidden_for = graphene.List('users.schema.SchoolClassNode') + description_hidden_for = graphene.List('users.schema.SchoolClassNode') class Meta: model = Chapter @@ -22,14 +23,14 @@ class ChapterNode(DjangoObjectType): filter_fields = [ 'slug', 'title', ] - interfaces = (relay.Node, ChapterInterface,) + interfaces = (relay.Node, ChapterInterface) def resolve_content_blocks(self, info, **kwargs): user = info.context.user school_classes = user.school_classes.values_list('pk', flat=True) by_parent = ContentBlock.get_by_parent(self) \ - .filter(contentblocksnapshot__isnull=True) # exclude snapshots + .filter(contentblocksnapshot__isnull=True) # exclude snapshot # .prefetch_related('visible_for') \ # .prefetch_related('hidden_for') @@ -49,52 +50,10 @@ class ChapterNode(DjangoObjectType): chapter=self ).first() - -class SnapshotChapterNode(DjangoObjectType): - title_hidden = graphene.Boolean() - description_hidden = graphene.Boolean() - title = graphene.String() - description = graphene.String() - id = graphene.ID(required=True) - - class Meta: - model = ChapterSnapshot - only_fields = '__all__' - filter_fields = [ - 'id', - ] - interfaces = (relay.Node, ChapterInterface,) + @staticmethod + def resolve_title_hidden_for(parent: Chapter, info, **kwargs): + return parent.title_hidden_for.all() @staticmethod - def resolve_title_hidden(parent, info): - return parent.title_hidden - - @staticmethod - def resolve_description_hidden(parent, info): - return parent.description_hidden - - @staticmethod - def resolve_title(parent, info): - return parent.chapter.title - - @staticmethod - def resolve_description(parent, info): - return parent.chapter.description - - @staticmethod - def resolve_content_blocks(parent, info, **kwargs): - snapshot = parent.snapshot - - user_created = Q(user_created=True) - hidden_for_snapshot = Q(hidden_for_snapshots=snapshot) - custom_hidden = Q(contentblocksnapshot__hidden=True) - - qs = ContentBlock.get_by_parent(parent.chapter) \ - .exclude(user_created) \ - .exclude(hidden_for_snapshot) \ - .exclude(custom_hidden) - - return qs - - def resolve_id(self, *args): - return to_global_id('SnapshotChapterNode', self.chapter.pk) + def resolve_description_hidden_for(parent: Chapter, info, **kwargs): + return parent.description_hidden_for.all() diff --git a/server/books/schema/nodes/content.py b/server/books/schema/nodes/content.py index be4364af..33013b32 100644 --- a/server/books/schema/nodes/content.py +++ b/server/books/schema/nodes/content.py @@ -3,13 +3,14 @@ from graphene import relay from graphene_django import DjangoObjectType from books.models import ContentBlock +from books.schema.interfaces.contentblock import ContentBlockInterface from books.utils import are_solutions_enabled_for +from core.logger import get_logger +from core.mixins import HiddenAndVisibleForMixin from notes.models import ContentBlockBookmark from notes.schema import ContentBlockBookmarkNode from rooms.models import ModuleRoomSlug -from core.logger import get_logger - logger = get_logger(__name__) @@ -35,12 +36,10 @@ def is_solution_and_hidden_for_user(type, user, module): return type == 'solution' and not (are_solutions_enabled_for(user, module) or user.is_teacher()) -class ContentBlockNode(DjangoObjectType): +class ContentBlockNode(DjangoObjectType, HiddenAndVisibleForMixin): mine = graphene.Boolean() bookmarks = graphene.List(ContentBlockBookmarkNode) - # contents = graphene.List(ContentNode) - class Meta: model = ContentBlock only_fields = [ @@ -49,10 +48,11 @@ class ContentBlockNode(DjangoObjectType): filter_fields = [ 'slug', 'title', ] - interfaces = (relay.Node,) + interfaces = (relay.Node, ContentBlockInterface,) + convert_choices_to_enum = False - def resolve_mine(self, info, **kwargs): - return self.owner is not None and self.owner.pk == info.context.user.pk + def resolve_mine(parent, info, **kwargs): + return parent.owner is not None and parent.owner.pk == info.context.user.pk def resolve_contents(self, info, **kwargs): updated_stream_data = [] diff --git a/server/books/schema/nodes/module.py b/server/books/schema/nodes/module.py index 6cf35cdc..1d8d6cd3 100644 --- a/server/books/schema/nodes/module.py +++ b/server/books/schema/nodes/module.py @@ -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 @@ -10,21 +11,12 @@ from books.schema.interfaces.module import ModuleInterface from books.schema.nodes.chapter import ChapterNode from notes.models import ModuleBookmark, ContentBlockBookmark, ChapterBookmark from notes.schema import ModuleBookmarkNode, ContentBlockBookmarkNode, ChapterBookmarkNode +from objectives.schema import ObjectiveGroupNode from surveys.models import Answer from surveys.schema import AnswerNode class ModuleNode(DjangoObjectType): - chapters = DjangoFilterConnectionField(ChapterNode) - topic = graphene.Field('books.schema.queries.TopicNode') - hero_image = graphene.String() - solutions_enabled = graphene.Boolean() - bookmark = graphene.Field(ModuleBookmarkNode) - my_submissions = DjangoFilterConnectionField(StudentSubmissionNode) - my_answers = DjangoFilterConnectionField(AnswerNode) - my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode) - my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode) - class Meta: model = Module only_fields = [ @@ -34,11 +26,17 @@ class ModuleNode(DjangoObjectType): 'slug': ['exact', 'icontains', 'in'], 'title': ['exact', 'icontains', 'in'], } - interfaces = (relay.Node, ModuleInterface, ) + interfaces = (ModuleInterface,) - def resolve_hero_image(self, info, **kwargs): - if self.hero_image: - return self.hero_image.file.url + chapters = graphene.List(ChapterNode) + solutions_enabled = graphene.Boolean() + bookmark = graphene.Field(ModuleBookmarkNode) + my_submissions = DjangoFilterConnectionField(StudentSubmissionNode) + my_answers = DjangoFilterConnectionField(AnswerNode) + my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode) + my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode) + snapshots = graphene.List('books.schema.nodes.SnapshotNode') + objective_groups = graphene.List(ObjectiveGroupNode) def resolve_chapters(self, info, **kwargs): return Chapter.get_by_parent(self) @@ -82,10 +80,16 @@ class ModuleNode(DjangoObjectType): chapters = Chapter.objects.live().descendant_of(self) return ChapterBookmark.objects.filter(chapter__in=chapters, user=user) - def resolve_objective_groups(self, root, **kwargs): - return self.objective_groups.all() \ + @staticmethod + def resolve_objective_groups(parent, info, **kwargs): + return parent.objective_groups.all() \ .prefetch_related('hidden_for') + @staticmethod + def resolve_snapshots(parent, info, **kwargs): + user = info.context.user + return parent.snapshots.filter(Q(creator=user) | Q(Q(creator__team=user.team ) & Q(shared=True))) + class RecentModuleNode(DjangoObjectType): class Meta: diff --git a/server/books/schema/nodes/snapshot.py b/server/books/schema/nodes/snapshot.py index 33fda7de..c9f1e301 100644 --- a/server/books/schema/nodes/snapshot.py +++ b/server/books/schema/nodes/snapshot.py @@ -1,19 +1,182 @@ -from graphene import relay +import graphene +from django.db.models import Q +from graphene import relay, ObjectType from graphene_django import DjangoObjectType -from graphene_django.filter import DjangoFilterConnectionField -from .chapter import SnapshotChapterNode from books.models.snapshot import Snapshot +from ..interfaces import ChapterInterface +from ..interfaces.contentblock import ContentBlockInterface +from ...models import ContentBlock + + +class SnapshotContentBlock: + def __init__(self, content_block, snapshot): + self.id = content_block.id + self.pk = content_block.pk + self.title = content_block.title + self.contents = content_block.contents + self.type = content_block.type + self.hidden = snapshot.hidden_content_blocks.filter(id=content_block.id).exists() or ( + hasattr(content_block, 'contentblocksnapshot') and content_block.contentblocksnapshot.hidden + ) + + +class SnapshotChapter: + def __init__(self, chapter, snapshot, description_hidden=False, title_hidden=False): + self.id = chapter.id + self.pk = chapter.pk + self.title = chapter.title + self.description = chapter.description + self.title_hidden = title_hidden + self.description_hidden = description_hidden + self.content_blocks = [] + base_qs = ContentBlock.get_by_parent(chapter) + without_owner = Q(owner__isnull=True) + this_snapshot = Q(contentblocksnapshot__snapshot=snapshot) + self.content_blocks = [ + SnapshotContentBlock( + content_block=content_block, + snapshot=snapshot + ) for content_block in + base_qs.filter(without_owner | this_snapshot) + ] + # all from module without owner + # all with snapshotcontentblock with this snapshot + + +class SnapshotObjective: + def __init__(self, objective, snapshot): + self.id = objective.id + self.text = objective.text + self.hidden = snapshot.hidden_objectives.filter(id=objective.id).exists() or ( + hasattr(objective, 'objectivesnapshot') and objective.objectivesnapshot.hidden + ) + + +class SnapshotObjectiveGroup: + def __init__(self, objective_group, hidden, snapshot): + self.title = objective_group.title + self.display_title = objective_group.get_title_display() + self.hidden = hidden + self.id = objective_group.id + base_qs = objective_group.objectives + default = Q(Q(owner__isnull=True) & Q(objectivesnapshot__snapshot__isnull=True)) + this_snapshot = Q(objectivesnapshot__snapshot=snapshot) + self.objectives = [ + SnapshotObjective( + objective=objective, + snapshot=snapshot + ) + for objective in base_qs.filter(default | this_snapshot) + ] + + +class SnapshotContentBlockNode(ObjectType): + class Meta: + interfaces = (relay.Node, ContentBlockInterface,) + + hidden = graphene.Boolean() + + +class SnapshotChapterNode(ObjectType): + class Meta: + interfaces = (relay.Node, ChapterInterface,) + + content_blocks = graphene.List(SnapshotContentBlockNode) + description_hidden = graphene.Boolean() + title_hidden = graphene.Boolean() + + +class SnapshotChangesNode(ObjectType): + hidden_objectives = graphene.Int(required=True) + new_objectives = graphene.Int(required=True) + hidden_content_blocks = graphene.Int(required=True) + new_content_blocks = graphene.Int(required=True) + + +class SnapshotObjectiveNode(ObjectType): + class Meta: + interfaces = (relay.Node,) + + hidden = graphene.Boolean(required=True) + text = graphene.String(required=True) + + +class SnapshotObjectiveGroupNode(ObjectType): + class Meta: + interfaces = (relay.Node,) + + title = graphene.String(required=True) + hidden = graphene.Boolean(required=True) + display_title = graphene.String(required=True) + objectives = graphene.List(SnapshotObjectiveNode, required=True) class SnapshotNode(DjangoObjectType): + title = graphene.String() + chapters = graphene.List(SnapshotChapterNode) + meta_title = graphene.String() + hero_image = graphene.String() + changes = graphene.Field(SnapshotChangesNode) + mine = graphene.Boolean() + shared = graphene.Boolean(required=True) + creator = graphene.String(required=True) + objective_groups = graphene.List(SnapshotObjectiveGroupNode) + class Meta: model = Snapshot interfaces = (relay.Node,) - # chapters = relay.ConnectionField('books.schema.connections.ChapterSnapshotConnection') - chapters = DjangoFilterConnectionField(SnapshotChapterNode) + @staticmethod + def resolve_chapters(parent, info, **kwargs): + return [ + SnapshotChapter( + chapter_snapshot.chapter, + snapshot=parent, + title_hidden=chapter_snapshot.title_hidden, + description_hidden=chapter_snapshot.description_hidden + ) + for chapter_snapshot in parent.chapters.through.objects.filter(snapshot=parent) + ] - def resolve_chapters(self, info, **kwargs): - # return Chapter.objects.filter(chapter_snapshots__snapshot=self) - return self.chapters.through.objects.all() + @staticmethod + def resolve_title(parent, info, **kwargs): + return parent.module.title + + @staticmethod + def resolve_meta_title(parent, info, **kwargs): + return parent.module.meta_title + + @staticmethod + def resolve_hero_image(parent, info, **kwargs): + if parent.module.hero_image: + return parent.module.hero_image.file.url + return '' + + @staticmethod + def resolve_changes(parent, info, **kwargs): + return { + 'hidden_objectives': 0, + 'new_objectives': 0, + 'hidden_content_blocks': parent.hidden_content_blocks.count(), + 'new_content_blocks': parent.custom_content_blocks.count() + } + + @staticmethod + def resolve_mine(parent, info, **kwargs): + return parent.creator == info.context.user + + @staticmethod + def resolve_creator(parent, info, **kwargs): + return f'{parent.creator.first_name} {parent.creator.last_name}' + + @staticmethod + def resolve_objective_groups(parent, info, **kwargs): + return [ + SnapshotObjectiveGroup( + objective_group=objective_group_snapshot.objective_group, + hidden=objective_group_snapshot.hidden, + snapshot=parent + ) + for objective_group_snapshot in parent.objective_groups.through.objects.filter(snapshot=parent) + ] diff --git a/server/books/schema/queries.py b/server/books/schema/queries.py index 2e82d215..3db2b931 100644 --- a/server/books/schema/queries.py +++ b/server/books/schema/queries.py @@ -4,10 +4,9 @@ from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_object from core.logger import get_logger - -from ..models import Book, Topic, Module, Chapter -from .nodes import ContentBlockNode, ChapterNode, ModuleNode, TopicNode from .connections import TopicConnection, ModuleConnection +from .nodes import ContentBlockNode, ChapterNode, ModuleNode, TopicNode, SnapshotNode +from ..models import Book, Topic, Module, Chapter, Snapshot logger = get_logger(__name__) @@ -18,6 +17,7 @@ class BookQuery(object): module = graphene.Field(ModuleNode, slug=graphene.String(), id=graphene.ID()) chapter = relay.Node.Field(ChapterNode) content_block = relay.Node.Field(ContentBlockNode) + snapshot = relay.Node.Field(SnapshotNode) topics = relay.ConnectionField(TopicConnection) modules = relay.ConnectionField(ModuleConnection) @@ -35,18 +35,26 @@ class BookQuery(object): def resolve_chapters(self, *args, **kwargs): return Chapter.objects.filter(**kwargs).live() + def resolve_snapshot(self, info, **kwargs): + id = kwargs.get('id') + snapshot = get_object(Snapshot, id) + return snapshot + def resolve_module(self, info, **kwargs): slug = kwargs.get('slug') id = kwargs.get('id') module = None + try: + if id is not None: + module = get_object(Module, id) - if id is not None: - module = get_object(Module, id) + elif slug is not None: + module = Module.objects.get(slug=slug) - elif slug is not None: - module = Module.objects.get(slug=slug) + return module - return module + except Module.DoesNotExist: + return None def resolve_topic(self, info, **kwargs): slug = kwargs.get('slug') diff --git a/server/books/tests/test_content_block_visibility.py b/server/books/tests/test_content_block_visibility.py index 0de602cd..501a77e5 100644 --- a/server/books/tests/test_content_block_visibility.py +++ b/server/books/tests/test_content_block_visibility.py @@ -3,8 +3,8 @@ from graphene.test import Client from graphql_relay import to_global_id from api.schema import schema -from books.models import ContentBlock, Chapter from books.factories import ModuleFactory +from books.models import ContentBlock, Chapter from users.factories import SchoolClassFactory from users.models import User from users.services import create_users @@ -29,7 +29,6 @@ class ContentBlocksVisibleForTeachers(TestCase): user_content_block.visible_for.add(school_class) user_content_block.save() - teacher_request = RequestFactory().get('/') teacher_request.user = teacher self.teacher_client = Client(schema=schema, context_value=teacher_request) @@ -49,7 +48,7 @@ class ContentBlocksVisibleForTeachers(TestCase): 'id': self.chapter }) self.assertIsNone(result.get('errors')) - self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks').get('edges')), 2) + self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks')), 2) def test_teacher_created_content_block(self): self.assertEqual(ContentBlock.objects.count(), 2) @@ -58,11 +57,7 @@ class ContentBlocksVisibleForTeachers(TestCase): query ChapterQuery($id: ID!) { chapter(id: $id) { contentBlocks { - edges { - node { - id - } - } + id } } } diff --git a/server/books/tests/test_content_blocks.py b/server/books/tests/test_content_blocks.py new file mode 100644 index 00000000..d5dbb58a --- /dev/null +++ b/server/books/tests/test_content_blocks.py @@ -0,0 +1,44 @@ +from books.factories import ModuleFactory, ChapterFactory +from books.models import ContentBlock +from core.tests.base_test import SkillboxTestCase + +CONTENT_BLOCK_QUERY = """ +query ContentBlockQuery($slug: String!) { + module(slug: $slug) { + chapters { + id + contentBlocks { + id + title + type + } + } + } +} +""" + + +class ContentBlockTestCase(SkillboxTestCase): + def setUp(self) -> None: + self.createDefault() + self.client = self.get_client() + + self.slug = 'module' + self.module = ModuleFactory(slug=self.slug) + self.chapter = ChapterFactory(parent=self.module) + self.content_block = ContentBlock( + type=ContentBlock.NORMAL, + title='Title' + ) + self.chapter.add_child(instance=self.content_block) + + def test_content_block(self): + result = self.client.execute(CONTENT_BLOCK_QUERY, variables={ + "slug": self.slug + }) + self.assertIsNone(result.get('errors')) + module = result.get('data').get('module') + content_block = module['chapters'][0]['contentBlocks'][0] + self.assertEqual(content_block['title'], 'Title') + self.assertIsNotNone(content_block['type']) + diff --git a/server/books/tests/test_copy_visibility_for_other_class.py b/server/books/tests/test_copy_visibility_for_other_class.py index 145b0462..004aa921 100644 --- a/server/books/tests/test_copy_visibility_for_other_class.py +++ b/server/books/tests/test_copy_visibility_for_other_class.py @@ -20,61 +20,47 @@ fragment SchoolClassFragment on SchoolClassNode { } """ -EDGES_FRAGMENT = SCHOOL_CLASS_FRAGMENT + """ -fragment SchoolClassNodeFragment on SchoolClassNodeConnection { - edges { - node { - ...SchoolClassFragment - } - } -} -""" - -CONTENT_BLOCK_QUERY = EDGES_FRAGMENT + """ +CONTENT_BLOCK_QUERY = SCHOOL_CLASS_FRAGMENT + """ query ContentBlockQuery($id: ID!) { contentBlock(id: $id) { hiddenFor { - ...SchoolClassNodeFragment + ...SchoolClassFragment } visibleFor { - ...SchoolClassNodeFragment + ...SchoolClassFragment } } } """ -CHAPTER_QUERY = EDGES_FRAGMENT + """ +CHAPTER_QUERY = SCHOOL_CLASS_FRAGMENT + """ query ChapterQuery($id: ID!) { chapter(id: $id) { id titleHiddenFor { - ...SchoolClassNodeFragment + ...SchoolClassFragment } descriptionHiddenFor { - ...SchoolClassNodeFragment + ...SchoolClassFragment } } } """ -OBJECTIVE_GROUP_QUERY = EDGES_FRAGMENT + """ +OBJECTIVE_GROUP_QUERY = SCHOOL_CLASS_FRAGMENT + """ query ObjectiveGroupQuery($id: ID!) { objectiveGroup(id: $id) { hiddenFor { - ...SchoolClassNodeFragment + ...SchoolClassFragment } objectives { - edges { - node { - id - text - hiddenFor { - ...SchoolClassNodeFragment - } - visibleFor { - ...SchoolClassNodeFragment - } - } + id + text + hiddenFor { + ...SchoolClassFragment + } + visibleFor { + ...SchoolClassFragment } } } @@ -193,61 +179,61 @@ class CopyVisibilityForClassesTestCase(TestCase): def _test_in_sync(self): # the hidden block is hidden for both now hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.hidden_content_block) - hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) + hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], hidden_for)) # the other hidden block is hidden for no one now other_hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.other_hidden_content_block) - hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor') self.assertEqual(len(hidden_for), 0) # the default block is still hidden for no one default_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.default_content_block) - hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor') self.assertEqual(len(hidden_for), 0) # the custom block is visible for both custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.custom_content_block) - visible_for = custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) + visible_for = custom_result.get('data').get('contentBlock').get('visibleFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], visible_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], visible_for)) # the other custom block is visible for no one other_custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.other_custom_content_block) - visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') + visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor') self.assertEqual(len(visible_for), 0) def test_hidden_for_and_visible_for_set_correctly(self): self.assertEqual(ContentBlock.objects.count(), 5) hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.hidden_content_block) - hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertFalse(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) + hidden_for = hidden_result.get('data').get('contentBlock').get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertFalse(SYNC_CLASS_NAME in map(lambda x: x['name'], hidden_for)) other_hidden_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.other_hidden_content_block) - hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor').get('edges') - self.assertFalse(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) + hidden_for = other_hidden_result.get('data').get('contentBlock').get('hiddenFor') + self.assertFalse(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], hidden_for)) default_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.default_content_block) - hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor').get('edges') + hidden_for = default_result.get('data').get('contentBlock').get('hiddenFor') self.assertEqual(len(hidden_for), 0) custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.custom_content_block) - visible_for = custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) - self.assertFalse(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) + visible_for = custom_result.get('data').get('contentBlock').get('visibleFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], visible_for)) + self.assertFalse(SYNC_CLASS_NAME in map(lambda x: x['name'], visible_for)) other_custom_result = self._get_result(CONTENT_BLOCK_QUERY, self.teacher_client, self.other_custom_content_block) - visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor').get('edges') - self.assertFalse(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], visible_for)) + visible_for = other_custom_result.get('data').get('contentBlock').get('visibleFor') + self.assertFalse(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], visible_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], visible_for)) def test_syncs_correctly(self): self.module.sync_from_school_class(self.template_school_class, self.school_class_to_be_synced) @@ -269,24 +255,24 @@ class CopyVisibilityForClassesTestCase(TestCase): result = self.student1_client.execute(query, variables=variables) self.assertIsNone(result.get('errors')) chapter = result.get('data').get('chapter') - title_hidden_for = chapter.get('titleHiddenFor').get('edges') - description_hidden_for = chapter.get('descriptionHiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], title_hidden_for)) - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], description_hidden_for)) - self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['node']['name'], title_hidden_for)) - self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['node']['name'], description_hidden_for)) + title_hidden_for = chapter.get('titleHiddenFor') + description_hidden_for = chapter.get('descriptionHiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], title_hidden_for)) + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], description_hidden_for)) + self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['name'], title_hidden_for)) + self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['name'], description_hidden_for)) self._execute_sync() result = self.student1_client.execute(query, variables=variables) self.assertIsNone(result.get('errors')) chapter = result.get('data').get('chapter') - title_hidden_for = chapter.get('titleHiddenFor').get('edges') - description_hidden_for = chapter.get('descriptionHiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], title_hidden_for)) - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], description_hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], title_hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], description_hidden_for)) + title_hidden_for = chapter.get('titleHiddenFor') + description_hidden_for = chapter.get('descriptionHiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], title_hidden_for)) + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], description_hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], title_hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], description_hidden_for)) def _objective_group_query(self): query = OBJECTIVE_GROUP_QUERY @@ -303,29 +289,29 @@ class CopyVisibilityForClassesTestCase(TestCase): def test_objective_group_visibility(self): query, variables = self._objective_group_query() objective_group = self._get_objective_group(self.student1_client, query, variables) - hidden_for = objective_group.get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['node']['name'], hidden_for)) + hidden_for = objective_group.get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['name'], hidden_for)) self._execute_sync() objective_group = self._get_objective_group(self.student1_client, query, variables) - hidden_for = objective_group.get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) + hidden_for = objective_group.get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], hidden_for)) def test_objective_visibility(self): query, variables = self._objective_group_query() objective_group = self._get_objective_group(self.student2_client, query, variables) - objective = objective_group.get('objectives').get('edges')[0]['node'] - hidden_for = objective.get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['node']['name'], hidden_for)) + objective = objective_group.get('objectives')[0] + hidden_for = objective.get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME not in map(lambda x: x['name'], hidden_for)) self._execute_sync() objective_group = self._get_objective_group(self.student2_client, query, variables) - objective = objective_group.get('objectives').get('edges')[0]['node'] - hidden_for = objective.get('hiddenFor').get('edges') - self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) - self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['node']['name'], hidden_for)) + objective = objective_group.get('objectives')[0] + hidden_for = objective.get('hiddenFor') + self.assertTrue(TEMPLATE_CLASS_NAME in map(lambda x: x['name'], hidden_for)) + self.assertTrue(SYNC_CLASS_NAME in map(lambda x: x['name'], hidden_for)) diff --git a/server/books/tests/test_create_snapshot.py b/server/books/tests/test_create_snapshot.py deleted file mode 100644 index d3938e31..00000000 --- a/server/books/tests/test_create_snapshot.py +++ /dev/null @@ -1,194 +0,0 @@ -from django.test import TestCase, RequestFactory -from graphene.test import Client -from graphql_relay import to_global_id, from_global_id - -from api.schema import schema -from books.factories import ModuleFactory, ChapterFactory, ContentBlockFactory -from books.models import Snapshot -from users.models import User, SchoolClass -from users.services import create_users - -MODULE_QUERY = """ -query ModulesQuery($slug: String!) { - module(slug: $slug) { - id - title - chapters { - edges { - node { - id - contentBlocks { - edges { - node { - id - title - visibleFor { - edges { - node { - name - } - } - } - hiddenFor { - edges { - node { - name - } - } - } - } - } - } - } - } - } - } -} -""" - -CREATE_SNAPSHOT_MUTATION = """ -mutation CreateSnapshot($input: CreateSnapshotInput!) { - createSnapshot(input: $input) { - snapshot { - id - chapters { - edges { - node { - id - descriptionHidden - titleHidden - title - description - contentBlocks { - edges { - node { - id - title - } - } - } - } - } - } - } - success - } -} -""" -APPLY_SNAPSHOT_MUTATION = """ -mutation ApplySnapshot($input: ApplySnapshotInput!) { - applySnapshot(input: $input) { - success - } -} -""" - - -def edges_to_array(entity): - return [edge['node'] for edge in entity.get('edges')] - - -class CreateSnapshotTestCase(TestCase): - def setUp(self): - create_users() - # teacher will create snapshot - self.teacher = User.objects.get(username='teacher') - self.module = ModuleFactory(slug='some-module') - self.skillbox_class = SchoolClass.objects.get(name='skillbox') - - # module M has a chapter - self.chapter = ChapterFactory(parent=self.module, slug='some-chapter') - - # chapter has some content blocks a, b, c - self.title_visible = 'visible' - self.title_hidden = 'hidden' - self.title_custom = 'custom' - self.visible_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, - title=self.title_visible, slug='cb-a') - self.hidden_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, - title=self.title_hidden, slug='cb-b') - # content block c is user created - self.custom_content_block = ContentBlockFactory(parent=self.chapter, owner=self.teacher, user_created=True, - module=self.module, title=self.title_custom, - slug='cb-c') - # content block a and c are visible to school class X - self.hidden_content_block.hidden_for.add(self.skillbox_class) - self.custom_content_block.visible_for.add(self.skillbox_class) - - # chapter description is hidden for school class X - self.chapter.title_hidden_for.add(self.skillbox_class) - - request = RequestFactory().get('/') - request.user = self.teacher - self.client = Client(schema=schema, context_value=request) - - # we make a snapshot S of the module M - # snapshot S looks like module M for school class X - - def _test_module_visibility(self, client, school_class_name): - result = client.execute(MODULE_QUERY, variables={ - 'slug': self.module.slug - }) - self.assertIsNone(result.get('errors')) - module = result.get('data').get('module') - chapter = edges_to_array(module.get('chapters'))[0] - self.assertIsNotNone(chapter) - content_blocks = edges_to_array(chapter.get('contentBlocks')) - content_block_titles = [node['title'] for node in content_blocks] - self.assertTrue(self.title_visible in content_block_titles) - self.assertTrue(self.title_hidden in content_block_titles) - self.assertTrue(self.title_custom in content_block_titles) - hidden_node = [node for node in content_blocks if - node['title'] == self.title_hidden][0] - custom_node = [node for node in content_blocks if - node['title'] == self.title_custom][0] - # check if hidden node is hidden for this school class - self.assertTrue( - school_class_name in [school_class['name'] for school_class in - edges_to_array(hidden_node.get('hiddenFor'))]) - # check if the custom node is visible for this school class - self.assertTrue( - school_class_name in [school_class['name'] for school_class in - edges_to_array(custom_node.get('visibleFor'))]) - - def test_setup(self): - # make sure everything is setup correctly - self._test_module_visibility(self.client, 'skillbox') - - def test_create_snapshot(self): - result = self.client.execute(CREATE_SNAPSHOT_MUTATION, variables={ - 'input': { - 'module': to_global_id('ContentBlockNode', self.module.pk), - 'selectedClass': to_global_id('SchoolClassNode', self.skillbox_class.pk), - } - }) - self.assertIsNone(result.get('errors')) - snapshot = result.get('data').get('createSnapshot').get('snapshot') - chapter = snapshot.get('chapters').get('edges')[0]['node'] - self.assertTrue(chapter['titleHidden']) - self.assertFalse(chapter['descriptionHidden']) - _, chapter_id = from_global_id(chapter['id']) - self.assertEqual(int(chapter_id), self.chapter.id) - content_blocks = [edge['node'] for edge in chapter['contentBlocks']['edges']] - self.assertEqual(len(content_blocks), 2) - self.assertEqual(content_blocks[0]['title'], self.title_visible) - self.assertEqual(content_blocks[1]['title'], self.title_custom) - - def test_apply_snapshot(self): - self.snapshot = Snapshot.objects.create_snapshot(module=self.module, school_class=self.skillbox_class, - user=self.teacher) - self.assertEqual(Snapshot.objects.count(), 1) - school_class_name = 'second_class' - second_class = SchoolClass.objects.get(name=school_class_name) - request = RequestFactory().get('/') - teacher2 = User.objects.get(username='teacher2') - request.user = teacher2 - client = Client(schema=schema, context_value=request) - result = client.execute(APPLY_SNAPSHOT_MUTATION, variables={ - 'input': { - 'snapshot': to_global_id('SnapshotNode', self.snapshot.pk), - 'selectedClass': to_global_id('SchoolClassNode', second_class.pk), - } - }) - self.assertIsNone(result.get('errors')) - self._test_module_visibility(client, school_class_name) diff --git a/server/books/tests/test_own_content_blocks.py b/server/books/tests/test_own_content_blocks.py index 1f427e93..a69ebb16 100644 --- a/server/books/tests/test_own_content_blocks.py +++ b/server/books/tests/test_own_content_blocks.py @@ -37,12 +37,8 @@ class OwnContentTestCase(TestCase): id title contentBlocks { - edges { - node { - id - title - } - } + id + title } } } @@ -51,14 +47,14 @@ class OwnContentTestCase(TestCase): "id": self.chapter_id }) self.assertIsNone(result.get('errors')) - self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks').get('edges')), 1) + self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks')), 1) custom_content_block = ContentBlock(title='own', slug='own', user_created=True, owner=self.user) self.chapter.specific.add_child(instance=custom_content_block) result = self.client.execute(chapterQuery, variables={ "id": self.chapter_id }) - self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks').get('edges')), 2) + self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks')), 2) for school_class in self.user.school_classes.all(): custom_content_block.visible_for.add(school_class) @@ -66,5 +62,5 @@ class OwnContentTestCase(TestCase): result = self.client.execute(chapterQuery, variables={ "id": self.chapter_id }) - self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks').get('edges')), 2) + self.assertEqual(len(result.get('data').get('chapter').get('contentBlocks')), 2) diff --git a/server/books/tests/test_snapshots.py b/server/books/tests/test_snapshots.py new file mode 100644 index 00000000..4455806e --- /dev/null +++ b/server/books/tests/test_snapshots.py @@ -0,0 +1,428 @@ +from django.test import RequestFactory +from graphene.test import Client +from graphql_relay import to_global_id, from_global_id + +from api.schema import schema +from api.utils import get_object +from books.factories import ModuleFactory, ChapterFactory, ContentBlockFactory +from books.models import Snapshot, ChapterSnapshot +from core.tests.base_test import SkillboxTestCase +from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory +from users.factories import SchoolClassFactory +from users.models import User, SchoolClass + +MODULE_QUERY = """ +query ModulesQuery($slug: String, $id: ID) { + module(slug: $slug, id: $id) { + id + title + objectiveGroups { + objectives { + id + text + hiddenFor { + name + } + visibleFor { + name + } + } + } + chapters { + id + contentBlocks { + id + title + visibleFor { + name + } + hiddenFor { + name + } + } + } + } +} +""" + +CREATE_SNAPSHOT_MUTATION = """ +mutation CreateSnapshot($input: CreateSnapshotInput!) { + createSnapshot(input: $input) { + snapshot { + id + created + creator + objectiveGroups { + objectives { + text + hidden + } + } + chapters { + id + descriptionHidden + titleHidden + title + description + contentBlocks { + id + title + hidden + } + } + } + success + } +} +""" +APPLY_SNAPSHOT_MUTATION = """ +mutation ApplySnapshot($input: ApplySnapshotInput!) { + applySnapshot(input: $input) { + success + } +} +""" + +SNAPSHOT_MODULE_QUERY = """ +query SnapshotDetail($id: ID!) { + snapshot(id: $id) { + id + objectiveGroups { + title + id + hidden + objectives { + hidden + id + text + } + } + chapters { + id + description + title + titleHidden + descriptionHidden + contentBlocks { + id + title + hidden + } + } + } +} +""" + +SHARE_SNAPSHOT_MUTATION = """ +mutation ShareSnapshot($input: ShareSnapshotInput!) { + shareSnapshot(input: $input) { + success + snapshot { + shared + } + } +} +""" + +MODULE_SNAPSHOTS_QUERY = """ +query SnapshotQuery($slug: String!) { + module(slug: $slug) { + snapshots { + id + title + created + creator + } + } +} +""" + + +class CreateSnapshotTestCase(SkillboxTestCase): + def _test_objective(self, objective, text, hidden): + self.assertEqual(objective['text'], text) + self.assertEqual(objective['hidden'], hidden) + + def _test_content_block(self, content_block, title, hidden): + self.assertEqual(content_block['title'], title) + self.assertEqual(content_block['hidden'], hidden) + + def setUp(self): + self.createDefault() + self.client = self.get_client() + # teacher will create snapshot + self.slug = 'some-module' + + self.module = ModuleFactory(slug=self.slug) + self.skillbox_class = SchoolClass.objects.get(name='skillbox') + + # module M has a chapter + self.chapter = ChapterFactory(parent=self.module, slug='some-chapter') + ChapterFactory(parent=self.module, slug='some-other-chapter') + + # chapter has some content blocks a, b, c + self.title_visible = 'visible' + self.title_hidden = 'hidden' + self.title_custom = 'custom' + self.title_custom_hidden = 'custom-hidden' + self.visible_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, + title=self.title_visible, slug='cb-a') + self.hidden_content_block = ContentBlockFactory(parent=self.chapter, module=self.module, + title=self.title_hidden, slug='cb-b') + # content block c is user created + self.custom_content_block = ContentBlockFactory(parent=self.chapter, owner=self.teacher, user_created=True, + module=self.module, title=self.title_custom, + slug='cb-c') + # content block d is user created, but hidden + self.custom_hidden_content_block = ContentBlockFactory(parent=self.chapter, owner=self.teacher, user_created=True, + module=self.module, title=self.title_custom_hidden, + slug='cb-d') + # content block a and c are visible to school class X + self.hidden_content_block.hidden_for.add(self.skillbox_class) + self.custom_content_block.visible_for.add(self.skillbox_class) + + # chapter description is hidden for school class X + self.chapter.title_hidden_for.add(self.skillbox_class) + + # we make a snapshot S of the module M + # snapshot S looks like module M for school class X + + objective_group = ObjectiveGroupFactory(module=self.module) + second_objective_group = ObjectiveGroupFactory(module=self.module) + + self.visible_objective = ObjectiveFactory(text='visible-objective', group=objective_group) + self.hidden_objective = ObjectiveFactory(text='hidden-objective', group=objective_group) + self.custom_objective = ObjectiveFactory(text='custom-objective', group=objective_group, owner=self.teacher) + self.custom_hidden_objective = ObjectiveFactory(text='custom-hidden-objective', group=objective_group, + owner=self.teacher) + + self.hidden_objective.hidden_for.add(self.skillbox_class) + self.custom_objective.visible_for.add(self.skillbox_class) + second_objective_group.hidden_for.add(self.skillbox_class) + + def _test_module_visibility(self, client, school_class_name): + result = client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + self.assertIsNone(result.get('errors')) + module = result.get('data').get('module') + chapter = module.get('chapters')[0] + self.assertIsNotNone(chapter) + content_blocks = chapter.get('contentBlocks') + content_block_titles = [content_block['title'] for content_block in content_blocks] + self.assertTrue(self.title_visible in content_block_titles) + self.assertTrue(self.title_hidden in content_block_titles) + self.assertTrue(self.title_custom in content_block_titles) + hidden_content_block = [content_block for content_block in content_blocks if + content_block['title'] == self.title_hidden][0] + custom_content_block = [content_block for content_block in content_blocks if + content_block['title'] == self.title_custom][0] + # check if hidden content block is hidden for this school class + self.assertTrue( + school_class_name in [school_class['name'] for school_class in + hidden_content_block.get('hiddenFor')]) + # check if the custom content block is visible for this school class + self.assertTrue( + school_class_name in [school_class['name'] for school_class in + custom_content_block.get('visibleFor')]) + + objectives = module['objectiveGroups'][0]['objectives'] + + self.assertEqual(len(objectives), 4) + + hidden_objective = [objective for objective in objectives if + objective['text'] == self.hidden_objective.text][0] + custom_objective = [objective for objective in objectives if + objective['text'] == self.custom_objective.text][0] + + # check if hidden objective is hidden for this school class + self.assertTrue( + school_class_name in [school_class['name'] for school_class in + hidden_objective.get('hiddenFor')]) + # check if the custom objective is visible for this school class + self.assertTrue( + school_class_name in [school_class['name'] for school_class in + custom_objective.get('visibleFor')]) + + def test_setup(self): + # make sure everything is setup correctly + self._test_module_visibility(self.client, 'skillbox') + + def test_create_snapshot(self): + result = self.client.execute(CREATE_SNAPSHOT_MUTATION, variables={ + 'input': { + 'module': self.slug, + 'selectedClass': to_global_id('SchoolClassNode', self.skillbox_class.pk), + } + }) + self.assertIsNone(result.get('errors')) + snapshot = result.get('data').get('createSnapshot').get('snapshot') + chapter = snapshot.get('chapters')[0] + + self.assertIsNotNone(snapshot.get('created')) + self.assertEqual(snapshot.get('creator'), f'{self.teacher.first_name} {self.teacher.last_name}') + + self.assertTrue(chapter['titleHidden']) + self.assertFalse(chapter['descriptionHidden']) + _, chapter_id = from_global_id(chapter['id']) + self.assertEqual(int(chapter_id), self.chapter.id) + + content_blocks = chapter['contentBlocks'] + self.assertEqual(len(content_blocks), 4) + visible, hidden, custom, custom_hidden = content_blocks + self._test_content_block(visible, self.title_visible, False) + self._test_content_block(hidden, self.title_hidden, True) + self._test_content_block(custom, self.title_custom, False) + self._test_content_block(custom_hidden, self.title_custom_hidden, True) + self.assertEqual(ChapterSnapshot.objects.count(), 2) + + visible, hidden, custom, custom_hidden = snapshot['objectiveGroups'][0]['objectives'] + + self._test_objective(objective=visible, text=self.visible_objective.text, hidden=False) + self._test_objective(objective=hidden, text=self.hidden_objective.text, hidden=True) + self._test_objective(objective=custom, text=self.custom_objective.text, hidden=False) + self._test_objective(objective=custom_hidden, text=self.custom_hidden_objective.text, hidden=True) + + id = snapshot['id'] + snapshot = get_object(Snapshot, id) + self.assertEqual(snapshot.objective_groups.count(), 2) + + def test_apply_snapshot(self): + self.snapshot = Snapshot.objects.create_snapshot(module=self.module, school_class=self.skillbox_class, + user=self.teacher) + self.assertEqual(Snapshot.objects.count(), 1) + self.assertEqual(self.snapshot.custom_objectives.count(), 2) + school_class_name = 'second_class' + second_class = SchoolClass.objects.get(name=school_class_name) + request = RequestFactory().get('/') + teacher2 = User.objects.get(username='teacher2') + request.user = teacher2 + client = Client(schema=schema, context_value=request) + result = client.execute(APPLY_SNAPSHOT_MUTATION, variables={ + 'input': { + 'snapshot': to_global_id('SnapshotNode', self.snapshot.pk), + 'selectedClass': to_global_id('SchoolClassNode', second_class.pk), + } + }) + self.assertIsNone(result.get('errors')) + self._test_module_visibility(client, school_class_name) + + def test_display_snapshot_module(self): + self.snapshot = Snapshot.objects.create_snapshot(module=self.module, school_class=self.skillbox_class, + user=self.teacher) + id = to_global_id('SnapshotNode', self.snapshot.id) + snapshot_result = self.client.execute(SNAPSHOT_MODULE_QUERY, variables={ + 'id': id + }) + self.assertIsNone(snapshot_result.get('errors')) + snapshot = snapshot_result.get('data').get('snapshot') + chapters = snapshot.get('chapters') + self.assertEqual(len(chapters), 2) + chapter = chapters[0] + content_blocks = chapter.get('contentBlocks') + self.assertEqual(len(content_blocks), 4) + first, second, third, fourth = content_blocks + self._test_content_block(first, self.title_visible, False) + self._test_content_block(second, self.title_hidden, True) + self._test_content_block(third, self.title_custom, False) + self._test_content_block(fourth, self.title_custom_hidden, True) + + objective_groups = snapshot['objectiveGroups'] + self.assertEqual(len(objective_groups), 2) + objective_group1, objective_group2 = objective_groups + + objective1, objective2, objective3, objective4 = objective_group1['objectives'] + self._test_objective(objective1, self.visible_objective.text, False) + self._test_objective(objective2, self.hidden_objective.text, True) + self._test_objective(objective3, self.custom_objective.text, False) + self._test_objective(objective4, self.custom_hidden_objective.text, True) + + self.assertEqual(objective_group2['hidden'], True) + + def test_apply_initial_snapshot(self): + teacher2 = User.objects.get(username='teacher2') + teacher2_client = self.get_client(user=teacher2) + third_class = SchoolClassFactory( + users=[teacher2], + name='third_class' + ) + + # make a neutral snapshot, nothing new, nothing hidden + result = teacher2_client.execute(CREATE_SNAPSHOT_MUTATION, variables={ + 'input': { + 'module': self.slug, + 'selectedClass': to_global_id('SchoolClassNode', third_class.pk), + } + }) + self.assertIsNone(result.get('errors')) + snapshot_id = result['data']['createSnapshot']['snapshot']['id'] + + result = self.client.execute(APPLY_SNAPSHOT_MUTATION, variables={ + 'input': { + 'snapshot': snapshot_id, + 'selectedClass': to_global_id('SchoolClassNode', self.skillbox_class.pk), + } + }) + self.assertIsNone(result.get('errors')) + + result = self.client.execute(MODULE_QUERY, variables={ + 'slug': self.module.slug + }) + self.assertIsNone(result.get('errors')) + module = result['data']['module'] + chapter1, chapter2 = module['chapters'] + cb1, cb2, cb3, cb4 = chapter1['contentBlocks'] + self.assertTrue(self.skillbox_class.name not in [sc['name'] for sc in cb1['hiddenFor']]) + self.assertTrue(self.skillbox_class.name not in [sc['name'] for sc in cb2['hiddenFor']]) + self.assertTrue(self.skillbox_class.name not in [sc['name'] for sc in cb3['visibleFor']]) + self.assertTrue(self.skillbox_class.name not in [sc['name'] for sc in cb4['visibleFor']]) + + +class SnapshotTestCase(SkillboxTestCase): + def setUp(self) -> None: + self.createDefault() + self.client = self.get_client() + self.slug = 'some-module' + + self.teacher2 = User.objects.get(username='teacher2') + self.module = ModuleFactory(slug=self.slug) + self.skillbox_class = SchoolClass.objects.get(name='skillbox') + self.snapshot = Snapshot.objects.create_snapshot(module=self.module, school_class=self.skillbox_class, + user=self.teacher) + Snapshot.objects.create_snapshot(module=self.module, school_class=self.skillbox_class, + user=self.teacher2) + + def test_show_only_own_snapshots(self): + result = self.client.execute(MODULE_SNAPSHOTS_QUERY, variables={ + "slug": self.slug + }) + self.assertIsNone(result.get('errors')) + snapshots = result['data']['module']['snapshots'] + self.assertEqual(len(snapshots), 1) + self.assertEqual(snapshots[0]['creator'], f'{self.teacher.first_name} {self.teacher.last_name}') + + def test_share_snapshot(self): + self.assertFalse(self.snapshot.shared) + result = self.client.execute(SHARE_SNAPSHOT_MUTATION, variables={ + 'input': { + 'snapshot': to_global_id('Snapshot', self.snapshot.id), + 'shared': True + } + }) + self.assertIsNone(result.get('errors')) + data = result['data']['shareSnapshot'] + self.assertTrue(data['success']) + self.assertTrue(data['snapshot']['shared']) + snapshot = Snapshot.objects.get(pk=self.snapshot.pk) + self.assertTrue(snapshot.shared) + + def test_dont_share_foreign_snapshot(self): + self.assertFalse(self.snapshot.shared) + teacher2_client = self.get_client(self.teacher2) + result = teacher2_client.execute(SHARE_SNAPSHOT_MUTATION, variables={ + 'input': { + 'snapshot': to_global_id('Snapshot', self.snapshot.id), + 'shared': True + } + }) + self.assertIsNotNone(result.get('errors')) diff --git a/server/core/mixins.py b/server/core/mixins.py new file mode 100644 index 00000000..f444085e --- /dev/null +++ b/server/core/mixins.py @@ -0,0 +1,20 @@ +import graphene + + +class HiddenForMixin: + hidden_for = graphene.List('users.schema.SchoolClassNode') + + @staticmethod + def resolve_hidden_for(parent, info, **kwargs): + return parent.hidden_for.all() + + +class VisibleForMixin: + visible_for = graphene.List('users.schema.SchoolClassNode') + + @staticmethod + def resolve_visible_for(parent, info, **kwargs): + return parent.visible_for.all() + +class HiddenAndVisibleForMixin(HiddenForMixin, VisibleForMixin): + pass diff --git a/server/core/settings.py b/server/core/settings.py index ed8b048c..db2e0cb9 100644 --- a/server/core/settings.py +++ b/server/core/settings.py @@ -360,7 +360,8 @@ RAVEN_DSN_JS = os.environ.get('RAVEN_DSN_JS', '') GOOGLE_TAG_MANAGER_CONTAINER_ID = os.environ.get('GOOGLE_TAG_MANAGER_CONTAINER_ID') GRAPHENE = { - 'SCHEMA': 'api.schema.schema' + 'SCHEMA': 'api.schema.schema', + 'SCHEMA_OUTPUT': 'schema.graphql' } # if DEBUG: @@ -371,8 +372,8 @@ GRAPHENE = { # http://docs.wagtail.io/en/v2.1/advanced_topics/settings.html?highlight=urls WAGTAIL_SITE_NAME = 'skillbox' -GRAPHQL_QUERIES_DIR = os.path.join(BASE_DIR, '..', 'client', 'src', 'graphql', 'gql') -GRAPHQL_MUTATIONS_DIR = os.path.join(GRAPHQL_QUERIES_DIR, 'mutations') +GRAPHQL_QUERIES_DIR = os.path.join(BASE_DIR, '..', 'client', 'src', 'graphql', 'gql', 'queries') +GRAPHQL_MUTATIONS_DIR = os.path.join(GRAPHQL_QUERIES_DIR, '../mutations') # Sendgrid Config diff --git a/server/core/tests/base_test.py b/server/core/tests/base_test.py new file mode 100644 index 00000000..5e3d1662 --- /dev/null +++ b/server/core/tests/base_test.py @@ -0,0 +1,19 @@ +from django.test import TestCase, RequestFactory +from graphene.test import Client + +from api.schema import schema +from users.models import User +from users.services import create_users + + +class SkillboxTestCase(TestCase): + def createDefault(self) -> None: + create_users() + self.teacher = User.objects.get(username='teacher') + + def get_client(self, user=None) -> Client: + request = RequestFactory().get('/') + if user is None: + user = self.teacher + request.user = user + return Client(schema=schema, context_value=request) diff --git a/server/graphql-schema.sh b/server/graphql-schema.sh new file mode 100755 index 00000000..5006b761 --- /dev/null +++ b/server/graphql-schema.sh @@ -0,0 +1,3 @@ +#!/bin/bash +python manage.py graphql_schema +sed -i 's/Node,/Node \&/g' schema.graphql diff --git a/server/objectives/migrations/0015_auto_20210511_2150.py b/server/objectives/migrations/0015_auto_20210511_2150.py new file mode 100644 index 00000000..0378a3b3 --- /dev/null +++ b/server/objectives/migrations/0015_auto_20210511_2150.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.22 on 2021-05-11 21:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0029_auto_20210511_1301'), + ('objectives', '0014_delete_objectiveprogressstatus'), + ] + + operations = [ + migrations.AlterModelOptions( + name='objective', + options={'verbose_name': 'Lernziel', 'verbose_name_plural': 'Lernziele'}, + ), + migrations.CreateModel( + name='ObjectiveSnapshot', + fields=[ + ('objective_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='objectives.Objective')), + ('hidden', models.BooleanField(default=False)), + ('snapshot', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='custom_objectives', to='books.Snapshot')), + ], + bases=('objectives.objective',), + ), + ] diff --git a/server/objectives/models.py b/server/objectives/models.py index 7b8166b8..49d4b893 100644 --- a/server/objectives/models.py +++ b/server/objectives/models.py @@ -1,6 +1,5 @@ from django.contrib.auth import get_user_model from django.db import models -from django.db.models import F from books.models import Module from core.utils import sync_visible_for, sync_hidden_for @@ -48,7 +47,8 @@ class Objective(models.Model): class Meta: verbose_name = 'Lernziel' verbose_name_plural = 'Lernziele' - ordering = [F('owner').asc(nulls_first=True), F('order').asc(nulls_last=True)] + # todo: reinstate ordering in resolver + # ordering = [F('owner').asc(nulls_first=True), F('order').asc(nulls_last=True)] text = models.CharField('text', blank=True, null=False, max_length=255) group = models.ForeignKey(ObjectiveGroup, blank=False, null=False, on_delete=models.CASCADE, @@ -64,3 +64,33 @@ class Objective(models.Model): def sync_visibility(self, school_class_template, school_class_to_sync): sync_hidden_for(self, school_class_template, school_class_to_sync) sync_visible_for(self, school_class_template, school_class_to_sync) + + def is_hidden_for_class(self, school_class): + return ( + self.owner is None and self.hidden_for.filter(id=school_class.id).exists() + ) or ( + self.owner is not None and not self.visible_for.filter(id=school_class.id).exists() + ) + + +class ObjectiveSnapshot(Objective): + hidden = models.BooleanField(default=False) + snapshot = models.ForeignKey( + 'books.Snapshot', + on_delete=models.SET_NULL, + null=True, + related_name='custom_objectives' + ) + + def to_regular_objective(self, owner, school_class): + objective = Objective.objects.create( + owner=owner, + text=self.text, + group=self.group, + order=self.order + ) + + objective.visible_for.add(school_class) + objective.save() + + return objective diff --git a/server/objectives/schema.py b/server/objectives/schema.py index 2872f8b1..e9e35046 100644 --- a/server/objectives/schema.py +++ b/server/objectives/schema.py @@ -4,39 +4,11 @@ from graphene import relay from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField +from core.mixins import HiddenAndVisibleForMixin, HiddenForMixin from objectives.models import ObjectiveGroup, Objective -class ObjectiveGroupNode(DjangoObjectType): - pk = graphene.Int() - display_title = graphene.String() - - class Meta: - model = ObjectiveGroup - filter_fields = ['title', 'module__slug'] - interfaces = (relay.Node,) - - def resolve_pk(self, *args, **kwargs): - return self.id - - def resolve_display_title(self, *args, **kwargs): - return self.get_title_display() - - def resolve_objectives(self, info, **kwargs): - 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 - return self.objectives.filter(objectives_from_publisher | objectives_from_user) - else: # student - return self.objectives.filter(objectives_from_publisher | objectives_from_teacher) - - -class ObjectiveNode(DjangoObjectType): +class ObjectiveNode(DjangoObjectType, HiddenAndVisibleForMixin): pk = graphene.Int() user_created = graphene.Boolean() mine = graphene.Boolean() @@ -56,6 +28,40 @@ class ObjectiveNode(DjangoObjectType): return self.owner is not None and self.owner.pk == info.context.user.pk +class ObjectiveGroupNode(DjangoObjectType, HiddenForMixin): + pk = graphene.Int() + display_title = graphene.String() + objectives = graphene.List(ObjectiveNode) + + class Meta: + model = ObjectiveGroup + filter_fields = ['title', 'module__slug'] + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + def resolve_display_title(self, *args, **kwargs): + return self.get_title_display() + + def resolve_objectives(self, info, **kwargs): + user = info.context.user + school_classes = user.school_classes.values_list('pk') + base_qs = self.objectives.filter(objectivesnapshot__isnull=True) + + objectives_from_publisher = Q(owner=None) + objectives_from_user = Q(owner=user) + objectives_from_teacher = Q(owner__isnull=False, visible_for__in=school_classes) + + # todo + # raise NotImplemented('not in correct order') + + if user.has_perm('users.can_manage_school_class_content'): # teacher + return base_qs.filter(objectives_from_publisher | objectives_from_user) + else: # student + return base_qs.filter(objectives_from_publisher | objectives_from_teacher) + + class ObjectivesQuery(object): objective_group = relay.Node.Field(ObjectiveGroupNode) objective_groups = DjangoFilterConnectionField(ObjectiveGroupNode) diff --git a/server/objectives/tests/test_objective_order.py b/server/objectives/tests/test_objective_order.py index f17dfcde..08396988 100644 --- a/server/objectives/tests/test_objective_order.py +++ b/server/objectives/tests/test_objective_order.py @@ -39,12 +39,8 @@ class ObjectiveOrderTestCase(TestCase): query ObjectiveGroupQuery($id: ID!) { objectiveGroup(id: $id) { objectives { - edges { - node { - id - text - } - } + id + text } } } @@ -55,9 +51,9 @@ class ObjectiveOrderTestCase(TestCase): }) self.assertIsNone(result.get('errors')) - objective_nodes = result.get('data').get('objectiveGroup').get('objectives').get('edges') + objective_nodes = result.get('data').get('objectiveGroup').get('objectives') - objective1, objective2, objective3, objective4 = [node['node'] for node in objective_nodes] + objective1, objective2, objective3, objective4 = [node for node in objective_nodes] self.assertEqual(objective1.get('text'), 'first') self.assertEqual(objective2.get('text'), 'second') diff --git a/schema.graphql b/server/schema.graphql similarity index 92% rename from schema.graphql rename to server/schema.graphql index bff17726..efd42593 100644 --- a/schema.graphql +++ b/server/schema.graphql @@ -151,6 +151,18 @@ type AnswerNodeEdge { cursor: String! } +input ApplySnapshotInput { + snapshot: ID! + selectedClass: ID! + clientMutationId: String +} + +type ApplySnapshotPayload { + success: Boolean + module: ModuleNode + clientMutationId: String +} + input AssignmentInput { id: ID! answer: String! @@ -208,18 +220,19 @@ type ChapterBookmarkNodeEdge { } interface ChapterInterface { - contentBlocks(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ContentBlockNodeConnection + description: String + title: String } type ChapterNode implements Node & ChapterInterface { - title: String! + title: String slug: String! - description: String! - titleHiddenFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! - descriptionHiddenFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! + description: String + titleHiddenFor: [SchoolClassNode] + descriptionHiddenFor: [SchoolClassNode] id: ID! - contentBlocks(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ContentBlockNodeConnection bookmark: ChapterBookmarkNode + contentBlocks: [ContentBlockNode] } type ChapterNodeConnection { @@ -266,14 +279,20 @@ input ContentBlockInput { visibility: [UserGroupBlockVisibility] } -type ContentBlockNode implements Node { - title: String! +interface ContentBlockInterface { + title: String + contents: GenericStreamFieldType + type: String +} + +type ContentBlockNode implements Node & ContentBlockInterface { + title: String slug: String! - hiddenFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! - visibleFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! + hiddenFor: [SchoolClassNode] + visibleFor: [SchoolClassNode] userCreated: Boolean! contents: GenericStreamFieldType - type: ContentBlockType! + type: String id: ID! mine: Boolean bookmarks: [ContentBlockBookmarkNode] @@ -289,14 +308,6 @@ type ContentBlockNodeEdge { cursor: String! } -enum ContentBlockType { - NORMAL - BASE_COMMUNICATION - TASK - BASE_SOCIETY - BASE_INTERDISCIPLINARY -} - input ContentElementInput { id: String type: InputTypes! @@ -334,7 +345,7 @@ type CreateSchoolClassPayload { } input CreateSnapshotInput { - module: ID! + module: String! selectedClass: ID! clientMutationId: String } @@ -407,6 +418,8 @@ type CustomMutation { updateChapterVisibility(input: UpdateChapterVisibilityInput!): UpdateChapterVisibilityPayload syncModuleVisibility(input: SyncModuleVisibilityInput!): SyncModuleVisibilityPayload createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload + applySnapshot(input: ApplySnapshotInput!): ApplySnapshotPayload + shareSnapshot(input: ShareSnapshotInput!): ShareSnapshotPayload _debug: DjangoDebug } @@ -425,6 +438,7 @@ type CustomQuery { module(slug: String, id: ID): ModuleNode chapter(id: ID!): ChapterNode contentBlock(id: ID!): ContentBlockNode + snapshot(id: ID!): SnapshotNode topics(before: String, after: String, first: Int, last: Int): TopicConnection modules(before: String, after: String, first: Int, last: Int): ModuleConnection chapters(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection @@ -623,28 +637,32 @@ type ModuleEdge { } interface ModuleInterface { + id: ID! pk: Int + heroImage: String! + topic: TopicNode } -type ModuleNode implements Node & ModuleInterface { +type ModuleNode implements ModuleInterface { title: String! slug: String! metaTitle: String! - heroImage: String + heroImage: String! teaser: String! intro: String! assignments(offset: Int, before: String, after: String, first: Int, last: Int): AssignmentNodeConnection! - objectiveGroups(offset: Int, before: String, after: String, first: Int, last: Int, title: String, module_Slug: String): ObjectiveGroupNodeConnection! + objectiveGroups: [ObjectiveGroupNode] id: ID! pk: Int - chapters(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection topic: TopicNode + chapters: [ChapterNode] solutionsEnabled: Boolean bookmark: ModuleBookmarkNode mySubmissions(offset: Int, before: String, after: String, first: Int, last: Int): StudentSubmissionNodeConnection myAnswers(offset: Int, before: String, after: String, first: Int, last: Int): AnswerNodeConnection myContentBookmarks(offset: Int, before: String, after: String, first: Int, last: Int): ContentBlockBookmarkNodeConnection myChapterBookmarks(offset: Int, before: String, after: String, first: Int, last: Int): ChapterBookmarkNodeConnection + snapshots: [SnapshotNode] } type ModuleNodeConnection { @@ -686,8 +704,8 @@ type ObjectiveGroupNode implements Node { id: ID! title: ObjectiveGroupTitle module: ModuleNode! - hiddenFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! - objectives(offset: Int, before: String, after: String, first: Int, last: Int, text: String): ObjectiveNodeConnection! + hiddenFor: [SchoolClassNode] + objectives: [ObjectiveNode] pk: Int displayTitle: String } @@ -713,9 +731,10 @@ type ObjectiveNode implements Node { text: String! group: ObjectiveGroupNode! owner: UserNode - hiddenFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! - visibleFor(offset: Int, before: String, after: String, first: Int, last: Int, name: String): SchoolClassNodeConnection! + hiddenFor: [SchoolClassNode] + visibleFor: [SchoolClassNode] order: Int + hiddenForSnapshots(offset: Int, before: String, after: String, first: Int, last: Int): SnapshotNodeConnection! pk: Int userCreated: Boolean mine: Boolean @@ -865,33 +884,81 @@ type SchoolClassNodeEdge { cursor: String! } +input ShareSnapshotInput { + snapshot: ID! + shared: Boolean! + clientMutationId: String +} + +type ShareSnapshotPayload { + success: Boolean! + snapshot: SnapshotNode + clientMutationId: String +} + +type SnapshotChangesNode { + hiddenObjectives: Int! + newObjectives: Int! + hiddenContentBlocks: Int! + newContentBlocks: Int! +} + type SnapshotChapterNode implements Node & ChapterInterface { id: ID! - chapter: ChapterNode! - snapshot: SnapshotNode! - titleHidden: Boolean - descriptionHidden: Boolean - contentBlocks(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ContentBlockNodeConnection - title: String description: String + title: String + contentBlocks: [SnapshotContentBlockNode] + descriptionHidden: Boolean + titleHidden: Boolean } -type SnapshotChapterNodeConnection { - pageInfo: PageInfo! - edges: [SnapshotChapterNodeEdge]! -} - -type SnapshotChapterNodeEdge { - node: SnapshotChapterNode - cursor: String! +type SnapshotContentBlockNode implements Node & ContentBlockInterface { + id: ID! + title: String + contents: GenericStreamFieldType + type: String + hidden: Boolean } type SnapshotNode implements Node { id: ID! module: ModuleNode! - chapters(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): SnapshotChapterNodeConnection + chapters: [SnapshotChapterNode] hiddenContentBlocks(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ContentBlockNodeConnection! - chapterSnapshots(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): SnapshotChapterNodeConnection! + created: DateTime! + creator: String! + shared: Boolean! + objectiveGroups: [SnapshotObjectiveGroupNode] + hiddenObjectives(offset: Int, before: String, after: String, first: Int, last: Int, text: String): ObjectiveNodeConnection! + title: String + metaTitle: String + heroImage: String + changes: SnapshotChangesNode + mine: Boolean +} + +type SnapshotNodeConnection { + pageInfo: PageInfo! + edges: [SnapshotNodeEdge]! +} + +type SnapshotNodeEdge { + node: SnapshotNode + cursor: String! +} + +type SnapshotObjectiveGroupNode implements Node { + id: ID! + title: String! + hidden: Boolean! + displayTitle: String! + objectives: [SnapshotObjectiveNode]! +} + +type SnapshotObjectiveNode implements Node { + id: ID! + hidden: Boolean! + text: String! } input SpellCheckInput {