Merged in develop (pull request #40)

Develop
This commit is contained in:
Christian Cueni 2019-11-26 15:13:42 +00:00
commit 0ff9404cbe
97 changed files with 2351 additions and 415 deletions

2
.gitignore vendored
View File

@ -43,5 +43,3 @@ server/media/
.coverage
# Cypress screenshots
client/cypress/screenshots

View File

@ -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 = "*"

179
Pipfile.lock generated
View File

@ -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": [

View File

@ -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 <firstname> <lastname> <email>
@ -49,6 +49,19 @@ Then you can just run in terminal:
create_teacher <firstname> <lastname> <email>
```
##### Import a CSV file
To import a CSV file locally, run:
```
python manage.py <csv-file>
```
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 <csv-url>
```
### Client

View File

@ -30,6 +30,9 @@ aliases:
caches:
- pip
- node
artifacts:
- client/cypress/**/*.png
- client/cypress/**/*.mp4
services:
- postgres
script:

5
client/.gitignore vendored
View File

@ -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

View File

@ -1,4 +1,4 @@
{
"baseUrl": "http://localhost:8000",
"video": false
"videoUploadOnPasses": false
}

View File

@ -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
// // }
// // });
// // });
//
// });
// })
})

View File

@ -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');
});
})

View File

@ -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');
});
})

View File

@ -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();
});

View File

@ -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",

View File

@ -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

View File

@ -18,11 +18,15 @@
<h3 v-if="instrumentLabel !== ''" class="content-block__instrument-label">{{instrumentLabel}}</h3>
<h4 class="content-block__title" v-if="!contentBlock.indent">{{contentBlock.title}}</h4>
<component v-for="component in contentBlocksWithContentLists.contents"
:key="component.id"
:is="component.type"
v-bind="component">
</component>
<content-component v-for="component in contentBlocksWithContentLists.contents"
:key="component.id"
:component="component"
:root="root"
:parent="contentBlock"
:bookmarks="contentBlock.bookmarks"
:notes="contentBlock.notes"
>
</content-component>
</div>
@ -33,22 +37,6 @@
</template>
<script>
import TextBlock from '@/components/content-blocks/TextBlock';
import InstrumentWidget from '@/components/content-blocks/InstrumentWidget';
import Task from '@/components/content-blocks/Task';
import ImageBlock from '@/components/content-blocks/ImageBlock';
import ImageUrlBlock from '@/components/content-blocks/ImageUrlBlock';
import VideoBlock from '@/components/content-blocks/VideoBlock';
import LinkBlock from '@/components/content-blocks/LinkBlock';
import DocumentBlock from '@/components/content-blocks/DocumentBlock';
import InfogramBlock from '@/components/content-blocks/InfogramBlock';
import GeniallyBlock from '@/components/content-blocks/GeniallyBlock';
import ThinglinkBlock from '@/components/content-blocks/ThinglinkBlock';
import SubtitleBlock from '@/components/content-blocks/SubtitleBlock';
import ContentListBlock from '@/components/content-blocks/ContentListBlock';
import Assignment from '@/components/content-blocks/assignment/Assignment';
import Survey from '@/components/content-blocks/SurveyBlock';
import Solution from '@/components/content-blocks/Solution';
import AddContentButton from '@/components/AddContentButton';
import MoreOptionsWidget from '@/components/MoreOptionsWidget';
import UserWidget from '@/components/UserWidget';
@ -56,7 +44,7 @@
import EyeIcon from '@/components/icons/EyeIcon';
import PenIcon from '@/components/icons/PenIcon';
import TrashIcon from '@/components/icons/TrashIcon';
import ModuleRoomSlug from '@/components/content-blocks/ModuleRoomSlug'
import ContentComponent from '@/components/content-blocks/ContentComponent';
import CHAPTER_QUERY from '@/graphql/gql/chapterQuery.gql';
import DELETE_CONTENT_BLOCK_MUTATION from '@/graphql/gql/mutations/deleteContentBlock.gql';
@ -77,24 +65,7 @@
name: 'content-block',
components: {
'text_block': TextBlock,
'basic_knowledge': InstrumentWidget, // for legacy
'instrument': InstrumentWidget,
'image_block': ImageBlock,
'image_url_block': ImageUrlBlock,
'video_block': VideoBlock,
'link_block': LinkBlock,
'document_block': DocumentBlock,
'infogram_block': InfogramBlock,
'genially_block': GeniallyBlock,
'thinglink_block': ThinglinkBlock,
'subtitle': SubtitleBlock,
'content_list': ContentListBlock,
'module_room_slug': ModuleRoomSlug,
Survey,
Solution,
Assignment,
Task,
ContentComponent,
AddContentButton,
VisibilityAction,
EyeIcon,
@ -181,6 +152,10 @@
},
hidden() {
return isHidden(this.contentBlock, this.schoolClass);
},
root() {
// we need the root content block id, not the generated content block if inside a content list block
return this.contentBlock.root ? this.contentBlock.root : this.contentBlock.id;
}
},

View File

@ -1,6 +1,7 @@
<template>
<div class="modal__backdrop">
<div class="modal" :class="{'modal--hide-header': hideHeader || fullscreen, 'modal--fullscreen': fullscreen}">
<div class="modal"
:class="{'modal--hide-header': hideHeader || fullscreen, 'modal--fullscreen': fullscreen, 'modal--small': small}">
<div class="modal__header">
<slot name="header"></slot>
</div>
@ -32,6 +33,10 @@
fullscreen: {
type: Boolean,
default: false
},
small: {
type: Boolean,
default: false
}
},
@ -68,49 +73,6 @@
-ms-grid-rows: auto 1fr 65px;
position: relative;
&--hide-header {
grid-template-rows: 1fr 65px;
grid-template-areas: "body" "footer";
}
&--hide-header &__header {
display: none;
}
&--hide-header &__body {
padding: $default-padding;
}
&--fullscreen {
width: 95vw;
height: auto;
grid-template-rows: 1fr;
-ms-grid-rows: 1fr;
grid-template-areas: "body";
overflow: hidden;
}
&--fullscreen &__footer {
display: none;
}
&--fullscreen &__body {
padding: 0;
scrollbar-width: none;
margin-right: -5px;
height: auto;
max-height: 95vh;
&::-webkit-scrollbar {
display: none;
}
}
&--fullscreen &__close-button {
display: flex;
}
&__backdrop {
display: flex;
justify-content: center;
@ -165,5 +127,59 @@
border-top: 1px solid $color-silver-light;
padding: 16px $modal-lateral-padding;
}
$parent: &;
&--hide-header {
grid-template-rows: 1fr 65px;
grid-template-areas: "body" "footer";
#{$parent}__header {
display: none;
}
#{$parent}__body {
padding: $default-padding;
}
}
&--fullscreen {
width: 95vw;
height: auto;
grid-template-rows: 1fr;
-ms-grid-rows: 1fr;
grid-template-areas: "body";
overflow: hidden;
#{$parent}__footer {
display: none;
}
#{$parent}__body {
padding: 0;
scrollbar-width: none;
margin-right: -5px;
height: auto;
max-height: 95vh;
&::-webkit-scrollbar {
display: none;
}
}
#{$parent}__close-button {
display: flex;
}
}
&--small {
height: auto;
#{$parent}__body {
min-height: 0;
}
}
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="content-component" :class="{'content-component--bookmarked': bookmarked}">
<bookmark-actions
v-if="showBookmarkActions()"
@add-note="addNote(component.id)"
@edit-note="editNote"
@bookmark="bookmarkContent(component.id, !bookmarked)"
:bookmarked="bookmarked"
:note="note"></bookmark-actions>
<component
:is="component.type"
v-bind="component"
:parent="parent"
>
</component>
</div>
</template>
<script>
import {mapGetters} from 'vuex';
import TextBlock from '@/components/content-blocks/TextBlock';
import InstrumentWidget from '@/components/content-blocks/InstrumentWidget';
import ImageBlock from '@/components/content-blocks/ImageBlock';
import ImageUrlBlock from '@/components/content-blocks/ImageUrlBlock';
import VideoBlock from '@/components/content-blocks/VideoBlock';
import LinkBlock from '@/components/content-blocks/LinkBlock';
import DocumentBlock from '@/components/content-blocks/DocumentBlock';
import InfogramBlock from '@/components/content-blocks/InfogramBlock';
import ThinglinkBlock from '@/components/content-blocks/ThinglinkBlock';
import GeniallyBlock from '@/components/content-blocks/GeniallyBlock';
import SubtitleBlock from '@/components/content-blocks/SubtitleBlock';
import ContentListBlock from '@/components/content-blocks/ContentListBlock';
import ModuleRoomSlug from '@/components/content-blocks/ModuleRoomSlug';
import Assignment from '@/components/content-blocks/assignment/Assignment';
import Survey from '@/components/content-blocks/SurveyBlock';
import Solution from '@/components/content-blocks/Solution';
import BookmarkActions from '@/components/notes/BookmarkActions';
import UPDATE_CONTENT_BOOKMARK from '@/graphql/gql/mutations/updateContentBookmark.gql';
import CONTENT_BLOCK_QUERY from '@/graphql/gql/contentBlockQuery.gql';
export default {
props: ['component', 'parent', 'bookmarks', 'notes', 'root'],
components: {
'text_block': TextBlock,
'basic_knowledge': InstrumentWidget, // for legacy
'instrument': InstrumentWidget,
'image_block': ImageBlock,
'image_url_block': ImageUrlBlock,
'video_block': VideoBlock,
'link_block': LinkBlock,
'document_block': DocumentBlock,
'infogram_block': InfogramBlock,
'genially_block': GeniallyBlock,
'subtitle': SubtitleBlock,
'content_list': ContentListBlock,
'module_room_slug': ModuleRoomSlug,
'thinglink_block': ThinglinkBlock,
Survey,
Solution,
Assignment,
BookmarkActions
},
computed: {
...mapGetters(['editModule']),
bookmarked() {
return this.bookmarks && !!this.bookmarks.find(bookmark => bookmark.uuid === this.component.id);
},
note() {
const bookmark = this.bookmarks && this.bookmarks.find(bookmark => bookmark.uuid === this.component.id);
return bookmark && bookmark.note;
}
},
methods: {
addNote(id) {
this.$store.dispatch('addNote', {
content: id,
contentBlock: this.root
});
},
editNote() {
this.$store.dispatch('editNote', this.note);
},
bookmarkContent(uuid, bookmarked) {
this.$apollo.mutate({
mutation: UPDATE_CONTENT_BOOKMARK,
variables: {
input: {
uuid,
contentBlock: this.root,
bookmarked
}
},
update: (store, response) => {
const query = CONTENT_BLOCK_QUERY;
const variables = {id: this.root};
const data = store.readQuery({
query,
variables
});
const bookmarks = data.contentBlock.bookmarks;
if (bookmarked) {
bookmarks.push({
note: null,
uuid: uuid,
__typename: 'ContentBlockBookmarkNode'
});
} else {
let index = bookmarks.findIndex(element => {
return element.uuid === uuid;
});
if (index > -1) {
bookmarks.splice(index, 1);
}
}
data.contentBlock.bookmarks = bookmarks;
store.writeQuery({
data,
query,
variables
});
},
optimisticResponse: {
__typename: 'Mutation',
updateContentBookmark: {
__typename: 'UpdateContentBookmarkPayload',
success: true
}
}
});
},
showBookmarkActions() {
return this.component.type !== 'content_list' && this.component.type !== 'basic_knowledge' && !this.editModule;
}
}
};
</script>
<style lang="scss" scoped>
@import "@/styles/_variables.scss";
.content-component {
position: relative;
&--bookmarked {
}
}
</style>

View File

@ -6,7 +6,10 @@
:key="contentBlock.id"
v-for="(contentBlock, index) in contentBlocks">
<p class="content-list__numbering">{{alphaIndex(index)}})</p>
<content-block :contentBlock="contentBlock"></content-block>
<content-block
:contentBlock="contentBlock"
:parent="parent"
></content-block>
</li>
</ol>
</div>
@ -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 {

View File

@ -0,0 +1,16 @@
<template>
<svg class="add-note-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path
d="M81.5,88.39746H18.7627a2.49981,2.49981,0,0,1-2.5-2.5V35.35352L1.72559,20.71191A2.50054,2.50054,0,0,1,3.5,16.4502h78a2.49981,2.49981,0,0,1,2.5,2.5V85.89746A2.49981,2.49981,0,0,1,81.5,88.39746Zm-60.2373-5H79V21.4502H9.50488L20.53711,32.56152a2.5013,2.5013,0,0,1,.72559,1.76172Z"/>
<path d="M64.9209,55.08447H36.18457a2.5,2.5,0,0,1,0-5H64.9209a2.5,2.5,0,0,1,0,5Z"/>
<path d="M48.26318,66.95313V38.21582a2.5,2.5,0,0,1,5,0v28.7373a2.5,2.5,0,0,1-5,0Z"/>
</svg>
</template>
<style scoped lang="scss">
.add-note-icon {
width: 29px;
height: 25px;
margin-left: 2px;
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" class="bookmark-icon">
<g :class="{'bookmark-icon--bookmarked': bookmarked}">
<path class="bookmark-icon__background"
d="M51,67.32872a5.06849,5.06849,0,0,1,2.98875.97385L84,90V11H18V90L48.01333,68.30149A5.064,5.064,0,0,1,51,67.32872Z"
/>
<path id="bookmark-icon__outline"
d="M84.5,93.07715a2.49662,2.49662,0,0,1-1.43359-.45215L51,70.17871,18.93359,92.625A2.49964,2.49964,0,0,1,15,90.57715V11.42285a2.49981,2.49981,0,0,1,2.5-2.5h67a2.49981,2.49981,0,0,1,2.5,2.5v79.1543a2.49947,2.49947,0,0,1-2.5,2.5ZM51,65.15527a4.8673,4.8673,0,0,1,2.80762.88574L82,85.77539V13.92285H20V85.77539L48.19434,66.04A4.863,4.863,0,0,1,51,65.15527Z"/>
</g>
</svg>
</template>
<script>
export default {
props: ['bookmarked']
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
.bookmark-icon {
width: 24px;
height: 28px;
&__background {
fill: white;
}
$parent: &;
&--bookmarked {
#{$parent}__background {
fill: $color-brand;
}
}
}
</style>

View File

@ -0,0 +1,17 @@
<template>
<svg class="note-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path
d="M81.5,88.39746H18.7627a2.49981,2.49981,0,0,1-2.5-2.5V35.35352L1.72559,20.71191A2.50054,2.50054,0,0,1,3.5,16.4502h78a2.49981,2.49981,0,0,1,2.5,2.5V85.89746A2.49981,2.49981,0,0,1,81.5,88.39746Zm-60.2373-5H79V21.4502H9.50488L20.53711,32.56152a2.5013,2.5013,0,0,1,.72559,1.76172Z"/>
<path d="M61.9209,40.92676H39.18457a2.5,2.5,0,0,1,0-5H61.9209a2.5,2.5,0,0,1,0,5Z"/>
<path d="M62.13184,55.24219H39.39453a2.5,2.5,0,0,1,0-5h22.7373a2.5,2.5,0,0,1,0,5Z"/>
<path d="M62.13184,69.55859H39.39453a2.5,2.5,0,0,1,0-5h22.7373a2.5,2.5,0,0,1,0,5Z"/>
</svg>
</template>
<style scoped lang="scss">
.note-icon {
width: 29px;
height: 25px;
margin-left: 2px;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="bookmark-actions">
<a class="bookmark-actions__action" @click="$emit('bookmark')"
:class="{'bookmark-actions__action--bookmarked': bookmarked}">
<bookmark-icon :bookmarked="bookmarked"></bookmark-icon>
</a>
<a class="bookmark-actions__action" v-if="bookmarked && !note" @click="$emit('add-note')">
<add-note-icon></add-note-icon>
</a>
<a class="bookmark-actions__action bookmark-actions__action--noted" @click="$emit('edit-note')" v-if="note">
<note-icon></note-icon>
</a>
</div>
</template>
<script>
import BookmarkIcon from '@/components/icons/BookmarkIcon';
import AddNoteIcon from '@/components/icons/AddNoteIcon';
import NoteIcon from '@/components/icons/NoteIcon';
export default {
props: ['bookmarked', 'note'],
components: {
BookmarkIcon,
AddNoteIcon,
NoteIcon
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.bookmark-actions {
height: 100%;
min-height: 60px;
padding: 0 2*$large-spacing;
position: absolute;
right: -5*$large-spacing;
display: none;
@include desktop {
display: flex;
}
flex-direction: column;
align-content: center;
&__action {
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
width: 26px;
display: flex;
justify-content: center;
&--bookmarked, &--noted {
opacity: 1;
}
}
$parent: &;
&:hover {
#{$parent}__action {
opacity: 1;
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<note-form @save="editNote" @hide="hide" :note="currentNote"></note-form>
</template>
<script>
import NoteForm from '@/components/notes/NoteForm';
import UPDATE_NOTE_MUTATION from '@/graphql/gql/mutations/updateNote.gql';
import MODULE_DETAILS_QUERY from '@/graphql/gql/moduleDetailsQuery.gql';
import {mapGetters} from 'vuex';
export default {
components: {
NoteForm
},
computed: {
...mapGetters(['currentNote'])
},
methods: {
editNote(note) {
this.$apollo.mutate({
mutation: UPDATE_NOTE_MUTATION,
variables: {
input: {
note
}
},
refetchQueries: [{
query: MODULE_DETAILS_QUERY,
variables: {
slug: this.$route.params.slug
}
}]
}).then(() => {
this.$store.dispatch('hideModal');
});
},
hide() {
this.$store.dispatch('hideModal');
}
}
}
</script>

View File

@ -0,0 +1,93 @@
<template>
<note-form @save="addNote" @hide="hide" :note="note"></note-form>
</template>
<script>
import NoteForm from '@/components/notes/NoteForm';
import ADD_NOTE_MUTATION from '@/graphql/gql/mutations/addNote.gql';
import CONTENT_BLOCK_QUERY from '@/graphql/gql/contentBlockQuery.gql';
import {mapGetters} from 'vuex';
export default {
components: {
NoteForm
},
data() {
return {
note: {}
}
},
computed: {
...mapGetters(['currentContent', 'currentContentBlock'])
},
methods: {
addNote(note) {
const content = this.currentContent;
const contentBlock = this.currentContentBlock;
const text = note.text;
this.$apollo.mutate({
mutation: ADD_NOTE_MUTATION,
variables: {
input: {
note: {
content,
contentBlock,
text
}
}
},
update: (store, {data: {addNote: {note}}}) => {
const query = CONTENT_BLOCK_QUERY;
const variables = {id: contentBlock};
const data = store.readQuery({
query,
variables
});
const bookmarks = data.contentBlock.bookmarks;
let index = bookmarks.findIndex(element => {
return element.uuid === content;
});
if (index > -1) {
let el = bookmarks[index];
el.note = note;
bookmarks.splice(index, 1, el);
}
data.contentBlock.bookmarks = bookmarks;
store.writeQuery({
data,
query,
variables
});
},
optimisticResponse: {
__typename: 'Mutation',
addNote: {
__typename: 'AddNotePayload',
note: {
__typename: 'NoteNode',
id: -1,
text: text
}
}
}
}).then(() => {
this.$store.dispatch('hideModal');
});
},
hide() {
this.$store.dispatch('hideModal');
}
}
}
</script>

View File

@ -0,0 +1,38 @@
<template>
<modal :hide-header="true" :small="true">
<modal-input v-on:input="localNote.text = $event"
placeholder="Notiz erfassen"
:value="localNote.text"
></modal-input>
<div slot="footer">
<a class="button button--primary" data-cy="modal-save-button"
@click="$emit('save', localNote)">Speichern</a>
<a class="button" @click="$emit('hide')">Abbrechen</a>
</div>
</modal>
</template>
<script>
import Modal from '@/components/Modal';
import ModalInput from '@/components/ModalInput';
export default {
props: ['note'],
components: {
Modal,
ModalInput
},
data() {
return {
localNote: Object.assign({},
{
...this.note
}
)
}
}
}
</script>

View File

@ -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">
<small v-if="errors.has('oldPassword') && submitted" class="skillboxform-input__error" data-cy="old-password-local-errors">{{ errors.first('oldPassword') }}</small>
<small v-for="error in oldPasswordErrors" :key="error" class=" skillboxform-input__error" data-cy="old-password-remote-errors">{{ error }}</small>
@ -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"

View File

@ -6,6 +6,13 @@ fragment ContentBlockParts on ContentBlockNode {
contents
userCreated
mine
bookmarks {
uuid
note {
id
text
}
}
hiddenFor {
edges {
node {

View File

@ -0,0 +1,8 @@
mutation AddNote($input: AddNoteInput!) {
addNote(input: $input) {
note {
id
text
}
}
}

View File

@ -0,0 +1,8 @@
mutation Registration($input: RegistrationInput!){
registration(input: $input) {
success
errors {
field
}
}
}

View File

@ -0,0 +1,5 @@
mutation UpdateContentBookmark($input: UpdateContentBookmarkInput!) {
updateContentBookmark(input: $input) {
success
}
}

View File

@ -0,0 +1,8 @@
mutation UpdateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
id
text
}
}
}

View File

@ -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 */

View File

@ -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() {

View File

@ -1,6 +1,6 @@
<template>
<div class="login">
<h1 class="login__title">Melden Sie sich jetzt an</h1>
<div class="login public-page">
<h1 class="login__title public-page__title">Melden Sie sich jetzt an</h1>
<form class="login__form login-form" novalidate @submit.prevent="validateBeforeSubmit">
<div class="login-form__field skillboxform-input">
<label for="email" class="skillboxform-input__label">E-Mail</label>
@ -10,6 +10,7 @@
type="text"
v-model="email"
v-validate="'required'"
data-vv-as="E-Mail"
:class="{ 'skillboxform-input__input--error': errors.has('email') }"
class="change-form__email skillbox-input skillboxform-input__input"
autocomplete="off"
@ -33,6 +34,7 @@
id="pw"
name="password"
type="password"
data-vv-as="Passwort"
v-model="password"
v-validate="'required'"
:class="{ 'skillboxform-input__input--error': errors.has('password') }"
@ -59,10 +61,11 @@
<button class="button button--primary button--big actions__submit" data-cy="login-button">Anmelden</button>
<a class="actions__reset text-link" href="/accounts/password_reset/">Passwort vergessen?</a>
</div>
<!--div class="registration">
<p class="registration__text">Haben Sie noch kein Konto?</p>
<a class="registration__link text-link" href="/accounts/password_reset/">Jetzt registrieren</a>
</div-->
<div class="account-link">
<p class="account-link__text">Haben Sie noch kein Konto?</p>
<router-link class="account-link__link text-link" :to="{name: 'registration'}">Jetzt registrieren
</router-link>
</div>
</form>
</div>
</template>
@ -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;
}
}
</style>

View File

@ -0,0 +1,233 @@
<template>
<div class="registration public-page">
<h1 class="registration__title public-page__title">Registrieren Sie ihr persönliches Konto.</h1>
<form class="registration__form registration-form" novalidate @submit.prevent="validateBeforeSubmit">
<div class="registration-form__field skillboxform-input">
<label for="firstname" class="skillboxform-input__label">Vorname</label>
<input
id="firstname"
name="firstname"
type="text"
v-model="firstname"
data-vv-as="Vorname"
v-validate="'required'"
:class="{ 'skillboxform-input__input--error': errors.has('firstname') }"
class="change-form__firstname skillbox-input skillboxform-input__input"
autocomplete="off"
data-cy="firstname-input"
/>
<small
v-if="errors.has('firstname') && submitted"
class="skillboxform-input__error"
data-cy="firstname-local-errors"
>{{ errors.first('firstname') }}</small>
<small
v-for="error in firstnameErrors"
:key="error"
class="skillboxform-input__error"
data-cy="firstname-remote-errors"
>{{ error }}</small>
</div>
<div class="change-form__field skillboxform-input">
<label for="lastname" class="skillboxform-input__label">Nachname</label>
<input
id="lastname"
name="lastname"
type="text"
v-model="lastname"
data-vv-as="Nachname"
v-validate="'required'"
:class="{ 'skillboxform-input__input--error': errors.has('lastname') }"
class="change-form__new skillbox-input skillboxform-input__input"
autocomplete="off"
data-cy="lastname-input"
/>
<small
v-if="errors.has('lastname') && submitted"
class="skillboxform-input__error"
data-cy="lastname-local-errors"
>{{ errors.first('lastname') }}</small>
<small
v-for="error in lastnameErrors"
:key="error"
class="skillboxform-input__error"
data-cy="lastname-remote-errors"
>{{ error }}</small>
</div>
<div class="change-form__field skillboxform-input">
<label for="email" class="skillboxform-input__label">E-Mail</label>
<input
id="email"
name="email"
type="text"
v-model="email"
data-vv-as="E-Mail"
v-validate="'required|email'"
:class="{ 'skillboxform-input__input--error': errors.has('email') }"
class="change-form__new skillbox-input skillboxform-input__input"
autocomplete="off"
data-cy="email-input"
/>
<small
v-if="errors.has('email') && submitted"
class="skillboxform-input__error"
data-cy="email-local-errors"
>{{ errors.first('email') }}</small>
<small
v-for="error in emailErrors"
:key="error"
class="skillboxform-input__error"
data-cy="email-remote-errors"
>{{ error }}</small>
</div>
<div class="change-form__field skillboxform-input">
<label for="licenseKey" class="skillboxform-input__label">Lizenz</label>
<input
id="licenseKey"
name="licenseKey"
type="text"
v-model="licenseKey"
data-vv-as="Lizenz"
v-validate="'required'"
:class="{ 'skillboxform-input__input--error': errors.has('licenseKey') }"
class="change-form__new skillbox-input skillboxform-input__input"
autocomplete="off"
data-cy="licenseKey-input"
/>
<small
v-if="errors.has('licenseKey') && submitted"
class="skillboxform-input__error"
data-cy="licenseKey-local-errors"
>{{ errors.first('licenseKey') }}</small>
<small
v-for="error in licenseKeyErrors"
:key="error"
class="skillboxform-input__error"
data-cy="licenseKey-remote-errors"
>{{ error }}</small>
</div>
<div class="skillboxform-input">
<small class="skillboxform-input__error" data-cy="registration-error" v-if="registrationError">{{registrationError}}</small>
</div>
<div class="actions">
<button class="button button--primary button--big actions__submit" data-cy="register-button">Jetzt registrieren</button>
</div>
<div class="account-link">
<p class="account-link__text">Haben Sie ein Konto?</p>
<router-link class="account-link__link text-link" :to="{name: 'login'}">Jetzt anmelden
</router-link>
</div>
</form>
</div>
</template>
<script>
import REGISTRATION_MUTATION from '@/graphql/gql/mutations/registration.gql';
export default {
components: {},
methods: {
validateBeforeSubmit() {
this.$validator.validate().then(result => {
this.submitted = true;
let that = this;
if (result) {
this.$apollo.mutate({
client: 'publicClient',
mutation: REGISTRATION_MUTATION,
variables: {
input: {
firstnameInput: this.firstname,
lastnameInput: this.lastname,
emailInput: this.email,
licenseKeyInput: this.licenseKey,
}
},
update(
store,
{
data: {
registration: { success, errors }
}
}
) {
try {
if (success) {
window.location.href = '/registration/set-password/done/';
} else {
errors.forEach(function(error) {
switch (error.field) {
case 'email':
that.emailErrors = ['Die angegebene E-Mail ist bereits registriert.'];
break;
case 'license_key':
that.licenseKeyErrors = ['Die angegebenen Lizenz ist unglültig'];
}
})
}
} catch (e) {
console.warn(e);
that.registrationError = 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie nochmals.';
}
}
});
}
});
},
resetForm() {
this.email = '';
this.lastname = '';
this.firstname = '';
this.licenseKey = '';
this.firstnameErrors = '';
this.lastnameErrors = '';
this.emailErrors = '';
this.licenseKeyErrors = '';
this.registrationError = '';
this.submitted = false;
this.$validator.reset();
}
},
data() {
return {
email: '',
lastname: '',
firstname: '',
licenseKey: '',
firstnameErrors: '',
lastnameErrors: '',
emailErrors: '',
licenseKeyErrors: '',
registrationError: '',
submitted: false
};
}
};
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_mixins.scss";
.text-link {
font-family: $sans-serif-font-family;
color: $color-brand;
}
.actions {
&__reset {
display: inline-block;
margin-left: $large-spacing;
}
}
.registration {
&__text {
font-family: $sans-serif-font-family;
margin-bottom: $small-spacing;
}
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<div class="no-class public-page">
<h1 class="public-page__title">Sie sind keiner Klasse zugeteilt</h1>
<p>Sie können mySkillbox nur verwenden wenn Sie in einer Klasse zugeteilt sind. Aktuell kann Sie nur der mySkillbox-Support einer Klasse zuteilen.</p>
<button class="button button--primary button--big logout-button" @click="logout">Abmelden</button>
</div>
</template>
<script>
import LOGOUT_MUTATION from '@/graphql/gql/mutations/logoutUser.gql';
export default {
methods: {
logout() {
this.$apollo.mutate({
mutation: LOGOUT_MUTATION,
}).then(({data}) => {
if (data.logout.success) { location.replace('/') }
});
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
.logout-button {
margin-top: $large-spacing;
}
</style>

View File

@ -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}
];

View File

@ -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;
},

View File

@ -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;
}
}

View File

@ -19,3 +19,4 @@
@import "visibility";
@import "solutions";
@import "password_forms";
@import "public-page";

View File

@ -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')

View File

@ -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')

View File

@ -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),
),
]

View File

@ -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()),

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -54,6 +54,8 @@ INSTALLED_APPS = [
'portfolio',
'statistics',
'surveys',
'notes',
'registration',
'wagtail.contrib.forms',
'wagtail.contrib.redirects',

View File

@ -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 {

View File

@ -7,7 +7,7 @@
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Setzen Sie Ihr neues Passwort' %}</h2>
<p class="reset__text">{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}</p>
<p class="reset__text">{% trans 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten' %}</p>
<form method="post" class="mt-1 reset__form">
{% csrf_token %}
{{ form.as_p }}

View File

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans 'Sie haben es geschafft' %}{% endblock %}
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Sie haben es geschafft' %}</h2>
<p class="reset__text">{% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}</p>
<p class="reset__text"><a href="/login">{% trans 'Jetzt anmelden' %}</a></p>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
<!-- templates/registration/password_reset_confirm.html -->
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans 'Setzen Sie Ihr Passwort' %}{% endblock %}
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Geben Sie ein persönliches Passwort ein:' %}</h2>
<p class="reset__text">{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}</p>
<form method="post" class="mt-1 reset__form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" name="action">{% trans 'Passwort speichern' %}</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
<!-- templates/registration/password_reset_form.html -->
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans 'Schauen Sie in Ihr Postfach' %}{% endblock %}
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Schauen Sie in Ihr Postfach' %}</h2>
<p class="reset__text">{% trans 'Wir haben ein E-Mail mit allen weiteren Anweisungen an Sie verschickt. Die E-Mail sollte in Kürze bei Ihnen ankommen.' %}</p>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -0,0 +1,21 @@
<!-- templates/registration/password_reset_form.html -->
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans 'Willkommen bei mySkillbox' %}{% endblock %}
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Willkommen bei Myskillbox' %}</h2>
<p class="reset__text">{% trans 'Bevor Sie mySkillbox verwenden können, müssen Sie Ihre E-Mail-Adresse bestätigen und ein persönliches Passwort festlegen.' %}</p>
<form method="post" class="mt-1 reset__form">
{% csrf_token %}
<div>
<label for="id_email">{% trans 'Geben Sie als erstes hier Ihre E-Mail-Adresse ein:' %}</label>
{{ form.email }}
</div>
<button class="btn mt-1" type="submit" name="action">{% trans 'E-Mail bestätigen' %}</button>
<input type="hidden" name="next" value="{{ next }}"/>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1 @@
Myskillbox: E-Mail bestätigen und Passwort setzen

View File

@ -6,7 +6,7 @@
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Sie haben es geschafft' %}</h2>
<p class="reset__text">% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}</p>
<p class="reset__text">{% trans 'Ihr Passwort wurde erfolgreich gespeichert. Sie können sich nun anmelden.' %}</p>
<p class="reset__text"><a href="/login">{% trans 'Jetzt anmelden' %}</a></p>
</div>
{% endblock %}

View File

@ -7,7 +7,7 @@
{% block body %}
<div class="reset">
<h2 class="reset__heading">{% trans 'Geben Sie ein persönliches Passwort ein:' %}</h2>
<p class="reset__text">{% trans 'Kein Problem! Geben Sie Ihre E-Mail-Adresse ein und erhalten Sie weitere Anweisungen.' %}</p>
<p class="reset__text">{% trans 'Das Passwort muss Grossbuchstaben, Zahlen und Sonderzeichen beinhalten.' %}</p>
<form method="post" class="mt-1 reset__form">
{% csrf_token %}
{{ form.as_p }}

View File

@ -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 %}

View File

@ -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

View File

@ -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/<uidb64>/<token>/', 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/<uidb64>/<token>/', 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/<uidb64>/<token>/', 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)),

View File

@ -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')

0
server/notes/__init__.py Normal file
View File

3
server/notes/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
server/notes/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotesConfig(AppConfig):
name = 'notes'

13
server/notes/inputs.py Normal file
View File

@ -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)

View File

@ -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),
),
]

View File

22
server/notes/models.py Normal file
View File

@ -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)

102
server/notes/mutations.py Normal file
View File

@ -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()

25
server/notes/schema.py Normal file
View File

@ -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

3
server/notes/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
server/notes/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -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 <christian.cueni@iterativ.ch>

View File

@ -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 <christian.cueni@iterativ.ch>
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',)

View File

@ -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 <christian.cueni@iterativ.ch>
from django.apps import AppConfig
class UserConfig(AppConfig):
name = 'registration'

View File

@ -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 <christian.cueni@iterativ.ch>
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

View File

@ -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 <christian.cueni@iterativ.ch>
from django.conf import settings

View File

@ -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 <christian.cueni@iterativ.ch>
from django.conf import settings

View File

@ -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 <christian.cueni@iterativ.ch>
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')

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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 <christian.cueni@iterativ.ch>
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)

View File

@ -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 <christian.cueni@iterativ.ch>
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()

View File

@ -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 <christian.cueni@iterativ.ch>
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

View File

@ -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 <christian.cueni@iterativ.ch>
from django.conf import settings

View File

@ -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 <christian.cueni@iterativ.ch>
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'))

View File

@ -33,5 +33,5 @@ class UpdateAnswer(relay.ClientIDMutation):
return cls(answer=answer)
class SurveysMutations:
class SurveyMutations:
update_answer = UpdateAnswer.Field()

View File

@ -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

View File

@ -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()),
],
),
]

View File

@ -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

View File

@ -7,20 +7,16 @@
#
# Created on 2019-10-01
# @author: chrigu <christian.cueni@iterativ.ch>
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:

View File

@ -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)

View File

@ -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'))

View File

@ -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')

View File

@ -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 <christian.cueni@iterativ.ch>
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 <christian.cueni@iterativ.ch>
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)