diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b398627..2ce54dc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,13 @@ jobs: python-version: - 3.8 + services: + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + steps: - name: Set up repo uses: actions/checkout@v2 @@ -35,7 +42,7 @@ jobs: run: pipenv verify - name: Install dependencies - run: pipenv install --deploy + run: pipenv install --dev --deploy - name: Run Tests run: pipenv run python3 manage.py test diff --git a/Pipfile b/Pipfile index 8674d33d..5d7cb660 100644 --- a/Pipfile +++ b/Pipfile @@ -22,9 +22,9 @@ django-extensions = "~=3.2" ipython = "~=8.12.3" # IPython follows NEP 29, so v8.13 does not support Python 3.8 mosspy = "~=1.0" django-debug-toolbar = "~=4.3" -pytest-django = "~=4.8" [dev-packages] +pytest-django = "~=4.8" django-stubs = "*" pre-commit = "*" typing-extensions = "*" diff --git a/Pipfile.lock b/Pipfile.lock index b2f93894..f52f7d75 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "301da1b8bb909bc1b06f965b4464190d8a63a56d5d9dab85d222edc95cf13a93" + "sha256": "1b32d7943ce33eec97cffaa80dc57be494e022645731ec93b5777c82569e1044" }, "pipfile-spec": 6, "requires": { @@ -469,14 +469,6 @@ "index": "pypi", "version": "==0.9.5" }, - "exceptiongroup": { - "hashes": [ - "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", - "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16" - ], - "markers": "python_version < '3.11'", - "version": "==1.2.1" - }, "executing": { "hashes": [ "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147", @@ -487,11 +479,11 @@ }, "filelock": { "hashes": [ - "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", - "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" + "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", + "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7" ], "markers": "python_version >= '3.8'", - "version": "==3.14.0" + "version": "==3.15.4" }, "gunicorn": { "hashes": [ @@ -519,11 +511,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", - "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2" + "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f", + "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812" ], "markers": "python_version < '3.10'", - "version": "==7.1.0" + "version": "==8.0.0" }, "incremental": { "hashes": [ @@ -532,14 +524,6 @@ ], "version": "==22.10.0" }, - "iniconfig": { - "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, "ipython": { "hashes": [ "sha256:3910c4b54543c2ad73d06579aa771041b7d5707b033bd488669b4cf544e3b363", @@ -808,11 +792,11 @@ }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.1" }, "parso": { "hashes": [ @@ -845,21 +829,13 @@ "markers": "python_version >= '3.8'", "version": "==4.2.2" }, - "pluggy": { - "hashes": [ - "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", - "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" - ], - "markers": "python_version >= '3.8'", - "version": "==1.5.0" - }, "prompt-toolkit": { "hashes": [ - "sha256:45abe60a8300f3c618b23c16c4bb98c6fc80af8ce8b17c7ae92db48db3ee63c1", - "sha256:869c50d682152336e23c4db7f74667639b5047494202ffe7670817053fd57795" + "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", + "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.46" + "version": "==3.0.47" }, "psutil": { "hashes": [ @@ -954,23 +930,6 @@ ], "version": "==24.1.0" }, - "pytest": { - "hashes": [ - "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343", - "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977" - ], - "markers": "python_version >= '3.8'", - "version": "==8.2.2" - }, - "pytest-django": { - "hashes": [ - "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", - "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.8.0" - }, "python-dateutil": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", @@ -988,12 +947,12 @@ }, "redis": { "hashes": [ - "sha256:30b47d4ebb6b7a0b9b40c1275a19b87bb6f46b3bed82a89012cf56dea4024ada", - "sha256:3417688621acf6ee368dec4a04dd95881be24efd34c79f00d31f62bb528800ae" + "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db", + "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==5.0.5" + "version": "==5.0.7" }, "requests": { "hashes": [ @@ -1021,11 +980,11 @@ }, "setuptools": { "hashes": [ - "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4", - "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0" + "sha256:937a48c7cdb7a21eb53cd7f9b59e525503aa8abaf3584c730dc5f7a5bec3a650", + "sha256:a58a8fde0541dab0419750bcc521fbdf8585f6e5cb41909df3a472ef7b81ca95" ], "markers": "python_version >= '3.8'", - "version": "==70.0.0" + "version": "==70.1.1" }, "six": { "hashes": [ @@ -1082,14 +1041,6 @@ ], "version": "==1.2.1" }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, "traitlets": { "hashes": [ "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", @@ -1122,7 +1073,7 @@ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], - "markers": "python_version >= '3.8'", + "markers": "python_version < '3.10'", "version": "==4.12.2" }, "tzdata": { @@ -1135,11 +1086,11 @@ }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", + "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.2" }, "vine": { "hashes": [ @@ -1151,12 +1102,12 @@ }, "virtualenv": { "hashes": [ - "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", - "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" + "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", + "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==20.26.2" + "version": "==20.26.3" }, "wcwidth": { "hashes": [ @@ -1434,13 +1385,21 @@ "markers": "python_version >= '3.7'", "version": "==0.20.1" }, + "exceptiongroup": { + "hashes": [ + "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", + "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16" + ], + "markers": "python_version < '3.11'", + "version": "==1.2.1" + }, "filelock": { "hashes": [ - "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f", - "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a" + "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", + "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7" ], "markers": "python_version >= '3.8'", - "version": "==3.14.0" + "version": "==3.15.4" }, "furo": { "hashes": [ @@ -1477,11 +1436,19 @@ }, "importlib-metadata": { "hashes": [ - "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", - "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2" + "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f", + "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812" ], "markers": "python_version < '3.10'", - "version": "==7.1.0" + "version": "==8.0.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" }, "jinja2": { "hashes": [ @@ -1600,11 +1567,11 @@ }, "packaging": { "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], - "markers": "python_version >= '3.7'", - "version": "==24.0" + "markers": "python_version >= '3.8'", + "version": "==24.1" }, "platformdirs": { "hashes": [ @@ -1614,6 +1581,14 @@ "markers": "python_version >= '3.8'", "version": "==4.2.2" }, + "pluggy": { + "hashes": [ + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, "pprintpp": { "hashes": [ "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", @@ -1638,6 +1613,23 @@ "markers": "python_version >= '3.8'", "version": "==2.18.0" }, + "pytest": { + "hashes": [ + "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343", + "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977" + ], + "markers": "python_version >= '3.8'", + "version": "==8.2.2" + }, + "pytest-django": { + "hashes": [ + "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", + "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.8.0" + }, "pytz": { "hashes": [ "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", @@ -1839,25 +1831,25 @@ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], - "markers": "python_version >= '3.8'", + "markers": "python_version < '3.10'", "version": "==4.12.2" }, "urllib3": { "hashes": [ - "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", - "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" + "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", + "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" ], "markers": "python_version >= '3.8'", - "version": "==2.2.1" + "version": "==2.2.2" }, "virtualenv": { "hashes": [ - "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c", - "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b" + "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", + "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==20.26.2" + "version": "==20.26.3" }, "zipp": { "hashes": [ diff --git a/pyproject.toml b/pyproject.toml index 23e1495b..bd318d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,10 @@ dependencies = { file = "requirements.txt" } [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="tin.settings" python_files="tests.py test_*.py *_tests.py" -norecursedirs = ["media"] +norecursedirs = ["media", "migrations", "sandboxing"] +testpaths = "tin" +addopts = "--doctest-modules tin --import-mode=importlib" +doctest_optionflags = "NORMALIZE_WHITESPACE NUMBER" [tool.black] line-length = 100 @@ -115,6 +118,8 @@ ignore = [ "E114", "E117", "E501", + "D206", + "D300", ] [tool.ruff.lint.pydocstyle] diff --git a/tin/apps/assignments/tests.py b/tin/apps/assignments/tests.py deleted file mode 100644 index 12599c9a..00000000 --- a/tin/apps/assignments/tests.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from django.urls import reverse - -from tin.tests import is_redirect, login - - -@login("teacher") -def test_create_folder(client, course) -> None: - response = client.post( - reverse("assignments:add_folder", args=[course.id]), {"name": "Fragment Shader"} - ) - assert is_redirect(response) - assert course.folders.exists() - - -@login("teacher") -def test_create_assignment(client, course) -> None: - data = { - "name": "Write a Vertex Shader", - "description": "See https://learnopengl.com/Getting-started/Shaders", - "language": "P", - "filename": "vertex.glsl", - "points_possible": "300", - "due": "04/16/2025", - "grader_timeout": "300", - "submission_limit_count": "90", - "submission_limit_interval": "30", - "submission_limit_cooldown": "30", - "is_quiz": False, - "quiz_action": "2", - } - response = client.post( - reverse("assignments:add", args=[course.id]), - data, - ) - assert is_redirect(response) - assignment_set = course.assignments.filter(name__exact=data["name"]) - assert assignment_set.count() == 1 diff --git a/tin/apps/assignments/tests/__init__.py b/tin/apps/assignments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tin/apps/assignments/tests/test_assignments.py b/tin/apps/assignments/tests/test_assignments.py new file mode 100644 index 00000000..2e456587 --- /dev/null +++ b/tin/apps/assignments/tests/test_assignments.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import csv +import io + +import pytest +from django.urls import reverse + +from tin.tests import is_redirect, login + + +@login("teacher") +def test_create_assignment(client, course) -> None: + data = { + "name": "Write a Vertex Shader", + "description": "See https://learnopengl.com/Getting-started/Shaders", + "language": "P", + "filename": "vertex.glsl", + "points_possible": "300", + "due": "04/16/2025", + "grader_timeout": "300", + "submission_limit_count": "90", + "submission_limit_interval": "30", + "submission_limit_cooldown": "30", + "is_quiz": False, + "quiz_action": "2", + } + response = client.post( + reverse("assignments:add", args=[course.id]), + data, + ) + assert is_redirect(response) + assignment_set = course.assignments.filter(name__exact=data["name"]) + assert assignment_set.count() == 1 + + +@login("teacher") +def test_delete_assignment(client, assignment): + response = client.post(reverse("assignments:delete", args=[assignment.id])) + assert is_redirect(response) + + with pytest.raises(type(assignment).DoesNotExist): + assignment.refresh_from_db() + + +@login("student") +def test_submit_assignment_with_text(client, assignment): + response = client.post( + reverse("assignments:submit", args=[assignment.id]), {"text": "print('I hate CSS')"} + ) + + assert is_redirect(response) + assert assignment.submissions.count() == 1 + + +@login("student") +def test_submit_assignment_with_file(client, assignment): + response = client.post( + reverse("assignments:submit", args=[assignment.id]), + {"file": io.BytesIO(b"print('I hate CSS')")}, + ) + + assert is_redirect(response) + assert assignment.submissions.count() == 1 + + +@login("teacher") +def test_csv_of_missing_assignment(client, assignment, student): + assignment.submissions.create(student=student) + response = client.get( + reverse("assignments:scores_csv", args=[assignment.id]), {"period": "all"} + ) + reader = csv.reader(io.StringIO(response.content.decode("utf-8"))) + next(reader) # skip row with headers + row = next(reader) + assert row is not None + (full_name, username, _period, raw, final, formatted) = row + assert student.full_name == full_name + assert student.username == username + assert raw == "NG" + assert final == "NG" + assert formatted == "NG" + + +@login("teacher") +def test_csv_of_missing_assignment_graded(client, assignment, student): + max_points = float(assignment.points_possible) / 2 + assignment.submissions.create( + student=student, + has_been_graded=True, + points_received=max_points, + ) + response = client.get( + reverse("assignments:scores_csv", args=[assignment.id]), {"period": "all"} + ) + reader = csv.reader(io.StringIO(response.content.decode("utf-8"))) + next(reader) # skip initial row with headers + row = next(reader) + assert row is not None + (full_name, username, _period, raw, final, formatted) = row + assert student.full_name == full_name + assert student.username == username + assert float(raw) == max_points + assert float(final) == max_points + assert formatted == "150 / 300 (50.00%)" diff --git a/tin/apps/assignments/tests/test_folders.py b/tin/apps/assignments/tests/test_folders.py new file mode 100644 index 00000000..65275b6a --- /dev/null +++ b/tin/apps/assignments/tests/test_folders.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import io + +from django.conf import settings +from django.urls import reverse + +from tin.tests import is_redirect, login, str_to_html + + +@login("teacher") +def test_create_folder(client, course) -> None: + response = client.post( + reverse("assignments:add_folder", args=[course.id]), {"name": "Fragment Shader"} + ) + assert is_redirect(response) + assert course.folders.exists() + + +@login("teacher") +def test_delete_folder(client, course) -> None: + folder = course.folders.create(name="My Folder") + response = client.post(reverse("assignments:delete_folder", args=[course.id, folder.id])) + + assert is_redirect(response) + assert not course.folders.exists() + + +@login("teacher") +def test_incorrect_files(client, assignment): + binary_data = io.BytesIO(b"b" * (settings.SUBMISSION_SIZE_LIMIT + 1)) + response = client.post( + reverse("assignments:manage_files", args=[assignment.id]), {"upload_file": binary_data} + ) + + error = str_to_html("That file's too large.") + assert error in response.content.decode("utf-8") + + +@login("teacher") +def test_files_management(client, assignment): + sample_grader = io.BytesIO(b"some text") + response = client.post( + reverse("assignments:manage_files", args=[assignment.id]), {"upload_file": sample_grader} + ) + + assert is_redirect(response) diff --git a/tin/apps/assignments/tests/test_grader.py b/tin/apps/assignments/tests/test_grader.py new file mode 100644 index 00000000..a0db4ac5 --- /dev/null +++ b/tin/apps/assignments/tests/test_grader.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import io + +from django.conf import settings +from django.urls import reverse + +from tin.tests import is_redirect, login, str_to_html + + +@login("teacher") +def test_grader_view(client, assignment): + sample_grader = io.BytesIO(b"some text") + response = client.post( + reverse("assignments:manage_grader", args=[assignment.id]), {"grader_file": sample_grader} + ) + + assert is_redirect(response) + + +@login("teacher") +def test_incorrect_grader(client, assignment): + binary_data = io.BytesIO(b"b" * (settings.SUBMISSION_SIZE_LIMIT + 1)) + response = client.post( + reverse("assignments:manage_grader", args=[assignment.id]), {"grader_file": binary_data} + ) + + error = str_to_html( + "That file's too large. Are you sure it's a Python program?", + ) + assert error in response.content.decode("utf-8") + + +@login("teacher") +def test_download_grader(client, assignment): + response = client.post( + reverse("assignments:download_grader", args=[assignment.id]), + ) + assert response.status_code == 404 + + code = "print('hello, world')" + assignment.save_grader_file(code) + assignment.save() + + response = client.post( + reverse("assignments:download_grader", args=[assignment.id]), + ) + + assert response.status_code == 200 + assert response.content.decode("utf-8") == code diff --git a/tin/apps/assignments/tests/test_quiz.py b/tin/apps/assignments/tests/test_quiz.py new file mode 100644 index 00000000..285606b3 --- /dev/null +++ b/tin/apps/assignments/tests/test_quiz.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json + +import pytest +from django.conf import settings +from django.urls import reverse + +from tin.tests import is_redirect, login + + +@login("student") +def test_submit_quiz_as_assignment(client, quiz): + response = client.post( + reverse("assignments:submit", args=[quiz.id]), {"text": "print('I hate CSS')"} + ) + + assert response.status_code == 404 + + +@login("student") +def test_submit_quiz(client, quiz): + response = client.post( + reverse("assignments:quiz", args=[quiz.id]), {"text": "print('I hate CSS')"} + ) + + assert is_redirect(response) + assert quiz.submissions.count() == 1 + + +@login("student") +def test_submit_assigment_as_quiz(client, assignment): + response = client.post( + reverse("assignments:quiz", args=[assignment.id]), {"text": "print('I hate CSS')"} + ) + + assert response.status_code == 404 + + +@login("student") +def test_quiz_ended_has_message(client, quiz): + response = client.post(reverse("assignments:quiz_end", args=[quiz.id])) + assert is_redirect(response) + all = tuple(msg for msg in quiz.log_messages.all()) + assert len(all) == 1 + (first,) = all + assert first.content == "Ended quiz" + + response = client.post( + reverse("assignments:quiz", args=[quiz.id]), {"text": "print('I hate CSS')"} + ) + + assert response.status_code == 404 + + +@login("student") +def test_quiz_data_basic(client, quiz): + response = client.get(reverse("assignments:report", args=[quiz.id])) + data = json.loads(response.content.decode("utf-8")) + assert data == {"action": "no action"} + + +@login("teacher") +@pytest.mark.parametrize( + ("quiz_action", "action"), + (("1", "color"), ("2", "lock")), +) +def test_quiz_data_with_severity(client, quiz, quiz_action, action): + quiz.quiz_action = quiz_action + quiz.save() + + response = client.get( + reverse("assignments:report", args=[quiz.id]), + {"severity": settings.QUIZ_ISSUE_THRESHOLD, "content": "hi"}, + ) + data = json.loads(response.content.decode("utf-8")) + assert data == {"action": action} + + msgs = tuple(quiz.log_messages.all()) + assert len(msgs) == 1 + assert msgs[0].content == "hi" + + +@login("teacher") +def test_quiz_data_after_close(client, quiz): + # this should end the quiz + client.post(reverse("assignments:quiz_end", args=[quiz.id])) + response = client.get( + reverse("assignments:report", args=[quiz.id]), + {"severity": settings.QUIZ_ISSUE_THRESHOLD, "content": "hi"}, + ) + + assert json.loads(response.content.decode("utf-8")) == {"action": "no action"} + msgs = tuple(quiz.log_messages.all()) + assert len(msgs) == 1 + assert msgs[0].content == "Ended quiz" + + +@login("teacher") +def test_clear_quiz_messages(client, student, quiz): + quiz.log_messages.create(student=student, content="hi", severity=0) + response = client.post(reverse("assignments:clear", args=[quiz.id, student.id])) + assert is_redirect(response) + assert not quiz.log_messages.exists() diff --git a/tin/tests/fixtures.py b/tin/tests/fixtures.py index 04451537..febed602 100644 --- a/tin/tests/fixtures.py +++ b/tin/tests/fixtures.py @@ -1,5 +1,8 @@ from __future__ import annotations +import shutil +from pathlib import Path + import pytest from django.utils import timezone @@ -10,9 +13,21 @@ @pytest.fixture(autouse=True) -def create_users(): +def tin_setup(settings): + # setup + settings.MEDIA_ROOT = Path(settings.BASE_DIR) / "tests" / "tin-media" users.add_users_to_database(password=PASSWORD, verbose=False) + # make sure no old/manual stuff added affects tests + if settings.MEDIA_ROOT.exists(): + shutil.rmtree(settings.MEDIA_ROOT) + settings.MEDIA_ROOT.mkdir(parents=True) + + yield + # cleanup the media so it doesn't cause + # problems elsewhere + shutil.rmtree(settings.MEDIA_ROOT) + @pytest.fixture def admin(django_user_model): diff --git a/tin/tests/utils.py b/tin/tests/utils.py index b615af38..7904c476 100644 --- a/tin/tests/utils.py +++ b/tin/tests/utils.py @@ -1,10 +1,15 @@ from __future__ import annotations -__all__ = ("login",) +__all__ = ( + "login", + "str_to_html", + "to_html", +) -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Callable import pytest +from django.template import Context, Engine if TYPE_CHECKING: from typing_extensions import ParamSpec, TypeVar @@ -13,7 +18,7 @@ P = ParamSpec("P") -def login(user: str, *args: Any) -> Callable[[Callable[P, T]], Callable[P, T]]: +def login(user: str) -> Callable[[Callable[P, T]], Callable[P, T]]: """Login ``client`` as a tin user type. .. code-block:: @@ -35,4 +40,39 @@ def test_something(client): response = client.post(reverse("courses:index"), {}) assert is_login_redirect(response) """ - return pytest.mark.usefixtures(f"{user}_login", *args) + return pytest.mark.usefixtures(f"{user}_login") + + +# we define these helper functions to avoid hardcoding +# stuff like the html escape code for ' +def to_html(s: str, ctx: dict[str, str]): + """Convert template code to an html string + + .. code-block:: pycon + + >>> template_logic = "Hello, my name is {{ username }}!" + >>> context = {"username": "2027adeshpan"} + >>> to_html(template_logic, context) + 'Hello, my name is 2027adeshpan!' + + Args: + s: The template string (see :class:`~django.template.Template`) + ctx: The variables/context to use for ``s`` (see :class:`~django.template.Context`) + """ + template = Engine().from_string(s) + context = Context(ctx) + return template.render(context) + + +def str_to_html(s: str): + """Converts a string to it's html representation + + .. code-block:: pycon + + >>> text = "It's annoying to remember HTML escape codes" + >>> str_to_html(text) + 'It's annoying to remember HTML escape codes' + """ + template = "{{ var }}" + ctx = {"var": s} + return to_html(template, ctx)