diff --git a/.gitignore b/.gitignore index bdd4074d7d98ff4c226296bfaf9fd16a18e1283d..649f630549301946f0fd84b316eb8de4ac1f7552 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ backend/secfit/.vscode/ backend/secfit/*/migrations/__pycache__/ backend/secfit/*/__pycache__/ backend/secfit/db.sqlite3 + +.idea/ +venv/ +.vscode/ +.DS_store \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..e76c38b9e24b975078e356d35a24a638cea3e136 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +variables: + HEROKU_APP_NAME_BACKEND: tdt4242-base + HEROKU_APP_NAME_FRONTEND: tdt4242-base-secfit + +stages: + - test + - deploy + +test: + image: python:3 + stage: test + script: + # this configures Django application to use attached postgres database that is run on `postgres` host + - cd backend/secfit + - apt-get update -qy + - pip install -r requirements.txt + - python manage.py test + +deploy: + image: ruby + stage: deploy + type: deploy + script: + - apt-get update -qy + - apt-get install -y ruby ruby-dev + - gem install dpl + - dpl --provider=heroku --app=$HEROKU_APP_NAME_BACKEND --api-key=$HEROKU_AUTH_TOKEN + - dpl --provider=heroku --app=$HEROKU_APP_NAME_FRONTEND --api-key=$HEROKU_AUTH_TOKEN + only: + - master + + diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000000000000000000000000000000000000..591ee5f685cc3821282287ac6c07be789d6dc7d1 --- /dev/null +++ b/Pipfile @@ -0,0 +1,43 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = "==2.24.0" +asgiref = "==3.2.10" +astroid = "==2.4.2" +certifi = "==2020.6.20" +chardet = "==3.0.4" +colorama = "==0.4.3" +dj-database-url = "==0.5.0" +django-cleanup = "==5.0.0" +django-cors-headers = "==3.4.0" +djangorestframework = "==3.11.1" +djangorestframework-simplejwt = "==4.6.0" +gunicorn = "==20.0.4" +httpie = "==2.2.0" +idna = "==2.10" +isort = "==4.3.21" +lazy-object-proxy = "==1.4.3" +mccabe = "==0.6.1" +psycopg2-binary = "*" +pylint = "==2.5.3" +pylint-django = "==2.3.0" +pylint-plugin-utils = "==0.6" +pytz = "==2020.1" +rope = "==0.17.0" +six = "==1.15.0" +sqlparse = "==0.3.1" +toml = "==0.10.1" +urllib3 = "==1.25.10" +whitenoise = "==5.2.0" +wrapt = "==1.12.1" +Django = "==3.1" +Pygments = "==2.6.1" +PyJWT = "==1.7.1" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000000000000000000000000000000000000..2c073933338816e2d57b6e5bfd0ad619fd8e8761 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,327 @@ +{ + "_meta": { + "hash": { + "sha256": "f8792657ccce48034fdaeda633380958787ebae652bda60c7f24c8f89d53b20e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.9" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "asgiref": { + "hashes": [ + "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", + "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" + ], + "index": "pypi", + "version": "==3.2.10" + }, + "astroid": { + "hashes": [ + "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", + "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" + ], + "index": "pypi", + "version": "==2.4.2" + }, + "certifi": { + "hashes": [ + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + ], + "index": "pypi", + "version": "==2020.6.20" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "index": "pypi", + "version": "==3.0.4" + }, + "colorama": { + "hashes": [ + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + ], + "index": "pypi", + "version": "==0.4.3" + }, + "dj-database-url": { + "hashes": [ + "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", + "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" + ], + "index": "pypi", + "version": "==0.5.0" + }, + "django": { + "hashes": [ + "sha256:1a63f5bb6ff4d7c42f62a519edc2adbb37f9b78068a5a862beff858b68e3dc8b", + "sha256:2d390268a13c655c97e0e2ede9d117007996db692c1bb93eabebd4fb7ea7012b" + ], + "index": "pypi", + "version": "==3.1" + }, + "django-cleanup": { + "hashes": [ + "sha256:84f0c0e0a74545adae4c944a76ccf8fb0c195dddccf3b7195c59267abb7763dd", + "sha256:de5948e74e00fc74d19bf15e062477b45090ba467587f45b2459eae8f97bc4f4" + ], + "index": "pypi", + "version": "==5.0.0" + }, + "django-cors-headers": { + "hashes": [ + "sha256:5240062ef0b16668ce8a5f43324c388d65f5439e1a30e22c38684d5ddaff0d15", + "sha256:f5218f2f0bb1210563ff87687afbf10786e080d8494a248e705507ebd92d7153" + ], + "index": "pypi", + "version": "==3.4.0" + }, + "djangorestframework": { + "hashes": [ + "sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32", + "sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b" + ], + "index": "pypi", + "version": "==3.11.1" + }, + "djangorestframework-simplejwt": { + "hashes": [ + "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", + "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" + ], + "index": "pypi", + "version": "==4.6.0" + }, + "gunicorn": { + "hashes": [ + "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", + "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" + ], + "index": "pypi", + "version": "==20.0.4" + }, + "httpie": { + "hashes": [ + "sha256:31ac28088ee6a0b6f3ba7a53379000c4d1910c1708c9ff768f84b111c14405a0", + "sha256:aab111d347a3059ba507aa9339c621e5cae6658cc96f365cd6a32ae0fb6ad8aa" + ], + "index": "pypi", + "version": "==2.2.0" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "index": "pypi", + "version": "==2.10" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "index": "pypi", + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "index": "pypi", + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "index": "pypi", + "version": "==0.6.1" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", + "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", + "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", + "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", + "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", + "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", + "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", + "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", + "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", + "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", + "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", + "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", + "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", + "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", + "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", + "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", + "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", + "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", + "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", + "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", + "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", + "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", + "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", + "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", + "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", + "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", + "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", + "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", + "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", + "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", + "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" + ], + "index": "pypi", + "version": "==2.8.6" + }, + "pygments": { + "hashes": [ + "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", + "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + ], + "index": "pypi", + "version": "==2.6.1" + }, + "pyjwt": { + "hashes": [ + "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", + "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" + ], + "index": "pypi", + "version": "==1.7.1" + }, + "pylint": { + "hashes": [ + "sha256:7dd78437f2d8d019717dbf287772d0b2dbdfd13fc016aa7faa08d67bccc46adc", + "sha256:d0ece7d223fe422088b0e8f13fa0a1e8eb745ebffcb8ed53d3e95394b6101a1c" + ], + "index": "pypi", + "version": "==2.5.3" + }, + "pylint-django": { + "hashes": [ + "sha256:770e0c55fb054c6378e1e8bb3fe22c7032a2c38ba1d1f454206ee9c6591822d7", + "sha256:b8dcb6006ae9fa911810aba3bec047b9410b7d528f89d5aca2506b03c9235a49" + ], + "index": "pypi", + "version": "==2.3.0" + }, + "pylint-plugin-utils": { + "hashes": [ + "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a", + "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a" + ], + "index": "pypi", + "version": "==0.6" + }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "index": "pypi", + "version": "==2020.1" + }, + "requests": { + "hashes": [ + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + ], + "index": "pypi", + "version": "==2.24.0" + }, + "rope": { + "hashes": [ + "sha256:658ad6705f43dcf3d6df379da9486529cf30e02d9ea14c5682aa80eb33b649e1" + ], + "index": "pypi", + "version": "==0.17.0" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "index": "pypi", + "version": "==1.15.0" + }, + "sqlparse": { + "hashes": [ + "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", + "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" + ], + "index": "pypi", + "version": "==0.3.1" + }, + "toml": { + "hashes": [ + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + ], + "index": "pypi", + "version": "==0.10.1" + }, + "urllib3": { + "hashes": [ + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" + ], + "index": "pypi", + "version": "==1.25.10" + }, + "whitenoise": { + "hashes": [ + "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7", + "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d" + ], + "index": "pypi", + "version": "==5.2.0" + }, + "wrapt": { + "hashes": [ + "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" + ], + "index": "pypi", + "version": "==1.12.1" + } + }, + "develop": {} +} diff --git a/backend/secfit/.coverage b/backend/secfit/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..a4e9f080c09bfdd33b4f1f976c1511d81ebc83d2 Binary files /dev/null and b/backend/secfit/.coverage differ diff --git a/backend/secfit/Procfile b/backend/secfit/Procfile new file mode 100644 index 0000000000000000000000000000000000000000..507db01c439e93dd7c81e0ec453289b04cf81497 --- /dev/null +++ b/backend/secfit/Procfile @@ -0,0 +1 @@ +web: gunicorn --pythonpath 'backend/secfit' secfit.wsgi --log-file - diff --git a/backend/secfit/comments/migrations/0002_auto_20210304_2241.py b/backend/secfit/comments/migrations/0002_auto_20210304_2241.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4fb6d46f3d27664651edc221a883f2e7a01888 --- /dev/null +++ b/backend/secfit/comments/migrations/0002_auto_20210304_2241.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2021-03-04 22:41 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('comments', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='comment', + name='timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='like', + name='timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/backend/secfit/comments/models.py b/backend/secfit/comments/models.py index e1bba07bce0d33e4325867917b71afea87651501..ac48dc2af65e65cd28ad46cefafec58bbaff399d 100644 --- a/backend/secfit/comments/models.py +++ b/backend/secfit/comments/models.py @@ -8,8 +8,10 @@ from django.urls import reverse from django.db import models from django.contrib.auth import get_user_model from workouts.models import Workout - +from django.utils import timezone # Create your models here. + + class Comment(models.Model): """Django model for a comment left on a workout. @@ -26,7 +28,7 @@ class Comment(models.Model): Workout, on_delete=models.CASCADE, related_name="comments" ) content = models.TextField() - timestamp = models.DateTimeField(auto_now_add=True) + timestamp = models.DateTimeField(default=timezone.now) class Meta: ordering = ["-timestamp"] @@ -44,5 +46,6 @@ class Like(models.Model): owner = models.ForeignKey( get_user_model(), on_delete=models.CASCADE, related_name="likes" ) - comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="likes") - timestamp = models.DateTimeField(auto_now_add=True) + comment = models.ForeignKey( + Comment, on_delete=models.CASCADE, related_name="likes") + timestamp = models.DateTimeField(default=timezone.now) diff --git a/backend/secfit/requirements.txt b/backend/secfit/requirements.txt index 9feb375bde1e8fb7befe6c102dd29beeee7c6940..d3da257234e9819009e5d6c0538bc7373dcc508b 100644 Binary files a/backend/secfit/requirements.txt and b/backend/secfit/requirements.txt differ diff --git a/backend/secfit/secfit/django_heroku.py b/backend/secfit/secfit/django_heroku.py new file mode 100644 index 0000000000000000000000000000000000000000..76218707447386e4c495d22bcb41d50b35681a4b --- /dev/null +++ b/backend/secfit/secfit/django_heroku.py @@ -0,0 +1,120 @@ +# import logging +import os + +import dj_database_url +from django.test.runner import DiscoverRunner + +MAX_CONN_AGE = 600 + + +def settings(config, *, db_colors=False, databases=True, test_runner=False, staticfiles=True, allowed_hosts=True, + logging=True, secret_key=True): + # Database configuration. + # TODO: support other database (e.g. TEAL, AMBER, etc, automatically.) + if databases: + # Integrity check. + if 'DATABASES' not in config: + config['DATABASES'] = {'default': None} + + conn_max_age = config.get('CONN_MAX_AGE', MAX_CONN_AGE) + + if db_colors: + # Support all Heroku databases. + # TODO: This appears to break TestRunner. + for (env, url) in os.environ.items(): + if env.startswith('HEROKU_POSTGRESQL'): + db_color = env[len('HEROKU_POSTGRESQL_'):].split('_')[0] + + # logger.info('Adding ${} to DATABASES Django setting ({}).'.format(env, db_color)) + + config['DATABASES'][db_color] = dj_database_url.parse(url, conn_max_age=conn_max_age, + ssl_require=True) + + if 'DATABASE_URL' in os.environ: + # logger.info('Adding $DATABASE_URL to default DATABASE Django setting.') + + # Configure Django for DATABASE_URL environment variable. + config['DATABASES']['default'] = dj_database_url.config( + conn_max_age=conn_max_age, ssl_require=True) + + # logger.info('Adding $DATABASE_URL to TEST default DATABASE Django setting.') + + # Enable test database if found in CI environment. + if 'CI' in os.environ: + config['DATABASES']['default']['TEST'] = config['DATABASES']['default'] + + # else: + # logger.info('$DATABASE_URL not found, falling back to previous settings!') + + if test_runner: + # Enable test runner if found in CI environment. + if 'CI' in os.environ: + config['TEST_RUNNER'] = 'django_heroku.HerokuDiscoverRunner' + + # Staticfiles configuration. + if staticfiles: + # logger.info('Applying Heroku Staticfiles configuration to Django settings.') + + config['STATIC_ROOT'] = os.path.join(config['BASE_DIR'], 'staticfiles') + config['STATIC_URL'] = '/static/' + + # Ensure STATIC_ROOT exists. + os.makedirs(config['STATIC_ROOT'], exist_ok=True) + + # Insert Whitenoise Middleware. + try: + config['MIDDLEWARE_CLASSES'] = tuple( + ['whitenoise.middleware.WhiteNoiseMiddleware'] + list(config['MIDDLEWARE_CLASSES'])) + except KeyError: + config['MIDDLEWARE'] = tuple( + ['whitenoise.middleware.WhiteNoiseMiddleware'] + list(config['MIDDLEWARE'])) + + # Enable GZip. + config['STATICFILES_STORAGE'] = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + + if allowed_hosts: + # logger.info('Applying Heroku ALLOWED_HOSTS configuration to Django settings.') + config['ALLOWED_HOSTS'] = ['*'] + """ + if logging: + logger.info('Applying Heroku logging configuration to Django settings.') + + config['LOGGING'] = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': ('%(asctime)s [%(process)d] [%(levelname)s] ' + + 'pathname=%(pathname)s lineno=%(lineno)s ' + + 'funcname=%(funcName)s %(message)s'), + 'datefmt': '%Y-%m-%d %H:%M:%S' + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + } + }, + 'handlers': { + 'null': { + 'level': 'DEBUG', + 'class': 'logging.NullHandler', + }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + } + }, + 'loggers': { + 'testlogger': { + 'handlers': ['console'], + 'level': 'INFO', + } + } + } + """ + # SECRET_KEY configuration. + if secret_key: + if 'SECRET_KEY' in os.environ: + # logger.info('Adding $SECRET_KEY to SECRET_KEY Django setting.') + # Set the Django setting from the environment variable. + config['SECRET_KEY'] = os.environ['SECRET_KEY'] diff --git a/backend/secfit/secfit/settings.py b/backend/secfit/secfit/settings.py index 92336536fbb75beeade6bfc8e60eb393531832c9..4253032219ff67105bfdb528721b638e7d19a3e7 100644 --- a/backend/secfit/secfit/settings.py +++ b/backend/secfit/secfit/settings.py @@ -15,6 +15,8 @@ import os # Get the GROUPID variable to accept connections from the application server and NGINX +from .django_heroku import settings + groupid = os.environ.get("GROUPID", "0") # Email configuration @@ -43,6 +45,8 @@ ALLOWED_HOSTS = [ "10." + groupid + ".0.4", "molde.idi.ntnu.no", "10.0.2.2", + "tdt4242-base.herokuapp.com", + "tdt4242-base-secfit.herokuapp.com" ] # Application definition @@ -58,7 +62,10 @@ INSTALLED_APPS = [ "workouts.apps.WorkoutsConfig", "users.apps.UsersConfig", "comments.apps.CommentsConfig", + "suggested_workouts.apps.SuggestedWorkoutsConfig", "corsheaders", + "django_heroku" + ] MIDDLEWARE = [ @@ -138,9 +145,17 @@ REST_FRAMEWORK = { "PAGE_SIZE": 10, "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", + 'rest_framework.authentication.SessionAuthentication', ), } +AUTH_PASSWORD_VALIDATORS = [{ + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 9, + }}] AUTH_USER_MODEL = "users.User" DEBUG = True + +settings(locals()) diff --git a/backend/secfit/secfit/urls.py b/backend/secfit/secfit/urls.py index 3146886ed88c67c7f8838c74cea38c7b7ee5555a..5bc17685ca2deb8ceb1642ae56bee320c1e47040 100644 --- a/backend/secfit/secfit/urls.py +++ b/backend/secfit/secfit/urls.py @@ -21,6 +21,7 @@ from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), path("", include("workouts.urls")), + path("", include("suggested_workouts.urls")), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/secfit/suggested_workouts/__init__.py b/backend/secfit/suggested_workouts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/secfit/suggested_workouts/admin.py b/backend/secfit/suggested_workouts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..65ffde0a2d8b0423ad3afb06a64562f4693152ac --- /dev/null +++ b/backend/secfit/suggested_workouts/admin.py @@ -0,0 +1,9 @@ +"""Module for registering models from workouts app to admin page so that they appear +""" +from django.contrib import admin + +# Register your models here. +from .models import SuggestedWorkout + +admin.site.register(SuggestedWorkout) + diff --git a/backend/secfit/suggested_workouts/apps.py b/backend/secfit/suggested_workouts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..e4fea100302e8b54f212b0dbfacd6d10f4f411c5 --- /dev/null +++ b/backend/secfit/suggested_workouts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SuggestedWorkoutsConfig(AppConfig): + name = 'suggested_workouts' diff --git a/backend/secfit/suggested_workouts/migrations/0001_initial.py b/backend/secfit/suggested_workouts/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..7d3c8a412ddc9ca6df96015173a93395d726eee5 --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1 on 2021-02-23 21:11 + +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), + ] + + operations = [ + migrations.CreateModel( + name='WorkoutRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(blank=True, default=True)), + ('reciever', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reciever', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sender', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SuggestedWorkout', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('date', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField()), + ('visibility', models.CharField(choices=[('PU', 'Public'), ('CO', 'Coach'), ('PR', 'Private')], default='CO', max_length=2)), + ('athlete', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='athlete', to=settings.AUTH_USER_MODEL)), + ('coach', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='author', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/0002_auto_20210304_2241.py b/backend/secfit/suggested_workouts/migrations/0002_auto_20210304_2241.py new file mode 100644 index 0000000000000000000000000000000000000000..5da3bc244f4fbc5bcb4a4faf37c5cffcc8deed65 --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0002_auto_20210304_2241.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1 on 2021-03-04 22:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('suggested_workouts', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='suggestedworkout', + name='visibility', + ), + migrations.AddField( + model_name='suggestedworkout', + name='status', + field=models.CharField(choices=[('a', 'Accepted'), ('p', 'Pending'), ('d', 'Declined')], default='p', max_length=8), + ), + migrations.AlterField( + model_name='suggestedworkout', + name='coach', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/0003_suggestedworkout_visibility.py b/backend/secfit/suggested_workouts/migrations/0003_suggestedworkout_visibility.py new file mode 100644 index 0000000000000000000000000000000000000000..d2910f0674211fb44aed07a9c21dbd28847a0250 --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0003_suggestedworkout_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-03-04 22:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('suggested_workouts', '0002_auto_20210304_2241'), + ] + + operations = [ + migrations.AddField( + model_name='suggestedworkout', + name='visibility', + field=models.CharField(default='PU', max_length=8), + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/0004_remove_suggestedworkout_visibility.py b/backend/secfit/suggested_workouts/migrations/0004_remove_suggestedworkout_visibility.py new file mode 100644 index 0000000000000000000000000000000000000000..b954002b01399a9197dbd879f89bf3e0e250e09d --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0004_remove_suggestedworkout_visibility.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2021-03-04 23:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('suggested_workouts', '0003_suggestedworkout_visibility'), + ] + + operations = [ + migrations.RemoveField( + model_name='suggestedworkout', + name='visibility', + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/0005_suggestedworkout_visibility.py b/backend/secfit/suggested_workouts/migrations/0005_suggestedworkout_visibility.py new file mode 100644 index 0000000000000000000000000000000000000000..782a3ccc84eeb87085b6168e2b1101a7ae8a1e7b --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0005_suggestedworkout_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-03-04 23:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('suggested_workouts', '0004_remove_suggestedworkout_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='suggestedworkout', + name='visibility', + field=models.CharField(blank=True, default='', max_length=8, null=True), + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/0006_auto_20210305_0929.py b/backend/secfit/suggested_workouts/migrations/0006_auto_20210305_0929.py new file mode 100644 index 0000000000000000000000000000000000000000..3daeb6a801389f183a47dffddb763e489c18c195 --- /dev/null +++ b/backend/secfit/suggested_workouts/migrations/0006_auto_20210305_0929.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2021-03-05 09:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('suggested_workouts', '0005_suggestedworkout_visibility'), + ] + + operations = [ + migrations.RemoveField( + model_name='suggestedworkout', + name='visibility', + ), + migrations.DeleteModel( + name='WorkoutRequest', + ), + ] diff --git a/backend/secfit/suggested_workouts/migrations/__init__.py b/backend/secfit/suggested_workouts/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/secfit/suggested_workouts/models.py b/backend/secfit/suggested_workouts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..9b326867e03189dc8271da93ed5e0f9614da3c99 --- /dev/null +++ b/backend/secfit/suggested_workouts/models.py @@ -0,0 +1,30 @@ +from django.db import models +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone + + +class SuggestedWorkout(models.Model): + # Visibility levels + ACCEPTED = "a" + PENDING = "p" + DECLINED = "d" + STATUS_CHOICES = ( + (ACCEPTED, "Accepted"), + (PENDING, "Pending"), + (DECLINED, "Declined"), + ) + name = models.CharField(max_length=100) + date = models.DateTimeField(null=True, blank=True) + notes = models.TextField() + coach = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="owner") + athlete = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="athlete") + + status = models.CharField( + max_length=8, choices=STATUS_CHOICES, default=PENDING) + + def __str__(self): + return self.name + \ No newline at end of file diff --git a/backend/secfit/suggested_workouts/serializer.py b/backend/secfit/suggested_workouts/serializer.py new file mode 100644 index 0000000000000000000000000000000000000000..0d214f20bf168c2d794589c98fae81479a43cae5 --- /dev/null +++ b/backend/secfit/suggested_workouts/serializer.py @@ -0,0 +1,128 @@ +from rest_framework import serializers +from .models import SuggestedWorkout +from users.models import User +from workouts.serializers import WorkoutFileSerializer, ExerciseInstanceSerializer +from workouts.models import ExerciseInstance, WorkoutFile + + +class SuggestedWorkoutSerializer(serializers.ModelSerializer): + suggested_exercise_instances = ExerciseInstanceSerializer( + many=True, required=False) + suggested_workout_files = WorkoutFileSerializer(many=True, required=False) + coach_username = serializers.SerializerMethodField() + + class Meta: + model = SuggestedWorkout + fields = ['id', 'athlete', 'coach_username', 'name', 'notes', 'date', + 'status', 'coach', 'suggested_exercise_instances', 'suggested_workout_files'] + extra_kwargs = {"coach": {"read_only": True}} + + def create(self, validated_data, coach): + """Custom logic for creating ExerciseInstances, WorkoutFiles, and a Workout. + + This is needed to iterate over the files and exercise instances, since this serializer is + nested. + + Args: + validated_data: Validated files and exercise_instances + + Returns: + Workout: A newly created Workout + """ + exercise_instances_data = validated_data.pop( + "suggested_exercise_instances") + files_data = [] + if "suggested_workout_files" in validated_data: + files_data = validated_data.pop("suggested_workout_files") + + suggested_workout = SuggestedWorkout.objects.create( + coach=coach, **validated_data) + + for exercise_instance_data in exercise_instances_data: + ExerciseInstance.objects.create( + suggested_workout=suggested_workout, **exercise_instance_data) + for file_data in files_data: + WorkoutFile.objects.create( + suggested_workout=suggested_workout, owner=suggested_workout.coach, file=file_data.get( + "file") + ) + + return suggested_workout + + def update(self, instance, validated_data): + exercise_instances_data = validated_data.pop( + "suggested_exercise_instances") + exercise_instances = instance.suggested_exercise_instances + + instance.name = validated_data.get("name", instance.name) + instance.notes = validated_data.get("notes", instance.notes) + instance.status = validated_data.get( + "status", instance.status) + instance.date = validated_data.get("date", instance.date) + instance.athlete = validated_data.get("athlete", instance.athlete) + instance.save() + + # Handle ExerciseInstances + + # This updates existing exercise instances without adding or deleting object. + # zip() will yield n 2-tuples, where n is + # min(len(exercise_instance), len(exercise_instance_data)) + for exercise_instance, exercise_instance_data in zip( + exercise_instances.all(), exercise_instances_data): + exercise_instance.exercise = exercise_instance_data.get( + "exercise", exercise_instance.exercise) + exercise_instance.number = exercise_instance_data.get( + "number", exercise_instance.number + ) + exercise_instance.sets = exercise_instance_data.get( + "sets", exercise_instance.sets + ) + exercise_instance.save() + + # If new exercise instances have been added to the workout, then create them + if len(exercise_instances_data) > len(exercise_instances.all()): + for i in range(len(exercise_instances.all()), len(exercise_instances_data)): + exercise_instance_data = exercise_instances_data[i] + ExerciseInstance.objects.create( + suggested_workout=instance, **exercise_instance_data + ) + # Else if exercise instances have been removed from the workout, then delete them + elif len(exercise_instances_data) < len(exercise_instances.all()): + for i in range(len(exercise_instances_data), len(exercise_instances.all())): + exercise_instances.all()[i].delete() + + # Handle WorkoutFiles + + if "suggested_workout_files" in validated_data: + files_data = validated_data.pop("suggested_workout_files") + files = instance.suggested_workout_files + + for file, file_data in zip(files.all(), files_data): + file.file = file_data.get("file", file.file) + file.save() + + # If new files have been added, creating new WorkoutFiles + if len(files_data) > len(files.all()): + for i in range(len(files.all()), len(files_data)): + WorkoutFile.objects.create( + suggested_workout=instance, + owner=instance.coach, + file=files_data[i].get("file"), + ) + # Else if files have been removed, delete WorkoutFiles + elif len(files_data) < len(files.all()): + for i in range(len(files_data), len(files.all())): + files.all()[i].delete() + + return instance + + def get_coach_username(self, obj): + """Returns the owning user's username + + Args: + obj (Workout): Current Workout + + Returns: + str: Username of owner + """ + return obj.coach.username diff --git a/backend/secfit/suggested_workouts/tests.py b/backend/secfit/suggested_workouts/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/backend/secfit/suggested_workouts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/secfit/suggested_workouts/urls.py b/backend/secfit/suggested_workouts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..c6a22224d2340cce1efa18137fbf96ca921964c9 --- /dev/null +++ b/backend/secfit/suggested_workouts/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from suggested_workouts import views +from rest_framework.urlpatterns import format_suffix_patterns + +urlpatterns = [ + path("api/suggested-workouts/create/", views.createSuggestedWorkouts, + name="suggested_workouts"), + path("api/suggested-workouts/athlete-list/", + views.listAthleteSuggestedWorkouts, name="suggested_workouts_for_athlete"), + path("api/suggested-workouts/coach-list/", + views.listCoachSuggestedWorkouts, name="suggested_workouts_by_coach"), + path("api/suggested-workouts/", views.listAllSuggestedWorkouts, + name="list_all_suggested_workouts"), + path("api/suggested-workout//", + views.detailedSuggestedWorkout, name="suggested-workout-detail") +] diff --git a/backend/secfit/suggested_workouts/views.py b/backend/secfit/suggested_workouts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..85797a3e687bc83b60d0e7522b8986280bb932b7 --- /dev/null +++ b/backend/secfit/suggested_workouts/views.py @@ -0,0 +1,102 @@ +from rest_framework.decorators import api_view +from suggested_workouts.models import SuggestedWorkout +from .serializer import SuggestedWorkoutSerializer +from users.models import User +from rest_framework import status +from rest_framework.response import Response +from workouts.parsers import MultipartJsonParser +from rest_framework.parsers import ( + JSONParser +) +from rest_framework.decorators import parser_classes +""" +Handling post request of a new suggested workout instance. Handling update, delete and list exercises as well. +""" + + +@api_view(['POST']) +@parser_classes([MultipartJsonParser, + JSONParser]) +def createSuggestedWorkouts(request): + serializer = SuggestedWorkoutSerializer(data=request.data) + if serializer.is_valid(): + chosen_athlete_id = request.data['athlete'] + chosen_athlete = User.objects.get(id=chosen_athlete_id) + if(request.user != chosen_athlete.coach): + return Response({"message": "You can not assign the workout to someone who is not your athlete."}, status=status.HTTP_400_BAD_REQUEST) + # new_suggested_workout = SuggestedWorkout.objects.create( + # coach=request.user, **serializer.validated_data) + serializer.create( + validated_data=serializer.validated_data, coach=request.user) + return Response({"message": "Suggested workout successfully created!"}, status=status.HTTP_201_CREATED) + return Response({"message": "Something went wrong.", "error": serializer.errors}) + + +@api_view(['GET']) +def listAthleteSuggestedWorkouts(request): + # Henter ut riktige workouts gitt brukeren som sender requesten + suggested_workouts = SuggestedWorkout.objects.filter(athlete=request.user) + if not request.user: + return Response({"message": "You have to log in to see this information."}, status=status.HTTP_401_UNAUTHORIZED) + serializer = SuggestedWorkoutSerializer( + suggested_workouts, many=True, context={'request': request}) + return Response(data=serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +def listCoachSuggestedWorkouts(request): + # Gjør spørring på workouts der request.user er coach + if not request.user: + return Response({"message": "You have to log in to see this information."}, status=status.HTTP_401_UNAUTHORIZED) + suggested_workouts = SuggestedWorkout.objects.filter(coach=request.user) + serializer = SuggestedWorkoutSerializer( + suggested_workouts, many=True, context={'request': request}) + return Response(data=serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +def listAllSuggestedWorkouts(request): + # Lister alle workouts som er foreslått + suggested_workouts = SuggestedWorkout.objects.all() + serializer = SuggestedWorkoutSerializer( + suggested_workouts, many=True, context={'request': request}) + if not request.user.id: + return Response({"message": "You have to log in to see this information."}, status=status.HTTP_401_UNAUTHORIZED) + # elif((request.user.id,) not in list(SuggestedWorkout.objects.values_list('coach')) or (request.user.id,) not in list(SuggestedWorkout.objects.values_list('athlete'))): + # return Response({"message": "You must either be a coach or athlete of the suggested workouts to see this information."}, status=status.HTTP_401_UNAUTHORIZED) + return Response(data=serializer.data, status=status.HTTP_200_OK) + + +""" +View for both deleting,updating and retrieving a single workout. +""" + + +@api_view(['GET', 'DELETE', 'PUT']) +@parser_classes([MultipartJsonParser, + JSONParser]) +def detailedSuggestedWorkout(request, pk): + detailed_suggested_workout = SuggestedWorkout.objects.get(id=pk) + if not request.user.id: + return Response({"message": "Access denied."}, status=status.HTTP_401_UNAUTHORIZED) + elif request.method == 'GET': + serializer = SuggestedWorkoutSerializer( + detailed_suggested_workout, context={'request': request}) + if(request.user.id != detailed_suggested_workout.coach.id and request.user.id != detailed_suggested_workout.athlete.id): + return Response({"messages": "You have to be a coach or athlete to see this information."}, status=status.HTTP_401_UNAUTHORIZED) + return Response(data=serializer.data, status=status.HTTP_200_OK) + elif request.method == 'DELETE': + if(request.user.id != detailed_suggested_workout.coach.id and request.user.id != detailed_suggested_workout.athlete.id): + return Response({"messages": "You have to be a coach or athlete to perform this action."}, status=status.HTTP_401_UNAUTHORIZED) + SuggestedWorkout.delete(detailed_suggested_workout) + return Response({"message": "Suggested workout successfully deleted."}, status=status.HTTP_204_NO_CONTENT) + elif request.method == 'PUT': + if(request.user.id != detailed_suggested_workout.coach.id and request.user.id != detailed_suggested_workout.athlete.id): + return Response({"messages": "You have to be a coach or athlete to perform this action."}, status=status.HTTP_401_UNAUTHORIZED) + serializer = SuggestedWorkoutSerializer( + detailed_suggested_workout, data=request.data) + if(serializer.is_valid()): + serializer.update(instance=SuggestedWorkout.objects.get(id=pk), + validated_data=serializer.validated_data) + return Response({"message": "Successfully updated the suggested workout!"}, status=status.HTTP_200_OK) + return Response({"message": "Something went wrong.", "error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/secfit/users/admin.py b/backend/secfit/users/admin.py index fc0af23c4473e29bcc06045aebfdd0d21989d22d..de6a0e93539eca3a56fc08eebce97d6fe05f68fd 100644 --- a/backend/secfit/users/admin.py +++ b/backend/secfit/users/admin.py @@ -11,9 +11,12 @@ class CustomUserAdmin(UserAdmin): add_form = CustomUserCreationForm form = CustomUserChangeForm model = get_user_model() - # list_display = UserAdmin.list_display + ('coach',) + list_display = UserAdmin.list_display + \ + ('coach',) + ('phone_number',) + \ + ('country',) + ('city',) + ('street_address',) fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("coach",)}),) - add_fieldsets = UserAdmin.add_fieldsets + ((None, {"fields": ("coach",)}),) + add_fieldsets = UserAdmin.add_fieldsets + \ + ((None, {"fields": ("coach", "phone_number")}),) admin.site.register(get_user_model(), CustomUserAdmin) diff --git a/backend/secfit/users/migrations/0002_auto_20200907_1200.py b/backend/secfit/users/migrations/0002_auto_20200907_1200.py deleted file mode 100644 index e1ffdfc85bbfa6746a249e02fb018710a3545c1a..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0002_auto_20200907_1200.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.1 on 2020-09-07 10:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="offer", - name="stale", - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name="offer", - name="status", - field=models.CharField( - choices=[("a", "Accepted"), ("p", "Pending"), ("d", "Declined")], - default="p", - max_length=8, - ), - ), - migrations.DeleteModel( - name="OfferResponse", - ), - ] diff --git a/backend/secfit/users/migrations/0002_auto_20210304_2241.py b/backend/secfit/users/migrations/0002_auto_20210304_2241.py new file mode 100644 index 0000000000000000000000000000000000000000..007428eed8563af6bd93de6878709da7a3ea4ae7 --- /dev/null +++ b/backend/secfit/users/migrations/0002_auto_20210304_2241.py @@ -0,0 +1,82 @@ +# Generated by Django 3.1 on 2021-03-04 22:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AthleteFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=users.models.athlete_directory_path)), + ], + ), + migrations.RemoveField( + model_name='offer', + name='offer_type', + ), + migrations.AddField( + model_name='offer', + name='status', + field=models.CharField(choices=[('a', 'Accepted'), ('p', 'Pending'), ('d', 'Declined')], default='p', max_length=8), + ), + migrations.AddField( + model_name='offer', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='user', + name='city', + field=models.TextField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='user', + name='country', + field=models.TextField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='user', + name='phone_number', + field=models.TextField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='user', + name='street_address', + field=models.TextField(blank=True, max_length=50), + ), + migrations.AlterField( + model_name='offer', + name='recipient', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_offers', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='user', + name='coach', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='athletes', to=settings.AUTH_USER_MODEL), + ), + migrations.DeleteModel( + name='OfferResponse', + ), + migrations.AddField( + model_name='athletefile', + name='athlete', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coach_files', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='athletefile', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='athlete_files', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/secfit/users/migrations/0003_auto_20200907_1954.py b/backend/secfit/users/migrations/0003_auto_20200907_1954.py deleted file mode 100644 index c7f18c817057b573e3275f36a69f00e13676e5f3..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0003_auto_20200907_1954.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1 on 2020-09-07 17:54 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0002_auto_20200907_1200"), - ] - - operations = [ - migrations.AlterField( - model_name="offer", - name="recipient", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_offers", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/backend/secfit/users/migrations/0004_auto_20200907_2021.py b/backend/secfit/users/migrations/0004_auto_20200907_2021.py deleted file mode 100644 index ff6be46f14e2bd0adc097f802b81ce758e4afcb1..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0004_auto_20200907_2021.py +++ /dev/null @@ -1,110 +0,0 @@ -# Generated by Django 3.1 on 2020-09-07 18:21 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0003_auto_20200907_1954"), - ] - - operations = [ - migrations.CreateModel( - name="AthleteRequest", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("a", "Accepted"), - ("p", "Pending"), - ("d", "Declined"), - ], - default="p", - max_length=8, - ), - ), - ("stale", models.BooleanField(default=False)), - ( - "owner", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sent_athlete_requests", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "recipient", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_athlete_requests", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="CoachRequest", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("a", "Accepted"), - ("p", "Pending"), - ("d", "Declined"), - ], - default="p", - max_length=8, - ), - ), - ("stale", models.BooleanField(default=False)), - ( - "owner", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="sent_coach_request", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "recipient", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_coach_requests", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.DeleteModel( - name="Offer", - ), - ] diff --git a/backend/secfit/users/migrations/0005_auto_20200907_2039.py b/backend/secfit/users/migrations/0005_auto_20200907_2039.py deleted file mode 100644 index 269e723bda3ff1b5b2207d1f7471b0f698682033..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0005_auto_20200907_2039.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.1 on 2020-09-07 18:39 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0004_auto_20200907_2021"), - ] - - operations = [ - migrations.AlterField( - model_name="athleterequest", - name="owner", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sent_athleterequests", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="athleterequest", - name="recipient", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_athleterequests", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="coachrequest", - name="owner", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sent_coachrequests", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AlterField( - model_name="coachrequest", - name="recipient", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_coachrequests", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/backend/secfit/users/migrations/0006_auto_20200907_2054.py b/backend/secfit/users/migrations/0006_auto_20200907_2054.py deleted file mode 100644 index ed2ff761a8072336a82493831b79d1b99edb2b34..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0006_auto_20200907_2054.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.1 on 2020-09-07 18:54 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0005_auto_20200907_2039"), - ] - - operations = [ - migrations.AddField( - model_name="athleterequest", - name="timestamp", - field=models.DateTimeField( - auto_now_add=True, default=django.utils.timezone.now - ), - preserve_default=False, - ), - migrations.AddField( - model_name="coachrequest", - name="timestamp", - field=models.DateTimeField( - auto_now_add=True, default=django.utils.timezone.now - ), - preserve_default=False, - ), - ] diff --git a/backend/secfit/users/migrations/0007_auto_20200910_0222.py b/backend/secfit/users/migrations/0007_auto_20200910_0222.py deleted file mode 100644 index 48a081d1f9387a1eb0fcd66648ec53d0ec8c1410..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0007_auto_20200910_0222.py +++ /dev/null @@ -1,131 +0,0 @@ -# Generated by Django 3.1 on 2020-09-10 00:22 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import users.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("users", "0006_auto_20200907_2054"), - ] - - operations = [ - migrations.CreateModel( - name="AthleteFile", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "file", - models.FileField(upload_to=users.models.athlete_directory_path), - ), - ], - ), - migrations.CreateModel( - name="Offer", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("a", "Accepted"), - ("p", "Pending"), - ("d", "Declined"), - ], - default="p", - max_length=8, - ), - ), - ( - "offer_type", - models.CharField( - choices=[("a", "Athlete"), ("c", "Coach")], - default="a", - max_length=8, - ), - ), - ("stale", models.BooleanField(default=False)), - ("timestamp", models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.RemoveField( - model_name="coachrequest", - name="owner", - ), - migrations.RemoveField( - model_name="coachrequest", - name="recipient", - ), - migrations.AlterField( - model_name="user", - name="coach", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="athletes", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.DeleteModel( - name="AthleteRequest", - ), - migrations.DeleteModel( - name="CoachRequest", - ), - migrations.AddField( - model_name="offer", - name="owner", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="sent_offers", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="offer", - name="recipient", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="received_offers", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="athletefile", - name="athlete", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="coach_files", - to=settings.AUTH_USER_MODEL, - ), - ), - migrations.AddField( - model_name="athletefile", - name="owner", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="athlete_files", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/backend/secfit/users/migrations/0008_auto_20201213_2228.py b/backend/secfit/users/migrations/0008_auto_20201213_2228.py deleted file mode 100644 index b2a2d3bd1f048227365bd679b1a253855cd4b776..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0008_auto_20201213_2228.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-12-13 21:28 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0007_auto_20200910_0222'), - ] - - operations = [ - migrations.RemoveField( - model_name='offer', - name='offer_type', - ), - migrations.RemoveField( - model_name='offer', - name='stale', - ), - ] diff --git a/backend/secfit/users/migrations/0009_auto_20210204_1055.py b/backend/secfit/users/migrations/0009_auto_20210204_1055.py deleted file mode 100644 index 90d76ebd4412716db60b8615e474bfa9bc0464b4..0000000000000000000000000000000000000000 --- a/backend/secfit/users/migrations/0009_auto_20210204_1055.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.1 on 2021-02-04 10:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0008_auto_20201213_2228'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='city', - field=models.TextField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='user', - name='country', - field=models.TextField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='user', - name='phone_number', - field=models.TextField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='user', - name='street_address', - field=models.TextField(blank=True, max_length=50), - ), - ] diff --git a/backend/secfit/users/tests.py b/backend/secfit/users/tests.py index 7ce503c2dd97ba78597f6ff6e4393132753573f6..3696cebc68a2b871e0601118d8ee3356522d0c85 100644 --- a/backend/secfit/users/tests.py +++ b/backend/secfit/users/tests.py @@ -1,3 +1,162 @@ -from django.test import TestCase +from django.contrib.auth import get_user_model, password_validation +# from django.test import TestCase +from users.serializers import UserSerializer +from rest_framework.test import APIRequestFactory, APITestCase +from rest_framework.request import Request +from random import choice +from string import ascii_uppercase +from users.models import User +from django import forms +from rest_framework import serializers +from rest_framework.exceptions import ValidationError -# Create your tests here. + +class UserSerializerTestCase(APITestCase): + # Set up test instance of a user and serialized data of that user + def setUp(self): + self.user_attributes = { + "id": 1, + "email": "fake@email.com", + "username": "fake_user", + "phone_number": "92345678", + "country": "Norway", + "city": "Trondheim", + "street_address": "Lade Alle", + } + factory = APIRequestFactory() + request = factory.get('/') + self.test_user = get_user_model()(**self.user_attributes) + self.test_user.set_password("password") + self.serialized_user = UserSerializer( + self.test_user, context={'request': Request(request)}) + + self.serializer_data = { + "id": self.user_attributes["id"], + "email": self.user_attributes["email"], + "username": self.user_attributes["username"], + "password": 'password', + "password1": 'password', + "athletes": [], + "phone_number": self.user_attributes["phone_number"], + "country": self.user_attributes["country"], + "city": self.user_attributes["city"], + "street_address": self.user_attributes["street_address"], + "coach": "", + "workouts": [], + "coach_files": [], + "athlete_files": [], + } + self.new_serializer_data = { + "email": 'email@fake.com', + "username": 'faker', + "athletes": [], + "password": 'fuck_django', + "password1": 'fuck_django', + "phone_number": '12345678', + "country": 'Norge', + "city": 'Oslo', + "street_address": 'Mora di', + "workouts": [], + "coach_files": [], + "athlete_files": [], } + + # Test that the serializer return the expecte fields for a given user instance + + def test_contains_expected_fields(self): + serialized_data = self.serialized_user.data + self.assertEqual(set(serialized_data.keys()), set([ + "url", + "id", + "email", + "username", + "athletes", + "phone_number", + "country", + "city", + "street_address", + "coach", + "workouts", + "coach_files", + "athlete_files", + ])) + # Testing if serialized data matched the retrieved instance in the database + + def test_corresponding_id_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "id" + ], self.user_attributes['id']) + + def test_corresponding_email_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "email" + ], self.user_attributes['email']) + + def test_corresponding_username_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "username" + ], self.user_attributes['username']) + + def test_corresponding_phone_number_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "phone_number" + ], self.user_attributes['phone_number']) + + def test_corresponding_country_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "country" + ], self.user_attributes['country']) + + def test_corresponding_city_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "country" + ], self.user_attributes['country']) + + def test_corresponding_street_address_field(self): + serialized_data = self.serialized_user.data + self.assertEqual(serialized_data[ + "street_address" + ], self.user_attributes['street_address']) + + def test_create_user(self): + # Sjekker at jeg får serialisert til OrderedDict, kompleks datatype som kan bruker for å lage instans + new_serializer = UserSerializer(data=self.new_serializer_data) + self.assertTrue(new_serializer.is_valid()) + # Lage bruker + new_serializer.save() + # Sjekker at brukeren faktisk ble laget med brukernavner, 'faker' + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).username, self.new_serializer_data['username']) + # Sjekk at resten av feltene til instansen faktisk er lik de du definerte i serializer sin data + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).email, self.new_serializer_data['email']) + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).street_address, self.new_serializer_data['street_address']) + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).phone_number, self.new_serializer_data['phone_number']) + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).country, self.new_serializer_data['country']) + self.assertEquals(get_user_model().objects.get( + username=self.new_serializer_data['username']).city, self.new_serializer_data['city']) + user_password = get_user_model().objects.get(username='faker').password + # Sjekker om plaintekst passordet matcher med den krypterte i databasen + self.assertTrue(self.new_serializer_data['password'], user_password) + + def test_validate_password(self): + with self.assertRaises(serializers.ValidationError): + UserSerializer(self.new_serializer_data).validate_password( + 'short') + + def test_valid_pasword(self): + self.new_serializer_data['password'] = '12345678910' + self.new_serializer_data['password1'] = '12345678910' + self.data = {'password': '12345678910', 'password1': '12345678910'} + user_ser = UserSerializer(instance=None, data=self.data) + # Returns the password as the value + self.assertEquals(user_ser.validate_password( + '12345678910'), self.data['password']) diff --git a/backend/secfit/workouts/admin.py b/backend/secfit/workouts/admin.py index cb43794b85492adcb933dc4e46f875029dc411cf..777980c0f343933b46363a7fb467c960ea514d25 100644 --- a/backend/secfit/workouts/admin.py +++ b/backend/secfit/workouts/admin.py @@ -9,3 +9,4 @@ admin.site.register(Exercise) admin.site.register(ExerciseInstance) admin.site.register(Workout) admin.site.register(WorkoutFile) + diff --git a/backend/secfit/workouts/migrations/0002_auto_20200910_0222.py b/backend/secfit/workouts/migrations/0002_auto_20200910_0222.py deleted file mode 100644 index 2d592a4c8a3e975b99c89af0b1e395ed73c12823..0000000000000000000000000000000000000000 --- a/backend/secfit/workouts/migrations/0002_auto_20200910_0222.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-09-10 00:22 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("workouts", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="workoutfile", - name="owner", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="workout_files", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/backend/secfit/workouts/migrations/0002_auto_20210304_2241.py b/backend/secfit/workouts/migrations/0002_auto_20210304_2241.py new file mode 100644 index 0000000000000000000000000000000000000000..739d4979daf258fd940a9720d18555f0ece5b077 --- /dev/null +++ b/backend/secfit/workouts/migrations/0002_auto_20210304_2241.py @@ -0,0 +1,54 @@ +# Generated by Django 3.1 on 2021-03-04 22:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('suggested_workouts', '0002_auto_20210304_2241'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('workouts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='RememberMe', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('remember_me', models.CharField(max_length=500)), + ], + ), + migrations.AddField( + model_name='exerciseinstance', + name='suggested_workout', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='suggested_exercise_instances', to='suggested_workouts.suggestedworkout'), + ), + migrations.AddField( + model_name='workout', + name='planned', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workoutfile', + name='suggested_workout', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='suggested_workout_files', to='suggested_workouts.suggestedworkout'), + ), + migrations.AlterField( + model_name='exerciseinstance', + name='workout', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='exercise_instances', to='workouts.workout'), + ), + migrations.AlterField( + model_name='workoutfile', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='workout_files', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='workoutfile', + name='workout', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='workouts.workout'), + ), + ] diff --git a/backend/secfit/workouts/migrations/0003_rememberme.py b/backend/secfit/workouts/migrations/0003_rememberme.py deleted file mode 100644 index 0f1e9ac4743d0acd3134e412aed5916fdcc6b7b6..0000000000000000000000000000000000000000 --- a/backend/secfit/workouts/migrations/0003_rememberme.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1 on 2021-02-04 10:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('workouts', '0002_auto_20200910_0222'), - ] - - operations = [ - migrations.CreateModel( - name='RememberMe', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('remember_me', models.CharField(max_length=500)), - ], - ), - ] diff --git a/backend/secfit/workouts/models.py b/backend/secfit/workouts/models.py index 5e3c6d1614d54992b42491bee8207a65410c5961..5f9ab10100892d9ac1b6f50567447fe1e260167e 100644 --- a/backend/secfit/workouts/models.py +++ b/backend/secfit/workouts/models.py @@ -7,6 +7,7 @@ from django.db import models from django.core.files.storage import FileSystemStorage from django.conf import settings from django.contrib.auth import get_user_model +from suggested_workouts.models import SuggestedWorkout class OverwriteStorage(FileSystemStorage): @@ -38,6 +39,7 @@ class Workout(models.Model): notes: Notes about the workout owner: User that logged the workout visibility: The visibility level of the workout: Public, Coach, or Private + planned: Indicates if it is a planned workout """ name = models.CharField(max_length=100) @@ -46,6 +48,7 @@ class Workout(models.Model): owner = models.ForeignKey( get_user_model(), on_delete=models.CASCADE, related_name="workouts" ) + planned = models.BooleanField(default=False) # Visibility levels PUBLIC = "PU" # Visible to all authenticated users @@ -67,7 +70,6 @@ class Workout(models.Model): def __str__(self): return self.name - class Exercise(models.Model): """Django model for an exercise type that users can create. @@ -94,6 +96,8 @@ class ExerciseInstance(models.Model): Kyle's workout on 15.06.2029 had one exercise instance: 3 (sets) reps (unit) of 10 (number) pushups (exercise type) + Each suggested workouts shall also have a relation with one or more exercise instances just like a regular workout. + Attributes: workout: The workout associated with this exercise instance exercise: The exercise type of this instance @@ -102,8 +106,10 @@ class ExerciseInstance(models.Model): """ workout = models.ForeignKey( - Workout, on_delete=models.CASCADE, related_name="exercise_instances" + Workout, on_delete=models.CASCADE, related_name="exercise_instances", null=True ) + suggested_workout = models.ForeignKey( + SuggestedWorkout, on_delete=models.CASCADE, related_name="suggested_exercise_instances", null=True, blank=True) exercise = models.ForeignKey( Exercise, on_delete=models.CASCADE, related_name="instances" ) @@ -121,21 +127,29 @@ def workout_directory_path(instance, filename): Returns: str: Path where workout file is stored """ - return f"workouts/{instance.workout.id}/{filename}" + if instance.workout != None: + return f"workouts/{instance.workout.id}/{filename}" + elif instance.suggested_workout != None: + return f"suggested_workouts/{instance.suggested_workout.id}/{filename}" + return f"images" class WorkoutFile(models.Model): - """Django model for file associated with a workout. Basically a wrapper. + """Django model for file associated with a workout or a suggested workout. Basically a wrapper. Attributes: workout: The workout for which this file has been uploaded + suggested_workout: The suggested workout for which the file has been uploaded owner: The user who uploaded the file file: The actual file that's being uploaded """ - workout = models.ForeignKey(Workout, on_delete=models.CASCADE, related_name="files") + workout = models.ForeignKey( + Workout, on_delete=models.CASCADE, related_name="files", null=True, blank=True) + suggested_workout = models.ForeignKey( + SuggestedWorkout, on_delete=models.CASCADE, related_name="suggested_workout_files", null=True, blank=True) owner = models.ForeignKey( - get_user_model(), on_delete=models.CASCADE, related_name="workout_files" + get_user_model(), on_delete=models.CASCADE, related_name="workout_files", null=True, blank=True ) file = models.FileField(upload_to=workout_directory_path) diff --git a/backend/secfit/workouts/parsers.py b/backend/secfit/workouts/parsers.py index 3255481ce8d327bdfa92f434bf1f03e04d158443..f1a4f70e303afc73272c462ee11ae5c4c709e65f 100644 --- a/backend/secfit/workouts/parsers.py +++ b/backend/secfit/workouts/parsers.py @@ -4,12 +4,48 @@ import json from rest_framework import parsers # Thanks to https://stackoverflow.com/a/50514630 + + class MultipartJsonParser(parsers.MultiPartParser): """Parser for serializing multipart data containing both files and JSON. This is currently unused. """ + def parse(self, stream, media_type=None, parser_context=None): + result = super().parse( + stream, media_type=media_type, parser_context=parser_context + ) + data = {} + new_files = {"suggested_workout_files": []} + + # for case1 with nested serializers + # parse each field with json + for key, value in result.data.items(): + if not isinstance(value, str): + data[key] = value + continue + if "{" in value or "[" in value: + try: + data[key] = json.loads(value) + except ValueError: + data[key] = value + else: + data[key] = value + + files = result.files.getlist("suggested_workout_files") + for file in files: + new_files["suggested_workout_files"].append({"file": file}) + + return parsers.DataAndFiles(data, new_files) + + +class MultipartJsonParserWorkout(parsers.MultiPartParser): + """Parser for serializing multipart data containing both files and JSON. + + This is currently unused. + """ + def parse(self, stream, media_type=None, parser_context=None): result = super().parse( stream, media_type=media_type, parser_context=parser_context diff --git a/backend/secfit/workouts/permissions.py b/backend/secfit/workouts/permissions.py index 4039b9ce7bd3b54fe089c93d31157ed6e0887d2c..8ff7fcb535422ad572efe3f09351ff9b383c6224 100644 --- a/backend/secfit/workouts/permissions.py +++ b/backend/secfit/workouts/permissions.py @@ -33,9 +33,10 @@ class IsCoachAndVisibleToCoach(permissions.BasePermission): """Checks whether the requesting user is the existing object's owner's coach and whether the object (workout) has a visibility of Public or Coach. """ + # Fixed bug where the function did not check for the visibility level def has_object_permission(self, request, view, obj): - return obj.owner.coach == request.user + return obj.owner.coach == request.user and (obj.visibility == 'PU' or obj.visibility == 'CO') class IsCoachOfWorkoutAndVisibleToCoach(permissions.BasePermission): @@ -44,7 +45,10 @@ class IsCoachOfWorkoutAndVisibleToCoach(permissions.BasePermission): """ def has_object_permission(self, request, view, obj): - return obj.workout.owner.coach == request.user + # Fixed bug where the function did not check for the visibility level + return obj.workout.owner.coach == request.user and ( + obj.workout.visibility == "PU" or obj.workout.visibility == "CO" + ) class IsPublic(permissions.BasePermission): diff --git a/backend/secfit/workouts/serializers.py b/backend/secfit/workouts/serializers.py index a966ed3d752dcf54767a10f2b4b53d416e095a33..cda2d9d0acdf3faca081f57b9f2eaa05bbc316cc 100644 --- a/backend/secfit/workouts/serializers.py +++ b/backend/secfit/workouts/serializers.py @@ -3,6 +3,9 @@ from rest_framework import serializers from rest_framework.serializers import HyperlinkedRelatedField from workouts.models import Workout, Exercise, ExerciseInstance, WorkoutFile, RememberMe +from datetime import datetime +import pytz +from suggested_workouts.models import SuggestedWorkout class ExerciseInstanceSerializer(serializers.HyperlinkedModelSerializer): @@ -17,10 +20,13 @@ class ExerciseInstanceSerializer(serializers.HyperlinkedModelSerializer): workout = HyperlinkedRelatedField( queryset=Workout.objects.all(), view_name="workout-detail", required=False ) + suggested_workout = HyperlinkedRelatedField(queryset=SuggestedWorkout.objects.all( + ), view_name="suggested-workout-detail", required=False) class Meta: model = ExerciseInstance - fields = ["url", "id", "exercise", "sets", "number", "workout"] + fields = ["url", "id", "exercise", "sets", + "number", "workout", "suggested_workout"] class WorkoutFileSerializer(serializers.HyperlinkedModelSerializer): @@ -37,10 +43,13 @@ class WorkoutFileSerializer(serializers.HyperlinkedModelSerializer): workout = HyperlinkedRelatedField( queryset=Workout.objects.all(), view_name="workout-detail", required=False ) + suggested_workout = HyperlinkedRelatedField( + queryset=SuggestedWorkout.objects.all(), view_name="suggested-workout-detail", required=False + ) class Meta: model = WorkoutFile - fields = ["url", "id", "owner", "file", "workout"] + fields = ["url", "id", "owner", "file", "workout", "suggested_workout"] def create(self, validated_data): return WorkoutFile.objects.create(**validated_data) @@ -52,7 +61,7 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): This serializer specifies nested serialization since a workout consists of WorkoutFiles and ExerciseInstances. - Serialized fields: url, id, name, date, notes, owner, owner_username, visiblity, + Serialized fields: url, id, name, date, notes, owner, planned, owner_username, visiblity, exercise_instances, files Attributes: @@ -74,6 +83,7 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): "date", "notes", "owner", + "planned", "owner_username", "visibility", "exercise_instances", @@ -93,6 +103,19 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): Returns: Workout: A newly created Workout """ + # Check if date is valid + timeNow = datetime.now() + timeNowAdjusted = pytz.utc.localize(timeNow) + + if validated_data["planned"]: + if timeNowAdjusted >= validated_data["date"]: + raise serializers.ValidationError( + {"date": ["Date must be a future date"]}) + else: + if timeNowAdjusted <= validated_data["date"]: + raise serializers.ValidationError( + {"date": ["Date must be an old date"]}) + exercise_instances_data = validated_data.pop("exercise_instances") files_data = [] if "files" in validated_data: @@ -101,10 +124,12 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): workout = Workout.objects.create(**validated_data) for exercise_instance_data in exercise_instances_data: - ExerciseInstance.objects.create(workout=workout, **exercise_instance_data) + ExerciseInstance.objects.create( + workout=workout, **exercise_instance_data) for file_data in files_data: WorkoutFile.objects.create( - workout=workout, owner=workout.owner, file=file_data.get("file") + workout=workout, owner=workout.owner, file=file_data.get( + "file") ) return workout @@ -122,12 +147,27 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): Returns: Workout: Updated Workout instance """ + # Add date and planned check + # Check if date is valid + timeNow = datetime.now() + timeNowAdjusted = pytz.utc.localize(timeNow) + + if validated_data["planned"]: + if timeNowAdjusted >= validated_data["date"]: + raise serializers.ValidationError( + {"date": ["Date must be a future date"]}) + else: + if timeNowAdjusted <= validated_data["date"]: + raise serializers.ValidationError( + {"date": ["Date must be an old date"]}) + exercise_instances_data = validated_data.pop("exercise_instances") exercise_instances = instance.exercise_instances instance.name = validated_data.get("name", instance.name) instance.notes = validated_data.get("notes", instance.notes) - instance.visibility = validated_data.get("visibility", instance.visibility) + instance.visibility = validated_data.get( + "visibility", instance.visibility) instance.date = validated_data.get("date", instance.date) instance.save() @@ -167,9 +207,10 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): if "files" in validated_data: files_data = validated_data.pop("files") files = instance.files - for file, file_data in zip(files.all(), files_data): + file.file = file_data.get("file", file.file) + file.save() # If new files have been added, creating new WorkoutFiles if len(files_data) > len(files.all()): diff --git a/backend/secfit/workouts/tests.py b/backend/secfit/workouts/tests.py index 7fbbf7847f5b0f201d408d4017cc865d614e2615..bd9bf49747fc0cd6da6b643d0b99f93d80f8f7d7 100644 --- a/backend/secfit/workouts/tests.py +++ b/backend/secfit/workouts/tests.py @@ -1,6 +1,230 @@ -""" -Tests for the workouts application. -""" -from django.test import TestCase +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from workouts.permissions import IsOwner, IsOwnerOfWorkout, IsCoachAndVisibleToCoach, IsCoachOfWorkoutAndVisibleToCoach, IsPublic, IsWorkoutPublic, IsReadOnly +from django.utils import timezone +from workouts.models import Workout, ExerciseInstance, Exercise +from rest_framework.test import APIRequestFactory, APITestCase -# Create your tests here. + +class WorkoutPermissionsTestCases(TestCase): + def setUp(self): + self.owner = get_user_model()(id=1, username='bitch', email='email@email.com', phone_number='92134654', + country='Norway', city='Paradise city', street_address='Hemmelig' + ) + self.owner.save() + self.user = get_user_model()(id=2, username='balle', email='email@fake.com', phone_number='92134654', + country='Norway', city='Hmm', street_address='Hemmelig' + ) + self.user.save() + self.factory = APIRequestFactory() + self.workout = Workout.objects.create(id=1, name='Ballesnerkel', date=timezone.now(), notes='Hva vil du?', + owner=self.owner, visibility='PU' + ) + self.workout.save() + # Creating an object that has a workout instance. This object is an ExerciseInstance whichi needs an Exercise + self.exercise = Exercise.objects.create( + name="dummy_exercise", description='Dummy description', unit='rep') + self.exercise.save() + self.exercise_instance = ExerciseInstance.objects.create( + workout=self.workout, suggested_workout=None, exercise=self.exercise, sets=2, number=2) + self.exercise_instance.save() + self.request = self.factory.delete('/') + + def test_ownership_workout(self): + self.request = self.factory.delete('/') + self.request.user = self.owner + permission = IsOwner.has_object_permission( + self, request=self.request, view=None, obj=self.workout) + self.assertTrue(permission) + self.request.user = self.user + self.assertFalse(IsOwner.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + + # Is used for files and exercise instances + def test_is_owner_of_workout(self): + # Make fake request + self.request = self.factory.delete('/') + # Fake post request + fake_request_data = { + "workout": "http://127.0.0.1:8000/api/workouts/1/"} + # Fake method for request + self.request.method = 'POST' + # Fake data + self.request.data = fake_request_data + # Setting initialized user who is the owner for the workout which is going to be retrieved + self.request.user = self.owner + permission_class = IsOwnerOfWorkout + # Check has permission is working + self.assertTrue(permission_class.has_permission( + self, request=self.request, view=None)) + # Check for a user who is not owner of workout + self.request.user = self.user + self.assertFalse(permission_class.has_permission( + self, request=self.request, view=None)) + # Now check for the case where there exist no workout for the id + fake_request_data_no_workout = {} + self.request.data = fake_request_data_no_workout + self.assertFalse(permission_class.has_permission( + self, request=self.request, view=None)) + + # Should always return True for has_permission when the method is not a POST + self.request.method = 'GET' + self.assertTrue(permission_class.has_permission( + self, request=self.request, view=None)) + # Check for the case where request.user is owner + self.request.user = self.owner + self.assertTrue(permission_class.has_permission( + self, request=self.request, view=None)) + """ + Test has_object_permission + """ + self.assertTrue(permission_class.has_object_permission( + self, self.request, view=None, obj=self.exercise_instance)) + # Test for where the requested user is not the workout for the exercise instance + self.request.user = self.user + self.assertFalse(permission_class.has_object_permission( + self, self.request, view=None, obj=self.exercise_instance)) + + def test_is_coach_and_visible_to_coach(self): + # Make a coach to the owner of workout defined in setUp + self.coach_of_owner = get_user_model()(id=3, username='owner', email='email@owner.com', phone_number='98154654', + country='England', city='London', street_address='...' + ) + self.coach_of_owner.save() + self.owner.coach = self.coach_of_owner + self.owner.save() + print(self.owner.coach) + self.request.user = self.coach_of_owner + permission_class = IsCoachAndVisibleToCoach + self.assertTrue(IsCoachAndVisibleToCoach.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + # Changing the visibility to coach to see if it still works + self.workout.visibility = 'CO' + self.workout.save() + self.assertTrue(IsCoachAndVisibleToCoach.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + # Changing request.user to someoner who is not the owner's coach + self.request.user = self.user + self.assertFalse(IsCoachAndVisibleToCoach.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + # Check if you get the same result when visibility is set to public and requested user is still not coach of owner + self.workout.visibility = 'PU' + self.workout.save() + self.assertFalse(self.assertFalse(IsCoachAndVisibleToCoach.has_object_permission( + self, request=self.request, view=None, obj=self.workout))) + # Now, check if the function returns false when visibility is set to private + # for both cases where requested user is coach or not coach of owner + self.workout.visibility = 'PR' + self.workout.save() + self.assertFalse(IsCoachAndVisibleToCoach.has_object_permission( + self, request=self.request, + view=None, obj=self.workout)) + # Changing requested user back to coach. Should still return false + self.request.user = self.coach_of_owner + self.assertFalse(IsCoachAndVisibleToCoach.has_object_permission(self, request=self.request, + view=None, obj=self.workout)) + + # This test fails. Had to fix the fault in the permission class + + def is_coach_of_workout_and_visible_to_coach(self): + """ + Testing for the exercise_instance instead of the workout directly + """ + permission_class = IsCoachOfWorkoutAndVisibleToCoach + # Make a coach to the owner of workout defined in setUp + self.coach_of_owner = get_user_model()(id=3, username='owner', email='email@owner.com', phone_number='98154654', + country='England', city='London', street_address='...' + ) + self.coach_of_owner.save() + self.owner.coach = self.coach_of_owner + self.owner.save() + # Check if false when requesting user is not the owner's coach + self.request.user = self.user + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.exercise_instance)) + + self.request.user = self.coach_of_owner + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.exercise_instance)) + # Changing the visibility to coach to see if it still works + self.workout.visibility = 'CO' + self.workout.save() + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.exercise_instance)) + # Changing request.user to someoner who is not the owner's coach + self.request.user = self.user + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.exercise_instance)) + # Check if you get the same result when visibility is set to public and requested user is still not coach of owner + self.workout.visibility = 'PU' + self.workout.save() + self.assertFalse(self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.exercise_instance))) + # Now, check if the function returns false when visibility is set to private + # for both cases where requested user is coach or not coach of owner + self.workout.visibility = 'PR' + self.workout.save() + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, + view=None, obj=self.exercise_instance)) + # Changing requested user back to coach. Should still return false + self.request.user = self.coach_of_owner + self.assertFalse(permission_class.has_object_permission(self, request=self.request, + view=None, obj=self.exercise_instance)) + + # This test fails. Had to fix the fault in the permission class + def test_is_public(self): + permission_class = IsPublic + self.workout.visibility = 'PU' + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + """ + Other visibility levels should return false + """ + self.workout.visibility = 'CO' + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + self.workout.visibility = 'PR' + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=self.workout)) + + def test_is_workout_public(self): + permission_class = IsWorkoutPublic + self.workout.visibility = 'PU' + self.assertTrue(permission_class.has_object_permission( + self, request=None, view=None, obj=self.exercise_instance)) + self.workout.visibility = 'CO' + self.assertFalse(permission_class.has_object_permission( + self, request=None, view=None, obj=self.exercise_instance)) + self.workout.visibility = 'PR' + self.assertFalse(permission_class.has_object_permission( + self, request=None, view=None, obj=self.exercise_instance)) + + def test_is_read_only(self): + permission_class = IsReadOnly + """ + Testing if false when unsafe methods are provided + """ + self.request.method = 'POST' + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) + self.request.method = 'PUT' + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) + self.request.method = 'DELETE' + self.assertFalse(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) + """ + Testing if safe methods return true + """ + self.request.method = 'HEAD' + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) + + self.request.method = 'GET' + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) + + self.request.method = 'OPTIONS' + self.assertTrue(permission_class.has_object_permission( + self, request=self.request, view=None, obj=None)) diff --git a/backend/secfit/workouts/views.py b/backend/secfit/workouts/views.py index efddf40454376b23d233f9fe2cecaf9da43fddb8..26254774232a1baa7737a3f36a476dd0a083f4fc 100644 --- a/backend/secfit/workouts/views.py +++ b/backend/secfit/workouts/views.py @@ -11,7 +11,7 @@ from rest_framework.response import Response from rest_framework.reverse import reverse from django.db.models import Q from rest_framework import filters -from workouts.parsers import MultipartJsonParser +from workouts.parsers import MultipartJsonParserWorkout from workouts.permissions import ( IsOwner, IsCoachAndVisibleToCoach, @@ -31,8 +31,11 @@ from rest_framework_simplejwt.tokens import RefreshToken from rest_framework.response import Response import json from collections import namedtuple -import base64, pickle +import base64 +import pickle from django.core.signing import Signer +from datetime import datetime +import pytz @api_view(["GET"]) @@ -115,7 +118,7 @@ class WorkoutList( permissions.IsAuthenticated ] # User must be authenticated to create/view workouts parser_classes = [ - MultipartJsonParser, + MultipartJsonParserWorkout, JSONParser, ] # For parsing JSON and Multi-part requests filter_backends = [filters.OrderingFilter] @@ -141,6 +144,16 @@ class WorkoutList( Q(visibility="PU") | (Q(visibility="CO") & Q(owner__coach=self.request.user)) ).distinct() + # Check if the planned workout has happened + if len(qs) > 0: + timeNow = datetime.now() + timeNowAdjusted = pytz.utc.localize(timeNow) + for i in range(0, len(qs)): + if qs[i].planned: + if timeNowAdjusted > qs[i].date: + # Update: set planned to false + qs[i].planned = False + qs[i].save() return qs @@ -155,14 +168,13 @@ class WorkoutDetail( HTTP methods: GET, PUT, DELETE """ - queryset = Workout.objects.all() serializer_class = WorkoutSerializer permission_classes = [ permissions.IsAuthenticated & (IsOwner | (IsReadOnly & (IsCoachAndVisibleToCoach | IsPublic))) ] - parser_classes = [MultipartJsonParser, JSONParser] + parser_classes = [MultipartJsonParserWorkout, JSONParser] def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) @@ -229,7 +241,6 @@ class ExerciseInstanceList( generics.GenericAPIView, ): """Class defining the web response for the creation""" - serializer_class = ExerciseInstanceSerializer permission_classes = [permissions.IsAuthenticated & IsOwnerOfWorkout] @@ -247,7 +258,7 @@ class ExerciseInstanceList( | ( (Q(workout__visibility="CO") | Q(workout__visibility="PU")) & Q(workout__owner__coach=self.request.user) - ) + ) | (Q(suggested_workout__coach=self.request.user) | Q(suggested_workout__athlete=self.request.user)) ).distinct() return qs @@ -259,14 +270,15 @@ class ExerciseInstanceDetail( mixins.DestroyModelMixin, generics.GenericAPIView, ): + queryset = ExerciseInstance.objects.all() serializer_class = ExerciseInstanceSerializer - permission_classes = [ - permissions.IsAuthenticated - & ( - IsOwnerOfWorkout - | (IsReadOnly & (IsCoachOfWorkoutAndVisibleToCoach | IsWorkoutPublic)) - ) - ] + # permission_classes = [ + # permissions.IsAuthenticated + # & ( + # IsOwnerOfWorkout + # | (IsReadOnly & (IsCoachOfWorkoutAndVisibleToCoach | IsWorkoutPublic)) + # ) + # ] def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) @@ -291,7 +303,7 @@ class WorkoutFileList( queryset = WorkoutFile.objects.all() serializer_class = WorkoutFileSerializer permission_classes = [permissions.IsAuthenticated & IsOwnerOfWorkout] - parser_classes = [MultipartJsonParser, JSONParser] + parser_classes = [MultipartJsonParserWorkout, JSONParser] def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) diff --git a/frontend/Procfile b/frontend/Procfile new file mode 100644 index 0000000000000000000000000000000000000000..c5d715421f1d1e0e364966d96ee675eacdc32d45 --- /dev/null +++ b/frontend/Procfile @@ -0,0 +1 @@ +web: cd frontend && cordova run browser --release --port=$PORT diff --git a/frontend/www/plannedWorkout.html b/frontend/www/plannedWorkout.html new file mode 100644 index 0000000000000000000000000000000000000000..f66b88c6b0e77fb44ff64a7465336dc7042fafa6 --- /dev/null +++ b/frontend/www/plannedWorkout.html @@ -0,0 +1,134 @@ + + + + + + Workout + + + + + + + + + + +
+
+
+

View/Edit Planned Workout

+
+
+
+
+

A planned workout is a future workout that will be autologged +

+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
+
+ + + + +
+
+
+

Exercises

+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ Comment panel +
+
+ +
+ +
+
+
    +
+
+
+
+ +
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/frontend/www/scripts/plannedWorkout.js b/frontend/www/scripts/plannedWorkout.js new file mode 100644 index 0000000000000000000000000000000000000000..da55ea239787a25d9591b7f1e7e3465238d560f3 --- /dev/null +++ b/frontend/www/scripts/plannedWorkout.js @@ -0,0 +1,426 @@ +let cancelWorkoutButton; +let okWorkoutButton; +let deleteWorkoutButton; +let editWorkoutButton; +let postCommentButton; + +async function retrieveWorkout(id) { + let workoutData = null; + let response = await sendRequest("GET", `${HOST}/api/workouts/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve workout data!", data); + document.body.prepend(alert); + } else { + workoutData = await response.json(); + let form = document.querySelector("#form-workout"); + let formData = new FormData(form); + + for (let key of formData.keys()) { + let selector = `input[name="${key}"], textarea[name="${key}"]`; + let input = form.querySelector(selector); + let newVal = workoutData[key]; + if (key == "date") { + // Creating a valid datetime-local string with the correct local time + let date = new Date(newVal); + date = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ).toISOString(); // get ISO format for local time + newVal = date.substring(0, newVal.length - 1); // remove Z (since this is a local time, not UTC) + } + if (key != "files") { + input.value = newVal; + } + } + + let input = form.querySelector("select:disabled"); + input.value = workoutData["visibility"]; + // files + let filesDiv = document.querySelector("#uploaded-files"); + for (let file of workoutData.files) { + let a = document.createElement("a"); + a.href = file.file; + let pathArray = file.file.split("/"); + a.text = pathArray[pathArray.length - 1]; + a.className = "me-2"; + filesDiv.appendChild(a); + } + + // create exercises + + // fetch exercise types + let exerciseTypeResponse = await sendRequest( + "GET", + `${HOST}/api/exercises/` + ); + let exerciseTypes = await exerciseTypeResponse.json(); + + //TODO: This should be in its own method. + for (let i = 0; i < workoutData.exercise_instances.length; i++) { + let templateExercise = document.querySelector("#template-exercise"); + let divExerciseContainer = templateExercise.content.firstElementChild.cloneNode( + true + ); + + let exerciseTypeLabel = divExerciseContainer.querySelector( + ".exercise-type" + ); + exerciseTypeLabel.for = `inputExerciseType${i}`; + + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + exerciseTypeSelect.id = `inputExerciseType${i}`; + exerciseTypeSelect.disabled = true; + + let splitUrl = workoutData.exercise_instances[i].exercise.split("/"); + let currentExerciseTypeId = splitUrl[splitUrl.length - 2]; + let currentExerciseType = ""; + + for (let j = 0; j < exerciseTypes.count; j++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[j].id; + if (currentExerciseTypeId == exerciseTypes.results[j].id) { + currentExerciseType = exerciseTypes.results[j]; + } + option.innerText = exerciseTypes.results[j].name; + exerciseTypeSelect.append(option); + } + + exerciseTypeSelect.value = currentExerciseType.id; + + let exerciseSetLabel = divExerciseContainer.querySelector( + ".exercise-sets" + ); + exerciseSetLabel.for = `inputSets${i}`; + + let exerciseSetInput = divExerciseContainer.querySelector( + "input[name='sets']" + ); + exerciseSetInput.id = `inputSets${i}`; + exerciseSetInput.value = workoutData.exercise_instances[i].sets; + exerciseSetInput.readOnly = true; + + let exerciseNumberLabel = divExerciseContainer.querySelector( + ".exercise-number" + ); + (exerciseNumberLabel.for = "for"), `inputNumber${i}`; + exerciseNumberLabel.innerText = currentExerciseType.unit; + + let exerciseNumberInput = divExerciseContainer.querySelector( + "input[name='number']" + ); + exerciseNumberInput.id = `inputNumber${i}`; + exerciseNumberInput.value = workoutData.exercise_instances[i].number; + exerciseNumberInput.readOnly = true; + + let exercisesDiv = document.querySelector("#div-exercises"); + exercisesDiv.appendChild(divExerciseContainer); + } + } + return workoutData; +} + +function handleCancelDuringWorkoutEdit() { + location.reload(); +} + +function handleEditWorkoutButtonClick() { + let addExerciseButton = document.querySelector("#btn-add-exercise"); + let removeExerciseButton = document.querySelector("#btn-remove-exercise"); + + setReadOnly(false, "#form-workout"); + document.querySelector("#inputOwner").readOnly = true; // owner field should still be readonly + + editWorkoutButton.className += " hide"; + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace( + " hide", + "" + ); + deleteWorkoutButton.className = deleteWorkoutButton.className.replace( + " hide", + "" + ); + addExerciseButton.className = addExerciseButton.className.replace( + " hide", + "" + ); + removeExerciseButton.className = removeExerciseButton.className.replace( + " hide", + "" + ); + + cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutEdit); +} + +async function deleteWorkout(id) { + let response = await sendRequest("DELETE", `${HOST}/api/workouts/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert(`Could not delete workout ${id}!`, data); + document.body.prepend(alert); + } else { + window.location.replace("workouts.html"); + } +} + +async function updateWorkout(id) { + let submitForm = generateWorkoutForm(); + + let response = await sendRequest( + "PUT", + `${HOST}/api/workouts/${id}/`, + submitForm, + "" + ); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not update workout!", data); + document.body.prepend(alert); + } else { + location.reload(); + } +} + +function generateWorkoutForm() { + // TODO: Add check for future date + var today = new Date().toISOString(); + + document.querySelector("#inputDateTime").min = today; + + let form = document.querySelector("#form-workout"); + + let formData = new FormData(form); + let submitForm = new FormData(); + + submitForm.append("name", formData.get("name")); + let date = new Date(formData.get("date")).toISOString(); + submitForm.append("date", date); + submitForm.append("notes", formData.get("notes")); + submitForm.append("visibility", formData.get("visibility")); + submitForm.append("planned", true); + + // adding exercise instances + let exerciseInstances = []; + let exerciseInstancesTypes = formData.getAll("type"); + let exerciseInstancesSets = formData.getAll("sets"); + let exerciseInstancesNumbers = formData.getAll("number"); + for (let i = 0; i < exerciseInstancesTypes.length; i++) { + exerciseInstances.push({ + exercise: `${HOST}/api/exercises/${exerciseInstancesTypes[i]}/`, + number: exerciseInstancesNumbers[i], + sets: exerciseInstancesSets[i], + }); + } + + submitForm.append("exercise_instances", JSON.stringify(exerciseInstances)); + // adding files + for (let file of formData.getAll("files")) { + submitForm.append("files", file); + } + return submitForm; +} + +async function createWorkout() { + let submitForm = generateWorkoutForm(); + + let response = await sendRequest( + "POST", + `${HOST}/api/workouts/`, + submitForm, + "" + ); + + if (response.ok) { + window.location.replace("workouts.html"); + } else { + let data = await response.json(); + let alert = createAlert("Could not create new workout!", data); + document.body.prepend(alert); + } +} + +function handleCancelDuringWorkoutCreate() { + window.location.replace("workouts.html"); +} + +async function createBlankExercise() { + let form = document.querySelector("#form-workout"); + + let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); + let exerciseTypes = await exerciseTypeResponse.json(); + + let exerciseTemplate = document.querySelector("#template-exercise"); + let divExerciseContainer = exerciseTemplate.content.firstElementChild.cloneNode( + true + ); + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + + for (let i = 0; i < exerciseTypes.count; i++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[i].id; + option.innerText = exerciseTypes.results[i].name; + exerciseTypeSelect.append(option); + } + + let currentExerciseType = exerciseTypes.results[0]; + exerciseTypeSelect.value = currentExerciseType.name; + + let divExercises = document.querySelector("#div-exercises"); + divExercises.appendChild(divExerciseContainer); +} + +function removeExercise(event) { + let divExerciseContainers = document.querySelectorAll( + ".div-exercise-container" + ); + if (divExerciseContainers && divExerciseContainers.length > 0) { + divExerciseContainers[divExerciseContainers.length - 1].remove(); + } +} + +function addComment(author, text, date, append) { + /* Taken from https://www.bootdey.com/snippets/view/Simple-Comment-panel#css*/ + let commentList = document.querySelector("#comment-list"); + let listElement = document.createElement("li"); + listElement.className = "media"; + let commentBody = document.createElement("div"); + commentBody.className = "media-body"; + let dateSpan = document.createElement("span"); + dateSpan.className = "text-muted pull-right me-1"; + let smallText = document.createElement("small"); + smallText.className = "text-muted"; + + if (date != "Now") { + let localDate = new Date(date); + smallText.innerText = localDate.toLocaleString(); + } else { + smallText.innerText = date; + } + + dateSpan.appendChild(smallText); + commentBody.appendChild(dateSpan); + + let strong = document.createElement("strong"); + strong.className = "text-success"; + strong.innerText = author; + commentBody.appendChild(strong); + let p = document.createElement("p"); + p.innerHTML = text; + + commentBody.appendChild(strong); + commentBody.appendChild(p); + listElement.appendChild(commentBody); + + if (append) { + commentList.append(listElement); + } else { + commentList.prepend(listElement); + } +} + +async function createComment(workoutid) { + let commentArea = document.querySelector("#comment-area"); + let content = commentArea.value; + let body = { + workout: `${HOST}/api/workouts/${workoutid}/`, + content: content, + }; + + let response = await sendRequest("POST", `${HOST}/api/comments/`, body); + if (response.ok) { + addComment(sessionStorage.getItem("username"), content, "Now", false); + } else { + let data = await response.json(); + let alert = createAlert("Failed to create comment!", data); + document.body.prepend(alert); + } +} + +async function retrieveComments(workoutid) { + let response = await sendRequest("GET", `${HOST}/api/comments/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve comments!", data); + document.body.prepend(alert); + } else { + let data = await response.json(); + let comments = data.results; + for (let comment of comments) { + let splitArray = comment.workout.split("/"); + if (splitArray[splitArray.length - 2] == workoutid) { + addComment(comment.owner, comment.content, comment.timestamp, true); + } + } + } +} + +window.addEventListener("DOMContentLoaded", async () => { + cancelWorkoutButton = document.querySelector("#btn-cancel-workout"); + okWorkoutButton = document.querySelector("#btn-ok-workout"); + deleteWorkoutButton = document.querySelector("#btn-delete-workout"); + editWorkoutButton = document.querySelector("#btn-edit-workout"); + let postCommentButton = document.querySelector("#post-comment"); + let divCommentRow = document.querySelector("#div-comment-row"); + let buttonAddExercise = document.querySelector("#btn-add-exercise"); + let buttonRemoveExercise = document.querySelector("#btn-remove-exercise"); + + buttonAddExercise.addEventListener("click", createBlankExercise); + buttonRemoveExercise.addEventListener("click", removeExercise); + + const urlParams = new URLSearchParams(window.location.search); + let currentUser = await getCurrentUser(); + + if (urlParams.has("id")) { + const id = urlParams.get("id"); + let workoutData = await retrieveWorkout(id); + await retrieveComments(id); + + if (workoutData["owner"] == currentUser.url) { + editWorkoutButton.classList.remove("hide"); + editWorkoutButton.addEventListener("click", handleEditWorkoutButtonClick); + deleteWorkoutButton.addEventListener( + "click", + (async (id) => await deleteWorkout(id)).bind(undefined, id) + ); + okWorkoutButton.addEventListener( + "click", + (async (id) => await updateWorkout(id)).bind(undefined, id) + ); + postCommentButton.addEventListener( + "click", + (async (id) => await createComment(id)).bind(undefined, id) + ); + divCommentRow.className = divCommentRow.className.replace(" hide", ""); + } + } else { + await createBlankExercise(); + let ownerInput = document.querySelector("#inputOwner"); + ownerInput.value = currentUser.username; + setReadOnly(false, "#form-workout"); + ownerInput.readOnly = !ownerInput.readOnly; + + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace( + " hide", + "" + ); + buttonAddExercise.className = buttonAddExercise.className.replace( + " hide", + "" + ); + buttonRemoveExercise.className = buttonRemoveExercise.className.replace( + " hide", + "" + ); + + okWorkoutButton.addEventListener( + "click", + async () => await createWorkout() + ); + cancelWorkoutButton.addEventListener( + "click", + handleCancelDuringWorkoutCreate + ); + divCommentRow.className += " hide"; + } +}); diff --git a/frontend/www/scripts/scripts.js b/frontend/www/scripts/scripts.js index 8c550009c43a70acc44ecd56c4434faa318c1c69..0a421de933dea21a0128fa7f5140a99e016c9d3a 100644 --- a/frontend/www/scripts/scripts.js +++ b/frontend/www/scripts/scripts.js @@ -117,6 +117,7 @@ function setReadOnly(readOnly, selector) { selector = `input[type="file"], select[name="${key}`; for (let input of form.querySelectorAll(selector)) { + console.log(input); if ((!readOnly && input.hasAttribute("disabled"))) { input.disabled = false; diff --git a/frontend/www/scripts/suggestedworkout.js b/frontend/www/scripts/suggestedworkout.js new file mode 100644 index 0000000000000000000000000000000000000000..7d490eef3c2e02003643101f43a70fea2ea28885 --- /dev/null +++ b/frontend/www/scripts/suggestedworkout.js @@ -0,0 +1,443 @@ +let cancelWorkoutButton; +let okWorkoutButton; +let deleteWorkoutButton; +let editWorkoutButton; +let postCommentButton; +let acceptWorkoutButton; +let declineWorkoutButton; +let athleteTitle; +let coachTitle; + +async function retrieveWorkout(id, currentUser) { + let workoutData = null; + let response = await sendRequest("GET", `${HOST}/api/suggested-workout/${id}/`); + + + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve workout data!", data); + document.body.prepend(alert); + } else { + workoutData = await response.json(); + let form = document.querySelector("#form-workout"); + let formData = new FormData(form); + + if (currentUser.id == workoutData.coach) { + let suggestTypeSelect = await selectAthletesForSuggest(currentUser); + suggestTypeSelect.value = workoutData.athlete; + + } + + + for (let key of formData.keys()) { + let selector = `input[name="${key}"], textarea[name="${key}"]`; + let input = form.querySelector(selector); + let newVal = workoutData[key]; + + if (key == "owner") { + input.value = workoutData.coach; + } + + /*if (key == "date") { + // Creating a valid datetime-local string with the correct local time + let date = new Date(newVal); + date = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)).toISOString(); // get ISO format for local time + newVal = date.substring(0, newVal.length - 1); // remove Z (since this is a local time, not UTC) + }*/ + if (key != "suggested_workout_files") { + input.value = newVal; + } + } + + let input = form.querySelector("select:disabled"); + // files + let filesDiv = document.querySelector("#uploaded-files"); + console.log(workoutData.suggested_workout_files); + for (let file of workoutData.suggested_workout_files) { + console.log("Her skal jeg") + let a = document.createElement("a"); + a.href = file.file; + let pathArray = file.file.split("/"); + a.text = pathArray[pathArray.length - 1]; + a.className = "me-2"; + filesDiv.appendChild(a); + } + + // create exercises + + // fetch exercise types + let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); + let exerciseTypes = await exerciseTypeResponse.json(); + + //TODO: This should be in its own method. + for (let i = 0; i < workoutData.suggested_exercise_instances.length; i++) { + let templateExercise = document.querySelector("#template-exercise"); + let divExerciseContainer = templateExercise.content.firstElementChild.cloneNode(true); + + let exerciseTypeLabel = divExerciseContainer.querySelector('.exercise-type'); + exerciseTypeLabel.for = `inputExerciseType${i}`; + + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + exerciseTypeSelect.id = `inputExerciseType${i}`; + exerciseTypeSelect.disabled = true; + + let splitUrl = workoutData.suggested_exercise_instances[i].exercise.split("/"); + let currentExerciseTypeId = splitUrl[splitUrl.length - 2]; + let currentExerciseType = ""; + + for (let j = 0; j < exerciseTypes.count; j++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[j].id; + if (currentExerciseTypeId == exerciseTypes.results[j].id) { + currentExerciseType = exerciseTypes.results[j]; + } + option.innerText = exerciseTypes.results[j].name; + exerciseTypeSelect.append(option); + } + + exerciseTypeSelect.value = currentExerciseType.id; + + let exerciseSetLabel = divExerciseContainer.querySelector('.exercise-sets'); + exerciseSetLabel.for = `inputSets${i}`; + + let exerciseSetInput = divExerciseContainer.querySelector("input[name='sets']"); + exerciseSetInput.id = `inputSets${i}`; + exerciseSetInput.value = workoutData.suggested_exercise_instances[i].sets; + exerciseSetInput.readOnly = true; + + let exerciseNumberLabel = divExerciseContainer.querySelector('.exercise-number'); + exerciseNumberLabel.for = "for", `inputNumber${i}`; + exerciseNumberLabel.innerText = currentExerciseType.unit; + + let exerciseNumberInput = divExerciseContainer.querySelector("input[name='number']"); + exerciseNumberInput.id = `inputNumber${i}`; + exerciseNumberInput.value = workoutData.suggested_exercise_instances[i].number; + exerciseNumberInput.readOnly = true; + + let exercisesDiv = document.querySelector("#div-exercises"); + exercisesDiv.appendChild(divExerciseContainer); + } + } + return workoutData; +} + +function handleCancelDuringWorkoutEdit() { + location.reload(); +} + +function handleEditWorkoutButtonClick() { + let addExerciseButton = document.querySelector("#btn-add-exercise"); + let removeExerciseButton = document.querySelector("#btn-remove-exercise"); + + setReadOnly(false, "#form-workout"); + + let dateInput = document.querySelector("#inputDateTime"); + dateInput.readOnly = !dateInput.readOnly; + + document.querySelector("#inputOwner").readOnly = true; + + + editWorkoutButton.className += " hide"; + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace(" hide", ""); + deleteWorkoutButton.className = deleteWorkoutButton.className.replace(" hide", ""); + addExerciseButton.className = addExerciseButton.className.replace(" hide", ""); + removeExerciseButton.className = removeExerciseButton.className.replace(" hide", ""); + + cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutEdit); + +} + +async function deleteSuggestedWorkout(id) { + let response = await sendRequest("DELETE", `${HOST}/api/suggested-workout/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert(`Could not delete workout ${id}!`, data); + document.body.prepend(alert); + } else { + window.location.replace("workouts.html"); + } +} + +async function updateWorkout(id) { + let submitForm = generateSuggestWorkoutForm(); + + let response = await sendRequest("PUT", `${HOST}/api/suggested-workout/${id}/`, submitForm, ""); + if (response.ok) { + location.reload(); + + } else { + let data = await response.json(); + let alert = createAlert("Could not update workout!", data); + document.body.prepend(alert); + } +} + + +async function acceptWorkout(id) { + let submitForm = generateWorkoutForm(); + + let response = await sendRequest("POST", `${HOST}/api/workouts/`, submitForm, ""); + + if (response.ok) { + await deleteSuggestedWorkout(id); + window.location.replace("workouts.html"); + } else { + let data = await response.json(); + let alert = createAlert("Could not create new workout!", data); + document.body.prepend(alert); + } +} + +function generateWorkoutForm() { + let form = document.querySelector("#form-workout"); + + let formData = new FormData(form); + let submitForm = new FormData(); + + submitForm.append("name", formData.get('name')); + let date = new Date(formData.get('date')).toISOString(); + submitForm.append("date", date); + submitForm.append("notes", formData.get("notes")); + submitForm.append("owner", formData.get("coach_username")); + submitForm.delete("athlete"); + submitForm.append("visibility", "PU"); + + // adding exercise instances + let exerciseInstances = []; + let exerciseInstancesTypes = formData.getAll("type"); + let exerciseInstancesSets = formData.getAll("sets"); + let exerciseInstancesNumbers = formData.getAll("number"); + for (let i = 0; i < exerciseInstancesTypes.length; i++) { + exerciseInstances.push({ + exercise: `${HOST}/api/exercises/${exerciseInstancesTypes[i]}/`, + number: exerciseInstancesNumbers[i], + sets: exerciseInstancesSets[i] + }); + } + + submitForm.append("exercise_instances", JSON.stringify(exerciseInstances)); + // adding files + for (let file of formData.getAll("files")) { + submitForm.append("suggested_workout_files", file); + } + + submitForm.append("planned", true); + return submitForm; +} + +function generateSuggestWorkoutForm() { + let form = document.querySelector("#form-workout"); + + let formData = new FormData(form); + let submitForm = new FormData(); + + submitForm.append("name", formData.get('name')); + //let date = new Date(formData.get('date')).toISOString(); + //submitForm.append("date", date); + submitForm.append("notes", formData.get("notes")); + submitForm.append("athlete", formData.get("athlete")); + submitForm.append("status", "p"); + + + console.log(formData.get("athlete")); + + // adding exercise instances + let exerciseInstances = []; + let exerciseInstancesTypes = formData.getAll("type"); + let exerciseInstancesSets = formData.getAll("sets"); + let exerciseInstancesNumbers = formData.getAll("number"); + + for (let i = 0; i < exerciseInstancesTypes.length; i++) { + exerciseInstances.push({ + exercise: `${HOST}/api/exercises/${exerciseInstancesTypes[i]}/`, + number: exerciseInstancesNumbers[i], + sets: exerciseInstancesSets[i] + }); + } + + submitForm.append("suggested_exercise_instances", JSON.stringify(exerciseInstances)); + // adding files + for (let file of formData.getAll("files")) { + if (file.name != "") { + submitForm.append("suggested_workout_files", file); + } + } + + + return submitForm; +} + +async function createSuggestWorkout() { + let submitForm = generateSuggestWorkoutForm(); + + + let response = await sendRequest("POST", `${HOST}/api/suggested-workouts/create/ `, submitForm, ""); + + if (response.ok) { + window.location.replace("workouts.html"); + } else { + let data = await response.json(); + let alert = createAlert("Could not create new workout!", data); + document.body.prepend(alert); + } +} + +function handleCancelDuringWorkoutCreate() { + window.location.replace("workouts.html"); +} + +async function selectAthletesForSuggest(currentUser) { + console.log(currentUser) + + let suggestTypes = []; + let suggestTemplate = document.querySelector("#template-suggest"); + let divSuggestContainer = suggestTemplate.content.firstElementChild.cloneNode(true); + let suggestTypeSelect = divSuggestContainer.querySelector("select"); + suggestTypeSelect.disabled = true; + + for (let athleteUrl of currentUser.athletes) { + let response = await sendRequest("GET", athleteUrl); + let athlete = await response.json(); + + suggestTypes.push(athlete) + } + + + for (let i = 0; i < suggestTypes.length; i++) { + let option = document.createElement("option"); + option.value = suggestTypes[i].id; + option.innerText = suggestTypes[i].username; + suggestTypeSelect.append(option); + } + + let currentSuggestType = suggestTypes[0]; + console.log(currentSuggestType); + suggestTypeSelect.value = currentSuggestType.id; + + let divSuggestWorkout = document.querySelector("#div-suggest-workout"); + divSuggestWorkout.appendChild(divSuggestContainer); + return suggestTypeSelect; +} + +async function createBlankExercise() { + let form = document.querySelector("#form-workout"); + + let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); + let exerciseTypes = await exerciseTypeResponse.json(); + + let exerciseTemplate = document.querySelector("#template-exercise"); + + let divExerciseContainer = exerciseTemplate.content.firstElementChild.cloneNode(true); + + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + + for (let i = 0; i < exerciseTypes.count; i++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[i].id; + option.innerText = exerciseTypes.results[i].name; + exerciseTypeSelect.append(option); + } + + let currentExerciseType = exerciseTypes.results[0]; + exerciseTypeSelect.value = currentExerciseType.name; + + let divExercises = document.querySelector("#div-exercises"); + divExercises.appendChild(divExerciseContainer); +} + +function removeExercise(event) { + let divExerciseContainers = document.querySelectorAll(".div-exercise-container"); + if (divExerciseContainers && divExerciseContainers.length > 0) { + divExerciseContainers[divExerciseContainers.length - 1].remove(); + } +} + + +window.addEventListener("DOMContentLoaded", async () => { + cancelWorkoutButton = document.querySelector("#btn-cancel-workout"); + okWorkoutButton = document.querySelector("#btn-ok-workout"); + deleteWorkoutButton = document.querySelector("#btn-delete-workout"); + editWorkoutButton = document.querySelector("#btn-edit-workout"); + acceptWorkoutButton = document.querySelector("#btn-accept-workout"); + declineWorkoutButton = document.querySelector("#btn-decline-workout"); + coachTitle = document.querySelector("#coach-title"); + athleteTitle = document.querySelector("#athlete-title"); + let postCommentButton = document.querySelector("#post-comment"); + let divCommentRow = document.querySelector("#div-comment-row"); + let buttonAddExercise = document.querySelector("#btn-add-exercise"); + let buttonRemoveExercise = document.querySelector("#btn-remove-exercise"); + + buttonAddExercise.addEventListener("click", createBlankExercise); + buttonRemoveExercise.addEventListener("click", removeExercise); + + const urlParams = new URLSearchParams(window.location.search); + let currentUser = await getCurrentUser(); + + + if (urlParams.has('id')) { + const id = urlParams.get('id'); + let workoutData = await retrieveWorkout(id, currentUser); + + + if (workoutData["coach"] == currentUser.id) { + coachTitle.className = coachTitle.className.replace("hide", ""); + + + editWorkoutButton.classList.remove("hide"); + editWorkoutButton.addEventListener("click", handleEditWorkoutButtonClick); + deleteWorkoutButton.addEventListener("click", (async (id) => await deleteSuggestedWorkout(id)).bind(undefined, id)); + okWorkoutButton.addEventListener("click", (async (id) => await updateWorkout(id)).bind(undefined, id)); + postCommentButton.addEventListener("click", (async (id) => await createComment(id)).bind(undefined, id)); + divCommentRow.className = divCommentRow.className.replace(" hide", ""); + } + + + if (workoutData["athlete"] == currentUser.id) { + athleteTitle.className = athleteTitle.className.replace("hide", ""); + setReadOnly(false, "#form-workout"); + + document.querySelector("#inputOwner").readOnly = true; + + + declineWorkoutButton.classList.remove("hide"); + acceptWorkoutButton.classList.remove("hide"); + + declineWorkoutButton.addEventListener("click", (async (id) => await deleteSuggestedWorkout(id)).bind(undefined, id)); + acceptWorkoutButton.addEventListener("click", (async (id) => await acceptWorkout(id)).bind(undefined, id)); + postCommentButton.addEventListener("click", (async (id) => await createComment(id)).bind(undefined, id)); + divCommentRow.className = divCommentRow.className.replace(" hide", ""); + } + } else { + await createBlankExercise(); + + + if (currentUser.athletes.length > 0) { + await selectAthletesForSuggest(currentUser); + } else { + let alert = createAlert("Will no be able to suggest workout due to not having any athltes", undefined); + document.body.prepend(alert); + } + + setReadOnly(false, "#form-workout"); + let ownerInput = document.querySelector("#inputOwner"); + ownerInput.value = currentUser.username; + ownerInput.readOnly = !ownerInput.readOnly; + + let dateInput = document.querySelector("#inputDateTime"); + dateInput.readOnly = !dateInput.readOnly; + + + coachTitle.className = coachTitle.className.replace("hide", ""); + + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace(" hide", ""); + buttonAddExercise.className = buttonAddExercise.className.replace(" hide", ""); + buttonRemoveExercise.className = buttonRemoveExercise.className.replace(" hide", ""); + + okWorkoutButton.addEventListener("click", (async (currentUser) => await createSuggestWorkout(currentUser)).bind(undefined, currentUser)); + cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutCreate); + divCommentRow.className += " hide"; + } + +}); \ No newline at end of file diff --git a/frontend/www/scripts/workout.js b/frontend/www/scripts/workout.js index 94eddb777de4dba4cc24b4ef447430a989b0c935..8123d50937a9ec5a92d90bdec8426681a0409502 100644 --- a/frontend/www/scripts/workout.js +++ b/frontend/www/scripts/workout.js @@ -2,352 +2,521 @@ let cancelWorkoutButton; let okWorkoutButton; let deleteWorkoutButton; let editWorkoutButton; +let exportWorkoutButton; let postCommentButton; +let planned = false; + +async function retrieveWorkout(id) { + let workoutData = null; + let response = await sendRequest("GET", `${HOST}/api/workouts/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve workout data!", data); + document.body.prepend(alert); + } else { + workoutData = await response.json(); + let form = document.querySelector("#form-workout"); + let formData = new FormData(form); + planned = workoutData.planned; + for (let key of formData.keys()) { + let selector = `input[name="${key}"], textarea[name="${key}"]`; + let input = form.querySelector(selector); + let newVal = workoutData[key]; + if (key == "date") { + // Creating a valid datetime-local string with the correct local time + let date = new Date(newVal); + date = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ).toISOString(); // get ISO format for local time + newVal = date.substring(0, newVal.length - 1); // remove Z (since this is a local time, not UTC) + } + if (key != "files") { + input.value = newVal; + } + } -async function retrieveWorkout(id) { - let workoutData = null; - let response = await sendRequest("GET", `${HOST}/api/workouts/${id}/`); - if (!response.ok) { - let data = await response.json(); - let alert = createAlert("Could not retrieve workout data!", data); - document.body.prepend(alert); - } else { - workoutData = await response.json(); - let form = document.querySelector("#form-workout"); - let formData = new FormData(form); - - for (let key of formData.keys()) { - let selector = `input[name="${key}"], textarea[name="${key}"]`; - let input = form.querySelector(selector); - let newVal = workoutData[key]; - if (key == "date") { - // Creating a valid datetime-local string with the correct local time - let date = new Date(newVal); - date = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)).toISOString(); // get ISO format for local time - newVal = date.substring(0, newVal.length - 1); // remove Z (since this is a local time, not UTC) - } - if (key != "files") { - input.value = newVal; - } - } + let input = form.querySelector("select:disabled"); + input.value = workoutData["visibility"]; + // files + let filesDiv = document.querySelector("#uploaded-files"); + for (let file of workoutData.files) { + let a = document.createElement("a"); + a.href = file.file; + let pathArray = file.file.split("/"); + a.text = pathArray[pathArray.length - 1]; + a.className = "me-2"; + filesDiv.appendChild(a); + } - let input = form.querySelector("select:disabled"); - input.value = workoutData["visibility"]; - // files - let filesDiv = document.querySelector("#uploaded-files"); - for (let file of workoutData.files) { - let a = document.createElement("a"); - a.href = file.file; - let pathArray = file.file.split("/"); - a.text = pathArray[pathArray.length - 1]; - a.className = "me-2"; - filesDiv.appendChild(a); - } + // create exercises + + // fetch exercise types + let exerciseTypeResponse = await sendRequest( + "GET", + `${HOST}/api/exercises/` + ); + let exerciseTypes = await exerciseTypeResponse.json(); + + //TODO: This should be in its own method. + for (let i = 0; i < workoutData.exercise_instances.length; i++) { + let templateExercise = document.querySelector("#template-exercise"); + let divExerciseContainer = templateExercise.content.firstElementChild.cloneNode( + true + ); + + let exerciseTypeLabel = divExerciseContainer.querySelector( + ".exercise-type" + ); + exerciseTypeLabel.for = `inputExerciseType${i}`; + + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + exerciseTypeSelect.id = `inputExerciseType${i}`; + exerciseTypeSelect.disabled = true; - // create exercises - - // fetch exercise types - let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); - let exerciseTypes = await exerciseTypeResponse.json(); - - //TODO: This should be in its own method. - for (let i = 0; i < workoutData.exercise_instances.length; i++) { - let templateExercise = document.querySelector("#template-exercise"); - let divExerciseContainer = templateExercise.content.firstElementChild.cloneNode(true); - - let exerciseTypeLabel = divExerciseContainer.querySelector('.exercise-type'); - exerciseTypeLabel.for = `inputExerciseType${i}`; - - let exerciseTypeSelect = divExerciseContainer.querySelector("select"); - exerciseTypeSelect.id = `inputExerciseType${i}`; - exerciseTypeSelect.disabled = true; - - let splitUrl = workoutData.exercise_instances[i].exercise.split("/"); - let currentExerciseTypeId = splitUrl[splitUrl.length - 2]; - let currentExerciseType = ""; - - for (let j = 0; j < exerciseTypes.count; j++) { - let option = document.createElement("option"); - option.value = exerciseTypes.results[j].id; - if (currentExerciseTypeId == exerciseTypes.results[j].id) { - currentExerciseType = exerciseTypes.results[j]; - } - option.innerText = exerciseTypes.results[j].name; - exerciseTypeSelect.append(option); - } - - exerciseTypeSelect.value = currentExerciseType.id; - - let exerciseSetLabel = divExerciseContainer.querySelector('.exercise-sets'); - exerciseSetLabel.for = `inputSets${i}`; - - let exerciseSetInput = divExerciseContainer.querySelector("input[name='sets']"); - exerciseSetInput.id = `inputSets${i}`; - exerciseSetInput.value = workoutData.exercise_instances[i].sets; - exerciseSetInput.readOnly = true; - - let exerciseNumberLabel = divExerciseContainer.querySelector('.exercise-number'); - exerciseNumberLabel.for = "for", `inputNumber${i}`; - exerciseNumberLabel.innerText = currentExerciseType.unit; - - let exerciseNumberInput = divExerciseContainer.querySelector("input[name='number']"); - exerciseNumberInput.id = `inputNumber${i}`; - exerciseNumberInput.value = workoutData.exercise_instances[i].number; - exerciseNumberInput.readOnly = true; - - let exercisesDiv = document.querySelector("#div-exercises"); - exercisesDiv.appendChild(divExerciseContainer); + let splitUrl = workoutData.exercise_instances[i].exercise.split("/"); + let currentExerciseTypeId = splitUrl[splitUrl.length - 2]; + let currentExerciseType = ""; + + for (let j = 0; j < exerciseTypes.count; j++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[j].id; + if (currentExerciseTypeId == exerciseTypes.results[j].id) { + currentExerciseType = exerciseTypes.results[j]; } + option.innerText = exerciseTypes.results[j].name; + exerciseTypeSelect.append(option); + } + + exerciseTypeSelect.value = currentExerciseType.id; + + let exerciseSetLabel = divExerciseContainer.querySelector( + ".exercise-sets" + ); + exerciseSetLabel.for = `inputSets${i}`; + + let exerciseSetInput = divExerciseContainer.querySelector( + "input[name='sets']" + ); + exerciseSetInput.id = `inputSets${i}`; + exerciseSetInput.value = workoutData.exercise_instances[i].sets; + exerciseSetInput.readOnly = true; + + let exerciseNumberLabel = divExerciseContainer.querySelector( + ".exercise-number" + ); + (exerciseNumberLabel.for = "for"), `inputNumber${i}`; + exerciseNumberLabel.innerText = currentExerciseType.unit; + + let exerciseNumberInput = divExerciseContainer.querySelector( + "input[name='number']" + ); + exerciseNumberInput.id = `inputNumber${i}`; + exerciseNumberInput.value = workoutData.exercise_instances[i].number; + exerciseNumberInput.readOnly = true; + + let exercisesDiv = document.querySelector("#div-exercises"); + exercisesDiv.appendChild(divExerciseContainer); } - return workoutData; + } + return workoutData; } function handleCancelDuringWorkoutEdit() { - location.reload(); + location.reload(); } function handleEditWorkoutButtonClick() { - let addExerciseButton = document.querySelector("#btn-add-exercise"); - let removeExerciseButton = document.querySelector("#btn-remove-exercise"); - - setReadOnly(false, "#form-workout"); - document.querySelector("#inputOwner").readOnly = true; // owner field should still be readonly + let addExerciseButton = document.querySelector("#btn-add-exercise"); + let removeExerciseButton = document.querySelector("#btn-remove-exercise"); + + setReadOnly(false, "#form-workout"); + document.querySelector("#inputOwner").readOnly = true; // owner field should still be readonly + + editWorkoutButton.className += " hide"; + exportWorkoutButton.className += " hide"; + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace( + " hide", + "" + ); + deleteWorkoutButton.className = deleteWorkoutButton.className.replace( + " hide", + "" + ); + addExerciseButton.className = addExerciseButton.className.replace( + " hide", + "" + ); + removeExerciseButton.className = removeExerciseButton.className.replace( + " hide", + "" + ); + + cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutEdit); +} - editWorkoutButton.className += " hide"; - okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); - cancelWorkoutButton.className = cancelWorkoutButton.className.replace(" hide", ""); - deleteWorkoutButton.className = deleteWorkoutButton.className.replace(" hide", ""); - addExerciseButton.className = addExerciseButton.className.replace(" hide", ""); - removeExerciseButton.className = removeExerciseButton.className.replace(" hide", ""); +//Taken from github: https://gist.github.com/dannypule/48418b4cd8223104c6c92e3016fc0f61 +function handleExportToCalendarClick(workoutData) { + const headers = { + subject: "Subject", + startDate: "Start date", + startTime: "Start time", + description: "Description", + }; + + const dataFormatted = []; + + const startTime = new Date(workoutData.date).toLocaleTimeString("en-us"); + const startDate = new Date(workoutData.date) + .toLocaleString("en-us", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .replace(/(\d+)\/(\d+)\/(\d+)/, "$1/$2/$3"); + + dataFormatted.push({ + subject: workoutData.name, + startDate: startDate, + startTime: startTime, + description: workoutData.notes, + }); + + console.log(dataFormatted); + + exportCSVFile(headers, dataFormatted, "event"); +} - cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutEdit); +//Taken from github: https://gist.github.com/dannypule/48418b4cd8223104c6c92e3016fc0f61 +function convertToCSV(objArray) { + var array = typeof objArray != "object" ? JSON.parse(objArray) : objArray; + var str = ""; -} + for (var i = 0; i < array.length; i++) { + var line = ""; + for (var index in array[i]) { + if (line != "") line += ","; -async function deleteWorkout(id) { - let response = await sendRequest("DELETE", `${HOST}/api/workouts/${id}/`); - if (!response.ok) { - let data = await response.json(); - let alert = createAlert(`Could not delete workout ${id}!`, data); - document.body.prepend(alert); - } else { - window.location.replace("workouts.html"); + line += array[i][index]; } + + str += line + "\r\n"; + } + + return str; } -async function updateWorkout(id) { - let submitForm = generateWorkoutForm(); - - let response = await sendRequest("PUT", `${HOST}/api/workouts/${id}/`, submitForm, ""); - if (!response.ok) { - let data = await response.json(); - let alert = createAlert("Could not update workout!", data); - document.body.prepend(alert); - } else { - location.reload(); +//Taken from github: https://gist.github.com/dannypule/48418b4cd8223104c6c92e3016fc0f61 +function exportCSVFile(headers, items, fileTitle) { + console.log(items, headers); + if (headers) { + items.unshift(headers); + } + + // Convert Object to JSON + var jsonObject = JSON.stringify(items); + + var csv = this.convertToCSV(jsonObject); + + var exportedFilenmae = fileTitle + ".csv" || "export.csv"; + + var blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + if (navigator.msSaveBlob) { + // IE 10+ + navigator.msSaveBlob(blob, exportedFilenmae); + } else { + var link = document.createElement("a"); + if (link.download !== undefined) { + // feature detection + // Browsers that support HTML5 download attribute + var url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", exportedFilenmae); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); } + } } -function generateWorkoutForm() { - let form = document.querySelector("#form-workout"); +async function deleteWorkout(id) { + let response = await sendRequest("DELETE", `${HOST}/api/workouts/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert(`Could not delete workout ${id}!`, data); + document.body.prepend(alert); + } else { + window.location.replace("workouts.html"); + } +} - let formData = new FormData(form); - let submitForm = new FormData(); - - submitForm.append("name", formData.get('name')); - let date = new Date(formData.get('date')).toISOString(); - submitForm.append("date", date); - submitForm.append("notes", formData.get("notes")); - submitForm.append("visibility", formData.get("visibility")); - - // adding exercise instances - let exerciseInstances = []; - let exerciseInstancesTypes = formData.getAll("type"); - let exerciseInstancesSets = formData.getAll("sets"); - let exerciseInstancesNumbers = formData.getAll("number"); - for (let i = 0; i < exerciseInstancesTypes.length; i++) { - exerciseInstances.push({ - exercise: `${HOST}/api/exercises/${exerciseInstancesTypes[i]}/`, - number: exerciseInstancesNumbers[i], - sets: exerciseInstancesSets[i] - }); - } +async function updateWorkout(id) { + let submitForm = generateWorkoutForm(); + let response = await sendRequest( + "PUT", + `${HOST}/api/workouts/${id}/`, + submitForm, + "" + ); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not update workout!", data); + document.body.prepend(alert); + } else { + location.reload(); + } +} - submitForm.append("exercise_instances", JSON.stringify(exerciseInstances)); - // adding files - for (let file of formData.getAll("files")) { - submitForm.append("files", file); - } - return submitForm; +function generateWorkoutForm() { + var today = new Date().toISOString(); + + document.querySelector("#inputDateTime").min = today; + + let form = document.querySelector("#form-workout"); + + let formData = new FormData(form); + let submitForm = new FormData(); + + submitForm.append("name", formData.get("name")); + let date = new Date(formData.get("date")).toISOString(); + submitForm.append("date", date); + submitForm.append("notes", formData.get("notes")); + submitForm.append("visibility", formData.get("visibility")); + + submitForm.append("planned", planned); + + // adding exercise instances + let exerciseInstances = []; + let exerciseInstancesTypes = formData.getAll("type"); + let exerciseInstancesSets = formData.getAll("sets"); + let exerciseInstancesNumbers = formData.getAll("number"); + for (let i = 0; i < exerciseInstancesTypes.length; i++) { + exerciseInstances.push({ + exercise: `${HOST}/api/exercises/${exerciseInstancesTypes[i]}/`, + number: exerciseInstancesNumbers[i], + sets: exerciseInstancesSets[i], + }); + } + + submitForm.append("exercise_instances", JSON.stringify(exerciseInstances)); + // adding files + for (let file of formData.getAll("files")) { + submitForm.append("files", file); + } + return submitForm; } async function createWorkout() { - let submitForm = generateWorkoutForm(); + let submitForm = generateWorkoutForm(); - let response = await sendRequest("POST", `${HOST}/api/workouts/`, submitForm, ""); + let response = await sendRequest( + "POST", + `${HOST}/api/workouts/`, + submitForm, + "" + ); - if (response.ok) { - window.location.replace("workouts.html"); - } else { - let data = await response.json(); - let alert = createAlert("Could not create new workout!", data); - document.body.prepend(alert); - } + if (response.ok) { + window.location.replace("workouts.html"); + } else { + let data = await response.json(); + let alert = createAlert("Could not create new workout!", data); + document.body.prepend(alert); + } } function handleCancelDuringWorkoutCreate() { - window.location.replace("workouts.html"); + window.location.replace("workouts.html"); } async function createBlankExercise() { - let form = document.querySelector("#form-workout"); + let form = document.querySelector("#form-workout"); - let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); - let exerciseTypes = await exerciseTypeResponse.json(); + let exerciseTypeResponse = await sendRequest("GET", `${HOST}/api/exercises/`); + let exerciseTypes = await exerciseTypeResponse.json(); - let exerciseTemplate = document.querySelector("#template-exercise"); - let divExerciseContainer = exerciseTemplate.content.firstElementChild.cloneNode(true); - let exerciseTypeSelect = divExerciseContainer.querySelector("select"); - - for (let i = 0; i < exerciseTypes.count; i++) { - let option = document.createElement("option"); - option.value = exerciseTypes.results[i].id; - option.innerText = exerciseTypes.results[i].name; - exerciseTypeSelect.append(option); - } + let exerciseTemplate = document.querySelector("#template-exercise"); + let divExerciseContainer = exerciseTemplate.content.firstElementChild.cloneNode( + true + ); + let exerciseTypeSelect = divExerciseContainer.querySelector("select"); + + for (let i = 0; i < exerciseTypes.count; i++) { + let option = document.createElement("option"); + option.value = exerciseTypes.results[i].id; + option.innerText = exerciseTypes.results[i].name; + exerciseTypeSelect.append(option); + } + + let currentExerciseType = exerciseTypes.results[0]; + exerciseTypeSelect.value = currentExerciseType.name; - let currentExerciseType = exerciseTypes.results[0]; - exerciseTypeSelect.value = currentExerciseType.name; - - let divExercises = document.querySelector("#div-exercises"); - divExercises.appendChild(divExerciseContainer); + let divExercises = document.querySelector("#div-exercises"); + divExercises.appendChild(divExerciseContainer); } function removeExercise(event) { - let divExerciseContainers = document.querySelectorAll(".div-exercise-container"); - if (divExerciseContainers && divExerciseContainers.length > 0) { - divExerciseContainers[divExerciseContainers.length - 1].remove(); - } + let divExerciseContainers = document.querySelectorAll( + ".div-exercise-container" + ); + if (divExerciseContainers && divExerciseContainers.length > 0) { + divExerciseContainers[divExerciseContainers.length - 1].remove(); + } } function addComment(author, text, date, append) { - /* Taken from https://www.bootdey.com/snippets/view/Simple-Comment-panel#css*/ - let commentList = document.querySelector("#comment-list"); - let listElement = document.createElement("li"); - listElement.className = "media"; - let commentBody = document.createElement("div"); - commentBody.className = "media-body"; - let dateSpan = document.createElement("span"); - dateSpan.className = "text-muted pull-right me-1"; - let smallText = document.createElement("small"); - smallText.className = "text-muted"; - - if (date != "Now") { - let localDate = new Date(date); - smallText.innerText = localDate.toLocaleString(); - } else { - smallText.innerText = date; - } - - dateSpan.appendChild(smallText); - commentBody.appendChild(dateSpan); - - let strong = document.createElement("strong"); - strong.className = "text-success"; - strong.innerText = author; - commentBody.appendChild(strong); - let p = document.createElement("p"); - p.innerHTML = text; - - commentBody.appendChild(strong); - commentBody.appendChild(p); - listElement.appendChild(commentBody); - - if (append) { - commentList.append(listElement); - } else { - commentList.prepend(listElement); - } - + /* Taken from https://www.bootdey.com/snippets/view/Simple-Comment-panel#css*/ + let commentList = document.querySelector("#comment-list"); + let listElement = document.createElement("li"); + listElement.className = "media"; + let commentBody = document.createElement("div"); + commentBody.className = "media-body"; + let dateSpan = document.createElement("span"); + dateSpan.className = "text-muted pull-right me-1"; + let smallText = document.createElement("small"); + smallText.className = "text-muted"; + + if (date != "Now") { + let localDate = new Date(date); + smallText.innerText = localDate.toLocaleString(); + } else { + smallText.innerText = date; + } + + dateSpan.appendChild(smallText); + commentBody.appendChild(dateSpan); + + let strong = document.createElement("strong"); + strong.className = "text-success"; + strong.innerText = author; + commentBody.appendChild(strong); + let p = document.createElement("p"); + p.innerHTML = text; + + commentBody.appendChild(strong); + commentBody.appendChild(p); + listElement.appendChild(commentBody); + + if (append) { + commentList.append(listElement); + } else { + commentList.prepend(listElement); + } } async function createComment(workoutid) { - let commentArea = document.querySelector("#comment-area"); - let content = commentArea.value; - let body = {workout: `${HOST}/api/workouts/${workoutid}/`, content: content}; - - let response = await sendRequest("POST", `${HOST}/api/comments/`, body); - if (response.ok) { - addComment(sessionStorage.getItem("username"), content, "Now", false); - } else { - let data = await response.json(); - let alert = createAlert("Failed to create comment!", data); - document.body.prepend(alert); - } + let commentArea = document.querySelector("#comment-area"); + let content = commentArea.value; + let body = { + workout: `${HOST}/api/workouts/${workoutid}/`, + content: content, + }; + + let response = await sendRequest("POST", `${HOST}/api/comments/`, body); + if (response.ok) { + addComment(sessionStorage.getItem("username"), content, "Now", false); + } else { + let data = await response.json(); + let alert = createAlert("Failed to create comment!", data); + document.body.prepend(alert); + } } async function retrieveComments(workoutid) { - let response = await sendRequest("GET", `${HOST}/api/comments/`); - if (!response.ok) { - let data = await response.json(); - let alert = createAlert("Could not retrieve comments!", data); - document.body.prepend(alert); - } else { - let data = await response.json(); - let comments = data.results; - for (let comment of comments) { - let splitArray = comment.workout.split("/"); - if (splitArray[splitArray.length - 2] == workoutid) { - addComment(comment.owner, comment.content, comment.timestamp, true); - } - } + let response = await sendRequest("GET", `${HOST}/api/comments/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve comments!", data); + document.body.prepend(alert); + } else { + let data = await response.json(); + let comments = data.results; + for (let comment of comments) { + let splitArray = comment.workout.split("/"); + if (splitArray[splitArray.length - 2] == workoutid) { + addComment(comment.owner, comment.content, comment.timestamp, true); + } } + } } window.addEventListener("DOMContentLoaded", async () => { - cancelWorkoutButton = document.querySelector("#btn-cancel-workout"); - okWorkoutButton = document.querySelector("#btn-ok-workout"); - deleteWorkoutButton = document.querySelector("#btn-delete-workout"); - editWorkoutButton = document.querySelector("#btn-edit-workout"); - let postCommentButton = document.querySelector("#post-comment"); - let divCommentRow = document.querySelector("#div-comment-row"); - let buttonAddExercise = document.querySelector("#btn-add-exercise"); - let buttonRemoveExercise = document.querySelector("#btn-remove-exercise"); - - buttonAddExercise.addEventListener("click", createBlankExercise); - buttonRemoveExercise.addEventListener("click", removeExercise); - - const urlParams = new URLSearchParams(window.location.search); - let currentUser = await getCurrentUser(); - - if (urlParams.has('id')) { - const id = urlParams.get('id'); - let workoutData = await retrieveWorkout(id); - await retrieveComments(id); - - if (workoutData["owner"] == currentUser.url) { - editWorkoutButton.classList.remove("hide"); - editWorkoutButton.addEventListener("click", handleEditWorkoutButtonClick); - deleteWorkoutButton.addEventListener("click", (async (id) => await deleteWorkout(id)).bind(undefined, id)); - okWorkoutButton.addEventListener("click", (async (id) => await updateWorkout(id)).bind(undefined, id)); - postCommentButton.addEventListener("click", (async (id) => await createComment(id)).bind(undefined, id)); - divCommentRow.className = divCommentRow.className.replace(" hide", ""); - } - } else { - await createBlankExercise(); - let ownerInput = document.querySelector("#inputOwner"); - ownerInput.value = currentUser.username; - setReadOnly(false, "#form-workout"); - ownerInput.readOnly = !ownerInput.readOnly; - - okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); - cancelWorkoutButton.className = cancelWorkoutButton.className.replace(" hide", ""); - buttonAddExercise.className = buttonAddExercise.className.replace(" hide", ""); - buttonRemoveExercise.className = buttonRemoveExercise.className.replace(" hide", ""); - - okWorkoutButton.addEventListener("click", async () => await createWorkout()); - cancelWorkoutButton.addEventListener("click", handleCancelDuringWorkoutCreate); - divCommentRow.className += " hide"; + cancelWorkoutButton = document.querySelector("#btn-cancel-workout"); + okWorkoutButton = document.querySelector("#btn-ok-workout"); + deleteWorkoutButton = document.querySelector("#btn-delete-workout"); + editWorkoutButton = document.querySelector("#btn-edit-workout"); + exportWorkoutButton = document.querySelector("#btn-export-workout"); + let postCommentButton = document.querySelector("#post-comment"); + let divCommentRow = document.querySelector("#div-comment-row"); + let buttonAddExercise = document.querySelector("#btn-add-exercise"); + let buttonRemoveExercise = document.querySelector("#btn-remove-exercise"); + + buttonAddExercise.addEventListener("click", createBlankExercise); + buttonRemoveExercise.addEventListener("click", removeExercise); + + const urlParams = new URLSearchParams(window.location.search); + let currentUser = await getCurrentUser(); + + if (urlParams.has("id")) { + const id = urlParams.get("id"); + let workoutData = await retrieveWorkout(id); + await retrieveComments(id); + + if (workoutData["owner"] == currentUser.url) { + editWorkoutButton.classList.remove("hide"); + exportWorkoutButton.classList.remove("hide"); + editWorkoutButton.addEventListener("click", handleEditWorkoutButtonClick); + exportWorkoutButton.addEventListener( + "click", + ((workoutData) => handleExportToCalendarClick(workoutData)).bind( + undefined, + workoutData + ) + ); + deleteWorkoutButton.addEventListener( + "click", + (async (id) => await deleteWorkout(id)).bind(undefined, id) + ); + okWorkoutButton.addEventListener( + "click", + (async (id) => await updateWorkout(id)).bind(undefined, id) + ); + postCommentButton.addEventListener( + "click", + (async (id) => await createComment(id)).bind(undefined, id) + ); + divCommentRow.className = divCommentRow.className.replace(" hide", ""); } + } else { + await createBlankExercise(); + let ownerInput = document.querySelector("#inputOwner"); + ownerInput.value = currentUser.username; + setReadOnly(false, "#form-workout"); + ownerInput.readOnly = !ownerInput.readOnly; -}); \ No newline at end of file + okWorkoutButton.className = okWorkoutButton.className.replace(" hide", ""); + cancelWorkoutButton.className = cancelWorkoutButton.className.replace( + " hide", + "" + ); + buttonAddExercise.className = buttonAddExercise.className.replace( + " hide", + "" + ); + buttonRemoveExercise.className = buttonRemoveExercise.className.replace( + " hide", + "" + ); + + okWorkoutButton.addEventListener( + "click", + async () => await createWorkout() + ); + cancelWorkoutButton.addEventListener( + "click", + handleCancelDuringWorkoutCreate + ); + divCommentRow.className += " hide"; + } +}); diff --git a/frontend/www/scripts/workouts.js b/frontend/www/scripts/workouts.js index 772be1ea070499ad9574d787d990c1ca17097bdf..f01af08fa0a57055abc0434fd27be6e9cc49c74a 100644 --- a/frontend/www/scripts/workouts.js +++ b/frontend/www/scripts/workouts.js @@ -1,106 +1,220 @@ async function fetchWorkouts(ordering) { - let response = await sendRequest("GET", `${HOST}/api/workouts/?ordering=${ordering}`); + let response = await sendRequest( + "GET", + `${HOST}/api/workouts/?ordering=${ordering}` + ); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } else { + let data = await response.json(); + + let workouts = data.results; + let container = document.getElementById("div-content"); + workouts.forEach((workout) => { + let templateWorkout = document.querySelector("#template-workout"); + let cloneWorkout = templateWorkout.content.cloneNode(true); + + let aWorkout = cloneWorkout.querySelector("a"); + aWorkout.href = `workout.html?id=${workout.id}`; + + let h5 = aWorkout.querySelector("h5"); + h5.textContent = workout.name; + + let localDate = new Date(workout.date); + + let table = aWorkout.querySelector("table"); + let rows = table.querySelectorAll("tr"); + rows[0].querySelectorAll( + "td" + )[1].textContent = localDate.toLocaleDateString(); // Date + rows[1].querySelectorAll( + "td" + )[1].textContent = localDate.toLocaleTimeString(); // Time + rows[2].querySelectorAll("td")[1].textContent = workout.owner_username; //Owner + rows[3].querySelectorAll("td")[1].textContent = + workout.exercise_instances.length; // Exercises + + container.appendChild(aWorkout); + }); + return workouts; + } +} + +async function fetchSuggestedWorkouts() { + let responseSuggestAthlete = await sendRequest("GET", `${HOST}/api/suggested-workouts/athlete-list/`); + let responseSuggestCoach = await sendRequest("GET", `${HOST}/api/suggested-workouts/coach-list/`); + + if (!responseSuggestCoach || !responseSuggestAthlete) { + throw new Error(`HTTP error! status: ${responseSuggestAthlete.status || responseSuggestCoach.status}`); } else { - let data = await response.json(); + let suggestWorkoutAthlete = await responseSuggestAthlete.json(); + let suggestWorkoutCoach = await responseSuggestCoach.json(); - let workouts = data.results; + let suggestedWorkouts = suggestWorkoutAthlete.concat(suggestWorkoutCoach); let container = document.getElementById('div-content'); - workouts.forEach(workout => { - let templateWorkout = document.querySelector("#template-workout"); + + suggestedWorkouts.forEach(workout => { + let templateWorkout = document.querySelector("#template-suggested-workout"); let cloneWorkout = templateWorkout.content.cloneNode(true); let aWorkout = cloneWorkout.querySelector("a"); - aWorkout.href = `workout.html?id=${workout.id}`; + aWorkout.href = `suggestworkout.html?id=${workout.id}`; let h5 = aWorkout.querySelector("h5"); h5.textContent = workout.name; - let localDate = new Date(workout.date); + //let localDate = new Date(workout.date); let table = aWorkout.querySelector("table"); let rows = table.querySelectorAll("tr"); - rows[0].querySelectorAll("td")[1].textContent = localDate.toLocaleDateString(); // Date - rows[1].querySelectorAll("td")[1].textContent = localDate.toLocaleTimeString(); // Time - rows[2].querySelectorAll("td")[1].textContent = workout.owner_username; //Owner - rows[3].querySelectorAll("td")[1].textContent = workout.exercise_instances.length; // Exercises + rows[0].querySelectorAll("td")[1].textContent = workout.coach_username; //Owner + rows[1].querySelectorAll("td")[1].textContent = workout.suggested_exercise_instances.length; // Exercises + rows[2].querySelectorAll("td")[1].textContent = workout.status === "p" ? "Pending" : "Accept"; // Exercises + container.appendChild(aWorkout); }); - return workouts; + + return [suggestWorkoutAthlete, suggestWorkoutCoach]; } + } + function createWorkout() { - window.location.replace("workout.html"); + window.location.replace("workout.html"); +} + +function suggestWorkout() { + window.location.replace("suggestworkout.html"); +} + +function planWorkout() { + window.location.replace("plannedWorkout.html"); } window.addEventListener("DOMContentLoaded", async () => { - let createButton = document.querySelector("#btn-create-workout"); + let createButton = document.querySelector("#btn-create-workout"); + let suggestButton = document.querySelector("#btn-suggest-workout"); + suggestButton.addEventListener("click", suggestWorkout); createButton.addEventListener("click", createWorkout); - let ordering = "-date"; + let planButton = document.querySelector("#btn-plan-workout"); + planButton.addEventListener("click", planWorkout); + let ordering = "-date"; - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.has('ordering')) { - let aSort = null; - ordering = urlParams.get('ordering'); + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has("ordering")) { + let aSort = null; + ordering = urlParams.get("ordering"); if (ordering == "name" || ordering == "owner" || ordering == "date") { - let aSort = document.querySelector(`a[href="?ordering=${ordering}"`); - aSort.href = `?ordering=-${ordering}`; - } - } - - let currentSort = document.querySelector("#current-sort"); - currentSort.innerHTML = (ordering.startsWith("-") ? "Descending" : "Ascending") + " " + ordering.replace("-", ""); - - let currentUser = await getCurrentUser(); - // grab username - if (ordering.includes("owner")) { - ordering += "__username"; + let aSort = document.querySelector(`a[href="?ordering=${ordering}"`); + aSort.href = `?ordering=-${ordering}`; + } } - let workouts = await fetchWorkouts(ordering); - + + let currentSort = document.querySelector("#current-sort"); + currentSort.innerHTML = + (ordering.startsWith("-") ? "Descending" : "Ascending") + + " " + + ordering.replace("-", ""); + + let currentUser = await getCurrentUser(); + // grab username + if (ordering.includes("owner")) { + ordering += "__username"; + } + let workouts = await fetchWorkouts(ordering); +let [athleteWorkout, coachWorkout] = await fetchSuggestedWorkouts(); + + let allWorkouts = workouts.concat(athleteWorkout, coachWorkout); + let tabEls = document.querySelectorAll('a[data-bs-toggle="list"]'); for (let i = 0; i < tabEls.length; i++) { let tabEl = tabEls[i]; - tabEl.addEventListener('show.bs.tab', function (event) { - let workoutAnchors = document.querySelectorAll('.workout'); - for (let j = 0; j < workouts.length; j++) { - // I'm assuming that the order of workout objects matches - // the other of the workout anchor elements. They should, given - // that I just created them. - let workout = workouts[j]; - let workoutAnchor = workoutAnchors[j]; - - switch (event.currentTarget.id) { - case "list-my-workouts-list": - if (workout.owner == currentUser.url) { - workoutAnchor.classList.remove('hide'); - } else { - workoutAnchor.classList.add('hide'); - } - break; - case "list-athlete-workouts-list": - if (currentUser.athletes && currentUser.athletes.includes(workout.owner)) { - workoutAnchor.classList.remove('hide'); + tabEl.addEventListener("show.bs.tab", function (event) { + let workoutAnchors = document.querySelectorAll(".workout"); + for (let j = 0; j < allWorkouts.length; j++) { + // I'm assuming that the order of workout objects matches + // the other of the workout anchor elements. They should, given + // that I just created them. + let workout = allWorkouts[j]; + let workoutAnchor = workoutAnchors[j]; + + switch (event.currentTarget.id) { + case "list-my-logged-workouts-list": + if (workout.owner == currentUser.url) { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + + if (!workout.planned) { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + break; + case "list-my-planned-workouts-list": + if (workout.owner == currentUser.url) { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + + if (workout.planned) { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + break; + case "list-athlete-workouts-list": + if ( + currentUser.athletes && + currentUser.athletes.includes(workout.owner) + ) { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + break; + case "list-public-workouts-list": + if (workout.visibility == "PU") { + workoutAnchor.classList.remove("hide"); + } else { + workoutAnchor.classList.add("hide"); + } + break; + case "list-suggested-coach-workouts-list": + if (currentUser.coach) { + let coachID = currentUser?.coach?.split('/'); + if (coachID[coachID.length - 2] == workout.coach) { + workoutAnchor.classList.remove('hide'); + + } } else { workoutAnchor.classList.add('hide'); } break; - case "list-public-workouts-list": - if (workout.visibility == "PU") { + case "list-suggested-athlete-workouts-list": + let athletes = currentUser?.athletes?.map((athlete) => { + let athleteIdSplit = athlete.split('/'); + return Number(athleteIdSplit[athleteIdSplit.length - 2]); + + }) + if (athletes.includes(workout.athlete)) { + console.log("hei") workoutAnchor.classList.remove('hide'); } else { workoutAnchor.classList.add('hide'); } break; + default : - workoutAnchor.classList.remove('hide'); - break; - } - } - }); - } -}); \ No newline at end of file + workoutAnchor.classList.remove("hide"); + break; + } + } + }); + } +}); diff --git a/frontend/www/styles/style.css b/frontend/www/styles/style.css index 066705ce965bb162c61ebaf65ff77b9a0824eaf3..89160462b2a3b07889aa1818320aeda482d845d9 100644 --- a/frontend/www/styles/style.css +++ b/frontend/www/styles/style.css @@ -62,3 +62,8 @@ .link-block { display: block; } + +.btn-green { + background-color: #256d27; + color: #fff; +} diff --git a/frontend/www/suggestworkout.html b/frontend/www/suggestworkout.html new file mode 100644 index 0000000000000000000000000000000000000000..6201d5fd7577dfadc577ffb77397481a9821c3dd --- /dev/null +++ b/frontend/www/suggestworkout.html @@ -0,0 +1,191 @@ + + + + + + Workout + + + + + + + + + + +
+
+
+

Suggest Workout to Athlete

+

+ Suggested Workout from Coach +

+
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
+
+
+ + + + + + + +
+
+
+

Exercises

+
+
+
+ + +
+
+
+
+ + + + + + + + + + + diff --git a/frontend/www/workout.html b/frontend/www/workout.html index 73747232adaea6ef4944cdd0fbec8693ca6b4a76..849b3fa093ad8ac03b08fe95801728260af00a84 100644 --- a/frontend/www/workout.html +++ b/frontend/www/workout.html @@ -17,9 +17,14 @@
-

View/Edit Workout

+

View/Edit Logged Workout

-
+
+
+
+

A logged workout is a workout you have completed

+
+
@@ -60,6 +65,7 @@
+
diff --git a/frontend/www/workouts.html b/frontend/www/workouts.html index b34439d55031d7f029647d8a425669b795d8fde0..195c27a60658fed654aa8588f132c9a42952f72c 100644 --- a/frontend/www/workouts.html +++ b/frontend/www/workouts.html @@ -1,63 +1,148 @@ - - + + Workouts - - + + - + - + -
-
-
-

View Workouts

-

Here you can view workouts completed by you, your athletes, - or the public. Click on a workout to view its details.

- -
-
-
-
- -
Sort by: Date Owner Name -
Currently sorting by: -
-
-
+
+
+
+

View Workouts

+

Here you can view workouts completed by you, your athletes, + or the public. Click on a workout to view its details.

+ + +
-
- -