diff --git a/.gitignore b/.gitignore index d52706e7..267a467b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,3 @@ server/media/ .coverage -# Cypress screenshots -client/cypress/screenshots diff --git a/Pipfile b/Pipfile index 44f2c4ea..d56dd984 100644 --- a/Pipfile +++ b/Pipfile @@ -35,7 +35,8 @@ django-libsass = "*" bleach = "*" newrelic = "*" sentry-sdk = "==0.7.2" -"django-sendgrid-v5" = "*" +"django-sendgrid-v5" = "==0.8.0" +python-http-client = "==3.2.1" coverage = "*" graphql-relay = "*" ipython = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 83142970..7cb566c6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "85020e50720b4f71b864719a7c4fae8a0cb4e80352d3bc7eb1e4726bf2405f6d" + "sha256": "725dd26226fe58559f67b30a09dec72086dc4681a69f2f2672f5bd87c9ac74b8" }, "pipfile-spec": 6, "requires": { @@ -23,6 +23,14 @@ ], "version": "==7.0.0" }, + "appnope": { + "hashes": [ + "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", + "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.0" + }, "backcall": { "hashes": [ "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", @@ -48,18 +56,18 @@ }, "boto3": { "hashes": [ - "sha256:778e66711d7de9352b6330692ae44c0ba012559dabb4c39d7416b0135fb335a6", - "sha256:c8371f1f9c52f64cef1f14a773629fc8434ecca6195b2ef2a429a7bbbf8ecf23" + "sha256:08d949fede71c14db8b9b638edaa13831d79daed84e2da27750629fd606bdb57", + "sha256:4d7c2cc266917cd0ff7e5e039158de80991e21696a2e8bf85201de2d06d7ceea" ], "index": "pypi", - "version": "==1.9.228" + "version": "==1.10.9" }, "botocore": { "hashes": [ - "sha256:7c391c46cf1f8d6c04758f84b51337a64136077e4a160eced87551fdcc051669", - "sha256:c9148df92ba21a90ea32f2c7f185c31b1c8b8e48417d0ba8cad02b9b3336c09a" + "sha256:61ad58e737e7a5d61308599606cd5a435cc3b97eb7fa693f99663c91bf1706d1", + "sha256:8cb038c110822681925a1f5d9005dc2bbc4259fff89d4abfaaf803a3489d0ee3" ], - "version": "==1.12.228" + "version": "==1.13.9" }, "certifi": { "hashes": [ @@ -122,10 +130,10 @@ }, "decorator": { "hashes": [ - "sha256:86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", - "sha256:f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6" + "sha256:54c38050039232e1db4ad7375cfce6748d7b41c29e95a081c8a6d2c30364a2ce", + "sha256:5d19b92a3c8f7f101c8dd86afd86b0f061a8ce4540ab8cd401fa2542756bce6d" ], - "version": "==4.4.0" + "version": "==4.4.1" }, "dj-database-url": { "hashes": [ @@ -242,9 +250,9 @@ }, "draftjs-exporter": { "hashes": [ - "sha256:503f222c81de9a0619158d8f88b638f9069af8de233dc020faa782c7a3b22100" + "sha256:5839cbc29d7bce2fb99837a404ca40c3a07313f2a20e2700de7ad6aa9a9a18fb" ], - "version": "==2.1.6" + "version": "==2.1.7" }, "factory-boy": { "hashes": [ @@ -256,16 +264,16 @@ }, "faker": { "hashes": [ - "sha256:1d3f700e8dfcefd6e657118d71405d53e86974448aba78884f119bbd84c0cddf", - "sha256:d5366e120191c5610fceeebfe1c298dc46da0277096f639c6dd7e2eaee0fa547" + "sha256:5902379d8df308a204fc11c4f621590ee83975805a6c7b2228203b9defa45250", + "sha256:5e8c755c619f332d5ec28b7586389665f136bcf528e165eb925e87c06a63eda7" ], - "version": "==2.0.1" + "version": "==2.0.3" }, "future": { "hashes": [ - "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8" + "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], - "version": "==0.17.1" + "version": "==0.18.2" }, "graphene": { "hashes": [ @@ -322,11 +330,11 @@ }, "ipython": { "hashes": [ - "sha256:c4ab005921641e40a68e405e286e7a1fcc464497e14d81b6914b4fd95e5dee9b", - "sha256:dd76831f065f17bddd7eaa5c781f5ea32de5ef217592cf019e34043b56895aa1" + "sha256:dfd303b270b7b5232b3d08bd30ec6fd685d8a58cabd54055e3d69d8f029f7280", + "sha256:ed7ebe1cba899c1c3ccad6f7f1c2d2369464cc77dba8eebc65e2043e19cda995" ], "index": "pypi", - "version": "==7.8.0" + "version": "==7.9.0" }, "ipython-genutils": { "hashes": [ @@ -351,30 +359,31 @@ }, "libsass": { "hashes": [ - "sha256:2457723fe04f4e690105f758aa125e809afc840812965095fa3f4edccd6275ef", - "sha256:2974772e7984b27a51a6d91ebc140183ddd574a9663bd02154ddfb75f13a3eed", - "sha256:2d067ce4f393fee2ce52bb810a364deac5454dfdb7945d31d1f4265f21f03ab8", - "sha256:57d0b99c4e3512233a44141f1bf852570d359724a606dfc4550eccd0f570460d", - "sha256:5b604e4f5befdecc76240c2ba243fd7e23c642ffc2dd86cbfd094a44ead6b08d", - "sha256:5dd647ffa1319a2a18572f41fee3bb561d7f77d8d4784074a00b2eb22c61a859", - "sha256:78f3f14e47612be4fa4b161278f2a3e880a19b6a3367f749e9ae240434b7e7f5", - "sha256:8d423e4b4c0e219488104b4ec4267688dbd816f3ae806beb4201918eff059b2d", - "sha256:a20473b0427d82e37fa68f0b3a8d219f0bb5ca6d3f7d93b0f5342219285e7064", - "sha256:c1f76c2a0993914f3c3088e9b6c7031f22e879c5d27a060cdc8c5aa1318eb9b6", - "sha256:c99fbc950f1955e8b6370aafdb9d84d324e4984a2e00a2b47f04dbcc3706a9d1", - "sha256:cb50f385117535f7671ac7ff3144c1ef0b8e088778c58d269ce6f31b87bfad72", - "sha256:f0f033a8154be60e1a2e1f79ee849ea69a1d62e5d476a78f69e4c7d8fd7c20e1", - "sha256:f2572b73b2e13e74b28388ae86c4fabb853ddbfc12279b4444243bd614710ce8", - "sha256:f8790db67e00c5bc7be1bdd81ed477563a4b191e839193ecc0c2c5ec679ec481" + "sha256:003a65b4facb4c5dbace53fb0f70f61c5aae056a04b4d112a198c3c9674b31f2", + "sha256:0fd8b4337b3b101c6e6afda9112cc0dc4bacb9133b59d75d65968c7317aa3272", + "sha256:338e9ae066bf1fde874e335324d5355c52d2081d978b4f74fc59536564b35b08", + "sha256:4dcfd561fb100250b89496e1362b96f2cc804f689a59731eb0f94f9a9e144f4a", + "sha256:50778d4be269a021ba2bf42b5b8f6ff3704ab96a82175a052680bddf3ba7cc9f", + "sha256:6a51393d75f6e3c812785b0fa0b7d67c54258c28011921f204643b55f7355ec0", + "sha256:74acd9adf506142699dfa292f0e569fdccbd9e7cf619e8226f7117de73566e32", + "sha256:81a013a4c2a614927fd1ef7a386eddabbba695cbb02defe8f31cf495106e974c", + "sha256:845a9573b25c141164972d498855f4ad29367c09e6d76fad12955ad0e1c83013", + "sha256:8b5b6d1a7c4ea1d954e0982b04474cc076286493f6af2d0a13c2e950fbe0be95", + "sha256:9b59afa0d755089c4165516400a39a289b796b5612eeef5736ab7a1ebf96a67c", + "sha256:a7e685466448c9b1bf98243339793978f654a1151eb5c975f09b83c7a226f4c1", + "sha256:c93df526eeef90b1ea4799c1d33b6cd5aea3e9f4633738fb95c1287c13e6b404", + "sha256:e318f06f06847ff49b1f8d086ac9ebce1e63404f7ea329adab92f4f16ba0e00e", + "sha256:fc5f8336750f76f1bfae82f7e9e89ae71438d26fc4597e3ab4c05ca8fcd41d8a", + "sha256:fcb7ab4dc81889e5fc99cafbc2017bc76996f9992fc6b175f7a80edac61d71df" ], - "version": "==0.19.2" + "version": "==0.19.4" }, "newrelic": { "hashes": [ - "sha256:9d6d3bf2e125410d485fd91279e1b0f50e8f0f5887284b345aed4ec81db33c0a" + "sha256:da9adab674d9fe7aa8fbabb6691b33fb7be94a32b6a80548ec7018be9df8ef65" ], "index": "pypi", - "version": "==5.0.2.126" + "version": "==5.2.1.129" }, "parso": { "hashes": [ @@ -438,11 +447,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463780", - "sha256:2519ad1d8038fd5fc8e770362237ad0364d16a7650fb5724af6997ed5515e3c1", - "sha256:977c6583ae813a37dc1c2e1b715892461fcbdaa57f6fc62f33a528c4886c8f55" + "sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4", + "sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31", + "sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db" ], - "version": "==2.0.9" + "version": "==2.0.10" }, "psycopg2": { "hashes": [ @@ -517,14 +526,15 @@ "sha256:c2776054245db376ea26c859b80e9280b1a470b96ed998d60d35951f89bbbe79", "sha256:e455ae0dfd5819ac483f7fecf08ab8693048d9dc47a0a6fe0d4aebf86d9d1d17" ], + "index": "pypi", "version": "==3.2.1" }, "pytz": { "hashes": [ - "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", - "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], - "version": "==2019.2" + "version": "==2019.3" }, "raven": { "hashes": [ @@ -611,17 +621,17 @@ }, "text-unidecode": { "hashes": [ - "sha256:5a1375bb2ba7968740508ae38d92e1f889a0832913cb1c447d5e2046061a396d", - "sha256:801e38bd550b943563660a91de8d4b6fa5df60a542be9093f7abf819f86050cc" + "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", + "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93" ], - "version": "==1.2" + "version": "==1.3" }, "traitlets": { "hashes": [ - "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", - "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9" + "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44", + "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7" ], - "version": "==4.3.2" + "version": "==4.3.3" }, "typing": { "hashes": [ @@ -640,11 +650,11 @@ }, "urllib3": { "hashes": [ - "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", - "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" + "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", + "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" ], "markers": "python_version >= '3.4'", - "version": "==1.25.3" + "version": "==1.25.6" }, "wagtail": { "hashes": [ @@ -689,13 +699,21 @@ } }, "develop": { + "appnope": { + "hashes": [ + "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", + "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.0" + }, "awscli": { "hashes": [ - "sha256:5c36a818130c8393b778e5b98b9bf38985522b62a684782512a2d57340697582", - "sha256:a022dfb7e6cc9ee8540c382b9bdc4637070dbbe2b09f3a74ad4f30719414c0b7" + "sha256:4a75cb44dc3dab14bc9bdb0d2731c37a5026bf0afa4acb620fec72c74e62915a", + "sha256:90b0b3e91a900e4569bc47f29769522337c46ff50e35f9e4a41830fdb425f000" ], "index": "pypi", - "version": "==1.16.238" + "version": "==1.16.273" }, "backcall": { "hashes": [ @@ -706,17 +724,18 @@ }, "botocore": { "hashes": [ - "sha256:7c391c46cf1f8d6c04758f84b51337a64136077e4a160eced87551fdcc051669", - "sha256:c9148df92ba21a90ea32f2c7f185c31b1c8b8e48417d0ba8cad02b9b3336c09a" + "sha256:61ad58e737e7a5d61308599606cd5a435cc3b97eb7fa693f99663c91bf1706d1", + "sha256:8cb038c110822681925a1f5d9005dc2bbc4259fff89d4abfaaf803a3489d0ee3" ], - "version": "==1.12.228" + "version": "==1.13.9" }, "colorama": { "hashes": [ - "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", - "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1" + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" ], - "version": "==0.3.9" + "markers": "python_version != '2.6' and python_version != '3.3'", + "version": "==0.4.1" }, "coverage": { "hashes": [ @@ -758,10 +777,10 @@ }, "decorator": { "hashes": [ - "sha256:86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", - "sha256:f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6" + "sha256:54c38050039232e1db4ad7375cfce6748d7b41c29e95a081c8a6d2c30364a2ce", + "sha256:5d19b92a3c8f7f101c8dd86afd86b0f061a8ce4540ab8cd401fa2542756bce6d" ], - "version": "==4.4.0" + "version": "==4.4.1" }, "docutils": { "hashes": [ @@ -780,11 +799,11 @@ }, "ipython": { "hashes": [ - "sha256:c4ab005921641e40a68e405e286e7a1fcc464497e14d81b6914b4fd95e5dee9b", - "sha256:dd76831f065f17bddd7eaa5c781f5ea32de5ef217592cf019e34043b56895aa1" + "sha256:dfd303b270b7b5232b3d08bd30ec6fd685d8a58cabd54055e3d69d8f029f7280", + "sha256:ed7ebe1cba899c1c3ccad6f7f1c2d2369464cc77dba8eebc65e2043e19cda995" ], "index": "pypi", - "version": "==7.8.0" + "version": "==7.9.0" }, "ipython-genutils": { "hashes": [ @@ -831,11 +850,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463780", - "sha256:2519ad1d8038fd5fc8e770362237ad0364d16a7650fb5724af6997ed5515e3c1", - "sha256:977c6583ae813a37dc1c2e1b715892461fcbdaa57f6fc62f33a528c4886c8f55" + "sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4", + "sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31", + "sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db" ], - "version": "==2.0.9" + "version": "==2.0.10" }, "ptyprocess": { "hashes": [ @@ -882,7 +901,7 @@ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" ], - "markers": "python_version != '2.6'", + "markers": "python_version != '2.6' and python_version != '3.3'", "version": "==5.1.2" }, "rsa": { @@ -908,18 +927,18 @@ }, "traitlets": { "hashes": [ - "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", - "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9" + "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44", + "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7" ], - "version": "==4.3.2" + "version": "==4.3.3" }, "urllib3": { "hashes": [ - "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", - "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" + "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", + "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" ], "markers": "python_version >= '3.4'", - "version": "==1.25.3" + "version": "==1.25.6" }, "wcwidth": { "hashes": [ diff --git a/README.md b/README.md index be120ae6..47fd4009 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ docker run --name skillboxdb -d -p 5432:5432 -e POSTGRES_PASSWORD=skillbox -e PO #### Commands -* Create a new teacher demo account on prod +##### Create a new teacher demo account on prod ``` heroku login # if not already logged in heroku run --remote heroku python server/manage.py create_teacher @@ -49,6 +49,19 @@ Then you can just run in terminal: create_teacher ``` +##### Import a CSV file + +To import a CSV file locally, run: +``` +python manage.py +``` + +To import a CSV file on prod, first upload the CSV file to some public S3 bucket (or make it publicly available some other way) + +``` +heroku login # if not already logged in +heroku run --remote heroku python server/manage.py import_users --s3 +``` ### Client diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml index c0e87a7a..6ea5f116 100644 --- a/bitbucket-pipelines.yml +++ b/bitbucket-pipelines.yml @@ -30,6 +30,9 @@ aliases: caches: - pip - node + artifacts: + - client/cypress/**/*.png + - client/cypress/**/*.mp4 services: - postgres script: diff --git a/client/.gitignore b/client/.gitignore index de748e1c..b5235d68 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -4,6 +4,7 @@ dist/ npm-debug.log* yarn-debug.log* yarn-error.log* +cypress/videos # Editor directories and files .idea @@ -12,3 +13,7 @@ yarn-error.log* *.ntvs* *.njsproj *.sln + +# Cypress +cypress/screenshots +cypress/videos diff --git a/client/cypress.json b/client/cypress.json index a7c41e00..66619dd5 100644 --- a/client/cypress.json +++ b/client/cypress.json @@ -1,4 +1,4 @@ { "baseUrl": "http://localhost:8000", - "video": false + "videoUploadOnPasses": false } diff --git a/client/cypress/integration/login-page-spec.js b/client/cypress/integration/login-page-spec.js deleted file mode 100644 index 7435078d..00000000 --- a/client/cypress/integration/login-page-spec.js +++ /dev/null @@ -1,94 +0,0 @@ -describe('The Login Page', () => { - it('login and redirect to main page', () => { - const username = 'test'; - const password = 'test'; - - cy.visit('/'); - cy.login(username, password, true); - cy.get('body').contains('Neues Wissen erwerben'); - }); - - it('user sees error message if username is omitted', () => { - const username = ''; - const password = 'test'; - - cy.visit('/'); - cy.login(username, password); - cy.get('[data-cy=email-local-errors]').contains('ist ein Pflichtfeld'); - }); - - it('user sees error message if password is omitted', () => { - const username = 'test'; - const password = ''; - - cy.visit('/'); - cy.login(username, password); - cy.get('[data-cy=password-local-errors]').contains('ist ein Pflichtfeld'); - }); - - it('user sees error message if credentials are invalid', () => { - const username = 'test'; - const password = '12345'; - - cy.visit('/'); - cy.login(username, password); - cy.get('[data-cy=login-error]').contains('Die E-Mail oder das Passwort ist falsch. Bitte versuchen Sie nochmals.'); - }); - - it('redirect after login', () => { - const username = 'test'; - const password = 'test'; - - cy.visit('/book/topic/berufliche-grundbildung'); - cy.login(username, password); - cy.get('body').contains('Berufliche Grundbildung'); - }); - // it('logs in programmatically without using the UI', () => { - // cy.visit('/accounts/login/'); // have to get a csrf token by getting the base page first - // - // const username = 'test'; - // const password = 'test'; - // - // cy.getCookie('csrftoken').then(token => { - // const options = { - // url: '/accounts/login/', - // method: 'POST', - // headers: { - // 'X-CSRFToken': token.value, - // 'content-type': 'multipart/form-data' - // }, - // from: true, - // body: { - // username: username, - // password: password, - // // csrfmiddlewaretoken: token.value - // } - // }; - // - // cy.request(options); - // cy.getCookie('sessionid').should('exist'); - // cy.visit('/'); - // - // cy.get('.start-page__title').should('contain', 'skillbox') - // - // - // // cy.visit('/'); - // // cy.getCookie('csrftoken') - // // .then((csrftoken) => { - // // const response = cy.request({ - // // method: 'POST', - // // url: '/login/', - // // form: true, - // // body: { - // // identification: username, - // // password: password, - // // csrfmiddlewaretoken: csrftoken.value - // // } - // // }); - // // }); - // - // }); - - - // }) -}) diff --git a/client/cypress/integration/login-page.spec.js b/client/cypress/integration/login-page.spec.js new file mode 100644 index 00000000..9e5a2e44 --- /dev/null +++ b/client/cypress/integration/login-page.spec.js @@ -0,0 +1,47 @@ +describe('The Login Page', () => { + it('login and redirect to main page', () => { + const username = 'test'; + const password = 'test'; + + cy.visit('/'); + cy.login(username, password, true); + cy.get('body').contains('Neues Wissen erwerben'); + }); + + it('user sees error message if username is omitted', () => { + const username = ''; + const password = 'test'; + + cy.visit('/'); + cy.login(username, password); + cy.get('[data-cy=email-local-errors]').contains('E-Mail ist ein Pflichtfeld'); + }); + + it('user sees error message if password is omitted', () => { + const username = 'test'; + const password = ''; + + cy.visit('/'); + cy.login(username, password); + cy.get('[data-cy=password-local-errors]').contains('Passwort ist ein Pflichtfeld'); + }); + + it('user sees error message if credentials are invalid', () => { + const username = 'test'; + const password = '12345'; + + cy.visit('/'); + cy.login(username, password); + cy.get('[data-cy=login-error]').contains('Die E-Mail oder das Passwort ist falsch. Bitte versuchen Sie nochmals.'); + }); + + it('redirect after login', () => { + const username = 'test'; + const password = 'test'; + + cy.visit('/book/topic/berufliche-grundbildung'); + cy.login(username, password); + cy.get('body').contains('Berufliche Grundbildung'); + }); + +}) diff --git a/client/cypress/integration/registration-page.spec.js b/client/cypress/integration/registration-page.spec.js new file mode 100644 index 00000000..d01d40de --- /dev/null +++ b/client/cypress/integration/registration-page.spec.js @@ -0,0 +1,77 @@ +describe('The Regstration Page', () => { + // works locally, but not in pipelines. + // it('register user', () => { + + // let timestamp = Math.round((new Date()).getTime() / 1000); + + // const firstname = 'pesche'; + // const lastname = 'peschemann'; + // const email = `skillboxtest${timestamp}@iterativ.ch`; + // const licenseKey = 'c1fa2e2a-2e27-480d-8469-2e88414c4ad8'; + + // cy.visit('/register'); + // cy.register(firstname, lastname, email, licenseKey); + // cy.get('.reset__heading').contains('Schauen Sie in Ihr Postfach'); + // }); + + it('user sees error message if firstname is omitted', () => { + let timestamp = Math.round((new Date()).getTime() / 1000); + const firstname = ''; + const lastname = 'peschemann'; + const email = `skillboxtest${timestamp}@iterativ.ch`; + const licenseKey = 'c1fa2e2a-2e27-480d-8469-2e88414c4ad8'; + + cy.visit('/register'); + cy.register(firstname, lastname, email, licenseKey); + cy.get('[data-cy="firstname-local-errors"]').contains('Vorname ist ein Pflichtfeld.'); + }); + + it('user sees error message if lastname is omitted', () => { + let timestamp = Math.round((new Date()).getTime() / 1000); + const firstname = 'pesche'; + const lastname = ''; + const email = `skillboxtest${timestamp}@iterativ.ch`; + const licenseKey = 'c1fa2e2a-2e27-480d-8469-2e88414c4ad8'; + + cy.visit('/register'); + cy.register(firstname, lastname, email, licenseKey); + cy.get('[data-cy="lastname-local-errors"]').contains('Nachname ist ein Pflichtfeld.'); + }); + + it('user sees error message if email is omitted', () => { + let timestamp = Math.round((new Date()).getTime() / 1000); + const firstname = 'pesche'; + const lastname = 'peschemann'; + const email = ``; + const licenseKey = 'c1fa2e2a-2e27-480d-8469-2e88414c4ad8'; + + cy.visit('/register'); + cy.register(firstname, lastname, email, licenseKey); + cy.get('[data-cy="email-local-errors"]').contains('E-Mail ist ein Pflichtfeld.'); + }); + + it('user sees error message if license is omitted', () => { + let timestamp = Math.round((new Date()).getTime() / 1000); + const firstname = 'pesche'; + const lastname = 'peschemann'; + const email = `skillboxtest${timestamp}@iterativ.ch`; + const licenseKey = ''; + + cy.visit('/register'); + cy.register(firstname, lastname, email, licenseKey); + cy.get('[data-cy="licenseKey-local-errors"]').contains('Lizenz ist ein Pflichtfeld.'); + }); + + it('user sees error message if license key is wrong', () => { + let timestamp = Math.round((new Date()).getTime() / 1000); + const firstname = 'pesche'; + const lastname = 'peschemann'; + const email = `skillboxtest${timestamp}@iterativ.ch`; + const licenseKey = 'asdsafsadfsadfasdf'; + + cy.visit('/register'); + cy.register(firstname, lastname, email, licenseKey); + cy.get('[data-cy="licenseKey-remote-errors"]').contains('Die angegebenen Lizenz ist unglültig'); + }); + +}) diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js index faaebc76..f76b462c 100644 --- a/client/cypress/support/commands.js +++ b/client/cypress/support/commands.js @@ -82,3 +82,23 @@ Cypress.Commands.add('changePassword', (oldPassword, newPassword) => { } cy.get('[data-cy=change-password-button]').click(); }); + +Cypress.Commands.add('register', (firstname, lastname, email, licenseKey) => { + if (firstname != '') { + cy.get('[data-cy=firstname-input]').type(firstname); + } + + if (lastname != '') { + cy.get('[data-cy=lastname-input]').type(lastname); + } + + if (email != '') { + cy.get('[data-cy=email-input]').type(email); + } + + if (licenseKey != '') { + cy.get('[data-cy=licenseKey-input]').type(licenseKey); + } + + cy.get('[data-cy=register-button]').click(); +}); diff --git a/client/package-lock.json b/client/package-lock.json index cdca0119..20e10dce 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8985,8 +8985,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -9004,13 +9003,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -9023,18 +9020,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -9137,8 +9131,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -9148,7 +9141,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -9161,20 +9153,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.2.4", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -9191,7 +9180,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -9264,8 +9252,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -9275,7 +9262,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -9351,8 +9337,7 @@ }, "safe-buffer": { "version": "5.1.1", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -9382,7 +9367,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -9400,7 +9384,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -9439,13 +9422,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.2", - "bundled": true, - "optional": true + "bundled": true } } }, @@ -11449,8 +11430,7 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", @@ -11475,8 +11455,7 @@ "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", diff --git a/client/src/App.vue b/client/src/App.vue index 479743b2..bdf12aba 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -23,6 +23,8 @@ import NewProjectEntryWizard from '@/components/portfolio/NewProjectEntryWizard'; import EditProjectEntryWizard from '@/components/portfolio/EditProjectEntryWizard'; import NewObjectiveWizard from '@/components/objective-groups/NewObjectiveWizard'; + import NewNoteWizard from '@/components/notes/NewNoteWizard'; + import EditNoteWizard from '@/components/notes/EditNoteWizard'; import FullscreenImage from '@/components/FullscreenImage'; import FullscreenInfographic from '@/components/FullscreenInfographic'; import FullscreenVideo from '@/components/FullscreenVideo'; @@ -50,6 +52,8 @@ NewProjectEntryWizard, EditProjectEntryWizard, NewObjectiveWizard, + NewNoteWizard, + EditNoteWizard, FullscreenImage, FullscreenInfographic, FullscreenVideo diff --git a/client/src/components/ContentBlock.vue b/client/src/components/ContentBlock.vue index 335fe510..0cafb6bd 100644 --- a/client/src/components/ContentBlock.vue +++ b/client/src/components/ContentBlock.vue @@ -18,11 +18,15 @@

{{instrumentLabel}}

{{contentBlock.title}}

- - + + @@ -33,22 +37,6 @@ + + diff --git a/client/src/components/content-blocks/ContentListBlock.vue b/client/src/components/content-blocks/ContentListBlock.vue index 60cd03f9..c1af491e 100644 --- a/client/src/components/content-blocks/ContentListBlock.vue +++ b/client/src/components/content-blocks/ContentListBlock.vue @@ -6,7 +6,10 @@ :key="contentBlock.id" v-for="(contentBlock, index) in contentBlocks">

{{alphaIndex(index)}})

- + @@ -38,7 +41,10 @@ return this.contents.map(contentBlock => { return Object.assign({}, contentBlock, { contents: [...contentBlock.value], - indent: true + indent: true, + bookmarks: this.parent.bookmarks, + notes: this.parent.notes, + root: this.parent.id }) }); } @@ -57,7 +63,7 @@ &__item { list-style: none; position: relative; - padding: 0 2*15px; + padding: 0 0 0 2*15px; } &__numbering { diff --git a/client/src/components/icons/AddNoteIcon.vue b/client/src/components/icons/AddNoteIcon.vue new file mode 100644 index 00000000..dd3b20f5 --- /dev/null +++ b/client/src/components/icons/AddNoteIcon.vue @@ -0,0 +1,16 @@ + + + diff --git a/client/src/components/icons/BookmarkIcon.vue b/client/src/components/icons/BookmarkIcon.vue new file mode 100644 index 00000000..991c84d0 --- /dev/null +++ b/client/src/components/icons/BookmarkIcon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/client/src/components/icons/NoteIcon.vue b/client/src/components/icons/NoteIcon.vue new file mode 100644 index 00000000..dd139bcf --- /dev/null +++ b/client/src/components/icons/NoteIcon.vue @@ -0,0 +1,17 @@ + + + diff --git a/client/src/components/notes/BookmarkActions.vue b/client/src/components/notes/BookmarkActions.vue new file mode 100644 index 00000000..39d04bf7 --- /dev/null +++ b/client/src/components/notes/BookmarkActions.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/client/src/components/notes/EditNoteWizard.vue b/client/src/components/notes/EditNoteWizard.vue new file mode 100644 index 00000000..ce94c1c6 --- /dev/null +++ b/client/src/components/notes/EditNoteWizard.vue @@ -0,0 +1,46 @@ + + + diff --git a/client/src/components/notes/NewNoteWizard.vue b/client/src/components/notes/NewNoteWizard.vue new file mode 100644 index 00000000..eca6123b --- /dev/null +++ b/client/src/components/notes/NewNoteWizard.vue @@ -0,0 +1,93 @@ + + + diff --git a/client/src/components/notes/NoteForm.vue b/client/src/components/notes/NoteForm.vue new file mode 100644 index 00000000..fc4dd78c --- /dev/null +++ b/client/src/components/notes/NoteForm.vue @@ -0,0 +1,38 @@ + + + diff --git a/client/src/components/profile/PasswordChangeForm.vue b/client/src/components/profile/PasswordChangeForm.vue index 4c33be61..6d298048 100644 --- a/client/src/components/profile/PasswordChangeForm.vue +++ b/client/src/components/profile/PasswordChangeForm.vue @@ -11,6 +11,7 @@ :class="{ 'skillboxform-input__input--error': errors.has('oldPassword') }" class="change-form__old skillbox-input skillboxform-input__input" autocomplete="off" + data-vv-as="Altes Passwort" data-cy="old-password"> {{ errors.first('oldPassword') }} {{ error }} @@ -21,6 +22,7 @@ name="newPassword" type="text" v-model="newPassword" + data-vv-as="Neues Passwort" v-validate="'required|min:8|strongPassword'" :class="{ 'skillboxform-input__input--error': errors.has('newPassword') }" class="change-form__new skillbox-input skillboxform-input__input" diff --git a/client/src/graphql/gql/fragments/contentBlockParts.gql b/client/src/graphql/gql/fragments/contentBlockParts.gql index 229c87cc..31b8489a 100644 --- a/client/src/graphql/gql/fragments/contentBlockParts.gql +++ b/client/src/graphql/gql/fragments/contentBlockParts.gql @@ -6,6 +6,13 @@ fragment ContentBlockParts on ContentBlockNode { contents userCreated mine + bookmarks { + uuid + note { + id + text + } + } hiddenFor { edges { node { diff --git a/client/src/graphql/gql/mutations/addNote.gql b/client/src/graphql/gql/mutations/addNote.gql new file mode 100644 index 00000000..051dadec --- /dev/null +++ b/client/src/graphql/gql/mutations/addNote.gql @@ -0,0 +1,8 @@ +mutation AddNote($input: AddNoteInput!) { + addNote(input: $input) { + note { + id + text + } + } +} diff --git a/client/src/graphql/gql/mutations/registration.gql b/client/src/graphql/gql/mutations/registration.gql new file mode 100644 index 00000000..4a5f8367 --- /dev/null +++ b/client/src/graphql/gql/mutations/registration.gql @@ -0,0 +1,8 @@ +mutation Registration($input: RegistrationInput!){ + registration(input: $input) { + success + errors { + field + } + } +} diff --git a/client/src/graphql/gql/mutations/updateContentBookmark.gql b/client/src/graphql/gql/mutations/updateContentBookmark.gql new file mode 100644 index 00000000..4dd42b9c --- /dev/null +++ b/client/src/graphql/gql/mutations/updateContentBookmark.gql @@ -0,0 +1,5 @@ +mutation UpdateContentBookmark($input: UpdateContentBookmarkInput!) { + updateContentBookmark(input: $input) { + success + } +} diff --git a/client/src/graphql/gql/mutations/updateNote.gql b/client/src/graphql/gql/mutations/updateNote.gql new file mode 100644 index 00000000..f5ef5981 --- /dev/null +++ b/client/src/graphql/gql/mutations/updateNote.gql @@ -0,0 +1,8 @@ +mutation UpdateNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + id + text + } + } +} diff --git a/client/src/main.js b/client/src/main.js index c7d1cb77..341a5d02 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -16,6 +16,7 @@ import veeDe from 'vee-validate/dist/locale/de'; import {dateFilter} from './filters/date-filter'; import autoGrow from '@/directives/auto-grow' import clickOutside from '@/directives/click-outside' +import ME_QUERY from '@/graphql/gql/meQuery.gql'; Vue.config.productionTip = false; @@ -98,13 +99,22 @@ Validator.extend('strongPassword', { return strongRegex.test(value); } }); + +Validator.extend('email', { + getMessage: field => 'Bitte geben Sie eine gülitge E-Mail an', + validate: value => { + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; + return emailRegex.test(value); + } +}); + Vue.use(VeeValidate, { locale: 'de' }); Vue.filter('date', dateFilter); -/* logged in guard */ +/* guards */ function getCookieValue(cookieName) { // https://stackoverflow.com/questions/5639346/what-is-the-shortest-function-for-reading-a-cookie-by-name-in-javascript @@ -112,18 +122,32 @@ function getCookieValue(cookieName) { return cookieValue ? cookieValue.pop() : ''; } -function redirectIfLoginRequird(to) { +function loginRequired(to) { // public pages have the meta.public property set to true - return (!to.hasOwnProperty('meta') || !to.meta.hasOwnProperty('public') || !to.meta.public) && getCookieValue('loginStatus') !== 'true'; + return !to.hasOwnProperty('meta') || !to.meta.hasOwnProperty('public') || !to.meta.public; } -router.beforeEach((to, from, next) => { - if (redirectIfLoginRequird(to)) { +function unauthorizedAccess(to) { + return loginRequired(to) && getCookieValue('loginStatus') !== 'true'; +} + +function redirectStudentsWithoutClass() { + return privateApolloClient.query({ + query: ME_QUERY, + }).then(({data}) => data.me.schoolClasses.edges.length === 0 && data.me.permissions.length === 0); +} + +router.beforeEach(async (to, from, next) => { + if (unauthorizedAccess(to)) { const redirectUrl = `/login?redirect=${to.path}`; next(redirectUrl); - } else { - next(); } + + if (to.name !== 'noClass' && loginRequired(to) && await redirectStudentsWithoutClass()) { + router.push({name: 'noClass'}) + } + + next(); }); /* eslint-disable no-new */ diff --git a/client/src/pages/instrument.vue b/client/src/pages/instrument.vue index 009a4b0e..4d05d476 100644 --- a/client/src/pages/instrument.vue +++ b/client/src/pages/instrument.vue @@ -24,6 +24,7 @@ import SectionTitleBlock from '@/components/content-blocks/SectionTitleBlock'; import SubtitleBlock from '@/components/content-blocks/SubtitleBlock'; import GeniallyBlock from '@/components/content-blocks/GeniallyBlock'; + import ThinglinkBlock from '@/components/content-blocks/ThinglinkBlock'; export default { apollo: { @@ -48,7 +49,8 @@ 'document_block': DocumentBlock, 'section_title': SectionTitleBlock, 'subtitle': SubtitleBlock, - 'genially_block': GeniallyBlock + 'genially_block': GeniallyBlock, + 'thinglink_block': ThinglinkBlock }, data() { diff --git a/client/src/pages/login.vue b/client/src/pages/login.vue index a2eea1e3..94155c82 100644 --- a/client/src/pages/login.vue +++ b/client/src/pages/login.vue @@ -1,6 +1,6 @@ @@ -92,16 +95,24 @@ export default { store, { data: { - login: { success } + login } } ) { try { - if (success) { + if (login.success) { const redirectUrl = that.$route.query.redirect ? that.$route.query.redirect : '/' that.$router.push(redirectUrl); } else { - that.loginError = 'Die E-Mail oder das Passwort ist falsch. Bitte versuchen Sie nochmals.'; + const firstError = login.errors[0]; + switch (firstError.field) { + case 'invalid_credentials': + that.loginError = 'Die E-Mail oder das Passwort ist falsch. Bitte versuchen Sie nochmals.'; + break; + case 'license_inactive': + that.loginError = 'Ihre Lizenz ist nicht mehr aktiv.'; + break; + } } } catch (e) { console.warn(e); @@ -137,15 +148,6 @@ export default { @import "@/styles/_variables.scss"; @import "@/styles/_mixins.scss"; -.login { - &__title { - margin-top: 48px; - font-size: 2.75rem; // 44px - margin-bottom: 24px; - font-weight: 600; - } -} - .text-link { font-family: $sans-serif-font-family; color: $color-brand; @@ -158,12 +160,4 @@ export default { } } -.registration { - margin-top: $large-spacing; - &__text { - font-family: $sans-serif-font-family; - margin-bottom: $small-spacing; - } -} - diff --git a/client/src/pages/registration.vue b/client/src/pages/registration.vue new file mode 100644 index 00000000..a58c4e12 --- /dev/null +++ b/client/src/pages/registration.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/client/src/pages/waitForClass.vue b/client/src/pages/waitForClass.vue new file mode 100644 index 00000000..7531fe01 --- /dev/null +++ b/client/src/pages/waitForClass.vue @@ -0,0 +1,31 @@ + + + + diff --git a/client/src/router/index.js b/client/src/router/index.js index 7f6f410e..c7e7bc6c 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -28,6 +28,8 @@ import surveyPage from '@/pages/survey' import styleGuidePage from '@/pages/styleguide' import moduleRoom from '@/pages/moduleRoom' import login from '@/pages/login' +import registration from '@/pages/registration' +import waitForClass from '@/pages/waitForClass' import store from '@/store/index'; @@ -117,6 +119,21 @@ const routes = [ props: true, meta: {layout: 'simple'} }, + { + path: '/register', + component: registration, + name: 'registration', + meta: { + public: true, + layout: 'public', + } + }, + { + path: '/no-class', + component: waitForClass, + name: 'noClass', + meta: {layout: 'public'} + }, {path: '/styleguide', component: styleGuidePage}, {path: '*', component: p404} ]; diff --git a/client/src/store/index.js b/client/src/store/index.js index 5ab7966f..6bc4b044 100644 --- a/client/src/store/index.js +++ b/client/src/store/index.js @@ -12,6 +12,7 @@ export default new Vuex.Store({ showMobileNavigation: false, contentBlockPosition: {}, scrollPosition: 0, + currentContent: '', currentContentBlock: '', currentRoomEntry: '', parentRoom: null, @@ -19,6 +20,7 @@ export default new Vuex.Store({ objectiveGroupType: '', currentObjectiveGroup: '', parentProject: null, + currentNote: null, currentProjectEntry: null, imageUrl: '', infographic: { @@ -33,18 +35,17 @@ export default new Vuex.Store({ }, getters: { - showModal: state => { - return state.showModal - }, - showMobileNavigation: state => { - return state.showMobileNavigation - }, + showModal: state => state.showModal, + showMobileNavigation: state => state.showMobileNavigation, scrollToAssignmentId: state => state.scrollToAssignmentId, scrollToAssignmentReady: state => state.scrollToAssignmentReady, scrollingToAssignment: state => state.scrollingToAssignment, currentProjectEntry: state => state.currentProjectEntry, editModule: state => state.editModule, - currentObjectiveGroup: state => state.currentObjectiveGroup + currentObjectiveGroup: state => state.currentObjectiveGroup, + currentContent: state => state.currentContent, + currentContentBlock: state => state.currentContentBlock, + currentNote: state => state.currentNote, }, actions: { @@ -58,6 +59,7 @@ export default new Vuex.Store({ }, resetModalState({commit}) { commit('setCurrentRoomEntry', ''); + commit('setCurrentContent', ''); commit('setCurrentContentBlock', ''); commit('setContentBlockPosition', {}); commit('setParentRoom', null); @@ -73,6 +75,7 @@ export default new Vuex.Store({ type: '' }); commit('setVimeoId', null); + commit('setCurrentNote', null); }, resetContentBlockPosition({commit}) { commit('setContentBlockPosition', {}); @@ -122,6 +125,15 @@ export default new Vuex.Store({ commit('setCurrentProjectEntry', payload); dispatch('showModal', 'edit-project-entry-wizard'); }, + addNote({commit, dispatch}, payload) { + commit('setCurrentContentBlock', payload.contentBlock); + commit('setCurrentContent', payload.content); + dispatch('showModal', 'new-note-wizard'); + }, + editNote({commit, dispatch}, payload) { + commit('setCurrentNote', payload); + dispatch('showModal', 'edit-note-wizard'); + }, showFullscreenImage({commit, dispatch}, payload) { commit('setImageUrl', payload); dispatch('showModal', 'fullscreen-image'); @@ -146,7 +158,7 @@ export default new Vuex.Store({ scrollingToAssignment({commit, state, dispatch}, payload) { if (payload && !state.scrollingToAssignment) { commit('setScrollingToAssignment', true); - }; + } if (!payload && state.scrollingToAssignment) { commit('setScrollingToAssignment', false); @@ -174,12 +186,18 @@ export default new Vuex.Store({ setContentBlockPosition(state, payload) { state.contentBlockPosition = payload; }, + setCurrentContent(state, payload) { + state.currentContent = payload; + }, setCurrentContentBlock(state, payload) { state.currentContentBlock = payload; }, setParentRoom(state, payload) { state.parentRoom = payload; }, + setCurrentNote(state, payload) { + state.currentNote = payload; + }, setCurrentRoomEntry(state, payload) { state.currentRoomEntry = payload; }, diff --git a/client/src/styles/_public-page.scss b/client/src/styles/_public-page.scss new file mode 100644 index 00000000..5a4e8a8c --- /dev/null +++ b/client/src/styles/_public-page.scss @@ -0,0 +1,16 @@ +.public-page { + &__title { + margin-top: 48px; + font-size: 2.75rem; // 44px + margin-bottom: 24px; + font-weight: 600; + } +} + +.account-link { + margin-top: $large-spacing; + &__text { + font-family: $sans-serif-font-family; + margin-bottom: $small-spacing; + } +} diff --git a/client/src/styles/main.scss b/client/src/styles/main.scss index 8720731c..7102ca4b 100644 --- a/client/src/styles/main.scss +++ b/client/src/styles/main.scss @@ -19,3 +19,4 @@ @import "visibility"; @import "solutions"; @import "password_forms"; +@import "public-page"; diff --git a/server/api/schema.py b/server/api/schema.py index f493adc4..5e5f0d12 100644 --- a/server/api/schema.py +++ b/server/api/schema.py @@ -11,16 +11,18 @@ from basicknowledge.queries import BasicKnowledgeQuery from books.schema.mutations.main import BookMutations from books.schema.queries import BookQuery from core.schema.mutations.main import CoreMutations +from notes.mutations import NoteMutations from objectives.mutations import ObjectiveMutations from objectives.schema import ObjectivesQuery from portfolio.mutations import PortfolioMutations from portfolio.schema import PortfolioQuery from surveys.schema import SurveysQuery -from surveys.mutations import SurveysMutations +from surveys.mutations import SurveyMutations from rooms.mutations import RoomMutations from rooms.schema import RoomsQuery, ModuleRoomsQuery from users.schema import AllUsersQuery, UsersQuery from users.mutations import ProfileMutations +from registration.mutations_public import RegistrationMutations class Query(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQuery, BookQuery, AssignmentsQuery, @@ -33,7 +35,7 @@ class Query(UsersQuery, AllUsersQuery, ModuleRoomsQuery, RoomsQuery, ObjectivesQ class Mutation(BookMutations, RoomMutations, AssignmentMutations, ObjectiveMutations, CoreMutations, PortfolioMutations, - ProfileMutations, SurveysMutations, graphene.ObjectType): + ProfileMutations, SurveyMutations, NoteMutations, RegistrationMutations, graphene.ObjectType): if settings.DEBUG: debug = graphene.Field(DjangoDebug, name='__debug') diff --git a/server/api/schema_public.py b/server/api/schema_public.py index 337ec6ee..e2d91f72 100644 --- a/server/api/schema_public.py +++ b/server/api/schema_public.py @@ -3,9 +3,10 @@ from django.conf import settings from graphene_django.debug import DjangoDebug from users.mutations_public import UserMutations +from registration.mutations_public import RegistrationMutations -class Mutation(UserMutations, graphene.ObjectType): +class Mutation(UserMutations, RegistrationMutations, graphene.ObjectType): if settings.DEBUG: debug = graphene.Field(DjangoDebug, name='__debug') diff --git a/server/books/migrations/0015_contentblock_bookmarks.py b/server/books/migrations/0015_contentblock_bookmarks.py new file mode 100644 index 00000000..1c08b9df --- /dev/null +++ b/server/books/migrations/0015_contentblock_bookmarks.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.6 on 2019-10-10 09:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('books', '0014_auto_20190912_1228'), + ] + + operations = [ + migrations.AddField( + model_name='contentblock', + name='bookmarks', + field=models.ManyToManyField(related_name='bookmarked_content_blocks', through='notes.ContentBlockBookmark', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/server/books/models/contentblock.py b/server/books/models/contentblock.py index b00ace41..713cd2fe 100644 --- a/server/books/models/contentblock.py +++ b/server/books/models/contentblock.py @@ -11,8 +11,9 @@ from books.blocks import TextBlock, BasicKnowledgeBlock, LinkBlock, VideoBlock, ThinglinkBlock from books.utils import get_type_and_value from core.wagtail_utils import StrictHierarchyPage +from notes.models import ContentBlockBookmark from surveys.models import Survey -from users.models import SchoolClass +from users.models import SchoolClass, User logger = logging.getLogger(__name__) @@ -34,10 +35,14 @@ class ContentBlock(StrictHierarchyPage): (BASE_SOCIETY, 'Instrument Gesellschaft'), ) + # blocks without owner are visible by default, need to be hidden for each class hidden_for = models.ManyToManyField(SchoolClass, related_name='hidden_content_blocks') + # blocks with owner are hidden by default, need to be shown for each class visible_for = models.ManyToManyField(SchoolClass, related_name='visible_content_blocks') user_created = models.BooleanField(default=False) + bookmarks = models.ManyToManyField(User, through=ContentBlockBookmark, related_name='bookmarked_content_blocks') + content_blocks = [ ('text_block', TextBlock()), ('basic_knowledge', BasicKnowledgeBlock()), diff --git a/server/books/schema/mutations/contentblock.py b/server/books/schema/mutations/contentblock.py index c557dfa9..d7234c81 100644 --- a/server/books/schema/mutations/contentblock.py +++ b/server/books/schema/mutations/contentblock.py @@ -10,6 +10,7 @@ from books.models import ContentBlock, Chapter, SchoolClass from books.schema.inputs import ContentBlockInput from books.schema.queries import ContentBlockNode from core.utils import set_hidden_for, set_visible_for +from notes.models import ContentBlockBookmark from .utils import handle_content_block, set_user_defined_block_type diff --git a/server/books/schema/queries.py b/server/books/schema/queries.py index c184e509..3ac6288b 100644 --- a/server/books/schema/queries.py +++ b/server/books/schema/queries.py @@ -5,6 +5,8 @@ from graphene_django.filter import DjangoFilterConnectionField from api.utils import get_object from books.utils import are_solutions_enabled_for +from notes.models import ContentBlockBookmark +from notes.schema import ContentBlockBookmarkNode from rooms.models import ModuleRoomSlug from ..models import Book, Topic, Module, Chapter, ContentBlock @@ -24,6 +26,7 @@ def process_module_room_slug_block(content): class ContentBlockNode(DjangoObjectType): mine = graphene.Boolean() + bookmarks = graphene.List(ContentBlockBookmarkNode) class Meta: model = ContentBlock @@ -54,6 +57,12 @@ class ContentBlockNode(DjangoObjectType): self.contents.stream_data = updated_stream_data return self.contents + def resolve_bookmarks(self, info, **kwargs): + return ContentBlockBookmark.objects.filter( + user=info.context.user, + content_block=self + ) + class ChapterNode(DjangoObjectType): content_blocks = DjangoFilterConnectionField(ContentBlockNode) diff --git a/server/core/management/commands/dummy_data.py b/server/core/management/commands/dummy_data.py index 8680d88f..27b9798a 100644 --- a/server/core/management/commands/dummy_data.py +++ b/server/core/management/commands/dummy_data.py @@ -9,7 +9,6 @@ from django.core.management import BaseCommand from django.db import connection from wagtail.core.models import Page -from assignments.factories import AssignmentFactory from books.factories import BookFactory, TopicFactory, ModuleFactory, ChapterFactory, ContentBlockFactory from core.factories import UserFactory from objectives.factories import ObjectiveGroupFactory, ObjectiveFactory @@ -103,5 +102,10 @@ class Command(BaseCommand): # ContentBlockFactory.create(parent=chapter, **self.filter_data(content_block_data, 'contents')) ContentBlockFactory.create(parent=chapter, module=module, **content_block_data) + + # now create all and rooms management.call_command('dummy_rooms', verbosity=0) + + # create license + management.call_command('create_dummy_license', verbosity=0) diff --git a/server/core/management/commands/import_users.py b/server/core/management/commands/import_users.py index 4d3576ac..db1b674f 100644 --- a/server/core/management/commands/import_users.py +++ b/server/core/management/commands/import_users.py @@ -2,6 +2,7 @@ import csv from django.core.management import BaseCommand import os +import requests from django.conf import settings from users.models import User, SchoolClass, Role, UserRole @@ -10,47 +11,56 @@ from users.models import User, SchoolClass, Role, UserRole class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('csv_file') + parser.add_argument('--s3', dest='s3', action='store_const', default=False, const=True, help='the file is on a remote server and publicly accessible like e.g. an S3 resource') + + def import_users(self, file_handler): + reader = csv.DictReader(file_handler) + for row in reader: + email = row['Email'].lower().strip() + if email == '': + self.stdout.write('No e-mail set, skipping') + continue + school_class_names = [c.strip() for c in row['Klassen'].split(',')] + first_name = row['Vorname'].strip() + last_name = row['Nachname'].strip() + + self.stdout.write("Creating user {} {}, {}".format(first_name, last_name, email)) + + user = User.objects.create_user_with_random_password(first_name, last_name, email) + + if row['Rolle'] == 'Lehrer': + self.stdout.write("Assigning teacher role") + teacher = Role.objects.get(key='teacher') + UserRole.objects.get_or_create(user=user, role=teacher) + else: + self.stdout.write("Assigning student role") + student = Role.objects.get(key='teacher') + UserRole.objects.get_or_create(user=user, role=student) + + self.stdout.write("Adding to class(es) {}".format(', '.join(school_class_names))) + for school_class_name in school_class_names: + school, _ = SchoolClass.objects.get_or_create(name=school_class_name) + user.school_classes.add(school) + + self.stdout.write("") def handle(self, *args, **options): self.stdout.write('Importing from {}!'.format(options['csv_file'])) - dir_path = settings.BASE_DIR - rel_path = options['csv_file'] - abs_path = os.path.join(dir_path, rel_path) + is_s3 = options['s3'] + csv_file = options['csv_file'] try: - with open(abs_path) as f: - reader = csv.DictReader(f) - for row in reader: - email = row['Email'].lower().strip() - if email == '': - self.stdout.write('No e-mail set, skipping') - continue - school_class_names = [c.strip() for c in row['Klassen'].split(',')] - first_name = row['Vorname'].strip() - last_name = row['Nachname'].strip() + if not is_s3: + dir_path = settings.BASE_DIR + rel_path = csv_file + abs_path = os.path.join(dir_path, rel_path) - self.stdout.write("Creating user {} {}, {}".format(first_name, last_name, email)) - - user, created = User.objects.get_or_create(email=email, username=email) - user.first_name = first_name - user.last_name = last_name - user.set_password(User.objects.make_random_password()) - user.save() - - if row['Rolle'] == 'Lehrer': - self.stdout.write("Assigning teacher role") - teacher = Role.objects.get(key='teacher') - UserRole.objects.get_or_create(user=user, role=teacher) - else: - self.stdout.write("Assigning student role") - student = Role.objects.get(key='teacher') - UserRole.objects.get_or_create(user=user, role=student) - - self.stdout.write("Adding to class(es) {}".format(', '.join(school_class_names))) - for school_class_name in school_class_names: - school, _ = SchoolClass.objects.get_or_create(name=school_class_name) - user.school_classes.add(school) - - self.stdout.write("") + with open(abs_path) as f: + self.import_users(f) + else: + with requests.Session() as s: + download = s.get(csv_file) + decoded_content = download.content.decode('utf-8') + self.import_users(decoded_content.splitlines()) except Exception as e: self.stdout.write(e) diff --git a/server/core/settings.py b/server/core/settings.py index 7d335a6e..5ade4f9a 100644 --- a/server/core/settings.py +++ b/server/core/settings.py @@ -54,6 +54,8 @@ INSTALLED_APPS = [ 'portfolio', 'statistics', 'surveys', + 'notes', + 'registration', 'wagtail.contrib.forms', 'wagtail.contrib.redirects', diff --git a/server/core/static/styles/main.scss b/server/core/static/styles/main.scss index b6b95d6f..2073aaf4 100644 --- a/server/core/static/styles/main.scss +++ b/server/core/static/styles/main.scss @@ -25,6 +25,8 @@ $base-font-size: 15px; $space: 10px; +$color-brand: #17A887; + body { font-family: $font-family; font-size: $base-font-size; @@ -115,6 +117,15 @@ input[type=text], input[type=password], input[type=email], select { .reset__text { margin-bottom: 52px; + line-height: 1.5rem; + font-size: 1.125rem; + + a { + color: $color-brand; + font-family: 'Montserrat', Arial, sans-serif; + font-size: 18px; + font-weight: 300; + } } .reset__form label { diff --git a/server/core/templates/registration/password_reset_confirm.html b/server/core/templates/registration/password_reset_confirm.html index 9edfe96b..23d4485f 100644 --- a/server/core/templates/registration/password_reset_confirm.html +++ b/server/core/templates/registration/password_reset_confirm.html @@ -7,7 +7,7 @@ {% block body %}

{% trans 'Setzen Sie Ihr neues Passwort' %}

-

{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}

+

{% trans 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten' %}

{% csrf_token %} {{ form.as_p }} diff --git a/server/core/templates/registration/registration_set_password_complete.html b/server/core/templates/registration/registration_set_password_complete.html new file mode 100644 index 00000000..32215383 --- /dev/null +++ b/server/core/templates/registration/registration_set_password_complete.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Sie haben es geschafft' %}{% endblock %} + +{% block body %} +
+

{% trans 'Sie haben es geschafft' %}

+

{% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}

+

{% trans 'Jetzt anmelden' %}

+
+{% endblock %} diff --git a/server/core/templates/registration/registration_set_password_confirm.html b/server/core/templates/registration/registration_set_password_confirm.html new file mode 100644 index 00000000..e5181163 --- /dev/null +++ b/server/core/templates/registration/registration_set_password_confirm.html @@ -0,0 +1,17 @@ + +{% extends 'base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Setzen Sie Ihr Passwort' %}{% endblock %} + +{% block body %} +
+

{% trans 'Geben Sie ein persönliches Passwort ein:' %}

+

{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}

+ + {% csrf_token %} + {{ form.as_p }} + + +
+{% endblock %} diff --git a/server/core/templates/registration/registration_set_password_done.html b/server/core/templates/registration/registration_set_password_done.html new file mode 100644 index 00000000..1dd1fe9c --- /dev/null +++ b/server/core/templates/registration/registration_set_password_done.html @@ -0,0 +1,12 @@ + +{% extends 'base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Schauen Sie in Ihr Postfach' %}{% endblock %} + +{% block body %} +
+

{% trans 'Schauen Sie in Ihr Postfach' %}

+

{% trans 'Wir haben ein E-Mail mit allen weiteren Anweisungen an Sie verschickt. Die E-Mail sollte in Kürze bei Ihnen ankommen.' %}

+
+{% endblock %} diff --git a/server/core/templates/registration/registration_set_password_email.html b/server/core/templates/registration/registration_set_password_email.html new file mode 100644 index 00000000..c752eada --- /dev/null +++ b/server/core/templates/registration/registration_set_password_email.html @@ -0,0 +1,12 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %} + +{% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'set_password_confirm' uidb64=uid token=token %} +{% endblock %} +{% trans "Ihr Benutzername lautet:" %} {{ user.get_username }} + +{% trans "Ihr mySkillbox Team" %} + +{% endautoescape %} diff --git a/server/core/templates/registration/registration_set_password_form.html b/server/core/templates/registration/registration_set_password_form.html new file mode 100644 index 00000000..4fba77cb --- /dev/null +++ b/server/core/templates/registration/registration_set_password_form.html @@ -0,0 +1,21 @@ + +{% extends 'base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Willkommen bei mySkillbox' %}{% endblock %} + +{% block body %} +
+

{% trans 'Willkommen bei Myskillbox' %}

+

{% trans 'Bevor Sie mySkillbox verwenden können, müssen Sie Ihre E-Mail-Adresse bestätigen und ein persönliches Passwort festlegen.' %}

+
+ {% csrf_token %} +
+ + {{ form.email }} +
+ + +
+
+{% endblock %} diff --git a/server/core/templates/registration/registration_set_password_subject.txt b/server/core/templates/registration/registration_set_password_subject.txt new file mode 100644 index 00000000..94018095 --- /dev/null +++ b/server/core/templates/registration/registration_set_password_subject.txt @@ -0,0 +1 @@ +Myskillbox: E-Mail bestätigen und Passwort setzen diff --git a/server/core/templates/registration/set_password_complete.html b/server/core/templates/registration/set_password_complete.html index b2ad19e7..32215383 100644 --- a/server/core/templates/registration/set_password_complete.html +++ b/server/core/templates/registration/set_password_complete.html @@ -6,7 +6,7 @@ {% block body %}

{% trans 'Sie haben es geschafft' %}

-

% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}

+

{% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}

{% trans 'Jetzt anmelden' %}

{% endblock %} diff --git a/server/core/templates/registration/set_password_confirm.html b/server/core/templates/registration/set_password_confirm.html index e5181163..123ab041 100644 --- a/server/core/templates/registration/set_password_confirm.html +++ b/server/core/templates/registration/set_password_confirm.html @@ -7,7 +7,7 @@ {% block body %}

{% trans 'Geben Sie ein persönliches Passwort ein:' %}

-

{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}

+

{% trans 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten.' %}

{% csrf_token %} {{ form.as_p }} diff --git a/server/core/templates/registration/set_password_email.html b/server/core/templates/registration/set_password_email.html index 732e86f9..c752eada 100644 --- a/server/core/templates/registration/set_password_email.html +++ b/server/core/templates/registration/set_password_email.html @@ -1,5 +1,5 @@ {% load i18n %}{% autoescape off %} -{% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf {{ site_name }} initial zu setzen.{% endblocktrans %} +{% blocktrans %}Sie erhalten diese E-Mail, um Ihr Passwort auf mySkillbox initial zu setzen.{% endblocktrans %} {% trans "Bitte öffnen Sie folgende Seite, um Ihr neues Passwort einzugeben:" %} {% block reset_link %} diff --git a/server/core/tests/test_api.py b/server/core/tests/test_api.py index 643058d7..e5949e94 100644 --- a/server/core/tests/test_api.py +++ b/server/core/tests/test_api.py @@ -1,9 +1,6 @@ import json from django.test import TestCase, Client -from django.test.utils import override_settings - -from core import settings from core.factories import UserFactory diff --git a/server/core/urls.py b/server/core/urls.py index cddcdeb0..ef247798 100644 --- a/server/core/urls.py +++ b/server/core/urls.py @@ -8,7 +8,8 @@ from wagtail.admin import urls as wagtailadmin_urls from wagtail.core import urls as wagtail_urls from core import views -from core.views import SetPasswordView, SetPasswordDoneView, SetPasswordConfirmView, SetPasswordCompleteView +from core.views import LegacySetPasswordView, LegacySetPasswordDoneView, LegacySetPasswordConfirmView,\ + LegacySetPasswordCompleteView, SetPasswordView, SetPasswordDoneView, SetPasswordConfirmView, SetPasswordCompleteView urlpatterns = [ # django admin @@ -16,11 +17,20 @@ urlpatterns = [ url(r'^accounts/', include('django.contrib.auth.urls')), url(r'^statistics/', include('statistics.urls', namespace='statistics')), + # legacy - will be removed # set password - path('welcome/', SetPasswordView.as_view(), name='set_password'), - path('set-password/done/', SetPasswordDoneView.as_view(), name='set_password_done'), - path('set-password///', SetPasswordConfirmView.as_view(), name='set_password_confirm'), - path('set-password/complete/', SetPasswordCompleteView.as_view(), name='set_password_complete'), + path('welcome/', LegacySetPasswordView.as_view(), name='set_password'), + path('set-password/done/', LegacySetPasswordDoneView.as_view(), name='set_password_done'), + path('set-password///', LegacySetPasswordConfirmView.as_view(), name='set_password_confirm'), + path('set-password/complete/', LegacySetPasswordCompleteView.as_view(), name='set_password_complete'), + + # set password upon registration + path('registration/welcome/', SetPasswordView.as_view(), name='registration_set_password'), + path('registration/set-password/done/', SetPasswordDoneView.as_view(), name='registration_set_password_done'), + path('registration/set-password///', SetPasswordConfirmView.as_view(), + name='registration_set_password_confirm'), + path('registration/set-password/complete/', SetPasswordCompleteView.as_view(), + name='registration_set_password_complete'), # wagtail url(r'^cms/', include(wagtailadmin_urls)), diff --git a/server/core/views.py b/server/core/views.py index 1cece2c1..b495cfc1 100644 --- a/server/core/views.py +++ b/server/core/views.py @@ -27,6 +27,31 @@ def home(request): class SetPasswordView(PasswordResetView): + email_template_name = 'registration/registration_set_password_email.html' + subject_template_name = 'registration/registration_set_password_subject.txt' + success_url = reverse_lazy('registration_set_password_done') + template_name = 'registration/registration_set_password_form.html' + title = _('Password setzen') + + +class SetPasswordDoneView(PasswordResetDoneView): + template_name = 'registration/registration_set_password_done.html' + title = _('Password setzen versandt') + + +class SetPasswordConfirmView(PasswordResetConfirmView): + success_url = reverse_lazy('registration_set_password_complete') + template_name = 'registration/registration_set_password_confirm.html' + title = _('Gib ein Passwort ein') + + +class SetPasswordCompleteView(PasswordResetCompleteView): + template_name = 'registration/registration_set_password_complete.html' + title = _('Passwort setzen erfolgreich') + + +# legacy +class LegacySetPasswordView(PasswordResetView): email_template_name = 'registration/set_password_email.html' subject_template_name = 'registration/set_password_subject.txt' success_url = reverse_lazy('set_password_done') @@ -34,17 +59,17 @@ class SetPasswordView(PasswordResetView): title = _('Password setzen') -class SetPasswordDoneView(PasswordResetDoneView): +class LegacySetPasswordDoneView(PasswordResetDoneView): template_name = 'registration/set_password_done.html' title = _('Password setzen versandt') -class SetPasswordConfirmView(PasswordResetConfirmView): +class LegacySetPasswordConfirmView(PasswordResetConfirmView): success_url = reverse_lazy('set_password_complete') template_name = 'registration/set_password_confirm.html' title = _('Gib ein Passwort ein') -class SetPasswordCompleteView(PasswordResetCompleteView): +class LegacySetPasswordCompleteView(PasswordResetCompleteView): template_name = 'registration/set_password_complete.html' title = _('Passwort setzen erfolgreich') diff --git a/server/notes/__init__.py b/server/notes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/notes/admin.py b/server/notes/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/server/notes/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/server/notes/apps.py b/server/notes/apps.py new file mode 100644 index 00000000..b6155aca --- /dev/null +++ b/server/notes/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotesConfig(AppConfig): + name = 'notes' diff --git a/server/notes/inputs.py b/server/notes/inputs.py new file mode 100644 index 00000000..0080269d --- /dev/null +++ b/server/notes/inputs.py @@ -0,0 +1,13 @@ +import graphene +from graphene import InputObjectType + + +class AddNoteArgument(InputObjectType): + content = graphene.UUID(required=True) + content_block = graphene.ID(required=True) + text = graphene.String(required=True) + + +class UpdateNoteArgument(InputObjectType): + id = graphene.ID(required=True) + text = graphene.String(required=True) diff --git a/server/notes/migrations/0001_initial.py b/server/notes/migrations/0001_initial.py new file mode 100644 index 00000000..e49e890a --- /dev/null +++ b/server/notes/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 2.0.6 on 2019-10-10 09:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('books', '0014_auto_20190912_1228'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ContentBlockBookmark', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(unique=True)), + ('content_block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.ContentBlock')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ], + ), + migrations.AddField( + model_name='contentblockbookmark', + name='note', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='notes.Note'), + ), + migrations.AddField( + model_name='contentblockbookmark', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/server/notes/migrations/__init__.py b/server/notes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/notes/models.py b/server/notes/models.py new file mode 100644 index 00000000..0404c54d --- /dev/null +++ b/server/notes/models.py @@ -0,0 +1,22 @@ +from django.db import models + +# Create your models here. +from core.wagtail_utils import StrictHierarchyPage +from users.models import User + + +class Note(models.Model): + text = models.TextField() + + +class Bookmark(models.Model): + uuid = models.UUIDField(unique=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) + note = models.OneToOneField(Note, null=True, on_delete=models.SET_NULL) + + class Meta: + abstract = True + + +class ContentBlockBookmark(Bookmark): + content_block = models.ForeignKey('books.ContentBlock', on_delete=models.CASCADE) diff --git a/server/notes/mutations.py b/server/notes/mutations.py new file mode 100644 index 00000000..8c1587e0 --- /dev/null +++ b/server/notes/mutations.py @@ -0,0 +1,102 @@ +from builtins import PermissionError + +import graphene +import json +from graphene import relay + +from api.utils import get_object +from books.models import ContentBlock +from notes.inputs import AddNoteArgument, UpdateNoteArgument +from notes.models import ContentBlockBookmark, Note +from notes.schema import NoteNode + + +class UpdateContentBookmark(relay.ClientIDMutation): + class Input: + uuid = graphene.UUID(required=True) + content_block = graphene.ID(required=True) + bookmarked = graphene.Boolean(required=True) + + success = graphene.Boolean() + errors = graphene.String() + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + uuid = kwargs.get('uuid') + user = info.context.user + content_block_id = kwargs.get('content_block') + bookmarked = kwargs.get('bookmarked') + + content_block = get_object(ContentBlock, content_block_id) + + if bookmarked: + ContentBlockBookmark.objects.create( + content_block=content_block, + uuid=uuid, + user=user + ) + else: + ContentBlockBookmark.objects.get( + content_block=content_block, + uuid=uuid, + user=user + ).delete() + + return cls(success=True) + + +class AddNote(relay.ClientIDMutation): + class Input: + note = graphene.Argument(AddNoteArgument) + + note = graphene.Field(NoteNode) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + user = info.context.user + + note = kwargs.get('note') + content_uuid = note.get('content') + content_block_id = note.get('content_block') + content_block = get_object(ContentBlock, content_block_id) + text = note.get('text') + + bookmark = ContentBlockBookmark.objects.get( + content_block=content_block, + uuid=content_uuid, + user=user + ) + + bookmark.note = Note.objects.create(text=text) + bookmark.save() + + return cls(note=bookmark.note) + + +class UpdateNote(relay.ClientIDMutation): + class Input: + note = graphene.Argument(UpdateNoteArgument) + + note = graphene.Field(NoteNode) + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + user = info.context.user + + note = kwargs.get('note') + id = note.get('id') + text = note.get('text') + note = get_object(Note, id) + + if note.contentblockbookmark.user != user: + raise PermissionError + + note.text = text + note.save() + return cls(note=note) + + +class NoteMutations: + add_note = AddNote.Field() + update_note = UpdateNote.Field() + update_content_bookmark = UpdateContentBookmark.Field() diff --git a/server/notes/schema.py b/server/notes/schema.py new file mode 100644 index 00000000..7c008951 --- /dev/null +++ b/server/notes/schema.py @@ -0,0 +1,25 @@ +import graphene +from graphene import relay +from graphene_django import DjangoObjectType + +from notes.models import Note, ContentBlockBookmark + + +class NoteNode(DjangoObjectType): + pk = graphene.Int() + + class Meta: + model = Note + interfaces = (relay.Node,) + + def resolve_pk(self, *args, **kwargs): + return self.id + + +class ContentBlockBookmarkNode(DjangoObjectType): + # note = graphene. + uuid = graphene.UUID() + note = graphene.Field(NoteNode) + + class Meta: + model = ContentBlockBookmark diff --git a/server/notes/tests.py b/server/notes/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/server/notes/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/notes/views.py b/server/notes/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/server/notes/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/server/registration/__init__.py b/server/registration/__init__.py new file mode 100644 index 00000000..49c9923c --- /dev/null +++ b/server/registration/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu diff --git a/server/registration/admin.py b/server/registration/admin.py new file mode 100644 index 00000000..64370f36 --- /dev/null +++ b/server/registration/admin.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-10 +# @author: chrigu +from django.contrib import admin + +from registration.models import LicenseType, License + + +@admin.register(LicenseType) +class LicenseTypeAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'key', 'for_role', 'active') + list_filter = ('for_role', 'active') + + +@admin.register(License) +class LicenseAdmin(admin.ModelAdmin): + list_display = ('license_type', 'licensee') + list_filter = ('license_type', 'licensee') + raw_id_fields = ('licensee',) diff --git a/server/registration/apps.py b/server/registration/apps.py new file mode 100644 index 00000000..a9b4ab38 --- /dev/null +++ b/server/registration/apps.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +from django.apps import AppConfig + + +class UserConfig(AppConfig): + name = 'registration' + diff --git a/server/registration/factories.py b/server/registration/factories.py new file mode 100644 index 00000000..50c493de --- /dev/null +++ b/server/registration/factories.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +import random + +import factory + +from registration.models import LicenseType, License + + +class LicenseTypeFactory(factory.django.DjangoModelFactory): + class Meta: + model = LicenseType + + name = factory.Sequence(lambda n: 'license-{}'.format(n)) + active = True + key = factory.Sequence(lambda n: "license-key-%03d" % n) + description = factory.Sequence(lambda n: "Some description %03d" % n) + + +class LicenseFactory(factory.django.DjangoModelFactory): + class Meta: + model = License + diff --git a/server/registration/management/__init__.py b/server/registration/management/__init__.py new file mode 100644 index 00000000..7e9d57d8 --- /dev/null +++ b/server/registration/management/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-23 +# @author: chrigu +from django.conf import settings diff --git a/server/registration/management/commands/__init__.py b/server/registration/management/commands/__init__.py new file mode 100644 index 00000000..7e9d57d8 --- /dev/null +++ b/server/registration/management/commands/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-23 +# @author: chrigu +from django.conf import settings diff --git a/server/registration/management/commands/create_dummy_license.py b/server/registration/management/commands/create_dummy_license.py new file mode 100644 index 00000000..8ae4c88e --- /dev/null +++ b/server/registration/management/commands/create_dummy_license.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-23 +# @author: chrigu +from django.conf import settings +from django.core.management import BaseCommand + +from registration.models import LicenseType +from users.models import Role + + +class Command(BaseCommand): + + def handle(self, *args, **options): + + try: + role = Role.objects.get(key=Role.objects.TEACHER_KEY) + except Role.DoesNotExist: + print("LicenseType requires that a Teacher Role exsits") + + LicenseType.objects.create(name='dummy_license', + for_role=role, + active=True, + key='c1fa2e2a-2e27-480d-8469-2e88414c4ad8', + description='dummy license') diff --git a/server/registration/migrations/0001_initial.py b/server/registration/migrations/0001_initial.py new file mode 100644 index 00000000..7fd0d43a --- /dev/null +++ b/server/registration/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 2.0.6 on 2019-10-09 09:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0009_auto_20191009_0905'), + ] + + operations = [ + migrations.CreateModel( + name='License', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='LicenseType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='License name')), + ('key', models.CharField(max_length=128)), + ('active', models.BooleanField(default=False, verbose_name='License active')), + ('description', models.TextField(default='', verbose_name='Description')), + ('for_role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.Role')), + ], + ), + migrations.AddField( + model_name='license', + name='license_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registration.LicenseType'), + ), + migrations.AddField( + model_name='license', + name='licensee', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/server/registration/migrations/0002_auto_20191010_0905.py b/server/registration/migrations/0002_auto_20191010_0905.py new file mode 100644 index 00000000..d946fa2e --- /dev/null +++ b/server/registration/migrations/0002_auto_20191010_0905.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.6 on 2019-10-10 09:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='licensetype', + name='key', + field=models.CharField(max_length=128, unique=True), + ), + ] diff --git a/server/registration/migrations/__init__.py b/server/registration/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/registration/models.py b/server/registration/models.py new file mode 100644 index 00000000..fb3e60a3 --- /dev/null +++ b/server/registration/models.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +from django.utils.translation import ugettext_lazy as _ +from django.db import models + +from users.managers import RoleManager +from users.models import Role, User + + +class LicenseType(models.Model): + + name = models.CharField(_('License name'), max_length=255, blank=False, null=False) + for_role = models.ForeignKey(Role, blank=False, null=False, on_delete=models.CASCADE) + key = models.CharField(max_length=128, blank=False, null=False, unique=True) + active = models.BooleanField(_('License active'), default=False) + description = models.TextField(_('Description'), default="") + + def is_teacher_license(self): + return self.for_role.key == RoleManager.TEACHER_KEY + + def __str__(self): + return '%s - role: %s' % (self.name, self.for_role) + + +class License(models.Model): + license_type = models.ForeignKey(LicenseType, blank=False, null=False, on_delete=models.CASCADE) + licensee = models.ForeignKey(User, blank=False, null=True, on_delete=models.CASCADE) diff --git a/server/registration/mutations_public.py b/server/registration/mutations_public.py new file mode 100644 index 00000000..181fdec5 --- /dev/null +++ b/server/registration/mutations_public.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +import graphene +from graphene import relay + +from core.views import SetPasswordView +from registration.models import License +from registration.serializers import RegistrationSerializer +from users.models import User, Role, UserRole, SchoolClass + + +class PublicFieldError(graphene.ObjectType): + code = graphene.String() + + +class MutationError(graphene.ObjectType): + field = graphene.String() + errors = graphene.List(PublicFieldError) + + +class Registration(relay.ClientIDMutation): + class Input: + firstname_input = graphene.String() + lastname_input = graphene.String() + email_input = graphene.String() + license_key_input = graphene.String() + + success = graphene.Boolean() + errors = graphene.List(MutationError) # todo: change for consistency + + @classmethod + def mutate_and_get_payload(cls, root, info, **kwargs): + first_name = kwargs.get('firstname_input') + last_name = kwargs.get('lastname_input') + email = kwargs.get('email_input') + license_key = kwargs.get('license_key_input') + registration_data = { + 'first_name': first_name, + 'last_name': last_name, + 'email': email, + 'license_key': license_key, + } + + serializer = RegistrationSerializer(data=registration_data) + + if serializer.is_valid(): + user = User.objects.create_user_with_random_password(serializer.data['first_name'], + serializer.data['last_name'], + serializer.data['email']) + sb_license = License.objects.create(licensee=user, license_type=serializer.context['license_type']) + + if sb_license.license_type.is_teacher_license(): + teacher_role = Role.objects.get(key=Role.objects.TEACHER_KEY) + UserRole.objects.get_or_create(user=user, role=teacher_role) + default_class_name = SchoolClass.generate_default_group_name() + default_class = SchoolClass.objects.create(name=default_class_name) + user.school_classes.add(default_class) + else: + student_role = Role.objects.get(key=Role.objects.STUDENT_KEY) + UserRole.objects.get_or_create(user=user, role=student_role) + + password_reset_view = SetPasswordView() + password_reset_view.request = info.context + form = password_reset_view.form_class({'email': user.email}) + + if not form.is_valid(): + return cls(success=False, errors=form.errors) + + password_reset_view.form_valid(form) + + return cls(success=True) + + errors = [] + for key, value in serializer.errors.items(): + error = MutationError(field=key, errors=[]) + for field_error in serializer.errors[key]: + error.errors.append(PublicFieldError(code=field_error.code)) + + errors.append(error) + + return cls(success=False, errors=errors) + + +class RegistrationMutations: + registration = Registration.Field() diff --git a/server/registration/serializers.py b/server/registration/serializers.py new file mode 100644 index 00000000..887fbbe1 --- /dev/null +++ b/server/registration/serializers.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework.fields import CharField, EmailField +from django.utils.translation import ugettext_lazy as _ +from registration.models import License, LicenseType + + +class RegistrationSerializer(serializers.Serializer): + first_name = CharField(allow_blank=False) + last_name = CharField(allow_blank=False) + email = EmailField(allow_blank=False) + license_key = CharField(allow_blank=False) + skillbox_license = None + + def validate_email(self, value): + lower_email = value.lower() + # the email is used as username + if len(get_user_model().objects.filter(username=lower_email)) > 0: + raise serializers.ValidationError(_(u'Diese E-Mail ist bereits registriert')) + elif len(get_user_model().objects.filter(email=lower_email)) > 0: + raise serializers.ValidationError(_(u'Dieser E-Mail ist bereits registriert')) + else: + return lower_email + + def validate_license_key(self, value): + license_types = LicenseType.objects.filter(key=value, active=True) + if len(license_types) == 0: + raise serializers.ValidationError(_(u'Die Lizenznummer ist ungültig')) + + self.context['license_type'] = license_types[0] # Assuming there is just ONE license per key + return value diff --git a/server/registration/tests/__init__.py b/server/registration/tests/__init__.py new file mode 100644 index 00000000..779000b2 --- /dev/null +++ b/server/registration/tests/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +from django.conf import settings diff --git a/server/registration/tests/test_registration.py b/server/registration/tests/test_registration.py new file mode 100644 index 00000000..15779a98 --- /dev/null +++ b/server/registration/tests/test_registration.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-08 +# @author: chrigu +from django.core import mail +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import TestCase, RequestFactory +from graphene.test import Client + +from api.schema import schema +from registration.factories import LicenseTypeFactory, LicenseFactory +from registration.models import License +from users.managers import RoleManager +from users.models import Role, User, UserRole, SchoolClass + + +class RegistrationTests(TestCase): + def setUp(self): + + self.teacher_role = Role.objects.create(key=Role.objects.TEACHER_KEY, name="Teacher Role") + self.student_role = Role.objects.create(key=Role.objects.STUDENT_KEY, name="Student Role") + + self.teacher_license_type = LicenseTypeFactory(for_role=self.teacher_role) + self.student_license_type = LicenseTypeFactory(for_role=self.student_role) + + self.teacher_license = LicenseFactory(license_type=self.teacher_license_type) + self.student_license = LicenseFactory(license_type=self.student_license_type) + + request = RequestFactory().post('/') + + self.email = 'sepp@skillbox.iterativ.ch' + self.first_name = 'Sepp' + self.last_name = 'Feuz' + + # adding session + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + self.client = Client(schema=schema, context_value=request) + + def make_register_mutation(self, first_name, last_name, email, license_key): + mutation = ''' + mutation Registration($input: RegistrationInput!){ + registration(input: $input) { + success + errors { + field + } + } + } + ''' + + return self.client.execute(mutation, variables={ + 'input': { + 'firstnameInput': first_name, + 'lastnameInput': last_name, + 'emailInput': email, + 'licenseKeyInput': license_key, + } + }) + + def _assert_user_registration(self, count, email, role_key): + users = User.objects.filter(username=self.email) + self.assertEqual(len(users), count) + user_roles = UserRole.objects.filter(user__email=email, role__key=role_key) + self.assertEqual(len(user_roles), count) + licenses = License.objects.filter(licensee__email=email, license_type__for_role__key=role_key) + self.assertEqual(len(licenses), count) + + def test_user_can_register_as_teacher(self): + self._assert_user_registration(0, self.email, RoleManager.TEACHER_KEY) + school_classes = SchoolClass.objects.filter(name__startswith='Meine Klasse') + self.assertEqual(len(school_classes), 0) + result = self.make_register_mutation(self.first_name, self.last_name, self.email, self.teacher_license_type.key) + self.assertTrue(result.get('data').get('registration').get('success')) + self._assert_user_registration(1, self.email, RoleManager.TEACHER_KEY) + school_classes = SchoolClass.objects.filter(name__startswith='Meine Klasse') + self.assertEqual(len(school_classes), 1) + user = User.objects.get(email=self.email) + self.assertTrue(school_classes[0].is_user_in_schoolclass(user)) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Myskillbox: E-Mail bestätigen und Passwort setzen') + + def test_user_can_register_as_student(self): + self._assert_user_registration(0, self.email, RoleManager.STUDENT_KEY) + result = self.make_register_mutation(self.first_name, self.last_name, self.email, self.student_license_type.key) + self.assertTrue(result.get('data').get('registration').get('success')) + self._assert_user_registration(1, self.email, RoleManager.STUDENT_KEY) + + def test_existing_user_cannot_register(self): + self._assert_user_registration(0, self.email, RoleManager.STUDENT_KEY) + self.make_register_mutation(self.first_name, self.last_name, self.email, self.student_license_type.key) + result = self.make_register_mutation(self.first_name, self.last_name, self.email, self.student_license_type.key) + self.assertEqual(result.get('data').get('registration').get('errors')[0].get('field'), 'email') + + def test_existing_user_cannot_register_with_uppercase_email(self): + self._assert_user_registration(0, self.email, RoleManager.STUDENT_KEY) + self.make_register_mutation(self.first_name, self.last_name, self.email.upper(), self.student_license_type.key) + result = self.make_register_mutation(self.first_name, self.last_name, self.email, self.student_license_type.key) + self.assertEqual(result.get('data').get('registration').get('errors')[0].get('field'), 'email') + + def test_user_cannot_register_if_firstname_is_missing(self): + result = self.make_register_mutation('', self.last_name, self.email, self.teacher_license_type.key) + self.assertEqual(result.get('data').get('registration').get('errors')[0].get('field'), 'first_name') + self.assertFalse(result.get('data').get('registration').get('success')) + + def test_user_cannot_register_if_lastname_is_missing(self): + result = self.make_register_mutation(self.first_name, '', self.email, self.teacher_license_type.key) + self.assertEqual(result.get('data').get('registration').get('errors')[0].get('field'), 'last_name') + self.assertFalse(result.get('data').get('registration').get('success')) + + def test_user_cannot_register_if_email_is_missing(self): + result = self.make_register_mutation(self.first_name, self.last_name, '', self.teacher_license_type.key) + self.assertEqual(result.get('data').get('registration').get('errors')[0].get('field'), 'email') + self.assertFalse(result.get('data').get('registration').get('success')) diff --git a/server/surveys/mutations.py b/server/surveys/mutations.py index 0fb962e8..92e81c05 100644 --- a/server/surveys/mutations.py +++ b/server/surveys/mutations.py @@ -33,5 +33,5 @@ class UpdateAnswer(relay.ClientIDMutation): return cls(answer=answer) -class SurveysMutations: +class SurveyMutations: update_answer = UpdateAnswer.Field() diff --git a/server/users/managers.py b/server/users/managers.py index 8bd35110..e20312cf 100644 --- a/server/users/managers.py +++ b/server/users/managers.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext_lazy as _ from django.db import models +from django.contrib.auth.models import UserManager as DjangoUserManager class RoleManager(models.Manager): @@ -78,3 +79,13 @@ class UserRoleManager(models.Manager): user_role = self.model(user=user, role=role) user_role.save() return user_role + + +class UserManager(DjangoUserManager): + def create_user_with_random_password(self, first_name, last_name, email): + user, created = self.model.objects.get_or_create(email=email, username=email) + user.first_name = first_name + user.last_name = last_name + user.set_password(self.model.objects.make_random_password()) + user.save() + return user diff --git a/server/users/migrations/0009_auto_20191009_0905.py b/server/users/migrations/0009_auto_20191009_0905.py new file mode 100644 index 00000000..e9b54b1a --- /dev/null +++ b/server/users/migrations/0009_auto_20191009_0905.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.6 on 2019-10-09 09:05 + +from django.db import migrations +import users.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_auto_20190904_1410'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', users.managers.UserManager()), + ], + ), + ] diff --git a/server/users/models.py b/server/users/models.py index c367e5c2..070fa503 100644 --- a/server/users/models.py +++ b/server/users/models.py @@ -1,10 +1,12 @@ +import re + from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser, Permission from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils.translation import ugettext_lazy as _ -from users.managers import RoleManager, UserRoleManager +from users.managers import RoleManager, UserRoleManager, UserManager DEFAULT_SCHOOL_ID = 1 @@ -14,6 +16,8 @@ class User(AbstractUser): avatar_url = models.CharField(max_length=254, blank=True, default='') email = models.EmailField(_('email address'), unique=True) + objects = UserManager() + def get_role_permissions(self): perms = set() for role in Role.objects.get_roles_for_user(self): @@ -70,6 +74,25 @@ class SchoolClass(models.Model): def __str__(self): return 'SchoolClass {}-{}'.format(self.id, self.name) + @classmethod + def generate_default_group_name(cls): + prefix = 'Meine Klasse' + prefix_regex = r'Meine Klasse (\d+)' + initial_default_group = '{} 1'.format(prefix) + my_group_filter = cls.objects.filter(name__startswith=prefix).order_by('-pk') + + if len(my_group_filter) == 0: + return initial_default_group + + match = re.search(prefix_regex, my_group_filter[0].name) + + if not match: + return initial_default_group + + index = int(match.group(1)) + + return '{} {}'.format(prefix, index + 1) + def is_user_in_schoolclass(self, user): return user.is_superuser or user.school_classes.filter(pk=self.id).count() > 0 diff --git a/server/users/mutations_public.py b/server/users/mutations_public.py index 6236a733..565b9d84 100644 --- a/server/users/mutations_public.py +++ b/server/users/mutations_public.py @@ -7,20 +7,16 @@ # # Created on 2019-10-01 # @author: chrigu -import re import graphene from django.contrib.auth import authenticate, login from graphene import relay - -class FieldError(graphene.ObjectType): - code = graphene.String() +from registration.models import License -class MutationError(graphene.ObjectType): +class LoginError(graphene.ObjectType): field = graphene.String() - errors = graphene.List(FieldError) class Login(relay.ClientIDMutation): @@ -29,17 +25,30 @@ class Login(relay.ClientIDMutation): password_input = graphene.String() success = graphene.Boolean() - errors = graphene.List(MutationError) # todo: change for consistency + errors = graphene.List(LoginError) # todo: change for consistency @classmethod def mutate_and_get_payload(cls, root, info, **kwargs): user = authenticate(username=kwargs.get('username_input'), password=kwargs.get('password_input')) - if user is not None: - login(info.context, user) - return cls(success=True, errors=[]) - else: - return cls(success=False, errors=['invalid_credentials']) + if user is None: + error = LoginError(field='invalid_credentials') + return cls(success=False, errors=[error]) + + user_license = None + + try: + user_license = License.objects.get(licensee=user) + except License.DoesNotExist: + # current users have no license, allow them to login + pass + + if user_license is not None and not user_license.license_type.active: + error = LoginError(field='license_inactive') + return cls(success=False, errors=[error]) + + login(info.context, user) + return cls(success=True, errors=[]) class UserMutations: diff --git a/server/users/serializers.py b/server/users/serializers.py index ad05594c..de6037ee 100644 --- a/server/users/serializers.py +++ b/server/users/serializers.py @@ -38,7 +38,7 @@ def validate_old_new_password(value): return value -def validate_strong_email(password): +def validate_strong_password(password): has_number = re.search('\d', password) has_upper = re.search('[A-Z]', password) @@ -56,7 +56,7 @@ class PasswordSerialzer(serializers.Serializer): new_password = CharField(allow_blank=True, min_length=MIN_PASSWORD_LENGTH) def validate_new_password(self, value): - return validate_strong_email(value) + return validate_strong_password(value) def validate_old_password(self, value): return validate_old_password(value, self.context.username) diff --git a/server/users/tests/test_login.py b/server/users/tests/test_login.py index c24dbe4a..68433116 100644 --- a/server/users/tests/test_login.py +++ b/server/users/tests/test_login.py @@ -13,11 +13,15 @@ from graphene.test import Client from api.schema_public import schema from core.factories import UserFactory +from registration.factories import LicenseTypeFactory, LicenseFactory +from users.models import Role class PasswordResetTests(TestCase): def setUp(self): self.user = UserFactory(username='aschi@iterativ.ch', email='aschi@iterativ.ch') + self.teacher_role = Role.objects.create(key=Role.objects.TEACHER_KEY, name="Teacher Role") + self.teacher_license_type = LicenseTypeFactory(for_role=self.teacher_role) request = RequestFactory().post('/') @@ -62,3 +66,25 @@ class PasswordResetTests(TestCase): result = self.make_login_mutation(self.user.email, 'test1234') self.assertFalse(result.get('data').get('login').get('success')) + + def test_user_with_active_license_can_login(self): + password = 'test123' + self.user.set_password(password) + self.user.save() + + LicenseFactory(license_type=self.teacher_license_type, licensee=self.user) + + result = self.make_login_mutation(self.user.email, password) + self.assertTrue(result.get('data').get('login').get('success')) + + def test_user_with_inactive_license_cannot_login(self): + password = 'test123' + self.user.set_password(password) + self.user.save() + + self.teacher_license_type.active = False + self.teacher_license_type.save() + LicenseFactory(license_type=self.teacher_license_type, licensee=self.user) + + result = self.make_login_mutation(self.user.email, password) + self.assertFalse(result.get('data').get('login').get('success')) diff --git a/server/users/tests/test_my_school_classes.py b/server/users/tests/test_my_school_classes.py index 0499d622..762869e7 100644 --- a/server/users/tests/test_my_school_classes.py +++ b/server/users/tests/test_my_school_classes.py @@ -17,7 +17,7 @@ from django.contrib.auth import authenticate from users.factories import SchoolClassFactory -class PasswordUpdate(TestCase): +class MySchoolClasses(TestCase): def setUp(self): self.user = UserFactory(username='aschi') self.another_user = UserFactory(username='pesche') diff --git a/server/users/tests/test_school_classes.py b/server/users/tests/test_school_classes.py new file mode 100644 index 00000000..67bf66a9 --- /dev/null +++ b/server/users/tests/test_school_classes.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-10-10 +# @author: chrigu +from django.conf import settings +# -*- coding: utf-8 -*- +# +# ITerativ GmbH +# http://www.iterativ.ch/ +# +# Copyright (c) 2019 ITerativ GmbH. All rights reserved. +# +# Created on 2019-04-09 +# @author: chrigu +from django.test import TestCase + +from users.models import SchoolClass + + +class SchoolClasses(TestCase): + + def setUp(self): + self.prefix = 'Meine Klasse' + + def test_default_class_name_initial(self): + class_name = SchoolClass.generate_default_group_name() + self.assertEqual('{} 1'.format(self.prefix), class_name) + + def test_default_class_name_initial_with_similar_existing(self): + SchoolClass.objects.create(name='{} abc212'.format(self.prefix)) + class_name = SchoolClass.generate_default_group_name() + self.assertEqual('{} 1'.format(self.prefix), class_name) + + def test_default_class_name_if_existing(self): + SchoolClass.objects.create(name='{} 1'.format(self.prefix)) + SchoolClass.objects.create(name='{} 10'.format(self.prefix)) + class_name = SchoolClass.generate_default_group_name() + self.assertEqual('{} 11'.format(self.prefix), class_name)