diff --git a/.github/workflows/lint-and-test.yaml b/.github/workflows/lint-and-test.yaml index 51c7c97..a75ef3c 100644 --- a/.github/workflows/lint-and-test.yaml +++ b/.github/workflows/lint-and-test.yaml @@ -44,6 +44,29 @@ jobs: uv run mypy --config-file=pyproject.toml yet_another_calendar - name: Run tests + working-directory: backend run: | - cd backend - YET_ANOTHER_CALENDAR_TUTOR_SECRET_KEY='test' uv run pytest --cov-fail-under=80 --cov=yet_another_calendar/web yet_another_calendar/tests/* + YET_ANOTHER_CALENDAR_TUTOR_SECRET_KEY='test' \ + uv run pytest --junitxml=../pytest.xml \ + --cov-report=term-missing:skip-covered \ + --cov-fail-under=80 \ + --cov=yet_another_calendar/web \ + yet_another_calendar/tests/* | tee ../pytest-coverage.txt + + - name: Pytest coverage comment + id: coverage + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt + junitxml-path: ./pytest.xml + coverage-path-prefix: backend/ + + # - name: Update README + # run: | + # sed -i '//,//c\\n${{ steps.coverage.outputs.coverageHtml }}\n' ./backend/README.md + + # - name: Commit changes + # uses: stefanzweifel/git-auto-commit-action@v5 + # with: + # commit_message: 'docs: update coverage badge' + # file_pattern: backend/README.md diff --git a/.gitignore b/.gitignore index ba9b9d5..ab7c653 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +pytest-coverage.txt +pytest.xml + .idea .version diff --git a/.vscode/launch.json b/.vscode/launch.json old mode 100644 new mode 100755 index b69112e..d190c43 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,14 +10,15 @@ "request": "launch", "module": "pytest", "console": "integratedTerminal", + "cwd": "${workspaceFolder}/backend", "args": [ - "backend/yet_another_calendar/tests/" - ], + "yet_another_calendar/tests/test_lms.py" + ] }, { "name": "Python debug backend (docker)", "request": "attach", - "type": "python", + "type": "debugpy", "pathMappings": [ { "localRoot": "${workspaceFolder}/backend", diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..e1bb5ff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "python.testing.pytestArgs": [ + "./backend/yet_another_calendar/tests/" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + + "ruff.configuration": "./backend/pyproject.toml", + "ruff.nativeServer": "on", + + "mypy.dmypyExecutable": "${workspaceFolder}/backend/.venv/bin/dmypy", + "mypy.mypyExecutable": "${workspaceFolder}/backend/.venv/bin/mypy", + "mypy.configFile": "backend/pyproject.toml", +} \ No newline at end of file diff --git a/backend/.flake8 b/backend/.flake8 deleted file mode 100644 index 12cd86e..0000000 --- a/backend/.flake8 +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -exclude = __pycache__,built,build,venv -ignore = E203, E266, W503 -max-line-length = 88 -max-complexity = 14 -select = B,C,E,F,W,T4,B9 diff --git a/backend/README.md b/backend/README.md index 41c649b..52cca8c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,6 +5,7 @@ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) + [![Redis](https://img.shields.io/badge/redis-%23DD0031.svg?style=for-the-badge&logo=redis&logoColor=white&style=flat)](https://redis.io/) [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white&style=flat)](https://www.docker.com/) [![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi&style=flat)](https://fastapi.tiangolo.com/) diff --git a/backend/generate_password_hash.py b/backend/generate_password_hash.py index a4cebec..9b24aa0 100644 --- a/backend/generate_password_hash.py +++ b/backend/generate_password_hash.py @@ -20,34 +20,34 @@ def generate_password_hash(password: str | None = None) -> str: if password is None: password = getpass.getpass("Enter tutor password: ") confirm_password = getpass.getpass("Confirm password: ") - + if password != confirm_password: logger.info("Passwords don't match. Please try again.") return generate_password_hash() - + if len(password) < 8: logger.info("Password must be at least 8 characters long. Please try again.") if password is None: return generate_password_hash() else: sys.exit(1) - + # Generate salt and hash salt = bcrypt.gensalt() password_hash = bcrypt.hashpw(password.encode('utf-8'), salt) - + return password_hash.decode('utf-8') if __name__ == "__main__": password_arg = sys.argv[1] if len(sys.argv) > 1 else None hash_value = generate_password_hash(password_arg) - + # Verify the hash works if password_arg: verification_result = bcrypt.checkpw(password_arg.encode('utf-8'), hash_value.encode('utf-8')) logger.info(f"Hash verification: {verification_result}") - + logger.info("\n" + "="*50) logger.info("Generated password hash:") logger.info(hash_value) @@ -57,4 +57,4 @@ def generate_password_hash(password: str | None = None) -> str: logger.info("\nFor Docker Compose (escape $ characters):") docker_hash = hash_value.replace("$", "$$") logger.info(f"YET_ANOTHER_CALENDAR_TUTOR_PASSWORD_HASH={docker_hash}") - logger.info("="*50) \ No newline at end of file + logger.info("="*50) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 89fcf7e..ad22217 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet_another_calendar" -version = "2.0.0" +version = "2.1.0" description = "" authors = [ { name = "Ivan Popov", email = "ma1n.py@yandex.ru" }, @@ -14,6 +14,7 @@ dependencies = [ "environs>=14.1.1", "fastapi>=0.115.11", "fastapi-cache2>=0.2.2", + "flake8-import-conventions>=0.1.0", "httpx[http2]==0.27.2", "icalendar>=6.1.1", "loguru>=0.7.3", @@ -36,7 +37,8 @@ dependencies = [ dev = [ "black>=25.1.0", "fakeredis>=2.27.0", - "mypy>=1.15.0", + "flake8-async>=25.7.1", + "mypy>=1.18.2", "pytest>=8.3.5", "pytest-asyncio>=0.25.3", "pytest-cov>=6.0.0", @@ -72,14 +74,15 @@ env = [ "YET_ANOTHER_CALENDAR_DB_BASE=yet_another_calendar_test", ] -[ruff] +[tool.ruff] target-version = "py311" lint.select = [ "A", # prevent using keywords that clobber python builtins "N", # pep8-naming "B", # bugbear: security warnings - "E", # pycodestyle + "E", # pycodestyle errors + "W", # pycodestyle warnings "F", # pyflakes "ISC", # implicit string concatenation "UP", # alert you when better syntax is available in your python version @@ -91,7 +94,15 @@ lint.select = [ "COM", # flake8-logging "ASYNC", # flake8-async "ANN", # flake8-annotations + "C4", # flake8-comprehensions + "EXE", # flake8-executable + "FIX", # flake8-fixme + "ICN", # flake8-import-conventions + "PIE", # flake8-pie + "PL", # Pylint + "PLE", # pylint errors + ] lint.ignore = [ "ANN401", @@ -102,14 +113,14 @@ lint.ignore = [ line-length = 119 exclude = [ ".venv/", - "yet_another_calendar/tests/*", + "*/tests/*", ] -[ruff.lint.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "pep257" ignore-decorators = ["typing.overload"] -[ruff.lint.pylint] +[tool.ruff.lint.pylint] allow-magic-value-types = ["int", "str", "float", "bytes"] [fastapi-template.options] diff --git a/backend/uv.lock b/backend/uv.lock index 0516756..ecc5ff4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12.8, <3.13" [[package]] @@ -25,6 +25,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041, upload-time = "2025-01-05T13:13:07.985Z" }, ] +[[package]] +name = "attrs" +version = "22.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/31/3f468da74c7de4fcf9b25591e682856389b3400b4b62f201e65f15ea3e07/attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99", size = 215900, upload-time = "2022-12-21T09:48:51.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/6e/6f83bf616d2becdf333a1640f1d463fef3150e2e926b7010cb0f81c95e88/attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", size = 60018, upload-time = "2022-12-21T09:48:49.401Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -258,6 +267,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "flake8-async" +version = "25.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "libcst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/11/ed62d9092005bb691c8fc93de30b730877beab113134d4a67240e21f798c/flake8_async-25.7.1.tar.gz", hash = "sha256:1f2947f2563a4c91135436046b1aaa84dfcdb801d47bc1eb28cdd5b8b42bb59c", size = 118088, upload-time = "2025-07-29T11:35:04.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/11/ece553dfecd4e9c9f64ce4402c2eb0a639d588945cf1c92168bc50ce3c5d/flake8_async-25.7.1-py3-none-any.whl", hash = "sha256:311ced052406379573dbafa4452eb9da5e4890a24668f97e0057509ac759bc9a", size = 56456, upload-time = "2025-07-29T11:35:03.285Z" }, +] + +[[package]] +name = "flake8-import-conventions" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "flake8" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/2a/94072dab358027f3ce2a9eee278624124da50be7d252d44b418f7aea2b27/flake8_import_conventions-0.1.0.tar.gz", hash = "sha256:0710db3a77de023c9a40b4483a9a259a1fdc3d09187892799614bf0f425df779", size = 5053, upload-time = "2023-02-08T08:59:55.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/bc/7770ca645bc9739019c2d51aaa9134f974ca623e4c8474f42db6eaf608dd/flake8_import_conventions-0.1.0-py3-none-any.whl", hash = "sha256:d0a32cbdb78239a4976aef5d3ac958f25a2b6c155760060d40a8bbebe481a03b", size = 6106, upload-time = "2023-02-08T08:59:54.608Z" }, +] + [[package]] name = "h11" version = "0.14.0" @@ -372,6 +420,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] +[[package]] +name = "libcst" +version = "1.8.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/e5/1ecfb0a74ef502d2f3ddc9fad51185a1d4e57d56aae3755073574b6f8237/libcst-1.8.4.tar.gz", hash = "sha256:f0f105d32c49baf712df2be360d496de67a2375bcf4e9707e643b7efc2f9a55a", size = 884416, upload-time = "2025-09-09T19:42:39.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/81/b8cb11f2c504af1ef163af6f601739faf52a1ab8bb76b7a3e649271b553e/libcst-1.8.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1d468514a21cf3444dc3f3a4b1effc6c05255c98cc79e02af394652d260139f0", size = 2201626, upload-time = "2025-09-09T19:41:18.542Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8e/871c6bcf9ed1043b7824ca7911dac0c031e4ab90c147387b8fae5a2a2db2/libcst-1.8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:870a49df8575c11ea4f5319d54750f95d2d06370a263bd42d924a9cf23cf0cbe", size = 2082143, upload-time = "2025-09-09T19:41:19.956Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/b00db484fdffba45bc1f88cbc0f0f69149602402b07752f1fecf3ba6652f/libcst-1.8.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c9c775bc473225a0ad8422150fd9cf18ed2eebd7040996772937ac558f294d6c", size = 2229271, upload-time = "2025-09-09T19:41:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/5d5871c2e22f12569ee80e4b5ee0abe3e1e4b8c5ea2340c10e2ac7b7af97/libcst-1.8.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:27eeb16edb7dc0711d67e28bb8c0288e4147210aeb2434f08c16ac5db6b559e5", size = 2326063, upload-time = "2025-09-09T19:41:23.164Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7e/34904ae945d970fabe51974398982f9be4ca7a4debbf8f72bad82dc8d3a5/libcst-1.8.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71e12101ef2a6e05b7610badb2bfa597379289f1408e305a8d19faacdb872f47", size = 2291055, upload-time = "2025-09-09T19:41:24.563Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/7aba1afc815824b93acffd8207e605f4d830bcea9703cf6c2d9f282ace94/libcst-1.8.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:69b672c1afac5fe00d689f585ba57ac5facc4632f39b977d4b3e4711571c76e2", size = 2402980, upload-time = "2025-09-09T19:41:25.938Z" }, + { url = "https://files.pythonhosted.org/packages/25/2b/c0303783b282c610f1ab8973d22d8805ea5376663c867b03d72b54da0ac9/libcst-1.8.4-cp312-cp312-win_amd64.whl", hash = "sha256:7832ee448fbdf18884a1f9af5fba1be6d5e98deb560514d92339fd6318aef651", size = 2110472, upload-time = "2025-09-09T19:41:27.306Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/4e1b62a7f824172ff9a6cd824935f3d8dddcfbc4fe63649b3de728a64495/libcst-1.8.4-cp312-cp312-win_arm64.whl", hash = "sha256:6840e4011b583e9b7a71c00e7ab4281aea7456877b3ea6ecedb68a39a000bc64", size = 1996000, upload-time = "2025-09-09T19:41:29.053Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -422,6 +489,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "multidict" version = "6.1.0" @@ -448,21 +524,22 @@ wheels = [ [[package]] name = "mypy" -version = "1.15.0" +version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" }, - { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" }, - { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" }, - { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, ] [[package]] @@ -583,6 +660,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/35/6c4c6fc8774a9e3629cd750dc24a7a4fb090a25ccd5c3246d127b70f9e22/propcache-0.3.0-py3-none-any.whl", hash = "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043", size = 12101, upload-time = "2025-02-20T19:03:27.202Z" }, ] +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -635,6 +721,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, ] +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -1027,7 +1122,7 @@ wheels = [ [[package]] name = "yet-another-calendar" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "bcrypt" }, @@ -1036,6 +1131,7 @@ dependencies = [ { name = "environs" }, { name = "fastapi" }, { name = "fastapi-cache2" }, + { name = "flake8-import-conventions" }, { name = "httpx", extra = ["http2"] }, { name = "icalendar" }, { name = "loguru" }, @@ -1058,6 +1154,7 @@ dependencies = [ dev = [ { name = "black" }, { name = "fakeredis" }, + { name = "flake8-async" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1075,6 +1172,7 @@ requires-dist = [ { name = "environs", specifier = ">=14.1.1" }, { name = "fastapi", specifier = ">=0.115.11" }, { name = "fastapi-cache2", specifier = ">=0.2.2" }, + { name = "flake8-import-conventions", specifier = ">=0.1.0" }, { name = "httpx", extras = ["http2"], specifier = "==0.27.2" }, { name = "icalendar", specifier = ">=6.1.1" }, { name = "loguru", specifier = ">=0.7.3" }, @@ -1097,7 +1195,8 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=25.1.0" }, { name = "fakeredis", specifier = ">=2.27.0" }, - { name = "mypy", specifier = ">=1.15.0" }, + { name = "flake8-async", specifier = ">=25.7.1" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.25.3" }, { name = "pytest-cov", specifier = ">=6.0.0" }, diff --git a/backend/yet_another_calendar/log.py b/backend/yet_another_calendar/log.py index 9c8d069..ac0e97f 100644 --- a/backend/yet_another_calendar/log.py +++ b/backend/yet_another_calendar/log.py @@ -4,7 +4,7 @@ from loguru import logger from rollbar.logger import RollbarHandler -from yet_another_calendar.settings import settings +from yet_another_calendar.settings import settings class InterceptHandler(logging.Handler): diff --git a/backend/yet_another_calendar/settings.py b/backend/yet_another_calendar/settings.py index c220966..282540f 100644 --- a/backend/yet_another_calendar/settings.py +++ b/backend/yet_another_calendar/settings.py @@ -74,11 +74,11 @@ class Settings(BaseSettings): modeus_continue_auth_url: str = "https://auth.modeus.org/commonauth" modeus_search_events_part: str = "/schedule-calendar-v2/api/calendar/events/search" modeus_search_people_part: str = "/schedule-calendar-v2/api/people/persons/search" - + # Donor account for tutors (no personal Modeus accounts needed) modeus_username: str = env.str("YET_ANOTHER_CALENDAR_MODEUS_USERNAME", "") modeus_password: str = env.str("YET_ANOTHER_CALENDAR_MODEUS_PASSWORD", "") - + utmn_base_url: str = "https://www.utmn.ru" utmn_get_teachers_part: str = "/o-tyumgu/sotrudniki/?PAGEN_1={page}" @@ -86,7 +86,7 @@ class Settings(BaseSettings): tutor_password_hash: str = env.str("YET_ANOTHER_CALENDAR_TUTOR_PASSWORD_HASH", "") tutor_secret_key: str = env.str("YET_ANOTHER_CALENDAR_TUTOR_SECRET_KEY") tutor_jwt_time_live: int = 60 * 60 * 24 * 30 # 1 month - + # Rate limiting settings (applies to all login endpoints) login_max_attempts: int = env.int("YET_ANOTHER_CALENDAR_LOGIN_MAX_ATTEMPTS", 5) login_lockout_time: int = env.int("YET_ANOTHER_CALENDAR_LOGIN_LOCKOUT_TIME", 900) # 15 minutes @@ -99,7 +99,7 @@ class Settings(BaseSettings): # Application domain for generating full URLs app_domain: str = env.str("YET_ANOTHER_CALENDAR_APP_DOMAIN", "https://yetanothercalendar.ru") - + rollbar_token: str = "" rollbar_environment: str = "dev" diff --git a/backend/yet_another_calendar/tests/conftest.py b/backend/yet_another_calendar/tests/conftest.py index a4f719d..1f60ecb 100644 --- a/backend/yet_another_calendar/tests/conftest.py +++ b/backend/yet_another_calendar/tests/conftest.py @@ -1,3 +1,4 @@ +"""Pytest fixtures.""" import datetime from typing import Any from collections.abc import Callable @@ -147,7 +148,7 @@ async def lms_client(): async with AsyncClient( http2=True, base_url=settings.lms_base_url, - transport=handlers.transport, + transport=handlers.lms_transport, ) as client: with patch("yet_another_calendar.web.api.lms.integration.AsyncClient.__aenter__", return_value=client): yield client diff --git a/backend/yet_another_calendar/tests/fixtures/lms/extended_lesson.json b/backend/yet_another_calendar/tests/fixtures/lms/extended_lesson.json new file mode 100644 index 0000000..f1dfe9c --- /dev/null +++ b/backend/yet_another_calendar/tests/fixtures/lms/extended_lesson.json @@ -0,0 +1 @@ +[{"id": 16325, "name": "\u0421\u0430\u043c\u043e\u0441\u0442\u043e\u044f\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u0440\u0430\u0431\u043e\u0442\u0430 \u0441\u0442\u0443\u0434\u0435\u043d\u0442\u043e\u0432 - \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u0447\u0430\u0441\u0442\u044c \u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430. \u041e\u0431\u0449\u0438\u0439 \u043e\u0431\u044a\u0435\u043c \u0431\u0430\u043b\u043b\u043e\u0432 15.", "visible": 1, "summary": "", "summaryformat": 1, "section": 0, "hiddenbynumsections": 0, "uservisible": true, "component": null, "itemid": null, "modules": []}, {"id": 16326, "name": "\u0424\u0438\u043b\u043e\u0441\u043e\u0444\u0438\u044f \u043c\u0435\u0434\u0438\u0430", "visible": 0, "summary": "", "summaryformat": 1, "section": 1, "hiddenbynumsections": 0, "uservisible": false, "component": null, "itemid": null, "modules": []}, {"id": 16327, "name": "\u0424\u0438\u043b\u043e\u0441\u043e\u0444\u0438\u044f \u043c\u0430\u0441\u0441\u043e\u0432\u043e\u0439 \u043a\u0443\u043b\u044c\u0442\u0443\u0440\u044b", "visible": 1, "summary": "", "summaryformat": 1, "section": 2, "hiddenbynumsections": 0, "uservisible": true, "component": null, "itemid": null, "modules": []}, {"id": 16328, "name": "\u0424\u0438\u043b\u043e\u0441\u043e\u0444\u0438\u044f \u0441\u043e\u0437\u043d\u0430\u043d\u0438\u044f", "visible": 0, "summary": "", "summaryformat": 1, "section": 3, "hiddenbynumsections": 0, "uservisible": false, "component": null, "itemid": null, "modules": []}, {"id": 16329, "name": "4 \u0431\u043b\u043e\u043a", "visible": 1, "summary": "", "summaryformat": 1, "section": 4, "hiddenbynumsections": 0, "uservisible": true, "component": null, "itemid": null, "modules": []}, {"id": 16330, "name": "4 \u0431\u043b\u043e\u043a \u0411", "visible": 1, "summary": "", "summaryformat": 1, "section": 5, "hiddenbynumsections": 0, "uservisible": true, "component": null, "itemid": null, "modules": []}] \ No newline at end of file diff --git a/backend/yet_another_calendar/tests/fixtures/lms/lms_course_info.json b/backend/yet_another_calendar/tests/fixtures/lms/lms_course_info.json new file mode 100644 index 0000000..50a666c --- /dev/null +++ b/backend/yet_another_calendar/tests/fixtures/lms/lms_course_info.json @@ -0,0 +1,250 @@ +[ + { + "id": 6899, + "shortname": "\\u0410\\u043d\\u0430\\u043b\\u0438\\u0442\\u0438\\u0447\\u0435\\u0441\\u043a\\u043e\\u0435 \\u0447\\u0442\\u0435\\u043d\\u0438\\u0435 (core HARD 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "fullname": "\\u0410\\u043d\\u0430\\u043b\\u0438\\u0442\\u0438\\u0447\\u0435\\u0441\\u043a\\u043e\\u0435 \\u0447\\u0442\\u0435\\u043d\\u0438\\u0435 (core HARD 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "displayname": "\\u0410\\u043d\\u0430\\u043b\\u0438\\u0442\\u0438\\u0447\\u0435\\u0441\\u043a\\u043e\\u0435 \\u0447\\u0442\\u0435\\u043d\\u0438\\u0435 (core HARD 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "enrolledusercount": 782, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/854602\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 437, + "progress": null, + "completed": false, + "startdate": 1756753200, + "enddate": 0, + "marker": 0, + "lastaccess": null, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1756716643 + }, + { + "id": 6865, + "shortname": "\\u0424\\u0438\\u043d\\u0430\\u043d\\u0441\\u043e\\u0432\\u043e\\u0435 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u0435 (core \\u041e\\u0424\\u041e 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "fullname": "\\u0424\\u0438\\u043d\\u0430\\u043d\\u0441\\u043e\\u0432\\u043e\\u0435 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u0435 (core \\u041e\\u0424\\u041e 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "displayname": "\\u0424\\u0438\\u043d\\u0430\\u043d\\u0441\\u043e\\u0432\\u043e\\u0435 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u0435 (core \\u041e\\u0424\\u041e 3 \\u0441\\u0435\\u043c\\u0435\\u0441\\u0442\\u0440 2025-2026)", + "enrolledusercount": 3254, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/847164\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 437, + "progress": 0, + "completed": false, + "startdate": 1756234800, + "enddate": 0, + "marker": 0, + "lastaccess": null, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1729841075 + }, + { + "id": 2745, + "shortname": "\\u0422\\u0435\\u0445\\u043d\\u0438\\u043a\\u0430 \\u0431\\u0435\\u0437\\u043e\\u043f\\u0430\\u0441\\u043d\\u043e\\u0441\\u0442\\u0438 (\\u0424\\u0438\\u0437\\u0438\\u0447\\u0435\\u0441\\u043a\\u0430\\u044f \\u043a\\u0443\\u043b\\u044c\\u0442\\u0443\\u0440\\u0430)", + "fullname": "\\u0422\\u0435\\u0445\\u043d\\u0438\\u043a\\u0430 \\u0431\\u0435\\u0437\\u043e\\u043f\\u0430\\u0441\\u043d\\u043e\\u0441\\u0442\\u0438 (\\u0424\\u0438\\u0437\\u0438\\u0447\\u0435\\u0441\\u043a\\u0430\\u044f \\u043a\\u0443\\u043b\\u044c\\u0442\\u0443\\u0440\\u0430)", + "displayname": "\\u0422\\u0435\\u0445\\u043d\\u0438\\u043a\\u0430 \\u0431\\u0435\\u0437\\u043e\\u043f\\u0430\\u0441\\u043d\\u043e\\u0441\\u0442\\u0438 (\\u0424\\u0438\\u0437\\u0438\\u0447\\u0435\\u0441\\u043a\\u0430\\u044f \\u043a\\u0443\\u043b\\u044c\\u0442\\u0443\\u0440\\u0430)", + "enrolledusercount": 15311, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/624765\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 437, + "progress": 50, + "completed": false, + "startdate": 1727982000, + "enddate": 0, + "marker": 0, + "lastaccess": 1732617928, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1757068823 + }, + { + "id": 2687, + "shortname": "\\u041c\\u0410 2024-2025", + "fullname": "\\u041c\\u0430\\u0442\\u0435\\u043c\\u0430\\u0442\\u0438\\u0447\\u0435\\u0441\\u043a\\u0438\\u0439 \\u0430\\u043d\\u0430\\u043b\\u0438\\u0437 (hard, 2024-2025)", + "displayname": "\\u041c\\u0430\\u0442\\u0435\\u043c\\u0430\\u0442\\u0438\\u0447\\u0435\\u0441\\u043a\\u0438\\u0439 \\u0430\\u043d\\u0430\\u043b\\u0438\\u0437 (hard, 2024-2025)", + "enrolledusercount": 936, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/617109\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 197, + "progress": 20, + "completed": false, + "startdate": 1725130800, + "enddate": 0, + "marker": 0, + "lastaccess": 1748343389, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1687185725 + }, + { + "id": 2683, + "shortname": "\\u0424\\u0438\\u043b\\u043e\\u0441\\u043e\\u0444\\u0438\\u044f: \\u0442\\u0435\\u0445\\u043d\\u043e\\u043b\\u043e\\u0433\\u0438\\u0438 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u044f (2024-2025) \\u041f\\u043e\\u043f\\u043e\\u0432\\u0430 \\u0422.\\u0410.", + "fullname": "\\u0424\\u0438\\u043b\\u043e\\u0441\\u043e\\u0444\\u0438\\u044f: \\u0442\\u0435\\u0445\\u043d\\u043e\\u043b\\u043e\\u0433\\u0438\\u0438 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u044f (2024-2025) \\u041f\\u043e\\u043f\\u043e\\u0432\\u0430 \\u0422.\\u0410.", + "displayname": "\\u0424\\u0438\\u043b\\u043e\\u0441\\u043e\\u0444\\u0438\\u044f: \\u0442\\u0435\\u0445\\u043d\\u043e\\u043b\\u043e\\u0433\\u0438\\u0438 \\u043c\\u044b\\u0448\\u043b\\u0435\\u043d\\u0438\\u044f (2024-2025) \\u041f\\u043e\\u043f\\u043e\\u0432\\u0430 \\u0422.\\u0410.", + "enrolledusercount": 3365, + "idnumber": "", + "visible": 1, + "summary": "test_summary", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/616672\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 197, + "progress": 0, + "completed": false, + "startdate": 1726426800, + "enddate": 0, + "marker": 0, + "lastaccess": null, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1729514370 + }, + { + "id": 2321, + "shortname": "\\u0418\\u0418", + "fullname": "\\u0418\\u0441\\u043a\\u0443\\u0441\\u0441\\u0442\\u0432\\u0435\\u043d\\u043d\\u044b\\u0439 \\u0438\\u043d\\u0442\\u0435\\u043b\\u043b\\u0435\\u043a\\u0442", + "displayname": "\\u0418\\u0441\\u043a\\u0443\\u0441\\u0441\\u0442\\u0432\\u0435\\u043d\\u043d\\u044b\\u0439 \\u0438\\u043d\\u0442\\u0435\\u043b\\u043b\\u0435\\u043a\\u0442", + "enrolledusercount": 19416, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/542728\\/course\\/generated\\/course.svg", + "showgrades": false, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 365, + "progress": null, + "completed": false, + "startdate": 1725130800, + "enddate": 0, + "marker": 0, + "lastaccess": 1732366896, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1724844481 + }, + { + "id": 7051, + "shortname": "Java 25_1", + "fullname": "\\u041e\\u0441\\u043d\\u043e\\u0432\\u044b Java", + "displayname": "\\u041e\\u0441\\u043d\\u043e\\u0432\\u044b Java", + "enrolledusercount": 33, + "idnumber": "", + "visible": 1, + "summary": "", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/859756\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 14, + "progress": 4.3478260869565215, + "completed": false, + "startdate": 1757098800, + "enddate": 0, + "marker": 0, + "lastaccess": 1757166376, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1757161001 + }, + { + "id": 5205, + "shortname": "\\u0420\\u0438\\u041c_\\u0412\\u0438\\u0412\\u0422\\u0414\\u0420", + "fullname": "\\u0420\\u043e\\u0441\\u0441\\u0438\\u044f \\u0438 \\u043c\\u0438\\u0440\\u2013\\u041c\\u0430\\u0441\\u0442\\u0435\\u0440\\u0441\\u043a\\u0430\\u044f \\u0412\\u0435\\u0440\\u0431\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0438 \\u0432\\u0438\\u0437\\u0443\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0442\\u0435\\u043a\\u0441\\u0442\\u044b \\u0414\\u0440\\u0435\\u0432\\u043d\\u0435\\u0439 \\u0420\\u0443\\u0441\\u0438\\u2013\\u041d\\u043e\\u0432\\u043e\\u043a\\u0440\\u0435\\u0449\\u0435\\u043d\\u043d\\u044b\\u0445 \\u0415.\\u0412. 2024\\u20132025", + "displayname": "\\u0420\\u043e\\u0441\\u0441\\u0438\\u044f \\u0438 \\u043c\\u0438\\u0440\\u2013\\u041c\\u0430\\u0441\\u0442\\u0435\\u0440\\u0441\\u043a\\u0430\\u044f \\u0412\\u0435\\u0440\\u0431\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0438 \\u0432\\u0438\\u0437\\u0443\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0442\\u0435\\u043a\\u0441\\u0442\\u044b \\u0414\\u0440\\u0435\\u0432\\u043d\\u0435\\u0439 \\u0420\\u0443\\u0441\\u0438\\u2013\\u041d\\u043e\\u0432\\u043e\\u043a\\u0440\\u0435\\u0449\\u0435\\u043d\\u043d\\u044b\\u0445 \\u0415.\\u0412. 2024\\u20132025", + "enrolledusercount": 80, + "idnumber": "", + "visible": 1, + "summary": "

\\u0418\\u0442\\u043e\\u0433\\u043e\\u0432\\u043e\\u0435 \\u0442\\u0435\\u0441\\u0442\\u0438\\u0440\\u043e\\u0432\\u0430\\u043d\\u0438\\u0435 \\u043f\\u043e \\u043a\\u0443\\u0440\\u0441\\u0443 \\u0420\\u043e\\u0441\\u0441\\u0438\\u044f \\u0438 \\u043c\\u0438\\u0440\\u2013\\u041c\\u0430\\u0441\\u0442\\u0435\\u0440\\u0441\\u043a\\u0430\\u044f \\u0412\\u0435\\u0440\\u0431\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0438 \\u0432\\u0438\\u0437\\u0443\\u0430\\u043b\\u044c\\u043d\\u044b\\u0435 \\u0442\\u0435\\u043a\\u0441\\u0442\\u044b \\u0414\\u0440\\u0435\\u0432\\u043d\\u0435\\u0439 \\u0420\\u0443\\u0441\\u0438\\u2013\\u041d\\u043e\\u0432\\u043e\\u043a\\u0440\\u0435\\u0449\\u0435\\u043d\\u043d\\u044b\\u0445 \\u0415.\\u0412. 2024\\u20132025<\\/p>\\r\\n

\\u041a\\u0443\\u0440\\u0441: 1<\\/p>\\r\\n

\\u041c\\u0430\\u043a\\u0441\\u0438\\u043c\\u0430\\u043b\\u044c\\u043d\\u043e\\u0435 \\u043a\\u043e\\u043b\\u0438\\u0447\\u0435\\u0441\\u0442\\u0432\\u043e \\u0431\\u0430\\u043b\\u043b\\u043e\\u0432: 20<\\/p>", + "summaryformat": 1, + "format": "topics", + "courseimage": "https:\\/\\/lms.utmn.ru\\/pluginfile.php\\/741002\\/course\\/generated\\/course.svg", + "showgrades": true, + "lang": "", + "enablecompletion": true, + "completionhascriteria": false, + "completionusertracked": true, + "category": 42, + "progress": 16.666666666666664, + "completed": false, + "startdate": 1736708400, + "enddate": 1768338000, + "marker": 0, + "lastaccess": 1736782018, + "isfavourite": false, + "hidden": false, + "overviewfiles": [], + "showactivitydates": true, + "showcompletionconditions": true, + "timemodified": 1736695682 + } +] \ No newline at end of file diff --git a/backend/yet_another_calendar/tests/fixtures/lms/lms_user_info.json b/backend/yet_another_calendar/tests/fixtures/lms/lms_user_info.json new file mode 100644 index 0000000..5bbfadd --- /dev/null +++ b/backend/yet_another_calendar/tests/fixtures/lms/lms_user_info.json @@ -0,0 +1,116 @@ +[ + { + "id": 1, + "username": "stud0000309025", + "firstname": "\u0418\u0432\u0430\u043d", + "lastname": "\u041f\u043e\u043f\u043e\u0432", + "fullname": "\u041f\u043e\u043f\u043e\u0432 \u0418\u0432\u0430\u043d \u0421\u0435\u0440\u0433\u0435\u0435\u0432\u0438\u0447", + "email": "stud0000309025@study.utmn.ru", + "department": "\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0438 \u0442\u0435\u0445\u043d\u043e\u043b\u043e\u0433\u0438\u0438: \u0420\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0430 IT-\u043f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432 \u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c", + "institution": "\u0428\u043a\u043e\u043b\u0430 \u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u043d\u044b\u0445 \u043d\u0430\u0443\u043a", + "idnumber": "stud0000309025", + "auth": "ldap", + "confirmed": true, + "lang": "ru", + "theme": "", + "timezone": "99", + "mailformat": 1, + "trackforums": 0, + "profileimageurlsmall": "https://lms.utmn.ru/theme/image.php/moove/core/1759229464/u/f2", + "profileimageurl": "https://lms.utmn.ru/theme/image.php/moove/core/1759229464/u/f1", + "preferences": [ + { + "name": "block_myoverview_hidden_course_1734", + "value": "1" + }, + { + "name": "block_myoverview_user_grouping_preference", + "value": "inprogress" + }, + { + "name": "block_myoverview_user_paging_preference", + "value": "0" + }, + { + "name": "core_message_migrate_data", + "value": "1" + }, + { + "name": "core_user_welcome", + "value": "1724088175" + }, + { + "name": "coursesectionspreferences_2359", + "value": "{\"contentcollapsed\":[13343]}" + }, + { + "name": "coursesectionspreferences_2367", + "value": "{\"contentcollapsed\":[13416]}" + }, + { + "name": "coursesectionspreferences_2687", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "coursesectionspreferences_5632", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "coursesectionspreferences_6899", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "coursesectionspreferences_7008", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "coursesectionspreferences_7017", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "coursesectionspreferences_7103", + "value": "{\"contentcollapsed\":[]}" + }, + { + "name": "drawer-open-block", + "value": "1" + }, + { + "name": "drawer-open-index", + "value": "0" + }, + { + "name": "filepicker_recentlicense", + "value": "unknown" + }, + { + "name": "filepicker_recentrepository", + "value": "4" + }, + { + "name": "last_time_enrolments_synced", + "value": "1759688875" + }, + { + "name": "login_failed_count_since_success", + "value": "0" + }, + { + "name": "tool_usertours_tour_completion_time_5", + "value": "1727380273" + }, + { + "name": "tool_usertours_tour_completion_time_7", + "value": "1724762611" + }, + { + "name": "tool_usertours_tour_completion_time_8", + "value": "1724088460" + }, + { + "name": "_lastloaded", + "value": 1759688889 + } + ] + } +] \ No newline at end of file diff --git a/backend/yet_another_calendar/tests/handlers.py b/backend/yet_another_calendar/tests/handlers.py index fdd6d92..8fe41f1 100644 --- a/backend/yet_another_calendar/tests/handlers.py +++ b/backend/yet_another_calendar/tests/handlers.py @@ -9,6 +9,11 @@ mock_cookies = schema.NetologyCookies.model_validate({"_netology-on-rails_session": "aboba"}) +def _fixture(relative_path: str) -> pathlib.Path: + """Helper to construct fixture paths.""" + return settings.test_parent_path / relative_path + + def get_httpx_response( status_code: int, body: typing.Any, @@ -18,127 +23,209 @@ def get_httpx_response( if fixture_path is None: return httpx.Response(status_code, json=body, headers=headers) - with open(settings.test_parent_path / fixture_path) as f: + with open(fixture_path) as f: if ".json" in str(fixture_path): response_json = json.load(f) return httpx.Response(status_code, json=response_json, headers=headers) return httpx.Response(status_code, text=f.read(), headers=headers) -def _bad_handler(request: httpx.Request) -> httpx.Response: - response_cases = { - # netology - "/backend/api/user/programs/calendar/filters/not-auth": get_httpx_response(401, - {"text": "Not authorized"}), - "/backend/api/unknown": get_httpx_response(404, {"detail": "Not Found"}), - "/backend/api/server_problem": get_httpx_response(500, {}), - settings.netology_sign_in_part: get_httpx_response(401, {"detail": "Unauthorized"}), - settings.netology_get_events_part.format(program_id=2): get_httpx_response(404, {}), - settings.modeus_continue_auth_url: get_httpx_response(400, {"detail": "Bad request"}), - - # modeus - "/schedule-calendar/assets/app.config.json": get_httpx_response(500, {}, - settings.test_parent_path / - "fixtures/wrong_app_config.json"), - "/oauth2/authorize": get_httpx_response(400, {}, headers={"Location": ""}), - "/error-tag": get_httpx_response(200, {}, - settings.test_parent_path / "fixtures/auth_form_error_tag.html"), - "/bad-request": get_httpx_response(400, {"ok": False}), - "/form-none": get_httpx_response(200, {}, - settings.test_parent_path / "fixtures/auth_form_none.html"), - "/unauthorized": get_httpx_response(401, {"ok": False}), - settings.modeus_search_events_part: get_httpx_response(401, {"ok": False}), - settings.modeus_search_people_part: get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/people_search_empty.json"), - - # lms - settings.lms_login_part: get_httpx_response(200, {"error": "Something went wrong"}), - "/lms/send_request_unknown": get_httpx_response(404, {"detail": "Not Found"}), - "/lms/send_request_server_error": get_httpx_response(500, {}), - settings.lms_get_user_part: get_httpx_response(403, {"text": "Forbidden"}), - - # utmn - bad cases - settings.utmn_get_teachers_part.format(page=404): get_httpx_response(404, {"detail": "Not Found"}), - settings.utmn_get_teachers_part.format(page=500): get_httpx_response(500, {"detail": "Server Error"}), - settings.utmn_get_teachers_part.format(page=1222): get_httpx_response(404, {"detail": "Not Found"}), +DEFAULT_MOCK_RESPONSE = get_httpx_response(404, {"detail": "Not Found"}) + +modeus_response_cases = { + "/schedule-calendar/assets/app.config.json": get_httpx_response( + 200, {}, _fixture("fixtures/app_config.json") + ), + "/oauth2/authorize": get_httpx_response( + 302, {}, headers={"Location": "https://fs.utmn.ru/adfs/ls?aboba=true"} + ), + "/form-ok": get_httpx_response(200, {}, _fixture("fixtures/auth_form_ok.html")), + "/ok": get_httpx_response(201, {"ok": True}), + settings.modeus_search_events_part: get_httpx_response( + 200, {}, _fixture("fixtures/full_events.json") + ), + settings.modeus_search_people_part: get_httpx_response( + 200, {}, _fixture("fixtures/people_search_ok.json") + ), +} + +utmn_response_cases = { + settings.utmn_get_teachers_part.format(page=1): get_httpx_response( + 200, {}, _fixture("fixtures/utmn_teachers_page_1.html") + ), + settings.utmn_get_teachers_part.format(page=2): get_httpx_response( + 200, {}, _fixture("fixtures/utmn_teachers_page_2.html") + ), + **{settings.utmn_get_teachers_part.format(page=index): get_httpx_response( + 200, {}, _fixture("fixtures/utmn_empty_page.html") + ) for index in range(3, 100) } +} + +netology_response_cases = { + "/backend/api/user/programs/calendar/filters": get_httpx_response(200, {"ok": True}), + "/backend/api/unauthorized": get_httpx_response(404, {"detail": "Not Found"}), + settings.netology_get_events_part.format(program_id=45526): get_httpx_response( + 200, {}, _fixture("fixtures/program_45526.json") + ), + settings.netology_get_events_part.format(program_id=57604): get_httpx_response( + 200, {}, _fixture("fixtures/program_57604.json") + ), + settings.netology_get_programs_part.format(calendar_id=45526): get_httpx_response( + 200, {}, _fixture("fixtures/profession.json") + ), + settings.netology_get_programs_part.format(calendar_id=2): get_httpx_response(404, {}), + settings.netology_sign_in_part: get_httpx_response(201, {"ok": True}), +} + +lms_wsfunction_responses = { + 'core_enrol_get_users_courses': get_httpx_response( + 200, {}, _fixture("fixtures/lms/lms_course_info.json") + ), + 'core_user_get_users_by_field': get_httpx_response( + 200, {}, _fixture("fixtures/lms/lms_user_info.json") + ), + 'core_course_get_contents': get_httpx_response( + 200, {}, _fixture("fixtures/lms/extended_lesson.json") + ), +} + +lms_login_responses = { + settings.lms_login_part: get_httpx_response(200, {"token": "token_12345"}), + "/lms/send_request_list": get_httpx_response(200, {"token": [1,2,3]}), +} + +def _handler( + request: httpx.Request, + response_cases: dict[str, httpx.Response], + lookup_key: str | None = None, + default_response: httpx.Response | None = None, + raise_on_missing: bool = False, +) -> httpx.Response: + """ + Generic handler for creating mock HTTP responses. + + Args: + request: The httpx.Request object + response_cases: Dictionary mapping URL keys to httpx.Response objects + lookup_key: The key to lookup in response_cases (if None, uses request.url.path) + default_response: Response to return when no case matches (defaults to DEFAULT_MOCK_RESPONSE) + raise_on_missing: If True, raises ValueError when no case is found and no default is provided + + Returns: + httpx.Response object + """ + if lookup_key is None: + lookup_key = request.url.path - case = response_cases.get(request.url.path) + case = response_cases.get(lookup_key) if case is None: - return httpx.Response(400, json={"Azamat": 'Lox'}) + if raise_on_missing: + raise ValueError(f"Can't find pattern for url {request.url}") + return default_response if default_response is not None else DEFAULT_MOCK_RESPONSE return case -def _handler(request: httpx.Request) -> httpx.Response: - response_cases = { - # netology - "/backend/api/user/programs/calendar/filters": get_httpx_response(200, {"ok": True}), - "/backend/api/unauthorized": get_httpx_response(404, {"detail": "Not Found"}), - settings.netology_get_events_part.format(program_id=45526): get_httpx_response(200, {}, - settings.test_parent_path / - 'fixtures/program_45526.json'), - settings.netology_get_events_part.format(program_id=57604): get_httpx_response(200, {}, - settings.test_parent_path / - 'fixtures/program_57604.json'), - settings.netology_get_programs_part.format(calendar_id=45526): get_httpx_response(200, {}, - settings.test_parent_path / - 'fixtures/profession.json'), - settings.netology_get_programs_part.format(calendar_id=2): get_httpx_response(404, {}), - - settings.netology_sign_in_part: get_httpx_response(201, {"ok": True}), - - # modeus - "/schedule-calendar/assets/app.config.json": get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/app_config.json"), - "/oauth2/authorize": get_httpx_response(302, {}, - headers={"Location": "https://fs.utmn.ru/adfs/ls?aboba=true"}), - "/form-ok": get_httpx_response(200, {}, - settings.test_parent_path / "fixtures/auth_form_ok.html"), - "/ok": get_httpx_response(201, {"ok": True}), - settings.modeus_search_events_part: get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/full_events.json"), - settings.modeus_search_people_part: get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/people_search_ok.json"), - - # lms - settings.lms_login_part: get_httpx_response(200, {"token": "token_12345"}), - "/lms/send_request_list": get_httpx_response(200, {"token": [1,2,3]}), - settings.lms_get_user_part: get_httpx_response(200, [{"name": "azamat"}]), - } +bad_response_cases = { + "/backend/api/user/programs/calendar/filters/not-auth": get_httpx_response( + 401, {"text": "Not authorized"} + ), + "/backend/api/unknown": get_httpx_response(404, {"detail": "Not Found"}), + "/backend/api/server_problem": get_httpx_response(500, {}), + settings.netology_sign_in_part: get_httpx_response(401, {"detail": "Unauthorized"}), + settings.netology_get_events_part.format(program_id=2): get_httpx_response(404, {}), + settings.modeus_continue_auth_url: get_httpx_response(400, {"detail": "Bad request"}), + "/schedule-calendar/assets/app.config.json": get_httpx_response( + 500, {}, _fixture("fixtures/wrong_app_config.json") + ), + "/oauth2/authorize": get_httpx_response(400, {}, headers={"Location": ""}), + "/error-tag": get_httpx_response(200, {}, _fixture("fixtures/auth_form_error_tag.html")), + "/bad-request": get_httpx_response(400, {"ok": False}), + "/form-none": get_httpx_response(200, {}, _fixture("fixtures/auth_form_none.html")), + "/unauthorized": get_httpx_response(401, {"ok": False}), + settings.modeus_search_events_part: get_httpx_response(401, {"ok": False}), + settings.modeus_search_people_part: get_httpx_response( + 200, {}, _fixture("fixtures/people_search_empty.json") + ), + settings.lms_login_part: get_httpx_response(200, {"error": "Something went wrong"}), + "/lms/send_request_unknown": get_httpx_response(404, {"detail": "Not Found"}), + "/lms/send_request_server_error": get_httpx_response(500, {}), + settings.lms_get_user_part: get_httpx_response(403, {"text": "Forbidden"}), + settings.utmn_get_teachers_part.format(page=404): get_httpx_response(404, {"detail": "Not Found"}), + settings.utmn_get_teachers_part.format(page=500): get_httpx_response(500, {"detail": "Server Error"}), + settings.utmn_get_teachers_part.format(page=1222): get_httpx_response(404, {"detail": "Not Found"}), +} - case = response_cases.get(request.url.path) - if case is None: - return httpx.Response(200, json={"Azamat": 'Lox'}) +def _bad_handler(request: httpx.Request) -> httpx.Response: + return _handler( + request, + bad_response_cases, + default_response=httpx.Response(400, json={"Azamat": 'Lox'}) + ) - return case + +def _lms_handler(request: httpx.Request, raise_error: bool = True) -> httpx.Response: + wsfunction = request.url.params.get("wsfunction") + if wsfunction: + lookup_key = wsfunction + response_cases = lms_wsfunction_responses + else: + lookup_key = request.url.path + response_cases = lms_login_responses + + return _handler(request, response_cases, lookup_key=lookup_key, raise_on_missing=raise_error) def _utmn_handler(request: httpx.Request) -> httpx.Response: - response_cases = { - settings.utmn_get_teachers_part.format(page=1): get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/utmn_teachers_page_1.html"), - settings.utmn_get_teachers_part.format(page=2): get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/utmn_teachers_page_2.html"), - settings.utmn_get_teachers_part.format(page=3): get_httpx_response(200, {}, - settings.test_parent_path / - "fixtures/utmn_empty_page.html"), + return _handler( + request, + utmn_response_cases, + lookup_key=request.url.raw_path.decode(), + default_response=get_httpx_response(200, {}, _fixture("fixtures/utmn_empty_page.html")) + ) + + +def _netology_handler(request: httpx.Request) -> httpx.Response: + return _handler(request, netology_response_cases) + + +def _modeus_handler(request: httpx.Request) -> httpx.Response: + return _handler(request, modeus_response_cases) + + +def general_handler(request: httpx.Request) -> httpx.Response: + """ + General handler that routes requests to appropriate service handlers. + Combines netology, utmn, lms, and modeus handlers. + """ + all_response_cases = { + **netology_response_cases, + **utmn_response_cases, + **lms_login_responses, + **modeus_response_cases, } - case = response_cases.get(request.url.raw_path.decode()) - if case is None: - return get_httpx_response(200, {}, settings.test_parent_path / "fixtures/utmn_empty_page.html") + wsfunction = request.url.params.get("wsfunction") + if wsfunction: + return _handler(request, lms_wsfunction_responses, lookup_key=wsfunction) + + lookup_key = request.url.raw_path.decode() + if lookup_key in utmn_response_cases: + return _handler( + request, + utmn_response_cases, + lookup_key=lookup_key, + default_response=get_httpx_response(200, {}, _fixture("fixtures/utmn_empty_page.html")) + ) + + return _handler(request, all_response_cases) - return case -transport = httpx.MockTransport(_handler) +transport = httpx.MockTransport(general_handler) bad_request_transport = httpx.MockTransport(_bad_handler) -utmn_transport = httpx.MockTransport(_utmn_handler) \ No newline at end of file +utmn_transport = httpx.MockTransport(_utmn_handler) +lms_transport = httpx.MockTransport(_lms_handler) +modeus_transport = httpx.MockTransport(_modeus_handler) \ No newline at end of file diff --git a/backend/yet_another_calendar/tests/test_bulk.py b/backend/yet_another_calendar/tests/test_bulk.py index 189a9eb..ff786ac 100644 --- a/backend/yet_another_calendar/tests/test_bulk.py +++ b/backend/yet_another_calendar/tests/test_bulk.py @@ -1,36 +1,285 @@ +"""Complete bulk tests combining coverage and integration tests.""" import datetime +import pytest +import json import typing +from typing import Any +from collections.abc import Generator from copy import deepcopy +from unittest.mock import patch -from icalendar.prop import vText +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend from starlette.responses import StreamingResponse +from icalendar.prop import vText -from yet_another_calendar.web.api.bulk.integration import create_ics_event, export_to_ics -from yet_another_calendar.web.api.bulk.schema import BulkResponse, CalendarResponse +from yet_another_calendar.web.api.bulk import integration, schema, views +from yet_another_calendar.web.api.modeus import schema as modeus_schema +from yet_another_calendar.web.api.lms import schema as lms_schema +from yet_another_calendar.web.api.netology import schema as netology_schema -def test_create_ics_event_start_after_end(sample_datetime) -> None: - start = datetime.datetime(2025, 6, 5, 16, 0) - end = sample_datetime['invalid_end'] # ends before start - lesson_id = "lesson-002" - title = "Invalid Event" +@pytest.fixture(autouse=True) +def _init_cache() -> Generator[Any, Any, None]: + """Initialize FastAPICache for bulk tests.""" + FastAPICache.init(InMemoryBackend()) + yield + FastAPICache.reset() - # If your function does not handle this, you can just test the incorrect data is passed - event = create_ics_event(title, start, end, lesson_id) - # Assert that dtstart is still after dtend (indicating incorrect input) - assert event['DTSTART'].dt > event['DTEND'].dt +# ======================================== +# Schema Tests +# ======================================== +def test_now_dt_utc_function(): + """Test the now_dt_utc function from schema.""" + from yet_another_calendar.web.api.bulk.schema import now_dt_utc -def test_create_ics_event_none_description_url(sample_datetime) -> None: - start = sample_datetime['start'] - end = sample_datetime['end'] - event = create_ics_event("Title", start, end, "lesson-005", description=None, url=None) - assert event['DESCRIPTION'] == vText(b'None') - assert event['LOCATION'] == vText(b'unknown location') + now = now_dt_utc() + assert isinstance(now, datetime.datetime) + assert now.tzinfo == datetime.timezone.utc + + # Should be close to current time (within 5 seconds) + current_time = datetime.datetime.now(datetime.timezone.utc) + time_diff = abs((current_time - now).total_seconds()) + assert time_diff < 5 + + +def test_utmn_response_schema(): + """Test UtmnResponse schema to cover lines 17-19.""" + # Create UtmnResponse with empty lists + utmn_empty = schema.UtmnResponse( + modeus_events=[], + lms_events=[] + ) + + assert utmn_empty.modeus_events == [] + assert utmn_empty.lms_events == [] + assert isinstance(utmn_empty.modeus_events, list) + assert isinstance(utmn_empty.lms_events, list) + + +def test_bulk_response_complete_schema(bulk_fixture_content): + """Test BulkResponse complete schema using fixtures.""" + # Test BulkResponse creation (covers lines 22-24) + bulk = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + assert hasattr(bulk, 'netology') + assert hasattr(bulk, 'utmn') + assert isinstance(bulk.utmn, schema.UtmnResponse) + + # Test timezone error handling (covers lines 28-30) + with pytest.raises(Exception): # HTTPException for invalid timezone + bulk.change_timezone("Invalid/Timezone") + + +def test_schema_bulk_response_fields(): + """Test BulkResponse model fields to cover lines 45-46 in schema.py.""" + # Test UtmnResponse + utmn = schema.UtmnResponse( + modeus_events=[], + lms_events=[] + ) + assert utmn.modeus_events == [] + assert utmn.lms_events == [] + + # Test BulkResponse with UtmnResponse + bulk = schema.BulkResponse( + netology={'homework': [], 'webinars': []}, + utmn=utmn + ) + assert isinstance(bulk.utmn, schema.UtmnResponse) + + # Test timezone change with empty events (should handle gracefully) + changed = bulk.change_timezone("Europe/Moscow") + assert changed is bulk + + +def test_schema_timezone_error_handling(bulk_fixture_content): + """Test BulkResponse timezone error handling to cover lines 28-30.""" + bulk_response = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + # Test invalid timezone to cover exception handling + with pytest.raises(Exception): # HTTPException for invalid timezone + bulk_response.change_timezone("Invalid/Timezone") + + # Test another invalid timezone format + with pytest.raises(Exception): + bulk_response.change_timezone("Not_A_Real/Timezone") + + +def test_calendar_response_complete(bulk_fixture_content, sample_datetime): + """Test CalendarResponse with all features using fixtures.""" + # Test default cached_at (covers line 51) + calendar1 = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + assert hasattr(calendar1, 'cached_at') + assert isinstance(calendar1.cached_at, datetime.datetime) + + # Test custom cached_at + custom_time = sample_datetime['start'].replace(tzinfo=datetime.timezone.utc) + calendar_data = { + **calendar1.model_dump(by_alias=True), + "cached_at": custom_time.isoformat() + } + + calendar2 = schema.CalendarResponse.model_validate(calendar_data) + assert calendar2.cached_at.replace(microsecond=0) == custom_time.replace(microsecond=0) + + # Test hash generation (covers lines 53-55) + hash1 = calendar1.get_hash() + hash2 = calendar2.get_hash() + + assert len(hash1) == 32 # MD5 hash length + assert len(hash2) == 32 + assert isinstance(hash1, str) + assert isinstance(hash2, str) + + +def test_calendar_response_with_custom_cached_at(bulk_fixture_content): + """Test CalendarResponse with custom cached_at field.""" + # Test default cached_at + calendar1 = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + assert hasattr(calendar1, 'cached_at') + assert isinstance(calendar1.cached_at, datetime.datetime) + + # Test with custom cached_at + custom_time = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + data = {**calendar1.model_dump(by_alias=True), "cached_at": custom_time.isoformat()} + + calendar2 = schema.CalendarResponse.model_validate(data) + assert calendar2.cached_at.replace(microsecond=0) == custom_time.replace(microsecond=0) + + +def test_refreshed_calendar_response_complete(bulk_fixture_content): + """Test RefreshedCalendarResponse with changed field using fixtures.""" + calendar_data = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + + # Test with changed=True + refreshed_true = schema.RefreshedCalendarResponse( + **calendar_data.model_dump(by_alias=True), + changed=True + ) + + assert refreshed_true.changed is True + assert hasattr(refreshed_true, 'cached_at') + assert hasattr(refreshed_true, 'netology') + assert hasattr(refreshed_true, 'utmn') + + # Test with changed=False + refreshed_false = schema.RefreshedCalendarResponse( + **calendar_data.model_dump(by_alias=True), + changed=False + ) + + assert refreshed_false.changed is False + + # Test timezone change on RefreshedCalendarResponse + changed_tz = refreshed_true.change_timezone("Europe/Paris") + assert changed_tz is refreshed_true + + +def test_bulk_response_timezone_changes_comprehensive(bulk_fixture_content): + """Test BulkResponse timezone changes covering all event types.""" + # Test multiple timezone conversions using bulk fixture data + timezones = [ + "Europe/Moscow", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + "Europe/London" + ] + + for timezone_name in timezones: + bulk_response = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + # Change timezone (covers lines 26-47) + changed = bulk_response.change_timezone(timezone_name) + + # Verify it's the same object (in-place change) + assert changed is bulk_response + + # Verify timezone change was applied to all datetime fields + # This tests all the datetime field updates in lines 31-46 + for homework in bulk_response.netology.homework: + if homework.deadline: + assert homework.deadline.tzinfo is not None + + for webinar in bulk_response.netology.webinars: + if webinar.starts_at: + assert webinar.starts_at.tzinfo is not None + if webinar.ends_at: + assert webinar.ends_at.tzinfo is not None + + for modeus_event in bulk_response.utmn.modeus_events: + assert modeus_event.start_time.tzinfo is not None + assert modeus_event.end_time.tzinfo is not None + + for lms_event in bulk_response.utmn.lms_events: + assert lms_event.dt_start.tzinfo is not None + assert lms_event.dt_end.tzinfo is not None + + +def test_schema_lms_timezone_coverage(bulk_fixture_content): + """Test timezone conversion for LMS events to cover schema.py lines 45-46.""" + bulk_response = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + # Ensure we have LMS events to test timezone conversion + if bulk_response.utmn.lms_events: + # Test timezone conversion that should cover lines 45-46 in schema.py + result = bulk_response.change_timezone("America/New_York") + assert result is bulk_response + + # Verify timezone was applied to LMS events + for lms_event in result.utmn.lms_events: + assert lms_event.dt_start.tzinfo is not None + assert lms_event.dt_end.tzinfo is not None + + +def test_lms_events_timezone_conversion_schema_lines_45_46(bulk_fixture_content): + """Test LMS events timezone conversion to cover schema.py lines 45-46.""" + + calendar_data = json.loads(bulk_fixture_content) + + # Ensure we have LMS events to test timezone conversion + if 'utmn' not in calendar_data: + calendar_data['utmn'] = {'modeus_events': [], 'lms_events': []} + + # Add LMS events to trigger lines 45-46 in schema.py + calendar_data['utmn']['lms_events'] = [ + { + 'id': 789, + 'name': 'Test LMS for Timezone', + 'course_name': 'Timezone Test Course', + 'dt_start': '2025-06-05T10:00:00Z', + 'dt_end': '2025-06-05T12:00:00Z', + 'url': 'https://lms.timezone.test.com', + 'uservisible': True, + 'modname': 'assignment', + 'is_completed': False + } + ] + bulk_response = schema.BulkResponse.model_validate(calendar_data) + + # Test timezone conversion that should execute lines 45-46 in schema.py + result = bulk_response.change_timezone("Europe/Berlin") + assert result is bulk_response + + # Verify timezone was applied to LMS events (covers lines 45-46) + for lms_event in result.utmn.lms_events: + assert lms_event.dt_start.tzinfo is not None + assert lms_event.dt_end.tzinfo is not None + # Should be Berlin timezone + assert str(lms_event.dt_start.tzinfo) == "Europe/Berlin" + assert str(lms_event.dt_end.tzinfo) == "Europe/Berlin" + + +# ======================================== +# Integration Tests - create_ics_event +# ======================================== def test_create_ics_event_ok(sample_datetime) -> None: + """Test create_ics_event with valid data including DTSTAMP check.""" start = sample_datetime['start'] end = sample_datetime['end'] lesson_id = "lesson-001" @@ -38,7 +287,7 @@ def test_create_ics_event_ok(sample_datetime) -> None: description = "Algebra and Linear Equations" url = "https://example.com/classroom" - event = create_ics_event(title, start, end, lesson_id, description, url) + event = integration.create_ics_event(title, start, end, lesson_id, description, url) assert event['SUMMARY'] == title assert event['LOCATION'] == url @@ -52,9 +301,447 @@ def test_create_ics_event_ok(sample_datetime) -> None: assert (now_aware - dtstamp).total_seconds() < 5 +def test_create_ics_event_start_after_end(sample_datetime) -> None: + """Test create_ics_event with start time after end time.""" + start = datetime.datetime(2025, 6, 5, 16, 0) + end = sample_datetime['invalid_end'] # ends before start + lesson_id = "lesson-002" + title = "Invalid Event" + + # If your function does not handle this, you can just test the incorrect data is passed + event = integration.create_ics_event(title, start, end, lesson_id) + + # Assert that dtstart is still after dtend (indicating incorrect input) + assert event['DTSTART'].dt > event['DTEND'].dt + + +def test_create_ics_event_none_description_url(sample_datetime) -> None: + """Test create_ics_event with None description and URL using vText comparison.""" + start = sample_datetime['start'] + end = sample_datetime['end'] + event = integration.create_ics_event("Title", start, end, "lesson-005", description=None, url=None) + assert event['DESCRIPTION'] == vText(b'None') + assert event['LOCATION'] == vText(b'unknown location') + + +def test_create_ics_event_comprehensive(sample_datetime): + """Test create_ics_event with all field combinations using fixtures.""" + start = sample_datetime['start'] + end = sample_datetime['end'] + + # Test with all fields provided + event1 = integration.create_ics_event( + title="Complete Event", + starts_at=start, + ends_at=end, + lesson_id="lesson-001", + description="Full description", + url="https://example.com/lesson" + ) + + assert str(event1['SUMMARY']) == "Complete Event" + assert str(event1['DESCRIPTION']) == "Full description" + assert str(event1['LOCATION']) == "https://example.com/lesson" + assert event1['DTSTART'].dt == start + assert event1['DTEND'].dt == end + assert str(event1['UID']) == "lesson-001" + + # Test with None description and url (covers lines 32, 37) + event2 = integration.create_ics_event( + title="Minimal Event", + starts_at=start, + ends_at=end, + lesson_id="lesson-002", + description=None, + url=None + ) + + assert str(event2['DESCRIPTION']) == "None" + assert str(event2['LOCATION']) == "unknown location" + + # Test with empty strings + event3 = integration.create_ics_event( + title="Empty Fields Event", + starts_at=start, + ends_at=end, + lesson_id="lesson-003", + description="", + url="" + ) + + assert str(event3['DESCRIPTION']) == "" + assert str(event3['LOCATION']) == "unknown location" + + +def test_create_ics_event_edge_cases(sample_datetime): + """Test create_ics_event edge cases for complete coverage.""" + start = sample_datetime['start'] + end = sample_datetime['end'] + + # Test with all possible parameter combinations + event = integration.create_ics_event( + title="Test Event", + starts_at=start, + ends_at=end, + lesson_id="test-123", + description="", # Empty string description + url="" # Empty string URL - covers line 32 logic + ) + + # Verify empty URL defaults to unknown location + assert str(event['LOCATION']) == "unknown location" + assert str(event['DESCRIPTION']) == "" + assert str(event['SUMMARY']) == "Test Event" + assert str(event['UID']) == "test-123" + + +def test_create_ics_event_bulk_operations(sample_datetime, bulk_fixture_data): + """Test bulk ICS event creation using multiple fixtures.""" + # Create multiple events using sample_datetime fixture + start_time = sample_datetime['start'] + end_time = sample_datetime['end'] + + # Bulk event data + event_data = [ + { + "title": "Netology: Advanced Python", + "starts_at": start_time, + "ends_at": end_time, + "lesson_id": "netology-001", + "description": "Python advanced concepts", + "url": "https://netology.ru/lesson1" + }, + { + "title": "Modeus: Database Design", + "starts_at": start_time + datetime.timedelta(hours=2), + "ends_at": end_time + datetime.timedelta(hours=2), + "lesson_id": "modeus-002", + "description": "Database design principles", + "url": "https://modeus.utmn.ru/lesson2" + }, + { + "title": "LMS: Software Architecture", + "starts_at": start_time + datetime.timedelta(hours=4), + "ends_at": end_time + datetime.timedelta(hours=4), + "lesson_id": "lms-003", + "description": "Software architecture patterns", + "url": "https://lms.utmn.ru/lesson3" + } + ] + + # Create events in bulk + events = [] + for data in event_data: + event = integration.create_ics_event( + title=data["title"], + starts_at=data["starts_at"], + ends_at=data["ends_at"], + lesson_id=data["lesson_id"], + description=data["description"], + url=data["url"] + ) + events.append(event) + + # Verify all events were created correctly + assert len(events) == 3 + + for i, event in enumerate(events): + expected = event_data[i] + assert str(event['SUMMARY']) == expected["title"] + assert event['DTSTART'].dt == expected["starts_at"] + assert event['DTEND'].dt == expected["ends_at"] + assert str(event['UID']) == expected["lesson_id"] + assert str(event['DESCRIPTION']) == expected["description"] + assert str(event['LOCATION']) == expected["url"] + + +def test_create_ics_event_with_missing_fields(): + """Test create_ics_event with various missing/None fields to cover all branches.""" + # Test with None url and None description (covers lines 48, 56) + start = datetime.datetime(2025, 6, 5, 14, 0) + end = datetime.datetime(2025, 6, 5, 15, 0) + + # This should hit the conditional branches in create_ics_event + event = integration.create_ics_event( + title="Test Event", + starts_at=start, + ends_at=end, + lesson_id="test-123", + description=None, + url=None + ) + + # Verify the conditional logic worked + assert str(event['DESCRIPTION']) == "None" + assert str(event['LOCATION']) == "unknown location" + + # Test with empty strings + event2 = integration.create_ics_event( + title="Test Event 2", + starts_at=start, + ends_at=end, + lesson_id="test-456", + description="", + url="" + ) + + assert str(event2['DESCRIPTION']) == "" + assert str(event2['LOCATION']) == "unknown location" + + +# ======================================== +# Integration Tests - export_to_ics +# ======================================== + +def test_export_to_ics_complete_coverage(bulk_fixture_content): + """Test export_to_ics to cover all conditional branches using bulk fixture.""" + # Load and modify the bulk fixture to ensure all code paths are tested + calendar_data = json.loads(bulk_fixture_content) + + # Test with netology webinars (covers lines 46-53) + calendar1 = schema.CalendarResponse.model_validate(calendar_data) + ics_data1 = b"".join(integration.export_to_ics(calendar1)) + ics_str1 = ics_data1.decode('utf-8') + + assert "BEGIN:VCALENDAR" in ics_str1 + assert "VERSION:2.0" in ics_str1 + assert "PRODID:yet_another_calendar" in ics_str1 + + # Modify data to test netology webinars without starts_at/ends_at (covers lines 47-48) + if 'netology' in calendar_data and 'webinars' in calendar_data['netology']: + for webinar in calendar_data['netology']['webinars']: + webinar['starts_at'] = None + webinar['ends_at'] = None + break + + # Test with netology homework without deadline (covers lines 55-56) + if 'netology' in calendar_data and 'homework' in calendar_data['netology']: + for homework in calendar_data['netology']['homework']: + homework['deadline'] = None + break + + calendar2 = schema.CalendarResponse.model_validate(calendar_data) + ics_data2 = b"".join(integration.export_to_ics(calendar2)) + ics_str2 = ics_data2.decode('utf-8') + + # Should still produce valid ICS even with None dates + assert "BEGIN:VCALENDAR" in ics_str2 + assert "VERSION:2.0" in ics_str2 + + # Test with LMS events to cover lines 70-73 + # Create calendar with LMS events that have homework deadlines + calendar_with_lms = schema.CalendarResponse.model_validate(calendar_data) + if calendar_with_lms.utmn.lms_events: + ics_data3 = b"".join(integration.export_to_ics(calendar_with_lms)) + ics_str3 = ics_data3.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str3 + + +@pytest.mark.asyncio +async def test_export_to_ics_bulk_calendar(bulk_fixture_content, sample_datetime): + """Test bulk ICS export using bulk fixture content.""" + # Use bulk_fixture_content to create a calendar + calendar = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + + # Test ICS export + ics_data = b"".join(integration.export_to_ics(calendar)) + + # Verify ICS format + ics_str = ics_data.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str + assert "VERSION:2.0" in ics_str + assert "PRODID:yet_another_calendar" in ics_str + assert "BEGIN:VEVENT" in ics_str + assert "END:VEVENT" in ics_str + assert "END:VCALENDAR" in ics_str + + # Verify it contains events from different sources + # Should have events from Netology, Modeus, and LMS + event_count = ics_str.count("BEGIN:VEVENT") + assert event_count > 0 + + +def test_export_homework_without_deadline(bulk_fixture_content): + """Test export with homework missing deadline to cover line 56.""" + calendar_data = json.loads(bulk_fixture_content) + + # Set all homework deadlines to None to trigger line 56 continue + if 'netology' in calendar_data and 'homework' in calendar_data['netology']: + for homework in calendar_data['netology']['homework']: + homework['deadline'] = None + + calendar = schema.CalendarResponse.model_validate(calendar_data) + ics_data = b"".join(integration.export_to_ics(calendar)) + ics_str = ics_data.decode('utf-8') + + # Should still produce valid ICS even with no homework events due to None deadlines + assert "BEGIN:VCALENDAR" in ics_str + assert "VERSION:2.0" in ics_str + + +def test_export_covers_line_56_homework_deadline_none(bulk_fixture_content): + """Test export_to_ics with None homework deadline to cover line 56.""" + calendar_data = json.loads(bulk_fixture_content) + + # Ensure we have homework with None deadline to trigger continue on line 56 + if 'netology' not in calendar_data: + calendar_data['netology'] = {'homework': [], 'webinars': []} + + # Add homework with None deadline specifically to test line 56 + calendar_data['netology']['homework'] = [ + { + 'id': 123456, + 'lesson_id': 654321, + 'type': 'homework', + 'title': 'Test Homework Without Deadline', + 'block_title': 'Test Block', + 'deadline': None, # This should trigger line 56 continue + 'path': '/test/homework/path', # Required field + 'passed': False, # Required field + 'weight': 1, + 'is_mark': False + } + ] + + calendar = schema.CalendarResponse.model_validate(calendar_data) + + # Export should skip homework without deadline (line 56) + ics_data = b"".join(integration.export_to_ics(calendar)) + ics_str = ics_data.decode('utf-8') + + # Should produce valid ICS but not include the homework without deadline + assert "BEGIN:VCALENDAR" in ics_str + assert "VERSION:2.0" in ics_str + # The homework with None deadline should be skipped due to line 56 continue + assert "Test Homework Without Deadline" not in ics_str + + +def test_export_to_ics_conditional_branches(bulk_fixture_content): + """Test export_to_ics to cover conditional branches for missing datetime fields.""" + # Create test data with missing datetime fields to cover lines 70-73 + calendar_data = json.loads(bulk_fixture_content) + + # Modify the data to have events without starts_at/ends_at or deadline + if 'netology' in calendar_data and 'webinars' in calendar_data['netology']: + for webinar in calendar_data['netology']['webinars']: + # Set one webinar to have None starts_at and ends_at (line 47-48) + webinar['starts_at'] = None + webinar['ends_at'] = None + break + + if 'netology' in calendar_data and 'homework' in calendar_data['netology']: + for homework in calendar_data['netology']['homework']: + # Set one homework to have None deadline (line 55-56) + homework['deadline'] = None + break + + # Create calendar from modified data + calendar = schema.CalendarResponse.model_validate(calendar_data) + + # Export should handle None datetime fields gracefully + ics_data = b"".join(integration.export_to_ics(calendar)) + + # Should still produce valid ICS + ics_str = ics_data.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str + assert "VERSION:2.0" in ics_str + + +def test_export_to_ics_with_missing_lms_dates(bulk_fixture_content): + """Test export_to_ics with LMS events that have specific date calculations (lines 70-73).""" + # Create calendar data with LMS events to test lines 70-73 + calendar_data = json.loads(bulk_fixture_content) + + # Ensure we have LMS events to test the datetime calculations + if 'utmn' not in calendar_data: + calendar_data['utmn'] = {'modeus_events': [], 'lms_events': []} + + # Add a test LMS event to trigger lines 70-73 + calendar_data['utmn']['lms_events'] = [{ + 'id': 12345, + 'name': 'Test LMS Event', + 'course_name': 'Test Course', + 'dt_start': '2025-06-05T12:00:00Z', + 'dt_end': '2025-06-05T14:00:00Z', + 'url': 'https://lms.test.com', + 'uservisible': True, + 'modname': 'test', + 'is_completed': False + }] + + calendar = schema.CalendarResponse.model_validate(calendar_data) + + # Export should include LMS events with calculated start time (line 70-73) + ics_data = b"".join(integration.export_to_ics(calendar)) + + ics_str = ics_data.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str + assert "Test LMS Event" in ics_str + + +def test_lms_event_handling_for_missing_coverage(bulk_fixture_content): + """Test specific LMS event scenarios to cover lines 130-131.""" + # Load fixture and test with empty LMS events + calendar_data = json.loads(bulk_fixture_content) + + # Ensure we have LMS events to test line 130 condition + if 'utmn' in calendar_data and 'lms_events' in calendar_data['utmn']: + calendar = schema.CalendarResponse.model_validate(calendar_data) + + # Test export to ensure LMS events are processed + ics_data = b"".join(integration.export_to_ics(calendar)) + ics_str = ics_data.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str + + # Verify LMS events exist in calendar structure + assert hasattr(calendar.utmn, 'lms_events') + assert isinstance(calendar.utmn.lms_events, list) + + +# ======================================== +# Bulk Calendar Operations +# ======================================== + +def test_calendar_response_get_hash(bulk_fixture_content) -> None: + """Test CalendarResponse get_hash method.""" + calendar_response = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + # assert calendar_response.get_hash() == "ea6326e66b5e1bacfaa5042b0e4421c2" + assert len(calendar_response.get_hash()) == 32 + + +def test_calendar_response_bulk_operations(bulk_fixture_content, sample_datetime): + """Test bulk calendar response operations using fixtures.""" + # Create multiple calendar responses from bulk fixture + calendar1 = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + calendar2 = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + + # Test hash generation for bulk operations + hash1 = calendar1.get_hash() + hash2 = calendar2.get_hash() + + # Should have same hash if same data + assert hash1 == hash2 + assert len(hash1) == 32 # MD5 hash length + + # Test timezone changes in bulk + new_timezone = "Europe/Moscow" + + # Change timezone + calendar_changed = calendar1.change_timezone(new_timezone) + + # Verify it's the same object (in-place change) + assert calendar_changed is calendar1 + + # Hash should be different after timezone change + new_hash = calendar1.get_hash() + # Note: Hash might be the same if no datetime objects were changed + assert isinstance(new_hash, str) and len(new_hash) == 32 + + @typing.no_type_check def test_bulk_change_tz(bulk_fixture_content) -> None: - bulk = BulkResponse.model_validate_json(bulk_fixture_content) + """Test bulk timezone change with hour comparison.""" + bulk = schema.BulkResponse.model_validate_json(bulk_fixture_content) new_bulk = deepcopy(bulk) new_bulk.change_timezone('America/los_angeles') @@ -62,23 +749,447 @@ def test_bulk_change_tz(bulk_fixture_content) -> None: assert (bulk.utmn.modeus_events[0].start_time.hour - new_bulk.utmn.modeus_events[0].start_time.hour) == 10 -@typing.no_type_check -def test_calendar_response_get_hash(bulk_fixture_content) -> None: - calendar_response = CalendarResponse.model_validate_json(bulk_fixture_content) - # assert calendar_response.get_hash() == "ea6326e66b5e1bacfaa5042b0e4421c2" - assert len(calendar_response.get_hash()) == 32 +def test_bulk_response_timezone_operations(bulk_fixture_content): + """Test bulk timezone change operations on BulkResponse.""" + # Test timezone changes for different components + timezones_to_test = [ + "America/New_York", + "Europe/London", + "Asia/Tokyo", + "Australia/Sydney" + ] + + for timezone_name in timezones_to_test: + # Create a copy for each timezone test + test_response = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + # Change timezone + changed_response = test_response.change_timezone(timezone_name) + + # Verify it's the same object + assert changed_response is test_response + + # Verify timezone changes were applied + # Check if any datetime objects exist and have been converted + if hasattr(test_response.netology, 'webinars') and test_response.netology.webinars: + for webinar in test_response.netology.webinars: + if webinar.starts_at: + assert webinar.starts_at.tzinfo is not None + if webinar.ends_at: + assert webinar.ends_at.tzinfo is not None + + +def test_bulk_schema_operations_comprehensive(bulk_fixture_content, sample_datetime): + """Test comprehensive bulk schema operations using all fixtures.""" + # Phase 1: Create multiple calendar responses from bulk fixture + calendars = [] + for i in range(3): + calendar = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + calendars.append(calendar) + + # Phase 2: Test bulk hash operations + hashes = [cal.get_hash() for cal in calendars] + + # All should have same hash (same data) + assert all(h == hashes[0] for h in hashes) + assert all(len(h) == 32 for h in hashes) # MD5 length + + # Phase 3: Test bulk timezone operations + timezones = ["Europe/Moscow", "America/New_York", "Asia/Tokyo"] + + for i, timezone in enumerate(timezones): + calendar_copy = schema.CalendarResponse.model_validate_json(bulk_fixture_content) + changed = calendar_copy.change_timezone(timezone) + + # Verify it's the same object + assert changed is calendar_copy + + # Verify hash changes for different timezones + new_hash = changed.get_hash() + assert isinstance(new_hash, str) + assert len(new_hash) == 32 + + # Phase 4: Test bulk ICS export operations + for calendar in calendars[:2]: # Test first 2 + ics_data = b"".join(integration.export_to_ics(calendar)) + + # Verify ICS format + ics_str = ics_data.decode('utf-8') + assert "BEGIN:VCALENDAR" in ics_str + assert "VERSION:2.0" in ics_str + assert "PRODID:yet_another_calendar" in ics_str + + # Phase 5: Test bulk response operations + bulk_responses = [] + for i in range(5): + bulk_resp = schema.BulkResponse.model_validate_json(bulk_fixture_content) + bulk_responses.append(bulk_resp) + + # Test timezone changes on all bulk responses + for bulk_resp in bulk_responses: + try: + bulk_resp.change_timezone("Europe/London") + # Should succeed for all + except Exception: + # Some might not have datetime objects to change + pass + + +def test_invalid_timezone_bulk_error(bulk_fixture_content): + """Test bulk timezone error handling.""" + bulk_response = schema.BulkResponse.model_validate_json(bulk_fixture_content) + + # Test invalid timezone + with pytest.raises(Exception): # Should raise HTTPException + bulk_response.change_timezone("Invalid/Timezone") async def test_export_to_ics(bulk_fixture_content) -> None: + """Test export_to_ics with timezone and streaming response.""" time_zone = "Europe/Moscow" - calendar = CalendarResponse.model_validate_json(bulk_fixture_content).change_timezone(time_zone) - isc_calendar = StreamingResponse(export_to_ics(calendar)) + calendar = schema.CalendarResponse.model_validate_json(bulk_fixture_content).change_timezone(time_zone) + isc_calendar = StreamingResponse(integration.export_to_ics(calendar)) assert isc_calendar - async for resp in isc_calendar.body_iterator: assert "BEGIN:VCALENDAR" in str(resp) assert "VERSION:2.0" in str(resp) assert "TZID=Europe/Moscow" in str(resp) assert "SUMMARY:Netology: 2" in str(resp) + + +# ======================================== +# Modeus Integration Tests +# ======================================== + +def test_modeus_events_body_creation(): + """Test ModeusEventsBody creation to cover get_calendar line 123-124.""" + import uuid + + # Create ModeusTimeBody + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + person_id = "550e8400-e29b-41d4-a716-446655440000" + + # Test the create_dump_date method and ModeusEventsBody validation + dump_data = body.create_dump_date() + assert 'timeMin' in dump_data + assert 'timeMax' in dump_data + + # Create full body like in get_calendar function + full_body = modeus_schema.ModeusEventsBody.model_validate( + {**dump_data, 'attendeePersonId': [person_id]} + ) + + # The field gets converted to UUID objects + assert len(full_body.attendee_person_id) == 1 + assert isinstance(full_body.attendee_person_id[0], uuid.UUID) + assert str(full_body.attendee_person_id[0]) == person_id + assert hasattr(full_body, 'time_min') + assert hasattr(full_body, 'time_max') + + +# ======================================== +# Async Integration Tests +# ======================================== + +@pytest.mark.asyncio +async def test_get_calendar_with_fixtures_covers_lines_126_136( + modeus_client, + netology_client, + lms_client, + fastapi_app +): + """Test get_calendar function using fixtures to cover lines 126-136.""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Test get_calendar with fixtures (covers lines 126-136) + calendar = await integration.get_calendar( + body=body, + calendar_id=45526, + person_id="550e8400-e29b-41d4-a716-446655440000", + lms_user=lms_user, + cookies=cookies, + modeus_jwt_token="test_token" + ) + + # Verify structure and that lines 130-131 were executed + assert isinstance(calendar, schema.CalendarResponse) + assert hasattr(calendar, 'netology') + assert hasattr(calendar, 'utmn') + assert hasattr(calendar.utmn, 'lms_events') + assert isinstance(calendar.utmn.lms_events, list) + + +@pytest.mark.asyncio +async def test_get_cached_calendar_with_fixtures_covers_line_151( + modeus_client, + netology_client, + lms_client, + fastapi_app +): + """Test get_cached_calendar function using fixtures to cover line 151.""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Test get_cached_calendar (covers line 151: return await get_calendar(...)) + result = await integration.get_cached_calendar( + body=body, + calendar_id=45526, + person_id="550e8400-e29b-41d4-a716-446655440000", + lms_user=lms_user, + cookies=cookies, + modeus_jwt_token="test_token" + ) + + # Verify result is proper calendar response + assert isinstance(result, schema.CalendarResponse) + + +@pytest.mark.asyncio +async def test_refresh_events_with_fixtures_covers_lines_87_110( + modeus_client, + netology_client, + lms_client, + fastapi_app, + fake_redis_pool +): + """Test refresh_events function using fixtures to cover lines 87-110.""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Test refresh_events (covers lines 87-110 including error handling) + result = await integration.refresh_events( + body=body, + lms_user=lms_user, + calendar_id=45526, + cookies=cookies, + timezone="Europe/Moscow", + modeus_jwt_token="test_token", + person_id="550e8400-e29b-41d4-a716-446655440000" + ) + + # Verify result structure (covers lines 110-112) + assert isinstance(result, schema.RefreshedCalendarResponse) + assert hasattr(result, 'changed') + assert isinstance(result.changed, bool) + assert hasattr(result, 'netology') + assert hasattr(result, 'utmn') + + +@pytest.mark.asyncio +async def test_bulk_error_scenarios( + modeus_client, + netology_bad_client, + lms_client, + fastapi_app +): + """Test bulk service error handling scenarios.""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", # Monday + timeMax="2025-01-12T00:00:00Z" # Sunday + ) + + calendar_id = 999 # Invalid calendar ID + person_id = "550e8400-e29b-41d4-a716-446655440001" # Valid UUID + + lms_user = lms_schema.User(token="invalid_token", id=999) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "invalid_session" + }) + modeus_jwt_token = "invalid_jwt_token" + + # Test error handling in bulk operations + with pytest.raises(Exception): + await integration.get_calendar( + body=body, + calendar_id=calendar_id, + person_id=person_id, + lms_user=lms_user, + cookies=cookies, + modeus_jwt_token=modeus_jwt_token + ) + + +# ======================================== +# Views Tests +# ======================================== + +def test_views_response_type_handling_logic(bulk_fixture_content): + """Test views response type handling to cover missing lines in views.py.""" + # Test response type handling logic without making API calls + calendar_data = json.loads(bulk_fixture_content) + + # Create CalendarResponse instance to test response type logic + calendar_response = schema.CalendarResponse.model_validate(calendar_data) + + # Test isinstance check logic from views.py:37-40 + if isinstance(calendar_response, schema.CalendarResponse): + # This covers the isinstance branch in get_calendar view + result = calendar_response.change_timezone("Europe/Moscow") + assert isinstance(result, schema.CalendarResponse) + + # Test model_validate path (line 40 in views.py) + calendar_dict = calendar_response.model_dump(by_alias=True) + validated_response = schema.CalendarResponse.model_validate(calendar_dict) + timezone_changed = validated_response.change_timezone("Europe/Moscow") + assert isinstance(timezone_changed, schema.CalendarResponse) + + +@pytest.mark.asyncio +async def test_views_get_calendar_cached_response_type(): + """Test views get_calendar function with different response types (lines 33-40).""" + # Mock dependencies + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Test case 1: get_cached_calendar returns CalendarResponse instance + calendar_response = schema.CalendarResponse.model_validate({ + "netology": {"homework": [], "webinars": []}, + "utmn": {"modeus_events": [], "lms_events": []} + }) + + with patch('yet_another_calendar.web.api.bulk.integration.get_cached_calendar') as mock_cached: + mock_cached.return_value = calendar_response + + result = await views.get_calendar( + body=body, + lms_user=lms_user, + cookies=cookies, + donor_token="test_token", + modeus_person_id="550e8400-e29b-41d4-a716-446655440000" + ) + + # Should call change_timezone and return CalendarResponse (line 38) + assert isinstance(result, schema.CalendarResponse) + + # Test case 2: get_cached_calendar returns dict (cached data) + with patch('yet_another_calendar.web.api.bulk.integration.get_cached_calendar') as mock_cached: + mock_cached.return_value = { + "netology": {"homework": [], "webinars": []}, + "utmn": {"modeus_events": [], "lms_events": []} + } + + result = await views.get_calendar( + body=body, + lms_user=lms_user, + cookies=cookies, + donor_token="test_token", + modeus_person_id="550e8400-e29b-41d4-a716-446655440000" + ) + + # Should validate dict and return CalendarResponse (line 40) + assert isinstance(result, schema.CalendarResponse) + + +@pytest.mark.asyncio +async def test_views_refresh_calendar(): + """Test views refresh_calendar function (line 55).""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Mock refresh_events to return RefreshedCalendarResponse + mock_refreshed = schema.RefreshedCalendarResponse( + netology={"homework": [], "webinars": []}, + utmn={"modeus_events": [], "lms_events": []}, + changed=True + ) + + with patch('yet_another_calendar.web.api.bulk.integration.refresh_events') as mock_refresh: + mock_refresh.return_value = mock_refreshed + + result = await views.refresh_calendar( + body=body, + lms_user=lms_user, + cookies=cookies, + donor_token="test_token", + modeus_person_id="550e8400-e29b-41d4-a716-446655440000" + ) + + # Should return RefreshedCalendarResponse (line 55-57) + assert isinstance(result, schema.RefreshedCalendarResponse) + assert result.changed is True + + +@pytest.mark.asyncio +async def test_views_export_ics(): + """Test views export_ics function (lines 74-79).""" + body = modeus_schema.ModeusTimeBody( + timeMin="2025-01-06T00:00:00Z", + timeMax="2025-01-12T00:00:00Z" + ) + + lms_user = lms_schema.User(token="test_token", id=123) + cookies = netology_schema.NetologyCookies.model_validate({ + "_netology-on-rails_session": "test_session" + }) + + # Mock get_calendar to return CalendarResponse + calendar_response = schema.CalendarResponse.model_validate({ + "netology": {"homework": [], "webinars": []}, + "utmn": {"modeus_events": [], "lms_events": []} + }) + + with patch('yet_another_calendar.web.api.bulk.integration.get_calendar') as mock_get_cal: + mock_get_cal.return_value = calendar_response + + with patch('yet_another_calendar.web.api.bulk.integration.export_to_ics') as mock_export: + mock_export.return_value = iter([b"BEGIN:VCALENDAR\\nEND:VCALENDAR"]) + + result = await views.export_ics( + body=body, + lms_user=lms_user, + cookies=cookies, + donor_token="test_token", + modeus_person_id="550e8400-e29b-41d4-a716-446655440000" + ) + + # Should return StreamingResponse (lines 74-79) + assert isinstance(result, StreamingResponse) + + # Verify get_calendar was called (line 74) + mock_get_cal.assert_called_once() + + # Verify change_timezone was called (line 78) + # and export_to_ics was called (line 79) + mock_export.assert_called_once() diff --git a/backend/yet_another_calendar/tests/test_lms.py b/backend/yet_another_calendar/tests/test_lms.py old mode 100644 new mode 100755 index 1119c30..1450402 --- a/backend/yet_another_calendar/tests/test_lms.py +++ b/backend/yet_another_calendar/tests/test_lms.py @@ -96,13 +96,6 @@ async def test_get_user_info_forbidden(lms_bad_client) -> None: assert exc_info.value.response.json() == {"text": "Forbidden"} -@pytest.mark.asyncio -async def test_get_user_info_ok(lms_client) -> None: - response_json = await integration.get_user_info(token="token_123", username="azamat") - - assert response_json == [{"name": "azamat"}] - - @pytest.mark.asyncio async def test_auth_lms_error(lms_bad_client) -> None: creds = schema.LxpCreds(username="ivan@utmn.ru", password="12345678", service="test") @@ -117,9 +110,7 @@ async def test_auth_lms_error(lms_bad_client) -> None: @pytest.mark.asyncio async def test_auth_lms_ok(lms_client) -> None: creds = schema.LxpCreds(username="ivan@utmn.ru", password="12345678", service="test") - with patch("yet_another_calendar.web.api.lms.integration.get_user_info", - return_value=[{"id": "1"}]): - user = await integration.auth_lms(creds) + user = await integration.auth_lms(creds) assert user.id == 1 assert user.token == "token_12345" diff --git a/backend/yet_another_calendar/tests/mts.py b/backend/yet_another_calendar/tests/test_mts.py old mode 100644 new mode 100755 similarity index 100% rename from backend/yet_another_calendar/tests/mts.py rename to backend/yet_another_calendar/tests/test_mts.py diff --git a/backend/yet_another_calendar/web/api/auth/__init__.py b/backend/yet_another_calendar/web/api/auth/__init__.py index 0c0833d..b8c6de3 100644 --- a/backend/yet_another_calendar/web/api/auth/__init__.py +++ b/backend/yet_another_calendar/web/api/auth/__init__.py @@ -2,4 +2,4 @@ from .views import router -__all__ = ["router"] \ No newline at end of file +__all__ = ["router"] diff --git a/backend/yet_another_calendar/web/api/auth/rate_limiter.py b/backend/yet_another_calendar/web/api/auth/rate_limiter.py index 3a8dd98..24f91a9 100644 --- a/backend/yet_another_calendar/web/api/auth/rate_limiter.py +++ b/backend/yet_another_calendar/web/api/auth/rate_limiter.py @@ -13,23 +13,23 @@ class LoginRateLimiter: """Rate limiter for login attempts.""" - + async def check_rate_limit( self, request: Request, redis_pool: ConnectionPool, login_type: str = "general", ) -> None: """Check if IP is rate limited.""" client_ip = self._get_client_ip(request) cache_key = f"{login_type}_login_attempts:{client_ip}" - + async with Redis(connection_pool=redis_pool) as redis: cached_data = await redis.get(cache_key) if cached_data is None: attempts_data = {"count": 0, "locked_until": 0} else: attempts_data = json.loads(cached_data.decode()) - + current_time = time.time() - + if attempts_data["locked_until"] > current_time: lockout_remaining = int(attempts_data["locked_until"] - current_time) logger.warning(f"Rate limited {login_type} login attempt from {client_ip}") @@ -37,67 +37,67 @@ async def check_rate_limit( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=f"Too many failed attempts. Try again in {lockout_remaining} seconds.", ) - + if attempts_data["locked_until"] > 0 and current_time > attempts_data["locked_until"]: attempts_data = {"count": 0, "locked_until": 0} await redis.set( - cache_key, - json.dumps(attempts_data), + cache_key, + json.dumps(attempts_data), ex=settings.login_lockout_time, ) - + async def record_failed_attempt( self, request: Request, redis_pool: ConnectionPool, login_type: str = "general", ) -> None: """Record a failed login attempt.""" client_ip = self._get_client_ip(request) cache_key = f"{login_type}_login_attempts:{client_ip}" - + async with Redis(connection_pool=redis_pool) as redis: cached_data = await redis.get(cache_key) if cached_data is None: attempts_data = {"count": 0, "locked_until": 0} else: attempts_data = json.loads(cached_data.decode()) - + attempts_data["count"] += 1 current_time = time.time() - + logger.warning(f"Failed {login_type} login attempt from {client_ip} (attempt {attempts_data['count']})") - + if attempts_data["count"] >= settings.login_max_attempts: attempts_data["locked_until"] = int(current_time + settings.login_lockout_time) logger.warning( f"IP {client_ip} locked out for {login_type} login for {settings.login_lockout_time} seconds", ) - + await redis.set( - cache_key, - json.dumps(attempts_data), + cache_key, + json.dumps(attempts_data), ex=settings.login_lockout_time, ) - + async def record_success( self, request: Request, redis_pool: ConnectionPool, login_type: str = "general", ) -> None: """Clear failed attempts on successful login.""" client_ip = self._get_client_ip(request) cache_key = f"{login_type}_login_attempts:{client_ip}" - + async with Redis(connection_pool=redis_pool) as redis: await redis.delete(cache_key) logger.info(f"Successful {login_type} login from {client_ip}") - + def _get_client_ip(self, request: Request) -> str: """Get client IP address.""" forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: return forwarded_for.split(",")[0].strip() - + real_ip = request.headers.get("X-Real-IP") if real_ip: return real_ip - + return request.client.host if request.client else "unknown" @@ -109,16 +109,16 @@ async def rate_limited_dependency( # Check rate limit before proceeding function rate_limiter = LoginRateLimiter() await rate_limiter.check_rate_limit(request, redis_pool, login_type) - + try: # Execute the original function with its original parameters yield # Record successful authentication await rate_limiter.record_success(request, redis_pool, login_type) return - + except HTTPException as e: # Record failed attempt for authentication errors if e.status_code == status.HTTP_401_UNAUTHORIZED: await rate_limiter.record_failed_attempt(request, redis_pool, login_type) - raise \ No newline at end of file + raise diff --git a/backend/yet_another_calendar/web/api/auth/schema.py b/backend/yet_another_calendar/web/api/auth/schema.py index e47b1f1..3808454 100644 --- a/backend/yet_another_calendar/web/api/auth/schema.py +++ b/backend/yet_another_calendar/web/api/auth/schema.py @@ -11,4 +11,4 @@ class TutorLoginRequest(BaseModel): class TutorLoginResponse(BaseModel): """Tutor login response.""" access_token: str - token_type: str = "bearer" \ No newline at end of file + token_type: str = "bearer" diff --git a/backend/yet_another_calendar/web/api/auth/utils.py b/backend/yet_another_calendar/web/api/auth/utils.py index 66a2051..65c4fa0 100644 --- a/backend/yet_another_calendar/web/api/auth/utils.py +++ b/backend/yet_another_calendar/web/api/auth/utils.py @@ -39,4 +39,4 @@ def verify_tutor_token(credentials: Annotated[HTTPAuthorizationCredentials, Depe status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, - ) from e \ No newline at end of file + ) from e diff --git a/backend/yet_another_calendar/web/api/auth/views.py b/backend/yet_another_calendar/web/api/auth/views.py index 1f0d27d..400ce75 100644 --- a/backend/yet_another_calendar/web/api/auth/views.py +++ b/backend/yet_another_calendar/web/api/auth/views.py @@ -21,12 +21,12 @@ async def tutor_login( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Tutor authentication not configured", ) - + if not utils.verify_password(login_request.password, settings.tutor_password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password", ) - + access_token = utils.create_access_token() - return schema.TutorLoginResponse(access_token=access_token) \ No newline at end of file + return schema.TutorLoginResponse(access_token=access_token) diff --git a/backend/yet_another_calendar/web/api/lms/integration.py b/backend/yet_another_calendar/web/api/lms/integration.py index 0bd040c..2dd04ed 100644 --- a/backend/yet_another_calendar/web/api/lms/integration.py +++ b/backend/yet_another_calendar/web/api/lms/integration.py @@ -17,7 +17,7 @@ def raise_error(serialized_response: dict[str, Any]) -> None: if serialized_response.get('errorcode') == 'invalidtoken': raise HTTPException(detail='Invalid token', status_code=status.HTTP_401_UNAUTHORIZED) - error = serialized_response.get('error') or serialized_response.get('exception') or {} + error = serialized_response.get('error') or serialized_response.get('exception') or {} if error: raise HTTPException(detail=f'{error}. Server response: {serialized_response}', status_code=status.HTTP_400_BAD_REQUEST) diff --git a/backend/yet_another_calendar/web/api/modeus/integration.py b/backend/yet_another_calendar/web/api/modeus/integration.py index fb8c173..b2ae4a0 100644 --- a/backend/yet_another_calendar/web/api/modeus/integration.py +++ b/backend/yet_another_calendar/web/api/modeus/integration.py @@ -174,10 +174,10 @@ async def get_donor_token() -> str: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Donor account credentials not configured", ) - + logger.info("Authenticating donor account") token = await login( - settings.modeus_username, + settings.modeus_username, settings.modeus_password, ) logger.info("Donor account authenticated successfully") diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index e760973..9b0685e 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -311,8 +311,3 @@ def to_search_payload(self) -> dict[str, typing.Any]: "profileName": self.profile_name, "specialtyCode": self.specialty_code, } - - -class ModeusDayEventsRequest(DayEventsRequest): - """Backward-compatibility wrapper kept for external/tests imports.""" - pass diff --git a/backend/yet_another_calendar/web/api/mts/integration.py b/backend/yet_another_calendar/web/api/mts/integration.py index 632877a..dfec647 100644 --- a/backend/yet_another_calendar/web/api/mts/integration.py +++ b/backend/yet_another_calendar/web/api/mts/integration.py @@ -34,10 +34,10 @@ async def get_links(redis_pool: ConnectionPool, lesson_ids: list[uuid.UUID]) -> async with Redis(connection_pool=redis_pool) as redis: keys = [_key(lesson_id) for lesson_id in lesson_ids] urls = await redis.mget(keys) - + result = {} for lesson_id, url in zip(lesson_ids, urls, strict=False): if url: result[str(lesson_id)] = url.decode() - + return result diff --git a/backend/yet_another_calendar/web/api/utmn/__init__.py b/backend/yet_another_calendar/web/api/utmn/__init__.py index 96165b4..c274164 100644 --- a/backend/yet_another_calendar/web/api/utmn/__init__.py +++ b/backend/yet_another_calendar/web/api/utmn/__init__.py @@ -1 +1 @@ -"""UTMN API.""" \ No newline at end of file +"""UTMN API.""" diff --git a/backend/yet_another_calendar/web/api/utmn/integration.py b/backend/yet_another_calendar/web/api/utmn/integration.py index 91c943f..33fcb89 100644 --- a/backend/yet_another_calendar/web/api/utmn/integration.py +++ b/backend/yet_another_calendar/web/api/utmn/integration.py @@ -17,7 +17,7 @@ async def get_teachers_by_page(timeout: int = 30, page: int = 1) -> dict[str, schema.Teacher]: """ Fetch teacher information from UTMN website. - + Returns: Dict[str, Teacher]: Dictionary where keys are teacher names (ФИО) and values are Teacher objects with avatar_profile and profile_url. @@ -25,20 +25,20 @@ async def get_teachers_by_page(timeout: int = 30, page: int = 1) -> dict[str, sc async with AsyncClient(http2=True, base_url=settings.utmn_base_url, timeout=timeout) as client: response = await client.get(settings.utmn_get_teachers_part.format(page=page)) response.raise_for_status() - + soup = BeautifulSoup(response.text, 'html.parser') employees = soup.select('div.item-employer') teachers = {} - + for employee in employees: name_element = employee.select_one('div.b-employer-desc h4') img_element = employee.select_one('div.b-employer-photo img') link_element = employee.select_one('div.b-employer-desc h4 a') - + if not name_element or not img_element or not link_element: continue - + name = name_element.text.strip() avatar = settings.utmn_base_url + str(img_element['src']) url = settings.utmn_base_url + str(link_element['href']) @@ -46,7 +46,7 @@ async def get_teachers_by_page(timeout: int = 30, page: int = 1) -> dict[str, sc avatar_profile=avatar, profile_url=url, ) - + return teachers @cache(expire=settings.redis_utmn_teachers_time_live) @@ -66,7 +66,7 @@ async def get_all_teachers_cached(timeout: int = 30, per_page: int = 5) -> dict[ page += per_page for task in tasks: teachers_from_tasks.update(task.result()) - + if len(teachers_from_tasks) == 0: break teachers.update(teachers_from_tasks) @@ -82,4 +82,4 @@ async def get_all_teachers(timeout: int = 30, per_page: int = 5) -> dict[str, sc return adapter.validate_python(teachers) except Exception as e: logger.exception(f"Error in get_all_teachers: {e}") - return {} \ No newline at end of file + return {} diff --git a/backend/yet_another_calendar/web/api/utmn/schema.py b/backend/yet_another_calendar/web/api/utmn/schema.py index e6ab8a7..40a8e62 100644 --- a/backend/yet_another_calendar/web/api/utmn/schema.py +++ b/backend/yet_another_calendar/web/api/utmn/schema.py @@ -5,6 +5,6 @@ class Teacher(BaseModel): """Teacher information from UTMN website.""" - + avatar_profile: str - profile_url: str \ No newline at end of file + profile_url: str diff --git a/backend/yet_another_calendar/web/application.py b/backend/yet_another_calendar/web/application.py index 51187ee..c7e64b0 100644 --- a/backend/yet_another_calendar/web/application.py +++ b/backend/yet_another_calendar/web/application.py @@ -63,7 +63,7 @@ async def request_error_exception_handler(request: Request, exc: HTTPError) -> R async def request_validation_exception_handler(request: Request, exc: RequestValidationError) -> Response: logger.opt(exception=exc).error(f"Validation error: {exc}. errors: {exc.errors()}") - + try: errors = json.loads(json.dumps(exc.errors())) except TypeError: @@ -90,12 +90,12 @@ def get_app() -> FastAPI: openapi_url="/api/openapi.json", default_response_class=UJSONResponse, ) - + if settings.rollbar_token: init_rollbar(app) configure_logging() - + if settings.debug: origins = ["*"] else: diff --git a/backend/yet_another_calendar/web/lifespan.py b/backend/yet_another_calendar/web/lifespan.py index 1be63bc..13e7056 100644 --- a/backend/yet_another_calendar/web/lifespan.py +++ b/backend/yet_another_calendar/web/lifespan.py @@ -113,4 +113,4 @@ def init_rollbar(app: FastAPI) -> None: # pragma: no cover }, ) app.add_middleware(RollbarMiddleware) - logger.info(f"Rollbar initialized with environment: {settings.rollbar_environment}") \ No newline at end of file + logger.info(f"Rollbar initialized with environment: {settings.rollbar_environment}")