From 87300885dcfdf5ec8fb90a0aa7a6db60ae6c8fc7 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 26 Feb 2025 16:09:21 -0500 Subject: [PATCH 1/3] Move testapp out of tests/ and rename main module --- .secrets.baseline | 52 ++++----- README.md | 7 +- tests/conftest.py => conftest.py | 28 ++++- pyproject.toml | 8 +- .../main}/__init__.py | 0 {tests/testapp => testapp/main}/admin.py | 2 +- {tests/testapp => testapp/main}/factories.py | 2 +- .../testapp => testapp/main}/integration.py | 0 {tests/testapp => testapp/main}/messages.py | 0 .../main}/migrations/0001_initial.py | 4 +- .../migrations/0002_auditabletestmodel.py | 2 +- .../0003_auditabletestmodelaudit.py | 4 +- .../main/migrations}/__init__.py | 0 {tests/testapp => testapp/main}/models.py | 0 .../main/settings}/__init__.py | 0 .../testapp => testapp/main}/settings/dev.py | 8 +- .../main}/settings/example.dev.py | 10 +- .../main}/settings/shared.py | 10 +- .../testapp => testapp/main}/settings/test.py | 6 +- .../main}/templates/mail/partials/_logo.html | 0 .../main}/templates/mail/sample/body.html | 0 .../main}/templates/mail/sample/subject.txt | 0 {tests/testapp => testapp/main}/urls.py | 2 +- {tests/testapp => testapp/main}/utils.py | 0 {tests/testapp => testapp/main}/views.py | 2 +- {tests/testapp => testapp/main}/wsgi.py | 2 +- {tests => testapp}/manage.py | 2 +- .../{mitol/common/templates => }/__init__.py | 0 .../utils => authentication}/__init__.py | 0 .../authentication/test_serializers.py | 0 .../views}/__init__.py | 0 .../authentication/views/test_djoser.py | 0 .../authentication/views/test_saml.py | 0 tests/{mitol/geoip => common}/__init__.py | 0 .../templates}/__init__.py | 0 .../common/templates/test_render_bundle.py | 0 tests/{mitol => }/common/test_apps.py | 8 +- tests/{mitol => }/common/test_decorators.py | 0 tests/{mitol => }/common/test_envs.py | 2 +- tests/{mitol => }/common/test_models.py | 6 +- tests/{mitol => }/common/test_pytest_utils.py | 0 .../utils}/__init__.py | 0 .../common/utils/test_collections.py | 0 .../{mitol => }/common/utils/test_currency.py | 0 .../{mitol => }/common/utils/test_datetime.py | 0 .../common/utils/test_http_requests.py | 0 tests/{mitol => }/common/utils/test_urls.py | 0 .../{mitol => }/common/utils/test_webpack.py | 0 .../__init__.py | 0 .../digitalcredentials/test_backend.py | 2 +- .../digitalcredentials/test_models.py | 6 +- .../digitalcredentials/test_requests_utils.py | 0 .../digitalcredentials/test_serializers.py | 8 +- .../digitalcredentials/test_views.py | 8 +- .../{mitol/hubspot_api => geoip}/__init__.py | 0 tests/{mitol => }/geoip/test_api.py | 0 .../{mitol/mail => google_sheets}/__init__.py | 0 tests/{mitol => }/google_sheets/test_api.py | 0 .../google_sheets/test_sheet_handler_api.py | 0 tests/{mitol => }/google_sheets/test_utils.py | 0 tests/{mitol => }/google_sheets/test_views.py | 2 +- .../__init__.py | 0 .../google_sheets_deferrals/test_api.py | 8 +- .../__init__.py | 0 .../google_sheets_refunds/test_api.py | 8 +- .../olposthog => hubspot_api}/__init__.py | 0 tests/{mitol => }/hubspot_api/test_api.py | 0 .../hubspot_api/test_decorators.py | 0 .../{mitol/openedx/utils => mail}/__init__.py | 0 tests/{mitol => }/mail/test_api.py | 0 tests/{mitol => }/mail/test_defaults.py | 0 tests/{mitol => }/mail/test_messages.py | 0 .../transcoding => mail/testapp}/__init__.py | 0 .../{mitol => }/mail/testapp/test_messages.py | 2 +- tests/{mitol => }/mail/testapp/test_urls.py | 0 .../__init__.py | 0 .../oauth_toolkit_extensions/test_backends.py | 0 .../oauth_toolkit_extensions/test_models.py | 0 tests/{testapp => olposthog}/__init__.py | 0 tests/{mitol => }/olposthog/test_features.py | 0 .../migrations => openedx/utils}/__init__.py | 0 .../{mitol => }/openedx/utils/test_courses.py | 0 .../payment_gateway/api/test_cybersource.py | 17 +-- .../utils/test_payment_utils.py | 0 tests/testapp/README.md | 104 ------------------ .../settings => transcoding}/__init__.py | 0 tests/{mitol => }/transcoding/test_utils.py | 0 tests/uvtestapp/__init__.py | 0 tests/{mitol => }/uvtestapp/test_api.py | 0 uv.lock | 2 +- 90 files changed, 120 insertions(+), 214 deletions(-) rename tests/conftest.py => conftest.py (83%) rename {tests/mitol/authentication => testapp/main}/__init__.py (100%) rename {tests/testapp => testapp/main}/admin.py (80%) rename {tests/testapp => testapp/main}/factories.py (97%) rename {tests/testapp => testapp/main}/integration.py (100%) rename {tests/testapp => testapp/main}/messages.py (100%) rename {tests/testapp => testapp/main}/migrations/0001_initial.py (96%) rename {tests/testapp => testapp/main}/migrations/0002_auditabletestmodel.py (94%) rename {tests/testapp => testapp/main}/migrations/0003_auditabletestmodelaudit.py (93%) rename {tests/mitol/authentication/views => testapp/main/migrations}/__init__.py (100%) rename {tests/testapp => testapp/main}/models.py (100%) rename {tests/mitol/common => testapp/main/settings}/__init__.py (100%) rename {tests/testapp => testapp/main}/settings/dev.py (52%) rename {tests/testapp => testapp/main}/settings/example.dev.py (69%) rename {tests/testapp => testapp/main}/settings/shared.py (97%) rename {tests/testapp => testapp/main}/settings/test.py (78%) rename {tests/testapp => testapp/main}/templates/mail/partials/_logo.html (100%) rename {tests/testapp => testapp/main}/templates/mail/sample/body.html (100%) rename {tests/testapp => testapp/main}/templates/mail/sample/subject.txt (100%) rename {tests/testapp => testapp/main}/urls.py (94%) rename {tests/testapp => testapp/main}/utils.py (100%) rename {tests/testapp => testapp/main}/views.py (91%) rename {tests/testapp => testapp/main}/wsgi.py (81%) rename {tests => testapp}/manage.py (88%) rename tests/{mitol/common/templates => }/__init__.py (100%) rename tests/{mitol/common/utils => authentication}/__init__.py (100%) rename tests/{mitol => }/authentication/test_serializers.py (100%) rename tests/{mitol/digitalcredentials => authentication/views}/__init__.py (100%) rename tests/{mitol => }/authentication/views/test_djoser.py (100%) rename tests/{mitol => }/authentication/views/test_saml.py (100%) rename tests/{mitol/geoip => common}/__init__.py (100%) rename tests/{mitol/google_sheets => common/templates}/__init__.py (100%) rename tests/{mitol => }/common/templates/test_render_bundle.py (100%) rename tests/{mitol => }/common/test_apps.py (87%) rename tests/{mitol => }/common/test_decorators.py (100%) rename tests/{mitol => }/common/test_envs.py (99%) rename tests/{mitol => }/common/test_models.py (99%) rename tests/{mitol => }/common/test_pytest_utils.py (100%) rename tests/{mitol/google_sheets_deferrals => common/utils}/__init__.py (100%) rename tests/{mitol => }/common/utils/test_collections.py (100%) rename tests/{mitol => }/common/utils/test_currency.py (100%) rename tests/{mitol => }/common/utils/test_datetime.py (100%) rename tests/{mitol => }/common/utils/test_http_requests.py (100%) rename tests/{mitol => }/common/utils/test_urls.py (100%) rename tests/{mitol => }/common/utils/test_webpack.py (100%) rename tests/{mitol/google_sheets_refunds => digitalcredentials}/__init__.py (100%) rename tests/{mitol => }/digitalcredentials/test_backend.py (98%) rename tests/{mitol => }/digitalcredentials/test_models.py (83%) rename tests/{mitol => }/digitalcredentials/test_requests_utils.py (100%) rename tests/{mitol => }/digitalcredentials/test_serializers.py (99%) rename tests/{mitol => }/digitalcredentials/test_views.py (99%) rename tests/{mitol/hubspot_api => geoip}/__init__.py (100%) rename tests/{mitol => }/geoip/test_api.py (100%) rename tests/{mitol/mail => google_sheets}/__init__.py (100%) rename tests/{mitol => }/google_sheets/test_api.py (100%) rename tests/{mitol => }/google_sheets/test_sheet_handler_api.py (100%) rename tests/{mitol => }/google_sheets/test_utils.py (100%) rename tests/{mitol => }/google_sheets/test_views.py (98%) rename tests/{mitol/mail/testapp => google_sheets_deferrals}/__init__.py (100%) rename tests/{mitol => }/google_sheets_deferrals/test_api.py (95%) rename tests/{mitol/oauth_toolkit_extensions => google_sheets_refunds}/__init__.py (100%) rename tests/{mitol => }/google_sheets_refunds/test_api.py (95%) rename tests/{mitol/olposthog => hubspot_api}/__init__.py (100%) rename tests/{mitol => }/hubspot_api/test_api.py (100%) rename tests/{mitol => }/hubspot_api/test_decorators.py (100%) rename tests/{mitol/openedx/utils => mail}/__init__.py (100%) rename tests/{mitol => }/mail/test_api.py (100%) rename tests/{mitol => }/mail/test_defaults.py (100%) rename tests/{mitol => }/mail/test_messages.py (100%) rename tests/{mitol/transcoding => mail/testapp}/__init__.py (100%) rename tests/{mitol => }/mail/testapp/test_messages.py (96%) rename tests/{mitol => }/mail/testapp/test_urls.py (100%) rename tests/{mitol/uvtestapp => oauth_toolkit_extensions}/__init__.py (100%) rename tests/{mitol => }/oauth_toolkit_extensions/test_backends.py (100%) rename tests/{mitol => }/oauth_toolkit_extensions/test_models.py (100%) rename tests/{testapp => olposthog}/__init__.py (100%) rename tests/{mitol => }/olposthog/test_features.py (100%) rename tests/{testapp/migrations => openedx/utils}/__init__.py (100%) rename tests/{mitol => }/openedx/utils/test_courses.py (100%) rename tests/{mitol => }/payment_gateway/api/test_cybersource.py (98%) rename tests/{mitol => }/payment_gateway/utils/test_payment_utils.py (100%) delete mode 100644 tests/testapp/README.md rename tests/{testapp/settings => transcoding}/__init__.py (100%) rename tests/{mitol => }/transcoding/test_utils.py (100%) create mode 100644 tests/uvtestapp/__init__.py rename tests/{mitol => }/uvtestapp/test_api.py (100%) diff --git a/.secrets.baseline b/.secrets.baseline index 4dc9c0ef..c863757e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -205,119 +205,119 @@ "line_number": 19 } ], - "tests/conftest.py": [ + "conftest.py": [ { "type": "Secret Keyword", - "filename": "tests/conftest.py", + "filename": "conftest.py", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 120 + "line_number": 123 } ], - "tests/mitol/common/utils/test_urls.py": [ + "tests/common/utils/test_urls.py": [ { "type": "Basic Auth Credentials", - "filename": "tests/mitol/common/utils/test_urls.py", + "filename": "tests/common/utils/test_urls.py", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, "line_number": 18 } ], - "tests/mitol/digitalcredentials/test_backend.py": [ + "tests/digitalcredentials/test_backend.py": [ { "type": "Secret Keyword", - "filename": "tests/mitol/digitalcredentials/test_backend.py", + "filename": "tests/digitalcredentials/test_backend.py", "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_verified": false, "line_number": 150 } ], - "tests/mitol/digitalcredentials/test_requests_utils.py": [ + "tests/digitalcredentials/test_requests_utils.py": [ { "type": "Base64 High Entropy String", - "filename": "tests/mitol/digitalcredentials/test_requests_utils.py", + "filename": "tests/digitalcredentials/test_requests_utils.py", "hashed_secret": "00df9aef8d11911143efbe3abdbd640d3d18cf06", "is_verified": false, "line_number": 60 } ], - "tests/mitol/google_sheets/test_api.py": [ + "tests/google_sheets/test_api.py": [ { "type": "Secret Keyword", - "filename": "tests/mitol/google_sheets/test_api.py", + "filename": "tests/google_sheets/test_api.py", "hashed_secret": "a0281cd072cea8e80e7866b05dc124815760b6c9", "is_verified": false, "line_number": 46 } ], - "tests/mitol/google_sheets/test_utils.py": [ + "tests/google_sheets/test_utils.py": [ { "type": "Secret Keyword", - "filename": "tests/mitol/google_sheets/test_utils.py", + "filename": "tests/google_sheets/test_utils.py", "hashed_secret": "43ed4c2d8375dfc89e3dc8c917f404b9481d355b", "is_verified": false, "line_number": 15 } ], - "tests/mitol/google_sheets/test_views.py": [ + "tests/google_sheets/test_views.py": [ { "type": "Secret Keyword", - "filename": "tests/mitol/google_sheets/test_views.py", + "filename": "tests/google_sheets/test_views.py", "hashed_secret": "43ed4c2d8375dfc89e3dc8c917f404b9481d355b", "is_verified": false, "line_number": 29 } ], - "tests/testapp/settings/dev.py": [ + "testapp/main/settings/dev.py": [ { "type": "Secret Keyword", - "filename": "tests/testapp/settings/dev.py", + "filename": "testapp/main/settings/dev.py", "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_verified": false, "line_number": 17 } ], - "tests/testapp/settings/example.dev.py": [ + "testapp/main/settings/example.dev.py": [ { "type": "Secret Keyword", - "filename": "tests/testapp/settings/example.dev.py", + "filename": "testapp/main/settings/example.dev.py", "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_verified": false, "line_number": 14 } ], - "tests/testapp/settings/shared.py": [ + "testapp/main/settings/shared.py": [ { "type": "Secret Keyword", - "filename": "tests/testapp/settings/shared.py", + "filename": "testapp/main/settings/shared.py", "hashed_secret": "8f2581750096043a1c68bedea8cfa6e13ad1a2e4", "is_verified": false, "line_number": 40 }, { "type": "Basic Auth Credentials", - "filename": "tests/testapp/settings/shared.py", + "filename": "testapp/main/settings/shared.py", "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_verified": false, "line_number": 120 }, { "type": "Secret Keyword", - "filename": "tests/testapp/settings/shared.py", + "filename": "testapp/main/settings/shared.py", "hashed_secret": "9bc34549d565d9505b287de0cd20ac77be1d3f2c", "is_verified": false, "line_number": 200 } ], - "tests/testapp/settings/test.py": [ + "testapp/main/settings/test.py": [ { "type": "Secret Keyword", - "filename": "tests/testapp/settings/test.py", + "filename": "testapp/main/settings/test.py", "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_verified": false, "line_number": 9 } ] }, - "generated_at": "2025-02-25T16:15:37Z" + "generated_at": "2025-02-26T21:06:50Z" } diff --git a/README.md b/README.md index 53b026b2..66c8caff 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ This repository is the home of MIT Open Learning's reusable django apps. This set of libraries is managed using [uv](https://docs.astral.sh/uv/). +### Setup + +To run this app in local development mode, copy `testapp/main/settings/example.dev.py` to `testapp/main/settings/dev.py`. This file has the same defaults as `testapp/main/settings/test.py`, but it is gitignored so you can safely add secrets to it. `manage.py` and `main/wsgi.py` both load `dev.py`. + + #### Use on your host system - Install `xmlsec` native libraries for your OS: https://xmlsec.readthedocs.io/en/stable/install.html @@ -63,7 +68,7 @@ To add a new one, it's easiest to copy one of the existing apps. There's one cal * Under `[tool.uv.sources]`, add a new entry for the new app, using (again) the same format as the other entries. 4. Test building: `uv build --package mitol-django-` . (This ensures that uv is OK with your changes.) 5. Add space for the app in the `tests` app: `mkdir tests/mitol/` and add a blank `__init__.py` to it. -6. Add the app to `testapp/settings/shared.py` +6. Add the app to `testapp/main/settings/shared.py` * You must add it to `INSTALLED_APPS`. * If your app has configuration settings, add to the `import_settings_module` call at the top too. diff --git a/tests/conftest.py b/conftest.py similarity index 83% rename from tests/conftest.py rename to conftest.py index 75951738..62ef0f4a 100644 --- a/tests/conftest.py +++ b/conftest.py @@ -1,5 +1,8 @@ -from datetime import timedelta # noqa: INP001 +import json +from contextlib import contextmanager +from datetime import timedelta from os import environ +from pathlib import Path from types import SimpleNamespace import pytest @@ -119,3 +122,26 @@ def google_sheets_client_creds_settings(settings): settings.MITOL_GOOGLE_SHEETS_DRIVE_CLIENT_ID = "nhijg1i.apps.googleusercontent.com" settings.MITOL_GOOGLE_SHEETS_DRIVE_CLIENT_SECRET = "secret" # noqa: S105 return settings + + +@pytest.fixture(scope="session") +def open_data_fixture_file(): + """Create a fixture that provides a function to load data fixtures""" + + @contextmanager + def _open_data_fixture_file(path): + with Path.open(Path(__file__).parent / "tests/data" / path, "r") as f: + yield f + + return _open_data_fixture_file + + +@pytest.fixture(scope="session") +def load_data_fixture_json(open_data_fixture_file): + """Return a function that will load fixture data as json""" + + def _load_data_fixture_json(path): + with open_data_fixture_file(path) as f: + return json.load(f) + + return _load_data_fixture_json diff --git a/pyproject.toml b/pyproject.toml index 1ed38d63..20b58bf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,13 +97,13 @@ exclude = ["BUILD", "pyproject.toml"] [tool.pytest.ini_options] addopts = "--cov . --cov-report term --cov-report html --cov-report xml --reuse-db" norecursedirs = ".git .tox .* CVS _darcs {arch} *.egg dist" -DJANGO_SETTINGS_MODULE = "testapp.settings.test" +DJANGO_SETTINGS_MODULE = "main.settings.test" # -- recommended but optional: -python_files = ["tests/mitol/**/test_*.py"] -pythonpath = ["src", "tests"] +python_files = ["tests/**/test_*.py"] +pythonpath = ["testapp", "src", "tests"] [tool.django-stubs] -django_settings_module = "testapp.settings.test" +django_settings_module = "main.settings.test" [tool.mypy] namespace_packages = true diff --git a/tests/mitol/authentication/__init__.py b/testapp/main/__init__.py similarity index 100% rename from tests/mitol/authentication/__init__.py rename to testapp/main/__init__.py diff --git a/tests/testapp/admin.py b/testapp/main/admin.py similarity index 80% rename from tests/testapp/admin.py rename to testapp/main/admin.py index 965fb3ac..085ba626 100644 --- a/tests/testapp/admin.py +++ b/testapp/main/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin -from testapp.models import DemoCourseware +from main.models import DemoCourseware @admin.register(DemoCourseware) diff --git a/tests/testapp/factories.py b/testapp/main/factories.py similarity index 97% rename from tests/testapp/factories.py rename to testapp/main/factories.py index eaadcf0d..5d1bad5c 100644 --- a/tests/testapp/factories.py +++ b/testapp/main/factories.py @@ -13,7 +13,7 @@ ) from mitol.payment_gateway.api import CartItem, Order, Refund -from testapp.models import DemoCourseware +from main.models import DemoCourseware FAKE = faker.Factory.create() diff --git a/tests/testapp/integration.py b/testapp/main/integration.py similarity index 100% rename from tests/testapp/integration.py rename to testapp/main/integration.py diff --git a/tests/testapp/messages.py b/testapp/main/messages.py similarity index 100% rename from tests/testapp/messages.py rename to testapp/main/messages.py diff --git a/tests/testapp/migrations/0001_initial.py b/testapp/main/migrations/0001_initial.py similarity index 96% rename from tests/testapp/migrations/0001_initial.py rename to testapp/main/migrations/0001_initial.py index 56ddfa8b..bbc8c6c4 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/testapp/main/migrations/0001_initial.py @@ -95,7 +95,7 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("second_levels", models.ManyToManyField(to="testapp.SecondLevel2")), + ("second_levels", models.ManyToManyField(to="main.SecondLevel2")), ], ), migrations.CreateModel( @@ -114,7 +114,7 @@ class Migration(migrations.Migration): "second_level", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to="testapp.SecondLevel1", + to="main.SecondLevel1", ), ), ], diff --git a/tests/testapp/migrations/0002_auditabletestmodel.py b/testapp/main/migrations/0002_auditabletestmodel.py similarity index 94% rename from tests/testapp/migrations/0002_auditabletestmodel.py rename to testapp/main/migrations/0002_auditabletestmodel.py index 43e7842c..e3b3c32d 100644 --- a/tests/testapp/migrations/0002_auditabletestmodel.py +++ b/testapp/main/migrations/0002_auditabletestmodel.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("testapp", "0001_initial"), + ("main", "0001_initial"), ] operations = [ diff --git a/tests/testapp/migrations/0003_auditabletestmodelaudit.py b/testapp/main/migrations/0003_auditabletestmodelaudit.py similarity index 93% rename from tests/testapp/migrations/0003_auditabletestmodelaudit.py rename to testapp/main/migrations/0003_auditabletestmodelaudit.py index 7ceb40c1..aff20d74 100644 --- a/tests/testapp/migrations/0003_auditabletestmodelaudit.py +++ b/testapp/main/migrations/0003_auditabletestmodelaudit.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("testapp", "0002_auditabletestmodel"), + ("main", "0002_auditabletestmodel"), ] operations = [ @@ -41,7 +41,7 @@ class Migration(migrations.Migration): models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, - to="testapp.auditabletestmodel", + to="main.auditabletestmodel", ), ), ], diff --git a/tests/mitol/authentication/views/__init__.py b/testapp/main/migrations/__init__.py similarity index 100% rename from tests/mitol/authentication/views/__init__.py rename to testapp/main/migrations/__init__.py diff --git a/tests/testapp/models.py b/testapp/main/models.py similarity index 100% rename from tests/testapp/models.py rename to testapp/main/models.py diff --git a/tests/mitol/common/__init__.py b/testapp/main/settings/__init__.py similarity index 100% rename from tests/mitol/common/__init__.py rename to testapp/main/settings/__init__.py diff --git a/tests/testapp/settings/dev.py b/testapp/main/settings/dev.py similarity index 52% rename from tests/testapp/settings/dev.py rename to testapp/main/settings/dev.py index 75db7741..c1813e5f 100644 --- a/tests/testapp/settings/dev.py +++ b/testapp/main/settings/dev.py @@ -1,17 +1,17 @@ """ Dev-only settings -Copy this file to testapp/settings/dev.py. DO NOT RENAME! +Copy this file to main/settings/dev.py. DO NOT RENAME! -You can then modify the copied file, testapp/settings/dev.py is gitignored. +You can then modify the copied file, main/settings/dev.py is gitignored. """ from mitol.common.envs import import_settings_modules -import_settings_modules("testapp.settings.shared") +import_settings_modules("main.settings.shared") MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL = "http://localhost:5000/" MITOL_DIGITAL_CREDENTIALS_BUILD_CREDENTIAL_FUNC = ( - "mitol.digitalcredentials.testapp.integration.build_credential" + "mitol.digitalcredentials.main.integration.build_credential" ) MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET = "abc123" # noqa: S105 diff --git a/tests/testapp/settings/example.dev.py b/testapp/main/settings/example.dev.py similarity index 69% rename from tests/testapp/settings/example.dev.py rename to testapp/main/settings/example.dev.py index 6749d60f..898a9eb1 100644 --- a/tests/testapp/settings/example.dev.py +++ b/testapp/main/settings/example.dev.py @@ -1,21 +1,21 @@ """ Dev-only settings -Copy this file to testapp/settings/dev.py. DO NOT RENAME! +Copy this file to main/settings/dev.py. DO NOT RENAME! -You can then modify the copied file, testapp/settings/dev.py is gitignored. +You can then modify the copied file, main/settings/dev.py is gitignored. """ from mitol.common.envs import import_settings_modules -import_settings_modules("testapp.settings.shared") +import_settings_modules("main.settings.shared") MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL = "http://localhost:5000/" -MITOL_DIGITAL_CREDENTIALS_BUILD_CREDENTIAL_FUNC = "testapp.integration.build_credential" +MITOL_DIGITAL_CREDENTIALS_BUILD_CREDENTIAL_FUNC = "main.integration.build_credential" MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET = "abc123" # noqa: S105 # mail app settings, customize for local development -MITOL_MAIL_MESSAGE_CLASSES = ["testapp.messages.SampleMessage"] +MITOL_MAIL_MESSAGE_CLASSES = ["main.messages.SampleMessage"] MITOL_MAIL_FROM_EMAIL = "invalid@localhost" MITOL_MAIL_REPLY_TO_ADDRESS = "invalid@localhost" diff --git a/tests/testapp/settings/shared.py b/testapp/main/settings/shared.py similarity index 97% rename from tests/testapp/settings/shared.py rename to testapp/main/settings/shared.py index ed774d44..e9d9e34a 100644 --- a/tests/testapp/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -1,5 +1,5 @@ """ -Django settings for testapp project. +Django settings for main project. Generated by 'django-admin startproject' using Django 2.2.15. @@ -73,7 +73,7 @@ "mitol.olposthog.apps.OlPosthog", "mitol.transcoding.apps.Transcoding", # test app, integrates the reusable apps - "testapp", + "main", ] MIDDLEWARE = [ @@ -86,12 +86,12 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "testapp.urls" +ROOT_URLCONF = "main.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR + "/testapp/templates/"], + "DIRS": [BASE_DIR + "/main/templates/"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -104,7 +104,7 @@ }, ] -WSGI_APPLICATION = "testapp.wsgi.application" +WSGI_APPLICATION = "main.wsgi.application" REST_FRAMEWORK = {"TEST_REQUEST_DEFAULT_FORMAT": "json"} diff --git a/tests/testapp/settings/test.py b/testapp/main/settings/test.py similarity index 78% rename from tests/testapp/settings/test.py rename to testapp/main/settings/test.py index c6f9a4ba..eda86173 100644 --- a/tests/testapp/settings/test.py +++ b/testapp/main/settings/test.py @@ -2,14 +2,14 @@ from mitol.common.envs import import_settings_modules -import_settings_modules("testapp.settings.shared") +import_settings_modules("main.settings.shared") MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL = "http://localhost:5000/" -MITOL_DIGITAL_CREDENTIALS_BUILD_CREDENTIAL_FUNC = "testapp.integration.build_credential" +MITOL_DIGITAL_CREDENTIALS_BUILD_CREDENTIAL_FUNC = "main.integration.build_credential" MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET = "abc123" # noqa: S105 # mail app settings -MITOL_MAIL_MESSAGE_CLASSES = ["testapp.messages.SampleMessage"] +MITOL_MAIL_MESSAGE_CLASSES = ["main.messages.SampleMessage"] MITOL_MAIL_FROM_EMAIL = "invalid@localhost" MITOL_MAIL_REPLY_TO_ADDRESS = "invalid@localhost" diff --git a/tests/testapp/templates/mail/partials/_logo.html b/testapp/main/templates/mail/partials/_logo.html similarity index 100% rename from tests/testapp/templates/mail/partials/_logo.html rename to testapp/main/templates/mail/partials/_logo.html diff --git a/tests/testapp/templates/mail/sample/body.html b/testapp/main/templates/mail/sample/body.html similarity index 100% rename from tests/testapp/templates/mail/sample/body.html rename to testapp/main/templates/mail/sample/body.html diff --git a/tests/testapp/templates/mail/sample/subject.txt b/testapp/main/templates/mail/sample/subject.txt similarity index 100% rename from tests/testapp/templates/mail/sample/subject.txt rename to testapp/main/templates/mail/sample/subject.txt diff --git a/tests/testapp/urls.py b/testapp/main/urls.py similarity index 94% rename from tests/testapp/urls.py rename to testapp/main/urls.py index e064a564..f25b48fc 100644 --- a/tests/testapp/urls.py +++ b/testapp/main/urls.py @@ -5,7 +5,7 @@ from oauth2_provider.urls import base_urlpatterns from rest_framework import routers -from testapp.views import DemoCoursewareViewSet +from main.views import DemoCoursewareViewSet router = routers.SimpleRouter() router.register(r"democourseware", DemoCoursewareViewSet) diff --git a/tests/testapp/utils.py b/testapp/main/utils.py similarity index 100% rename from tests/testapp/utils.py rename to testapp/main/utils.py diff --git a/tests/testapp/views.py b/testapp/main/views.py similarity index 91% rename from tests/testapp/views.py rename to testapp/main/views.py index 83b7c16f..3f830e08 100644 --- a/tests/testapp/views.py +++ b/testapp/main/views.py @@ -3,7 +3,7 @@ from mitol.digitalcredentials.mixins import DigitalCredentialsRequestViewSetMixin from rest_framework.viewsets import ModelViewSet -from testapp.models import DemoCourseware +from main.models import DemoCourseware class DemoCoursewareViewSet(ModelViewSet, DigitalCredentialsRequestViewSetMixin): diff --git a/tests/testapp/wsgi.py b/testapp/main/wsgi.py similarity index 81% rename from tests/testapp/wsgi.py rename to testapp/main/wsgi.py index 4c7beec6..ac839e70 100644 --- a/tests/testapp/wsgi.py +++ b/testapp/main/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings.dev") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings.dev") application = get_wsgi_application() diff --git a/tests/manage.py b/testapp/manage.py similarity index 88% rename from tests/manage.py rename to testapp/manage.py index 02c55a65..c98ee921 100755 --- a/tests/manage.py +++ b/testapp/manage.py @@ -6,7 +6,7 @@ def main() -> None: - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings.dev") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "main.settings.dev") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/tests/mitol/common/templates/__init__.py b/tests/__init__.py similarity index 100% rename from tests/mitol/common/templates/__init__.py rename to tests/__init__.py diff --git a/tests/mitol/common/utils/__init__.py b/tests/authentication/__init__.py similarity index 100% rename from tests/mitol/common/utils/__init__.py rename to tests/authentication/__init__.py diff --git a/tests/mitol/authentication/test_serializers.py b/tests/authentication/test_serializers.py similarity index 100% rename from tests/mitol/authentication/test_serializers.py rename to tests/authentication/test_serializers.py diff --git a/tests/mitol/digitalcredentials/__init__.py b/tests/authentication/views/__init__.py similarity index 100% rename from tests/mitol/digitalcredentials/__init__.py rename to tests/authentication/views/__init__.py diff --git a/tests/mitol/authentication/views/test_djoser.py b/tests/authentication/views/test_djoser.py similarity index 100% rename from tests/mitol/authentication/views/test_djoser.py rename to tests/authentication/views/test_djoser.py diff --git a/tests/mitol/authentication/views/test_saml.py b/tests/authentication/views/test_saml.py similarity index 100% rename from tests/mitol/authentication/views/test_saml.py rename to tests/authentication/views/test_saml.py diff --git a/tests/mitol/geoip/__init__.py b/tests/common/__init__.py similarity index 100% rename from tests/mitol/geoip/__init__.py rename to tests/common/__init__.py diff --git a/tests/mitol/google_sheets/__init__.py b/tests/common/templates/__init__.py similarity index 100% rename from tests/mitol/google_sheets/__init__.py rename to tests/common/templates/__init__.py diff --git a/tests/mitol/common/templates/test_render_bundle.py b/tests/common/templates/test_render_bundle.py similarity index 100% rename from tests/mitol/common/templates/test_render_bundle.py rename to tests/common/templates/test_render_bundle.py diff --git a/tests/mitol/common/test_apps.py b/tests/common/test_apps.py similarity index 87% rename from tests/mitol/common/test_apps.py rename to tests/common/test_apps.py index ff6a6357..7caa6124 100644 --- a/tests/mitol/common/test_apps.py +++ b/tests/common/test_apps.py @@ -1,7 +1,7 @@ """Tests for apps""" +import main import pytest -import testapp from django.core.exceptions import ImproperlyConfigured from mitol.common.apps import BaseApp @@ -13,7 +13,7 @@ class NoRequiredSettingsApp(BaseApp): """Test app that requires no settings""" # no error is raised - app = NoRequiredSettingsApp("test", testapp) + app = NoRequiredSettingsApp("test", main) app.ready() @@ -28,7 +28,7 @@ class RequiredSettingsApp(BaseApp): settings.TEST_SETTING = True # no error is raised - app = RequiredSettingsApp("test", testapp) + app = RequiredSettingsApp("test", main) app.ready() @@ -42,6 +42,6 @@ class RequiredSettingsApp(BaseApp): settings.TEST_SETTING = True - app = RequiredSettingsApp("test", testapp) + app = RequiredSettingsApp("test", main) with pytest.raises(ImproperlyConfigured): app.ready() diff --git a/tests/mitol/common/test_decorators.py b/tests/common/test_decorators.py similarity index 100% rename from tests/mitol/common/test_decorators.py rename to tests/common/test_decorators.py diff --git a/tests/mitol/common/test_envs.py b/tests/common/test_envs.py similarity index 99% rename from tests/mitol/common/test_envs.py rename to tests/common/test_envs.py index e0fc2d8c..a616a233 100644 --- a/tests/mitol/common/test_envs.py +++ b/tests/common/test_envs.py @@ -23,7 +23,7 @@ "FEATURE_DEF": "False", "FLAG_GHI": "False", # this here to ensure the envs.reload() works - "DJANGO_SETTINGS_MODULE": "testapp.settings.test", + "DJANGO_SETTINGS_MODULE": "main.settings.test", "CRONTAB_BLANK": " ", "CRONTAB_EMPTY": "", "CRONTAB_VALID": "* * * * *", diff --git a/tests/mitol/common/test_models.py b/tests/common/test_models.py similarity index 99% rename from tests/mitol/common/test_models.py rename to tests/common/test_models.py index 9aeb724b..1b7abd4f 100644 --- a/tests/mitol/common/test_models.py +++ b/tests/common/test_models.py @@ -6,9 +6,7 @@ import pytest import pytz from freezegun import freeze_time -from mitol.common.factories import UserFactory -from mitol.common.utils.serializers import serialize_model_object -from testapp.models import ( +from main.models import ( AuditableTestModel, AuditableTestModelAudit, FirstLevel1, @@ -18,6 +16,8 @@ SecondLevel2, Updateable, ) +from mitol.common.factories import UserFactory +from mitol.common.utils.serializers import serialize_model_object pytestmark = pytest.mark.django_db diff --git a/tests/mitol/common/test_pytest_utils.py b/tests/common/test_pytest_utils.py similarity index 100% rename from tests/mitol/common/test_pytest_utils.py rename to tests/common/test_pytest_utils.py diff --git a/tests/mitol/google_sheets_deferrals/__init__.py b/tests/common/utils/__init__.py similarity index 100% rename from tests/mitol/google_sheets_deferrals/__init__.py rename to tests/common/utils/__init__.py diff --git a/tests/mitol/common/utils/test_collections.py b/tests/common/utils/test_collections.py similarity index 100% rename from tests/mitol/common/utils/test_collections.py rename to tests/common/utils/test_collections.py diff --git a/tests/mitol/common/utils/test_currency.py b/tests/common/utils/test_currency.py similarity index 100% rename from tests/mitol/common/utils/test_currency.py rename to tests/common/utils/test_currency.py diff --git a/tests/mitol/common/utils/test_datetime.py b/tests/common/utils/test_datetime.py similarity index 100% rename from tests/mitol/common/utils/test_datetime.py rename to tests/common/utils/test_datetime.py diff --git a/tests/mitol/common/utils/test_http_requests.py b/tests/common/utils/test_http_requests.py similarity index 100% rename from tests/mitol/common/utils/test_http_requests.py rename to tests/common/utils/test_http_requests.py diff --git a/tests/mitol/common/utils/test_urls.py b/tests/common/utils/test_urls.py similarity index 100% rename from tests/mitol/common/utils/test_urls.py rename to tests/common/utils/test_urls.py diff --git a/tests/mitol/common/utils/test_webpack.py b/tests/common/utils/test_webpack.py similarity index 100% rename from tests/mitol/common/utils/test_webpack.py rename to tests/common/utils/test_webpack.py diff --git a/tests/mitol/google_sheets_refunds/__init__.py b/tests/digitalcredentials/__init__.py similarity index 100% rename from tests/mitol/google_sheets_refunds/__init__.py rename to tests/digitalcredentials/__init__.py diff --git a/tests/mitol/digitalcredentials/test_backend.py b/tests/digitalcredentials/test_backend.py similarity index 98% rename from tests/mitol/digitalcredentials/test_backend.py rename to tests/digitalcredentials/test_backend.py index 42121eec..534a7a92 100644 --- a/tests/mitol/digitalcredentials/test_backend.py +++ b/tests/digitalcredentials/test_backend.py @@ -7,6 +7,7 @@ import responses from django.core.exceptions import ImproperlyConfigured from django.urls import reverse +from main.factories import DemoCoursewareDigitalCredentialRequestFactory from mitol.digitalcredentials.backend import ( build_api_url, build_credential, @@ -14,7 +15,6 @@ issue_credential, verify_presentations, ) -from testapp.factories import DemoCoursewareDigitalCredentialRequestFactory def test_build_api_url(): diff --git a/tests/mitol/digitalcredentials/test_models.py b/tests/digitalcredentials/test_models.py similarity index 83% rename from tests/mitol/digitalcredentials/test_models.py rename to tests/digitalcredentials/test_models.py index 48e07d89..913eaf89 100644 --- a/tests/mitol/digitalcredentials/test_models.py +++ b/tests/digitalcredentials/test_models.py @@ -1,15 +1,13 @@ """Models tests""" -# Models require an installed app, so most of the tests are instead in: -# mitol-django-digitalcredentials/testapp/models_test.py from hashlib import sha256 import pytest -from mitol.digitalcredentials.factories import LearnerDIDFactory -from testapp.factories import ( +from main.factories import ( DemoCoursewareDigitalCredentialFactory, DemoCoursewareDigitalCredentialRequestFactory, ) +from mitol.digitalcredentials.factories import LearnerDIDFactory pytestmark = pytest.mark.django_db diff --git a/tests/mitol/digitalcredentials/test_requests_utils.py b/tests/digitalcredentials/test_requests_utils.py similarity index 100% rename from tests/mitol/digitalcredentials/test_requests_utils.py rename to tests/digitalcredentials/test_requests_utils.py diff --git a/tests/mitol/digitalcredentials/test_serializers.py b/tests/digitalcredentials/test_serializers.py similarity index 99% rename from tests/mitol/digitalcredentials/test_serializers.py rename to tests/digitalcredentials/test_serializers.py index f9e6fffa..ef52677e 100644 --- a/tests/mitol/digitalcredentials/test_serializers.py +++ b/tests/digitalcredentials/test_serializers.py @@ -5,6 +5,10 @@ import pytest import responses from django.contrib.contenttypes.models import ContentType +from main.factories import ( + DemoCoursewareDigitalCredentialFactory, + DemoCoursewareDigitalCredentialRequestFactory, +) from mitol.digitalcredentials.factories import LearnerDIDFactory from mitol.digitalcredentials.models import ( DigitalCredential, @@ -15,10 +19,6 @@ DigitalCredentialIssueSerializer, DigitalCredentialRequestSerializer, ) -from testapp.factories import ( - DemoCoursewareDigitalCredentialFactory, - DemoCoursewareDigitalCredentialRequestFactory, -) pytestmark = pytest.mark.django_db diff --git a/tests/mitol/digitalcredentials/test_views.py b/tests/digitalcredentials/test_views.py similarity index 99% rename from tests/mitol/digitalcredentials/test_views.py rename to tests/digitalcredentials/test_views.py index 673a5264..0ae9c3c3 100644 --- a/tests/mitol/digitalcredentials/test_views.py +++ b/tests/digitalcredentials/test_views.py @@ -5,13 +5,13 @@ import pytest import responses from django.urls import reverse -from mitol.digitalcredentials.backend import create_deep_link_url -from mitol.digitalcredentials.models import DigitalCredentialRequest -from rest_framework.test import APIClient -from testapp.factories import ( +from main.factories import ( DemoCoursewareDigitalCredentialRequestFactory, DemoCoursewareFactory, ) +from mitol.digitalcredentials.backend import create_deep_link_url +from mitol.digitalcredentials.models import DigitalCredentialRequest +from rest_framework.test import APIClient pytestmark = pytest.mark.django_db diff --git a/tests/mitol/hubspot_api/__init__.py b/tests/geoip/__init__.py similarity index 100% rename from tests/mitol/hubspot_api/__init__.py rename to tests/geoip/__init__.py diff --git a/tests/mitol/geoip/test_api.py b/tests/geoip/test_api.py similarity index 100% rename from tests/mitol/geoip/test_api.py rename to tests/geoip/test_api.py diff --git a/tests/mitol/mail/__init__.py b/tests/google_sheets/__init__.py similarity index 100% rename from tests/mitol/mail/__init__.py rename to tests/google_sheets/__init__.py diff --git a/tests/mitol/google_sheets/test_api.py b/tests/google_sheets/test_api.py similarity index 100% rename from tests/mitol/google_sheets/test_api.py rename to tests/google_sheets/test_api.py diff --git a/tests/mitol/google_sheets/test_sheet_handler_api.py b/tests/google_sheets/test_sheet_handler_api.py similarity index 100% rename from tests/mitol/google_sheets/test_sheet_handler_api.py rename to tests/google_sheets/test_sheet_handler_api.py diff --git a/tests/mitol/google_sheets/test_utils.py b/tests/google_sheets/test_utils.py similarity index 100% rename from tests/mitol/google_sheets/test_utils.py rename to tests/google_sheets/test_utils.py diff --git a/tests/mitol/google_sheets/test_views.py b/tests/google_sheets/test_views.py similarity index 98% rename from tests/mitol/google_sheets/test_views.py rename to tests/google_sheets/test_views.py index 792cc73d..11137e5b 100644 --- a/tests/mitol/google_sheets/test_views.py +++ b/tests/google_sheets/test_views.py @@ -3,12 +3,12 @@ import pytest from django.test.client import RequestFactory from django.urls import reverse +from main.utils import set_request_session from mitol.google_sheets.factories import GoogleApiAuthFactory from mitol.google_sheets.models import GoogleApiAuth from mitol.google_sheets.views import complete_google_auth from pytest_lazy_fixtures import lf as lazy_fixture from rest_framework import status -from testapp.utils import set_request_session lazy = lazy_fixture diff --git a/tests/mitol/mail/testapp/__init__.py b/tests/google_sheets_deferrals/__init__.py similarity index 100% rename from tests/mitol/mail/testapp/__init__.py rename to tests/google_sheets_deferrals/__init__.py diff --git a/tests/mitol/google_sheets_deferrals/test_api.py b/tests/google_sheets_deferrals/test_api.py similarity index 95% rename from tests/mitol/google_sheets_deferrals/test_api.py rename to tests/google_sheets_deferrals/test_api.py index 7cfb5abe..f519fa11 100644 --- a/tests/mitol/google_sheets_deferrals/test_api.py +++ b/tests/google_sheets_deferrals/test_api.py @@ -1,6 +1,5 @@ """Deferral request API tests""" -import os from types import SimpleNamespace import pytest @@ -15,12 +14,9 @@ @pytest.fixture() -def request_csv_rows(settings): +def request_csv_rows(open_data_fixture_file): """Fake deferral request spreadsheet data rows (loaded from CSV)""" - fake_request_csv_filepath = os.path.join( # noqa: PTH118 - settings.BASE_DIR, "data/google_sheets_deferrals/deferral_requests.csv" - ) - with open(fake_request_csv_filepath) as f: # noqa: PTH123 + with open_data_fixture_file("google_sheets_deferrals/deferral_requests.csv") as f: # Return all rows except for the header return [line.split(",") for i, line in enumerate(f.readlines()) if i > 0] diff --git a/tests/mitol/oauth_toolkit_extensions/__init__.py b/tests/google_sheets_refunds/__init__.py similarity index 100% rename from tests/mitol/oauth_toolkit_extensions/__init__.py rename to tests/google_sheets_refunds/__init__.py diff --git a/tests/mitol/google_sheets_refunds/test_api.py b/tests/google_sheets_refunds/test_api.py similarity index 95% rename from tests/mitol/google_sheets_refunds/test_api.py rename to tests/google_sheets_refunds/test_api.py index 798beb1e..fce51080 100644 --- a/tests/mitol/google_sheets_refunds/test_api.py +++ b/tests/google_sheets_refunds/test_api.py @@ -1,6 +1,5 @@ """Refund request API tests""" -import os from types import SimpleNamespace import pytest @@ -15,12 +14,9 @@ @pytest.fixture() -def request_csv_rows(settings): +def request_csv_rows(open_data_fixture_file): """Fake refund request spreadsheet data rows (loaded from CSV)""" - fake_request_csv_filepath = os.path.join( # noqa: PTH118 - settings.BASE_DIR, "data/google_sheets_refunds/refund_requests.csv" - ) - with open(fake_request_csv_filepath) as f: # noqa: PTH123 + with open_data_fixture_file("google_sheets_refunds/refund_requests.csv") as f: # Return all rows except for the header return [line.split(",") for i, line in enumerate(f.readlines()) if i > 0] diff --git a/tests/mitol/olposthog/__init__.py b/tests/hubspot_api/__init__.py similarity index 100% rename from tests/mitol/olposthog/__init__.py rename to tests/hubspot_api/__init__.py diff --git a/tests/mitol/hubspot_api/test_api.py b/tests/hubspot_api/test_api.py similarity index 100% rename from tests/mitol/hubspot_api/test_api.py rename to tests/hubspot_api/test_api.py diff --git a/tests/mitol/hubspot_api/test_decorators.py b/tests/hubspot_api/test_decorators.py similarity index 100% rename from tests/mitol/hubspot_api/test_decorators.py rename to tests/hubspot_api/test_decorators.py diff --git a/tests/mitol/openedx/utils/__init__.py b/tests/mail/__init__.py similarity index 100% rename from tests/mitol/openedx/utils/__init__.py rename to tests/mail/__init__.py diff --git a/tests/mitol/mail/test_api.py b/tests/mail/test_api.py similarity index 100% rename from tests/mitol/mail/test_api.py rename to tests/mail/test_api.py diff --git a/tests/mitol/mail/test_defaults.py b/tests/mail/test_defaults.py similarity index 100% rename from tests/mitol/mail/test_defaults.py rename to tests/mail/test_defaults.py diff --git a/tests/mitol/mail/test_messages.py b/tests/mail/test_messages.py similarity index 100% rename from tests/mitol/mail/test_messages.py rename to tests/mail/test_messages.py diff --git a/tests/mitol/transcoding/__init__.py b/tests/mail/testapp/__init__.py similarity index 100% rename from tests/mitol/transcoding/__init__.py rename to tests/mail/testapp/__init__.py diff --git a/tests/mitol/mail/testapp/test_messages.py b/tests/mail/testapp/test_messages.py similarity index 96% rename from tests/mitol/mail/testapp/test_messages.py rename to tests/mail/testapp/test_messages.py index 008002e3..14d2d25a 100644 --- a/tests/mitol/mail/testapp/test_messages.py +++ b/tests/mail/testapp/test_messages.py @@ -1,6 +1,6 @@ """Messages tests""" -from testapp.messages import SampleMessage +from main.messages import SampleMessage def test_create_message_subclass(mocker): diff --git a/tests/mitol/mail/testapp/test_urls.py b/tests/mail/testapp/test_urls.py similarity index 100% rename from tests/mitol/mail/testapp/test_urls.py rename to tests/mail/testapp/test_urls.py diff --git a/tests/mitol/uvtestapp/__init__.py b/tests/oauth_toolkit_extensions/__init__.py similarity index 100% rename from tests/mitol/uvtestapp/__init__.py rename to tests/oauth_toolkit_extensions/__init__.py diff --git a/tests/mitol/oauth_toolkit_extensions/test_backends.py b/tests/oauth_toolkit_extensions/test_backends.py similarity index 100% rename from tests/mitol/oauth_toolkit_extensions/test_backends.py rename to tests/oauth_toolkit_extensions/test_backends.py diff --git a/tests/mitol/oauth_toolkit_extensions/test_models.py b/tests/oauth_toolkit_extensions/test_models.py similarity index 100% rename from tests/mitol/oauth_toolkit_extensions/test_models.py rename to tests/oauth_toolkit_extensions/test_models.py diff --git a/tests/testapp/__init__.py b/tests/olposthog/__init__.py similarity index 100% rename from tests/testapp/__init__.py rename to tests/olposthog/__init__.py diff --git a/tests/mitol/olposthog/test_features.py b/tests/olposthog/test_features.py similarity index 100% rename from tests/mitol/olposthog/test_features.py rename to tests/olposthog/test_features.py diff --git a/tests/testapp/migrations/__init__.py b/tests/openedx/utils/__init__.py similarity index 100% rename from tests/testapp/migrations/__init__.py rename to tests/openedx/utils/__init__.py diff --git a/tests/mitol/openedx/utils/test_courses.py b/tests/openedx/utils/test_courses.py similarity index 100% rename from tests/mitol/openedx/utils/test_courses.py rename to tests/openedx/utils/test_courses.py diff --git a/tests/mitol/payment_gateway/api/test_cybersource.py b/tests/payment_gateway/api/test_cybersource.py similarity index 98% rename from tests/mitol/payment_gateway/api/test_cybersource.py rename to tests/payment_gateway/api/test_cybersource.py index 0c014437..d96867ce 100644 --- a/tests/mitol/payment_gateway/api/test_cybersource.py +++ b/tests/payment_gateway/api/test_cybersource.py @@ -1,6 +1,5 @@ import hashlib # noqa: INP001 import json -import os import random from collections import namedtuple from dataclasses import dataclass @@ -12,6 +11,7 @@ from CyberSource.rest import ApiException from django.conf import settings from factory import fuzzy +from main.factories import CartItemFactory, OrderFactory, RefundFactory from mitol.common.utils.datetime import now_in_utc from mitol.payment_gateway.api import ( CyberSourcePaymentGateway, @@ -23,7 +23,6 @@ InvalidTransactionException, RefundDuplicateException, ) -from testapp.factories import CartItemFactory, OrderFactory, RefundFactory from urllib3.response import HTTPResponse ISO_8601_FORMAT = "%Y-%m-%dT%H:%M:%SZ" @@ -45,19 +44,9 @@ def cartitems(): @pytest.fixture() -def response_payload(request): +def response_payload(request, load_data_fixture_json): """Fixture to return dictionary of a specific JSON file with provided name in request param""" # noqa: E501 - - with open( # noqa: PTH123 - os.path.join( # noqa: PTH118 - os.getcwd(), # noqa: PTH109 - "tests/data/payment_gateway/api", - f"{request.param}.json", - ), - ) as f: - response_txt = f.read() - response_json = json.loads(response_txt) - yield response_json + return load_data_fixture_json(f"payment_gateway/api/{request.param}.json") @dataclass diff --git a/tests/mitol/payment_gateway/utils/test_payment_utils.py b/tests/payment_gateway/utils/test_payment_utils.py similarity index 100% rename from tests/mitol/payment_gateway/utils/test_payment_utils.py rename to tests/payment_gateway/utils/test_payment_utils.py diff --git a/tests/testapp/README.md b/tests/testapp/README.md deleted file mode 100644 index 594e4fe9..00000000 --- a/tests/testapp/README.md +++ /dev/null @@ -1,104 +0,0 @@ -`mitol-django-digital-credentials` test app ---- - -### Setup - -To run this app in local development mode, copy `testapp/settings/example.dev.py` to `testapp/settings/dev.py`. This file has the same defaults as `testapp/settings/test.py`, but it is gitignored so you can safely add secrets to it. `manage.py` and `testapp/wsgi.py` both load `dev.py`. - -### Usage - -#### Digital Credentials - -Ensure the [`sign-and-verify`](https://github.com/digitalcredentials/sign-and-verify) service is running locally. - -You can test this app out by running: - -- `./pants django-run tests:manage -- migrate` - migrates sqlite db (gitignored) -- `/pants django-run tests:manage -- runserver` -- `/pants django-run testsmanage -- shell`: - -```python -import json -import requests -from datetime import timedelta -from oauth2_provider.models import AccessToken, get_application_model -from django.contrib.auth.models import User -from mitol.common.utils import now_in_utc -from testapp.factories import DemoCoursewareDigitalCredentialRequestFactory - -Application = get_application_model() - -### Create a user -learner, _ = User.objects.get_or_create( - username="myuser", - defaults=dict(email="myuser@example.com") -) - -# create oauth2 app and access token -application, _ = Application.objects.get_or_create( - name="Test Application", - defaults=dict( - redirect_uris="http://localhost", - user=learner, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - ) -) -access_token, _ = AccessToken.objects.update_or_create( - user=learner, - token="1234567890", - application=application, - defaults=dict( - expires=now_in_utc() + timedelta(days=1), - scope="read write digitalcredentials", - ) -) - -request = DemoCoursewareDigitalCredentialRequestFactory.create(learner=learner) - -did = "did:example:456" -response = requests.post( - "http://localhost:5000/generate/controlproof", - json={ - "presentationId": did, - "holder": "did:web:digitalcredentials.github.io", - "verificationMethod": "did:web:digitalcredentials.github.io#96K4BSIWAkhcclKssb8yTWMQSz4QzPWBy-JsAFlwoIs", - "challenge": str(request.uuid) - } -) -data = json.dumps(response.json()) -print(f"""curl \ - -H 'Authorization: Bearer {access_token.token}' \ - -H 'Accept: application/json' \ - -H 'Content-Type: application/json' \ - --data '{data}' \ - http://localhost:8000/api/credentials/request/{request.uuid}/""") -``` - -- Copy/paste and run curl command printed above - - -#### Mail - -You can test this app out by running: - -- `./pants django-run manage -- migrate` - migrates sqlite db (gitignored) -- `./pants django-run manage -- shell`: - -```python -### Create a user -from django.contrib.auth import get_user_model -User = get_user_model() -user = User.objects.create(username="", email="") -``` -```python -### Send an email -from mitol.mail.api import get_message_sender -from mitol.mail.messages import TemplatedMessage -from testapp.messages import SampleMessage -from django.contrib.auth import get_user_model -User = get_user_model() -user = User.objects.get(username="") -with get_message_sender(SampleMessage, shared_context={}) as sender: - sender.build_and_send_message(user, {}) -``` diff --git a/tests/testapp/settings/__init__.py b/tests/transcoding/__init__.py similarity index 100% rename from tests/testapp/settings/__init__.py rename to tests/transcoding/__init__.py diff --git a/tests/mitol/transcoding/test_utils.py b/tests/transcoding/test_utils.py similarity index 100% rename from tests/mitol/transcoding/test_utils.py rename to tests/transcoding/test_utils.py diff --git a/tests/uvtestapp/__init__.py b/tests/uvtestapp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mitol/uvtestapp/test_api.py b/tests/uvtestapp/test_api.py similarity index 100% rename from tests/mitol/uvtestapp/test_api.py rename to tests/uvtestapp/test_api.py diff --git a/uv.lock b/uv.lock index d702b774..9887b33f 100644 --- a/uv.lock +++ b/uv.lock @@ -1473,7 +1473,7 @@ requires-dist = [ [[package]] name = "mitol-django-transcoding" -version = "2025.2.22" +version = "2025.2.25" source = { editable = "src/transcoding" } dependencies = [ { name = "boto3" }, From 6446ddb6b2b73334f6d6d0f8b4102f2c00e3f921 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Thu, 27 Feb 2025 09:23:47 -0500 Subject: [PATCH 2/3] Add custom user model --- .secrets.baseline | 110 +++++++++---------- testapp/main/settings/shared.py | 2 + testapp/users/__init__.py | 0 testapp/users/admin.py | 1 + testapp/users/apps.py | 6 ++ testapp/users/migrations/0001_initial.py | 131 +++++++++++++++++++++++ testapp/users/migrations/__init__.py | 0 testapp/users/models.py | 6 ++ testapp/users/tests.py | 1 + testapp/users/views.py | 1 + 10 files changed, 203 insertions(+), 55 deletions(-) create mode 100644 testapp/users/__init__.py create mode 100644 testapp/users/admin.py create mode 100644 testapp/users/apps.py create mode 100644 testapp/users/migrations/0001_initial.py create mode 100644 testapp/users/migrations/__init__.py create mode 100644 testapp/users/models.py create mode 100644 testapp/users/tests.py create mode 100644 testapp/users/views.py diff --git a/.secrets.baseline b/.secrets.baseline index c863757e..6bab73a8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -136,6 +136,15 @@ "line_number": 20 } ], + "conftest.py": [ + { + "type": "Secret Keyword", + "filename": "conftest.py", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 123 + } + ], "docker-compose.yml": [ { "type": "Secret Keyword", @@ -205,13 +214,54 @@ "line_number": 19 } ], - "conftest.py": [ + "testapp/main/settings/dev.py": [ { "type": "Secret Keyword", - "filename": "conftest.py", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "filename": "testapp/main/settings/dev.py", + "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_verified": false, - "line_number": 123 + "line_number": 17 + } + ], + "testapp/main/settings/example.dev.py": [ + { + "type": "Secret Keyword", + "filename": "testapp/main/settings/example.dev.py", + "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", + "is_verified": false, + "line_number": 14 + } + ], + "testapp/main/settings/shared.py": [ + { + "type": "Secret Keyword", + "filename": "testapp/main/settings/shared.py", + "hashed_secret": "8f2581750096043a1c68bedea8cfa6e13ad1a2e4", + "is_verified": false, + "line_number": 40 + }, + { + "type": "Basic Auth Credentials", + "filename": "testapp/main/settings/shared.py", + "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", + "is_verified": false, + "line_number": 122 + }, + { + "type": "Secret Keyword", + "filename": "testapp/main/settings/shared.py", + "hashed_secret": "9bc34549d565d9505b287de0cd20ac77be1d3f2c", + "is_verified": false, + "line_number": 202 + } + ], + "testapp/main/settings/test.py": [ + { + "type": "Secret Keyword", + "filename": "testapp/main/settings/test.py", + "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", + "is_verified": false, + "line_number": 9 } ], "tests/common/utils/test_urls.py": [ @@ -267,57 +317,7 @@ "is_verified": false, "line_number": 29 } - ], - "testapp/main/settings/dev.py": [ - { - "type": "Secret Keyword", - "filename": "testapp/main/settings/dev.py", - "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", - "is_verified": false, - "line_number": 17 - } - ], - "testapp/main/settings/example.dev.py": [ - { - "type": "Secret Keyword", - "filename": "testapp/main/settings/example.dev.py", - "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", - "is_verified": false, - "line_number": 14 - } - ], - "testapp/main/settings/shared.py": [ - { - "type": "Secret Keyword", - "filename": "testapp/main/settings/shared.py", - "hashed_secret": "8f2581750096043a1c68bedea8cfa6e13ad1a2e4", - "is_verified": false, - "line_number": 40 - }, - { - "type": "Basic Auth Credentials", - "filename": "testapp/main/settings/shared.py", - "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", - "is_verified": false, - "line_number": 120 - }, - { - "type": "Secret Keyword", - "filename": "testapp/main/settings/shared.py", - "hashed_secret": "9bc34549d565d9505b287de0cd20ac77be1d3f2c", - "is_verified": false, - "line_number": 200 - } - ], - "testapp/main/settings/test.py": [ - { - "type": "Secret Keyword", - "filename": "testapp/main/settings/test.py", - "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", - "is_verified": false, - "line_number": 9 - } ] }, - "generated_at": "2025-02-26T21:06:50Z" + "generated_at": "2025-02-27T14:23:42Z" } diff --git a/testapp/main/settings/shared.py b/testapp/main/settings/shared.py index e9d9e34a..bee80636 100644 --- a/testapp/main/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -44,6 +44,7 @@ ALLOWED_HOSTS = ["*"] +AUTH_USER_MODEL = "users.User" # Application definition @@ -74,6 +75,7 @@ "mitol.transcoding.apps.Transcoding", # test app, integrates the reusable apps "main", + "users", ] MIDDLEWARE = [ diff --git a/testapp/users/__init__.py b/testapp/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testapp/users/admin.py b/testapp/users/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/testapp/users/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/testapp/users/apps.py b/testapp/users/apps.py new file mode 100644 index 00000000..88f7b179 --- /dev/null +++ b/testapp/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/testapp/users/migrations/0001_initial.py b/testapp/users/migrations/0001_initial.py new file mode 100644 index 00000000..634bd9b1 --- /dev/null +++ b/testapp/users/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 4.2.16 on 2025-02-27 14:21 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/testapp/users/migrations/__init__.py b/testapp/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testapp/users/models.py b/testapp/users/models.py new file mode 100644 index 00000000..75843bba --- /dev/null +++ b/testapp/users/models.py @@ -0,0 +1,6 @@ +from django.contrib.auth.models import AbstractUser + + +# Create your models here. +class User(AbstractUser): + """Custom user""" diff --git a/testapp/users/tests.py b/testapp/users/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/testapp/users/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/testapp/users/views.py b/testapp/users/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/testapp/users/views.py @@ -0,0 +1 @@ +# Create your views here. From ed57c209bbe93c85f9f281b9171ffb797b0d07f9 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Thu, 27 Feb 2025 08:01:26 -0500 Subject: [PATCH 3/3] Add mitol-django-scim --- .secrets.baseline | 8 +- pyproject.toml | 9 + src/scim/CHANGELOG.md | 7 + src/scim/README.md | 36 ++ src/scim/changelog.d/scriv.ini | 5 + src/scim/mitol/__init__.py | 1 + src/scim/mitol/scim/__init__.py | 6 + src/scim/mitol/scim/adapters.py | 259 ++++++++++++ src/scim/mitol/scim/apps.py | 12 + src/scim/mitol/scim/config.py | 13 + src/scim/mitol/scim/constants.py | 7 + src/scim/mitol/scim/filters.py | 126 ++++++ src/scim/mitol/scim/forms.py | 0 src/scim/mitol/scim/parser.py | 195 +++++++++ src/scim/mitol/scim/py.typed | 0 src/scim/mitol/scim/settings/__init__.py | 0 src/scim/mitol/scim/settings/scim.py | 17 + src/scim/mitol/scim/urls.py | 18 + src/scim/mitol/scim/utils.py | 13 + src/scim/mitol/scim/views.py | 212 ++++++++++ src/scim/pyproject.toml | 39 ++ testapp/main/settings/shared.py | 3 + testapp/main/urls.py | 1 + tests/hubspot_api/test_api.py | 5 +- tests/mitol/scim/__init__.py | 0 tests/mitol/scim/test_parser.py | 63 +++ tests/mitol/scim/test_views.py | 501 +++++++++++++++++++++++ uv.lock | 89 ++++ 28 files changed, 1640 insertions(+), 5 deletions(-) create mode 100644 src/scim/CHANGELOG.md create mode 100644 src/scim/README.md create mode 100644 src/scim/changelog.d/scriv.ini create mode 100644 src/scim/mitol/__init__.py create mode 100644 src/scim/mitol/scim/__init__.py create mode 100644 src/scim/mitol/scim/adapters.py create mode 100644 src/scim/mitol/scim/apps.py create mode 100644 src/scim/mitol/scim/config.py create mode 100644 src/scim/mitol/scim/constants.py create mode 100644 src/scim/mitol/scim/filters.py create mode 100644 src/scim/mitol/scim/forms.py create mode 100644 src/scim/mitol/scim/parser.py create mode 100644 src/scim/mitol/scim/py.typed create mode 100644 src/scim/mitol/scim/settings/__init__.py create mode 100644 src/scim/mitol/scim/settings/scim.py create mode 100644 src/scim/mitol/scim/urls.py create mode 100644 src/scim/mitol/scim/utils.py create mode 100644 src/scim/mitol/scim/views.py create mode 100644 src/scim/pyproject.toml create mode 100644 tests/mitol/scim/__init__.py create mode 100644 tests/mitol/scim/test_parser.py create mode 100644 tests/mitol/scim/test_views.py diff --git a/.secrets.baseline b/.secrets.baseline index 6bab73a8..5b8e9a9e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -238,21 +238,21 @@ "filename": "testapp/main/settings/shared.py", "hashed_secret": "8f2581750096043a1c68bedea8cfa6e13ad1a2e4", "is_verified": false, - "line_number": 40 + "line_number": 41 }, { "type": "Basic Auth Credentials", "filename": "testapp/main/settings/shared.py", "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_verified": false, - "line_number": 122 + "line_number": 125 }, { "type": "Secret Keyword", "filename": "testapp/main/settings/shared.py", "hashed_secret": "9bc34549d565d9505b287de0cd20ac77be1d3f2c", "is_verified": false, - "line_number": 202 + "line_number": 205 } ], "testapp/main/settings/test.py": [ @@ -319,5 +319,5 @@ } ] }, - "generated_at": "2025-02-27T14:23:42Z" + "generated_at": "2025-02-27T14:24:52Z" } diff --git a/pyproject.toml b/pyproject.toml index 20b58bf1..fe3779d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "mitol-django-payment_gateway", "mitol-django-uvtestapp", "mitol-django-transcoding", + "mitol-django-scim", ] readme = "README.md" requires-python = ">= 3.10" @@ -33,11 +34,13 @@ build-backend = "hatchling.build" managed = true dev-dependencies = [ "GitPython", + "anys>=0.3.1", "bumpver", "click", "click-log", "cloup", "coverage<=7.6.1", + "deepmerge>=2.0", "dj-database-url", "django-stubs[compatible-mypy]", "factory-boy~=3.2", @@ -85,6 +88,7 @@ mitol-django-openedx = { workspace = true } mitol-django-payment_gateway = { workspace = true } mitol-django-uvtestapp = { workspace = true } mitol-django-transcoding = { workspace = true } +mitol-django-scim = { workspace = true } [tool.hatch.build.targets.sdist] include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] @@ -216,6 +220,11 @@ convention = "pep257" [tool.ruff.lint.flake8-quotes] inline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"django.contrib.auth.models.User".msg = "use get_user_model() or settings.AUTH_USER_MODEL" + + [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101"] "test_*.py" = ["S101"] diff --git a/src/scim/CHANGELOG.md b/src/scim/CHANGELOG.md new file mode 100644 index 00000000..cf45d9ca --- /dev/null +++ b/src/scim/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project uses date-based versioning. + + diff --git a/src/scim/README.md b/src/scim/README.md new file mode 100644 index 00000000..017948ca --- /dev/null +++ b/src/scim/README.md @@ -0,0 +1,36 @@ +## SCIM + +## Prerequisites + +- You need the following a local [Keycloak](https://www.keycloak.org/) instance running. Note which major version you are running (should be at least 26.x). + - You should have custom user profile fields setup on your `olapps` realm: + - `fullName`: required, otherwise defaults + - `emailOptIn`: defaults + +## Install the scim-for-keycloak plugin + +Sign up for an account on https://scim-for-keycloak.de and follow the instructions here: https://scim-for-keycloak.de/documentation/installation/install + +## Configure SCIM + +In the SCIM admin console, do the following: + +### Configure Remote SCIM Provider + +- In django-admin, go to OAuth Toolkit and create a new access token +- Go to Remote SCIM Provider +- Click the `+` button +- Specify a base URL for your learn API backend: `http://:8063/scim/v2/` +- At the bottom of the page, click "Use default configuration" +- Add a new authentication method: + - Type: Long Life Bearer Token + - Bearer Token: the access token you created above +- On the Schemas tab, edit the User schema and add these custom attributes: + - Add a `fullName` attribute and set the Custom Attribute Name to `fullName` + - Add an attribute named `emailOptIn` with the following settings: + - Type: integer + - Custom Attribute Name: `emailOptIn` +- On the Realm Assignments tab, assign to the `olapps` realm +- Go to the Synchronization tab and perform one: + - Identifier attribute: email + - Synchronization strategy: Search and Bulk diff --git a/src/scim/changelog.d/scriv.ini b/src/scim/changelog.d/scriv.ini new file mode 100644 index 00000000..ccf3d587 --- /dev/null +++ b/src/scim/changelog.d/scriv.ini @@ -0,0 +1,5 @@ +[scriv] +format = md +md_header_level = 2 +entry_title_template = file: ../../scripts/scriv/entry_title.${config:format}.j2 +version = literal: __init__.py: __version__ diff --git a/src/scim/mitol/__init__.py b/src/scim/mitol/__init__.py new file mode 100644 index 00000000..5284146e --- /dev/null +++ b/src/scim/mitol/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/src/scim/mitol/scim/__init__.py b/src/scim/mitol/scim/__init__.py new file mode 100644 index 00000000..4b9733ec --- /dev/null +++ b/src/scim/mitol/scim/__init__.py @@ -0,0 +1,6 @@ +"""mitol.scim""" + +default_app_config = "mitol.scim.apps.ScimApp" + +__version__ = "0.0.0" +__distributionname__ = "mitol-django-scim" diff --git a/src/scim/mitol/scim/adapters.py b/src/scim/mitol/scim/adapters.py new file mode 100644 index 00000000..39b4ac2e --- /dev/null +++ b/src/scim/mitol/scim/adapters.py @@ -0,0 +1,259 @@ +import json +import logging +from typing import Optional, Union + +from django.contrib.auth import get_user_model +from django.db import transaction +from django_scim import constants +from django_scim.adapters import SCIMUser +from scim2_filter_parser.attr_paths import AttrPath + +User = get_user_model() + + +logger = logging.getLogger(__name__) + + +def get_user_model_for_scim(): + """ + Get function for the django_scim library configuration (USER_MODEL_GETTER). + + Returns: + model: User model. + """ + return User + + +class LearnSCIMUser(SCIMUser): + """ + Custom adapter to extend django_scim library. This is required in order + to extend the profiles.models.Profile model to work with the + django_scim library. + """ + + password_changed = False + activity_changed = False + + resource_type = "User" + + id_field = "scim_id" + + ATTR_MAP = { + ("active", None, None): "is_active", + ("name", "givenName", None): "first_name", + ("name", "familyName", None): "last_name", + ("userName", None, None): "username", + } + + IGNORED_PATHS = { + ("schemas", None, None), + } + + @property + def is_new_user(self): + """_summary_ + + Returns: + bool: True is the user does not currently exist, + False if the user already exists. + """ + return not bool(self.obj.id) + + @property + def id(self): + """ + Return the SCIM id + """ + return self.obj.scim_id + + @property + def emails(self): + """ + Return the email of the user per the SCIM spec. + """ + return [{"value": self.obj.email, "primary": True}] + + @property + def display_name(self): + """ + Return the displayName of the user per the SCIM spec. + """ + return self.obj.profile.name + + @property + def meta(self): + """ + Return the meta object of the user per the SCIM spec. + """ + return { + "resourceType": self.resource_type, + "created": self.obj.created_on.isoformat(timespec="milliseconds"), + "lastModified": self.obj.updated_on.isoformat(timespec="milliseconds"), + "location": self.location, + } + + def to_dict(self): + """ + Return a ``dict`` conforming to the SCIM User Schema, + ready for conversion to a JSON object. + """ + return { + "id": self.id, + "externalId": self.obj.scim_external_id, + "schemas": [constants.SchemaURI.USER], + "userName": self.obj.username, + "name": { + "givenName": self.obj.first_name, + "familyName": self.obj.last_name, + }, + "displayName": self.display_name, + "emails": self.emails, + "active": self.obj.is_active, + "groups": [], + "meta": self.meta, + } + + def from_dict(self, d): + """ + Consume a ``dict`` conforming to the SCIM User Schema, updating the + internal user object with data from the ``dict``. + + Please note, the user object is not saved within this method. To + persist the changes made by this method, please call ``.save()`` on the + adapter. Eg:: + + scim_user.from_dict(d) + scim_user.save() + """ + self.parse_emails(d.get("emails")) + + self.obj.is_active = d.get("active", True) + self.obj.username = d.get("userName") + self.obj.first_name = d.get("name", {}).get("givenName", "") + self.obj.last_name = d.get("name", {}).get("familyName", "") + self.obj.scim_username = d.get("userName") + self.obj.scim_external_id = d.get("externalId") + + def _save(self): + self.obj.save() + + def _save_related(self): + pass + + def save(self): + """ + Save instances of the Profile and User models. + """ + with transaction.atomic(): + self._save() + self._save_related() + logger.info("User saved. User id %i", self.obj.id) + + def delete(self): + """ + Update User's is_active to False. + """ + self.obj.is_active = False + self.obj.save() + logger.info("Deactivated user id %i", self.obj.id) + + def handle_add( + self, + path: Optional[AttrPath], + value: Union[str, list, dict], + operation: dict, # noqa: ARG002 + ): + """ + Handle add operations per: + https://tools.ietf.org/html/rfc7644#section-3.5.2.1 + + Args: + path (AttrPath) + value (Union[str, list, dict]) + """ + if path is None: + return + + if path.first_path == ("externalId", None, None): + self.obj.scim_external_id = value + self.obj.save() + + def parse_scim_for_keycloak_payload(self, payload: str) -> dict: + """ + Parse the payload sent from scim-for-keycloak and normalize it + """ + result = {} + + for key, value in json.loads(payload).items(): + if key == "schema": + continue + + if isinstance(value, dict): + for nested_key, nested_value in value.items(): + result[self.split_path(f"{key}.{nested_key}")] = nested_value + else: + result[key] = value + + return result + + def parse_path_and_values( + self, path: Optional[str], value: Union[str, list, dict] + ) -> list: + """Parse the incoming value(s)""" + if isinstance(value, str): + # scim-for-keycloak sends this as a noncompliant JSON-encoded string + if path is None: + val = json.loads(value) + else: + msg = "Called with a non-null path and a str value" + raise ValueError(msg) + else: + val = value + + results = [] + + for attr_path, attr_value in val.items(): + if isinstance(attr_value, dict): + # nested object, we want to recursively flatten it to `first.second` + results.extend(self.parse_path_and_values(attr_path, attr_value)) + else: + flattened_path = ( + f"{path}.{attr_path}" if path is not None else attr_path + ) + new_path = self.split_path(flattened_path) + new_value = attr_value + results.append((new_path, new_value)) + + return results + + def handle_replace( + self, + path: Optional[AttrPath], + value: Union[str, list, dict], + operation: dict, # noqa: ARG002 + ): + """ + Handle the replace operations. + + All operations happen within an atomic transaction. + """ + + if not isinstance(value, dict): + # Restructure for use in loop below. + value = {path: value} + + for nested_path, nested_value in (value or {}).items(): + if nested_path.first_path in self.ATTR_MAP: + setattr(self.obj, self.ATTR_MAP[nested_path.first_path], nested_value) + elif nested_path.first_path == ("fullName", None, None): + self.obj.profile.name = nested_value + elif nested_path.first_path == ("emailOptIn", None, None): + self.obj.profile.email_optin = nested_value == 1 + elif nested_path.first_path == ("emails", None, None): + self.parse_emails(nested_value) + elif nested_path.first_path not in self.IGNORED_PATHS: + logger.debug( + "Ignoring SCIM update for path: %s", nested_path.first_path + ) + + self.save() diff --git a/src/scim/mitol/scim/apps.py b/src/scim/mitol/scim/apps.py new file mode 100644 index 00000000..2f734c98 --- /dev/null +++ b/src/scim/mitol/scim/apps.py @@ -0,0 +1,12 @@ +import os + +from django.apps import AppConfig + + +class ScimApp(AppConfig): + name = "mitol.scim" + label = "scim" + verbose_name = "Scim" + + # necessary because this is a namespaced app + path = os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120 diff --git a/src/scim/mitol/scim/config.py b/src/scim/mitol/scim/config.py new file mode 100644 index 00000000..49da7264 --- /dev/null +++ b/src/scim/mitol/scim/config.py @@ -0,0 +1,13 @@ +from django_scim.models import SCIMServiceProviderConfig + + +class LearnSCIMServiceProviderConfig(SCIMServiceProviderConfig): + """Custom provider config""" + + def to_dict(self): + result = super().to_dict() + + result["bulk"]["supported"] = True + result["filter"]["supported"] = True + + return result diff --git a/src/scim/mitol/scim/constants.py b/src/scim/mitol/scim/constants.py new file mode 100644 index 00000000..c51546da --- /dev/null +++ b/src/scim/mitol/scim/constants.py @@ -0,0 +1,7 @@ +"""SCIM constants""" + + +class SchemaURI: + BULK_REQUEST = "urn:ietf:params:scim:api:messages:2.0:BulkRequest" + + BULK_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:BulkResponse" diff --git a/src/scim/mitol/scim/filters.py b/src/scim/mitol/scim/filters.py new file mode 100644 index 00000000..e774b742 --- /dev/null +++ b/src/scim/mitol/scim/filters.py @@ -0,0 +1,126 @@ +import operator +from collections.abc import Callable +from typing import Optional + +from django.contrib.auth import get_user_model +from django.db.models import Model, Q +from pyparsing import ParseResults + +from mitol.scim.parser import Filters, TermType + + +class FilterQuery: + """Filters for users""" + + model_cls: type[Model] + + attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]] + + related_selects: list[str] = [] + + dj_op_mapping = { + "eq": "exact", + "ne": "exact", + "gt": "gt", + "ge": "gte", + "lt": "lt", + "le": "lte", + "pr": "isnull", + "co": "contains", + "sw": "startswith", + "ew": "endswith", + } + + dj_negated_ops = ("ne", "pr") + + @classmethod + def _filter_expr(cls, parsed: ParseResults) -> Q: + if parsed is None: + msg = "Expected a filter, got: None" + raise ValueError(msg) + + if parsed.term_type == TermType.attr_expr: + return cls._attr_expr(parsed) + + msg = f"Unsupported term type: {parsed.term_type}" + raise ValueError(msg) + + @classmethod + def _attr_expr(cls, parsed: ParseResults) -> Q: + dj_op = cls.dj_op_mapping[parsed.comparison_operator.lower()] + + scim_keys = (parsed.attr_name, parsed.sub_attr) + + path_parts = list( + filter( + lambda part: part is not None, + ( + *cls.attr_map.get(scim_keys, scim_keys), + dj_op, + ), + ) + ) + path = "__".join(path_parts) + + q = Q(**{path: parsed.value}) + + if parsed.comparison_operator in cls.dj_negated_ops: + q = ~q + + return q + + @classmethod + def _filters(cls, parsed: ParseResults) -> Q: + parsed_iter = iter(parsed) + q = cls._filter_expr(next(parsed_iter)) + + try: + while operator := cls._logical_op(next(parsed_iter)): + filter_q = cls._filter_expr(next(parsed_iter)) + + # combine the previous and next Q() objects using the bitwise operator + q = operator(q, filter_q) + except StopIteration: + pass + + return q + + @classmethod + def _logical_op(cls, parsed: ParseResults) -> Callable[[Q, Q], Q] | None: + """Convert a defined operator to the corresponding bitwise operator""" + if parsed is None: + return None + + if parsed.logical_operator.lower() == "and": + return operator.and_ + elif parsed.logical_operator.lower() == "or": + return operator.or_ + else: + msg = f"Unexpected operator: {parsed.operator}" + raise ValueError(msg) + + @classmethod + def search(cls, filter_query, request=None): # noqa: ARG003 + """Create a search query""" + parsed = Filters.parse_string(filter_query, parse_all=True) + + return cls.model_cls.objects.select_related(*cls.related_selects).filter( + cls._filters(parsed) + ) + + +class UserFilterQuery(FilterQuery): + """FilterQuery for User""" + + attr_map: dict[tuple[str, Optional[str]], tuple[str, ...]] = { + ("userName", None): ("username",), + ("emails", "value"): ("email",), + ("active", None): ("is_active",), + ("fullName", None): ("profile", "name"), + ("name", "givenName"): ("first_name",), + ("name", "familyName"): ("last_name",), + } + + related_selects = ["profile"] + + model_cls = get_user_model() diff --git a/src/scim/mitol/scim/forms.py b/src/scim/mitol/scim/forms.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/parser.py b/src/scim/mitol/scim/parser.py new file mode 100644 index 00000000..15b149e1 --- /dev/null +++ b/src/scim/mitol/scim/parser.py @@ -0,0 +1,195 @@ +""" +SCIM filter parsers + + _tag_term_type(TermType.attr_name) + +This module aims to compliantly parse SCIM filter queries per the spec: +https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2 + +Note that this implementation defines things slightly differently +because a naive implementation exactly matching the filter grammar will +result in hitting Python's recursion limit because the grammar defines +logical lists (AND/OR chains) as a recursive relationship. + +This implementation avoids that by defining separately FilterExpr and +Filter. As a result of this, some definitions are collapsed and removed +(e.g. valFilter => FilterExpr). +""" + +from enum import auto + +try: + from enum import StrEnum +except ImportError: + # for python < 3.11 + from strenum import StrEnum + +from pyparsing import ( + CaselessKeyword, + Char, + Combine, + DelimitedList, + FollowedBy, + Forward, + Group, + Literal, + Suppress, + Tag, + alphanums, + alphas, + common, + dbl_quoted_string, + nested_expr, + one_of, + remove_quotes, + ungroup, +) + + +class TagName(StrEnum): + """Tag names""" + + term_type = auto() + value_type = auto() + + +class TermType(StrEnum): + """Tag term type""" + + urn = auto() + attr_name = auto() + attr_path = auto() + attr_expr = auto() + value_path = auto() + presence = auto() + + logical_op = auto() + compare_op = auto() + negation_op = auto() + + filter_expr = auto() + filters = auto() + + +class ValueType(StrEnum): + """Tag value_type""" + + boolean = auto() + number = auto() + string = auto() + null = auto() + + +def _tag_term_type(term_type: TermType) -> Tag: + return Tag(TagName.term_type.name, term_type) + + +def _tag_value_type(value_type: ValueType) -> Tag: + return Tag(TagName.value_type.name, value_type) + + +NameChar = Char(alphanums + "_-") +AttrName = Combine( + Char(alphas) + + NameChar[...] + # ensure we're not somehow parsing an URN + + ~FollowedBy(":") +).set_results_name("attr_name") + _tag_term_type(TermType.attr_name) + +# Example URN-qualifed attr: +# urn:ietf:params:scim:schemas:core:2.0:User:userName +# |--------------- URN --------------------|:| attr | +UrnAttr = Combine( + Combine( + Literal("urn:") + + DelimitedList( + # characters ONLY if followed by colon + Char(alphanums + ".-_")[1, ...] + FollowedBy(":"), + # separator + Literal(":"), + # combine everything back into a singular token + combine=True, + )[1, ...] + ).set_results_name("urn") + # separator between URN and attribute name + + Literal(":") + + AttrName + + _tag_term_type(TermType.urn) +) + + +SubAttr = ungroup(Combine(Suppress(".") + AttrName)).set_results_name("sub_attr") ^ ( + Tag("sub_attr", None) +) + +AttrPath = ( + ( + # match on UrnAttr first + UrnAttr ^ AttrName + ) + + SubAttr + + _tag_term_type(TermType.attr_path) +) + +ComparisonOperator = one_of( + ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le"], + caseless=True, + as_keyword=True, +).set_results_name("comparison_operator") + _tag_term_type(TermType.compare_op) + +LogicalOperator = Group( + one_of(["or", "and"], caseless=True).set_results_name("logical_operator") + + _tag_term_type(TermType.logical_op) +) + +NegationOperator = Group( + ( + CaselessKeyword("not") + + _tag_term_type(TermType.negation_op) + + Tag("negated", True) # noqa: FBT003 + )[..., 1] + ^ Tag("negated", False) # noqa: FBT003 +) + +ValueTrue = Literal("true").set_parse_action(lambda: True) + _tag_value_type( + ValueType.boolean +) +ValueFalse = Literal("false").set_parse_action(lambda: False) + _tag_value_type( + ValueType.boolean +) +ValueNull = Literal("null").set_parse_action(lambda: None) + _tag_value_type( + ValueType.null +) +ValueNumber = (common.integer | common.fnumber) + _tag_value_type(ValueType.number) +ValueString = dbl_quoted_string.set_parse_action(remove_quotes) + _tag_value_type( + ValueType.string +) + +ComparisonValue = ungroup( + ValueTrue | ValueFalse | ValueNull | ValueNumber | ValueString +).set_results_name("value") + +AttrPresence = Group( + AttrPath + Literal("pr").set_results_name("presence").set_parse_action(lambda: True) +) + _tag_term_type(TermType.presence) +AttrExpression = AttrPresence | Group( + AttrPath + ComparisonOperator + ComparisonValue + _tag_term_type(TermType.attr_expr) +) + +# these are forward references, so that we can have +# parsers circularly reference themselves +FilterExpr = Forward() +Filters = Forward() + +ValuePath = Group(AttrPath + nested_expr("[", "]", Filters)).set_results_name( + "value_path" +) + _tag_term_type(TermType.value_path) + +FilterExpr <<= ( + AttrExpression | ValuePath | (NegationOperator + nested_expr("(", ")", Filters)) +) + _tag_term_type(TermType.filter_expr) + +Filters <<= ( + # comment to force it to wrap the below for operator precedence + (FilterExpr + (LogicalOperator + FilterExpr)[...]) + + _tag_term_type(TermType.filters) +) diff --git a/src/scim/mitol/scim/py.typed b/src/scim/mitol/scim/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/settings/__init__.py b/src/scim/mitol/scim/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/scim/mitol/scim/settings/scim.py b/src/scim/mitol/scim/settings/scim.py new file mode 100644 index 00000000..f65cbf98 --- /dev/null +++ b/src/scim/mitol/scim/settings/scim.py @@ -0,0 +1,17 @@ +SCIM_SERVICE_PROVIDER = { + "AUTHENTICATION_SCHEMES": [ + { + "type": "oauth2", + "name": "OAuth 2", + "description": "Oauth 2 implemented with bearer token", + "specUri": "", + "documentationUri": "", + }, + ], + "BASE_LOCATION_GETTER": "mitol.scim.utils.base_scim_location_getter", + "SERVICE_PROVIDER_CONFIG_MODEL": "mitol.scim.config.LearnSCIMServiceProviderConfig", + "USER_ADAPTER": "mitol.scim.adapters.LearnSCIMUser", + "USER_MODEL_GETTER": "mitol.scim.adapters.get_user_model_for_scim", + "USER_FILTER_PARSER": "mitol.scim.filters.UserFilterQuery", + "GET_IS_AUTHENTICATED_PREDICATE": "mitol.scim.utils.is_authenticated_predicate", +} diff --git a/src/scim/mitol/scim/urls.py b/src/scim/mitol/scim/urls.py new file mode 100644 index 00000000..9ac226cf --- /dev/null +++ b/src/scim/mitol/scim/urls.py @@ -0,0 +1,18 @@ +"""URL configurations for SCIM""" + +from django.urls import include, re_path + +from mitol.scim import views + +ol_scim_urls = ( + [ + re_path(r"^Bulk$", views.BulkView.as_view(), name="bulk"), + re_path(r"^\.search$", views.SearchView.as_view(), name="users-search"), + ], + "ol-scim", +) + +urlpatterns = [ + re_path(r"^scim/v2/", include(ol_scim_urls)), + re_path(r"^scim/v2/", include("django_scim.urls", namespace="scim")), +] diff --git a/src/scim/mitol/scim/utils.py b/src/scim/mitol/scim/utils.py new file mode 100644 index 00000000..dcb870e3 --- /dev/null +++ b/src/scim/mitol/scim/utils.py @@ -0,0 +1,13 @@ +"""Utils""" + +from django.conf import settings + + +def is_authenticated_predicate(user): + """Verify that the user is active and staff""" + return user.is_authenticated and user.is_active and user.is_staff + + +def base_scim_location_getter(request=None, *args, **kwargs): # noqa: ARG001 + """Return the base url for SCIM location urls""" + return settings.SITE_BASE_URL diff --git a/src/scim/mitol/scim/views.py b/src/scim/mitol/scim/views.py new file mode 100644 index 00000000..3fc86c13 --- /dev/null +++ b/src/scim/mitol/scim/views.py @@ -0,0 +1,212 @@ +"""SCIM view customizations""" + +import copy +import json +import logging +from http import HTTPStatus +from urllib.parse import urljoin, urlparse + +from django.http import HttpRequest, HttpResponse +from django.urls import Resolver404, resolve, reverse +from django_scim import constants as djs_constants +from django_scim import exceptions +from django_scim import views as djs_views +from django_scim.utils import get_base_scim_location_getter + +from mitol.scim import constants + +log = logging.getLogger() + + +class InMemoryHttpRequest(HttpRequest): + """ + A spoofed HttpRequest that only exists in-memory. + It does not implement all features of HttpRequest and is only used + for the bulk SCIM operations here so we can reuse view implementations. + """ + + def __init__(self, request, path, method, body): + super().__init__() + + self.META = copy.deepcopy( + { + key: value + for key, value in request.META.items() + if not key.startswith(("wsgi", "uwsgi")) + } + ) + self.path = path + self.method = method + self.content_type = djs_constants.SCIM_CONTENT_TYPE + + # normally HttpRequest would read this in, but we already have the value + self._body = body + + +class BulkView(djs_views.SCIMView): + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): # noqa: ARG002 + body = self.load_body(request.body) + + if body.get("schemas") != [constants.SchemaURI.BULK_REQUEST]: + msg = "Invalid schema uri. Must be SearchRequest." + raise exceptions.BadRequestError(msg) + + fail_on_errors = body.get("failOnErrors", None) + + if fail_on_errors is not None and isinstance(int, fail_on_errors): + msg = "Invalid failOnErrors. Must be an integer." + raise exceptions.BaseRequestError(msg) + + operations = body.get("Operations") + + results = self._attempt_operations(request, operations, fail_on_errors) + + response = { + "schemas": [constants.SchemaURI.BULK_RESPONSE], + "Operations": results, + } + + content = json.dumps(response) + + return HttpResponse( + content=content, + content_type=djs_constants.SCIM_CONTENT_TYPE, + status=HTTPStatus.OK, + ) + + def _attempt_operations(self, request, operations, fail_on_errors): + """Attempt to run the operations that were passed""" + responses = [] + num_errors = 0 + + for operation in operations: + # per-spec,if we've hit the error threshold stop processing and return + if fail_on_errors is not None and num_errors >= fail_on_errors: + break + + op_response = self._attempt_operation(request, operation) + + # if the operation returned a non-2xx status code, record it as a failure + if int(op_response.get("status")) >= HTTPStatus.MULTIPLE_CHOICES: + num_errors += 1 + + responses.append(op_response) + + return responses + + def _attempt_operation(self, bulk_request, operation): + """Attempt an operation as part of a bulk request""" + + method = operation.get("method") + bulk_id = operation.get("bulkId") + path = operation.get("path") + data = operation.get("data") + + try: + url_match = resolve(path, urlconf="django_scim.urls") + except Resolver404: + return self._operation_error( + bulk_id, + HTTPStatus.NOT_IMPLEMENTED, + "Endpoint is not supported for /Bulk", + ) + + # this is an ephemeral request not tied to the real request directly + op_request = InMemoryHttpRequest( + bulk_request, path, method, json.dumps(data).encode(djs_constants.ENCODING) + ) + + op_response = url_match.func(op_request, *url_match.args, **url_match.kwargs) + result = { + "method": method, + "bulkId": bulk_id, + "status": str(op_response.status_code), + } + + location = None + + if op_response.status_code >= HTTPStatus.BAD_REQUEST and op_response.content: + result["response"] = json.loads(op_response.content.decode("utf-8")) + + location = op_response.headers.get("Location", None) + + if location is not None: + result["location"] = location + # this is a custom field that the scim-for-keycloak plugin requires + try: + path = urlparse(location).path + location_match = resolve(path) + # this URL will be something like /scim/v2/Users/12345 + # resolving it gives the uuid + result["id"] = location_match.kwargs["uuid"] + except Resolver404: + log.exception("Unable to resolve resource url: %s", location) + + return result + + def _operation_error(self, method, bulk_id, status_code, detail): + """Return a failure response""" + status_code = str(status_code) + return { + "method": method, + "status": status_code, + "bulkId": bulk_id, + "response": { + "schemas": [djs_constants.SchemaURI.ERROR], + "status": status_code, + "detail": detail, + }, + } + + +class SearchView(djs_views.UserSearchView): + """ + View for /.search endpoint + """ + + def post(self, request, *args, **kwargs): # noqa: ARG002 + body = self.load_body(request.body) + if body.get("schemas") != [djs_constants.SchemaURI.SERACH_REQUEST]: + msg = "Invalid schema uri. Must be SearchRequest." + raise exceptions.BadRequestError(msg) + + start = body.get("startIndex", 1) + count = body.get("count", 50) + sort_by = body.get("sortBy", None) + sort_order = body.get("sortOrder", "ascending") + query = body.get("filter", None) + + if sort_by is not None and sort_by not in ("email", "username"): + msg = "Sorting only supports email or username" + raise exceptions.BadRequestError(msg) + + if sort_order is not None and sort_order not in ("ascending", "descending"): + msg = "Sorting only supports ascending or descending" + raise exceptions.BadRequestError(msg) + + if not query: + msg = "No filter query specified" + raise exceptions.BadRequestError(msg) + + try: + qs = self.__class__.parser_getter().search(query, request) + except ValueError as e: + msg = "Invalid filter/search query: " + str(e) + raise exceptions.BadRequestError(msg) from e + + if sort_by is not None: + qs = qs.order_by(sort_by) + + if sort_order == "descending": + qs = qs.reverse() + + response = self._build_response(request, qs, start, count) + + path = reverse(self.scim_adapter.url_name) + url = urljoin(get_base_scim_location_getter()(request=request), path).rstrip( + "/" + ) + response["Location"] = url + "/.search" + return response diff --git a/src/scim/pyproject.toml b/src/scim/pyproject.toml new file mode 100644 index 00000000..2d34f646 --- /dev/null +++ b/src/scim/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "mitol-django-scim" +version = "0.0.0" +description = "Django application for SCIM integrations" +dependencies = [ + "django-stubs>=1.13.1", + "django>=3.0", + "django_scim2>=0.19.1", + "mitol-django-common", + # Remove this why py310 support is dropped + "StrEnum>=0.4.15 ; python_version < '3.11'", +] +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.10" + +[tool.bumpver] +current_version = "0.0.0" +version_pattern = "YYYY.MM.DD[.INC0]" + +[tool.bumpver.file_patterns] +"pyproject.toml" = [ + 'version = "{version}"', +] +"mitol/scim/__init__.py" = [ + '__version__ = "{version}"', +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] +exclude = ["BUILD", "pyproject.toml"] + +[tool.hatch.build.targets.wheel] +include = ["CHANGELOG.md", "README.md", "py.typed", "**/*.py"] +exclude = ["BUILD", "pyproject.toml"] diff --git a/testapp/main/settings/shared.py b/testapp/main/settings/shared.py index bee80636..51b976de 100644 --- a/testapp/main/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -28,6 +28,7 @@ "mitol.google_sheets_deferrals.settings.google_sheets_deferrals", "mitol.hubspot_api.settings.hubspot_api", "mitol.transcoding.settings.job", + "mitol.scim.settings.scim", ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -43,6 +44,7 @@ DEBUG = True ALLOWED_HOSTS = ["*"] +AUTH_USER_MODEL = "testapp.User" AUTH_USER_MODEL = "users.User" @@ -73,6 +75,7 @@ "mitol.geoip.apps.GeoIPApp", "mitol.olposthog.apps.OlPosthog", "mitol.transcoding.apps.Transcoding", + "mitol.scim.apps.ScimApp", # test app, integrates the reusable apps "main", "users", diff --git a/testapp/main/urls.py b/testapp/main/urls.py index f25b48fc..27514082 100644 --- a/testapp/main/urls.py +++ b/testapp/main/urls.py @@ -24,4 +24,5 @@ ), path("admin/", admin.site.urls), path("api/", include("mitol.transcoding.urls")), + path("api/", include("mitol.scim.urls")), ] diff --git a/tests/hubspot_api/test_api.py b/tests/hubspot_api/test_api.py index b3fdba02..6cfc2cd1 100644 --- a/tests/hubspot_api/test_api.py +++ b/tests/hubspot_api/test_api.py @@ -6,7 +6,8 @@ from collections.abc import Iterable import pytest -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from faker import Faker from hubspot.crm.objects import ( @@ -24,6 +25,8 @@ fake = Faker() +User = get_user_model() + test_object_type = "deals" diff --git a/tests/mitol/scim/__init__.py b/tests/mitol/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mitol/scim/test_parser.py b/tests/mitol/scim/test_parser.py new file mode 100644 index 00000000..b688141d --- /dev/null +++ b/tests/mitol/scim/test_parser.py @@ -0,0 +1,63 @@ +import pytest +from faker import Faker +from mitol.scim.parser import Filters + +faker = Faker() + + +def test_scim_filter_parser(): + """Runer the parser tests""" + success, results = Filters.run_tests( + """\ + userName eq "bjensen" + + name.familyName co "O'Malley" + + userName sw "J" + + urn:ietf:params:scim:schemas:core:2.0:User:userName sw "J" + + title pr + + meta.lastModified gt "2011-05-13T04:42:34Z" + + meta.lastModified ge "2011-05-13T04:42:34Z" + + meta.lastModified lt "2011-05-13T04:42:34Z" + + meta.lastModified le "2011-05-13T04:42:34Z" + + title pr and userType eq "Employee" + + title pr or userType eq "Intern" + + schemas eq "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + + userType eq "Employee" and (emails co "example.com" or emails.value co "example.org") + + userType ne "Employee" and not (emails co "example.com" or emails.value co "example.org") + + userType eq "Employee" and (emails.type eq "work") + + userType eq "Employee" and emails[type eq "work" and value co "@example.com"] + + emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"] + """ # noqa: E501 + ) + + # run_tests will output error messages + assert success + + +@pytest.mark.parametrize("count", [10, 100, 1000, 5000]) +def test_large_filter(count): + """Test that the parser can handle large filters""" + + filter_str = " OR ".join( + [f'email.value eq "{faker.email()}"' for _ in range(count)] + ) + + success, _ = Filters.run_tests(filter_str) + + # run_tests will output error messages + assert success diff --git a/tests/mitol/scim/test_views.py b/tests/mitol/scim/test_views.py new file mode 100644 index 00000000..2055ec82 --- /dev/null +++ b/tests/mitol/scim/test_views.py @@ -0,0 +1,501 @@ +import itertools +import json +import operator +import random +from collections.abc import Callable +from functools import reduce +from http import HTTPStatus +from types import SimpleNamespace + +import pytest +from anys import ANY_STR +from deepmerge import always_merger +from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import reverse +from django_scim import constants as djs_constants +from mitol.common.factories import UserFactory +from mitol.scim import constants + +User = get_user_model() + + +@pytest.fixture() +def scim_client(staff_user): + """Test client for scim""" + client = Client() + client.force_login(staff_user) + return client + + +def test_scim_user_post(scim_client): + """Test that we can create a user via SCIM API""" + user_q = User.objects.filter(scim_external_id="1") + assert not user_q.exists() + + resp = scim_client.post( + reverse("scim:users"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": "jdoe@example.com", "primary": True}], + "active": True, + "userName": "jdoe", + "externalId": "1", + "name": { + "familyName": "Doe", + "givenName": "John", + }, + "fullName": "John Smith Doe", + "emailOptIn": 1, + } + ), + ) + + assert resp.status_code == HTTPStatus.CREATED, f"Error response: {resp.content}" + + user = user_q.first() + + assert user is not None + assert user.email == "jdoe@example.com" + assert user.username == "jdoe" + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user.profile.name == "John Smith Doe" + assert user.profile.email_optin is True + + +def test_scim_user_put(scim_client): + """Test that a user can be updated via PUT""" + user = UserFactory.create() + + resp = scim_client.put( + f"{reverse('scim:users')}/{user.profile.scim_id}", + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": "jsmith@example.com", "primary": True}], + "active": True, + "userName": "jsmith", + "externalId": "1", + "name": { + "familyName": "Smith", + "givenName": "Jimmy", + }, + "fullName": "Jimmy Smith", + "emailOptIn": 0, + } + ), + ) + + assert resp.status_code == HTTPStatus.OK, f"Error response: {resp.content}" + + user.refresh_from_db() + + assert user.email == "jsmith@example.com" + assert user.username == "jsmith" + assert user.first_name == "Jimmy" + assert user.last_name == "Smith" + assert user.profile.name == "Jimmy Smith" + assert user.profile.email_optin is False + + +def test_scim_user_patch(scim_client): + """Test that a user can be updated via PATCH""" + user = UserFactory.create() + + resp = scim_client.patch( + f"{reverse('scim:users')}/{user.profile.scim_id}", + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.PATCH_OP], + "Operations": [ + { + "op": "replace", + # yes, the value we get from scim-for-keycloak is + # a JSON encoded string...inside JSON... + "value": json.dumps( + { + "schemas": [djs_constants.SchemaURI.USER], + "emailOptIn": 1, + "fullName": "Billy Bob", + "name": { + "givenName": "Billy", + "familyName": "Bob", + }, + } + ), + } + ], + } + ), + ) + + assert resp.status_code == HTTPStatus.OK, f"Error response: {resp.content}" + + user_updated = User.objects.get(pk=user.id) + + assert user_updated.email == user.email + assert user_updated.username == user.username + assert user_updated.first_name == "Billy" + assert user_updated.last_name == "Bob" + assert user_updated.profile.name == "Billy Bob" + assert user_updated.profile.email_optin is True + + +def _user_to_scim_payload(user): + """Test util to serialize a user to a SCIM representation""" + return { + "schemas": [djs_constants.SchemaURI.USER], + "emails": [{"value": user.email, "primary": True}], + "userName": user.username, + "emailOptIn": 1 if user.profile.email_optin else 0, + "fullName": user.profile.name, + "name": { + "givenName": user.first_name, + "familyName": user.last_name, + }, + } + + +USER_FIELD_TYPES: dict[str, type] = { + "username": str, + "email": str, + "first_name": str, + "last_name": str, + "profile.email_optin": bool, + "profile.name": str, +} + +USER_FIELDS_TO_SCIM: dict[str, Callable] = { + "username": lambda value: {"userName": value}, + "email": lambda value: {"emails": [{"value": value, "primary": True}]}, + "first_name": lambda value: {"name": {"givenName": value}}, + "last_name": lambda value: {"name": {"familyName": value}}, + "profile.email_optin": lambda value: {"emailOptIn": 1 if value else 0}, + "profile.name": lambda value: {"fullName": value}, +} + + +def _post_operation(data, bulk_id_gen): + """Operation for a bulk POST""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "post", + "bulkId": bulk_id, + "path": "/Users", + "data": _user_to_scim_payload(data), + }, + user=None, + expected_user_state=data, + expected_response={ + "method": "post", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "201", + "id": ANY_STR, + }, + ) + + +def _put_operation(user, data, bulk_id_gen): + """Operation for a bulk PUT""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "put", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + "data": _user_to_scim_payload(data), + }, + user=user, + expected_user_state=data, + expected_response={ + "method": "put", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "200", + "id": str(user.profile.scim_id), + }, + ) + + +def _patch_operation(user, data, fields_to_patch, bulk_id_gen): + """Operation for a bulk PUT""" + + def _expected_patch_value(field): + field_getter = operator.attrgetter(field) + return field_getter(data if field in fields_to_patch else user) + + bulk_id = str(next(bulk_id_gen)) + field_updates = [ + mk_scim_value(operator.attrgetter(user_path)(data)) + for user_path, mk_scim_value in USER_FIELDS_TO_SCIM.items() + if user_path in fields_to_patch + ] + + return SimpleNamespace( + payload={ + "method": "patch", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + "data": { + "schemas": [djs_constants.SchemaURI.PATCH_OP], + "Operations": [ + { + "op": "replace", + "value": reduce(always_merger.merge, field_updates, {}), + } + ], + }, + }, + user=user, + expected_user_state=SimpleNamespace( + email=_expected_patch_value("email"), + username=_expected_patch_value("username"), + first_name=_expected_patch_value("first_name"), + last_name=_expected_patch_value("last_name"), + profile=SimpleNamespace( + name=_expected_patch_value("profile.name"), + email_optin=_expected_patch_value("profile.email_optin"), + ), + ), + expected_response={ + "method": "patch", + "location": ANY_STR, + "bulkId": bulk_id, + "status": "200", + "id": str(user.profile.scim_id), + }, + ) + + +def _delete_operation(user, bulk_id_gen): + """Operation for a bulk DELETE""" + bulk_id = str(next(bulk_id_gen)) + return SimpleNamespace( + payload={ + "method": "delete", + "bulkId": bulk_id, + "path": f"/Users/{user.profile.scim_id}", + }, + user=user, + expected_user_state=None, + expected_response={ + "method": "delete", + "bulkId": bulk_id, + "status": "204", + }, + ) + + +@pytest.fixture() +def bulk_test_data(): + """Test data for the /Bulk API tests""" + existing_users = UserFactory.create_batch(500) + remaining_users = set(existing_users) + + users_to_put = random.sample(sorted(remaining_users, key=lambda user: user.id), 100) + remaining_users = remaining_users - set(users_to_put) + + users_to_patch = random.sample( + sorted(remaining_users, key=lambda user: user.id), 100 + ) + remaining_users = remaining_users - set(users_to_patch) + + users_to_delete = random.sample( + sorted(remaining_users, key=lambda user: user.id), 100 + ) + remaining_users = remaining_users - set(users_to_delete) + + user_post_data = UserFactory.build_batch(100) + user_put_data = UserFactory.build_batch(len(users_to_put)) + user_patch_data = UserFactory.build_batch(len(users_to_patch)) + + bulk_id_gen = itertools.count() + + post_operations = [_post_operation(data, bulk_id_gen) for data in user_post_data] + put_operations = [ + _put_operation(user, data, bulk_id_gen) + for user, data in zip(users_to_put, user_put_data) + ] + patch_operations = [ + _patch_operation(user, patch_data, fields_to_patch, bulk_id_gen) + for user, patch_data, fields_to_patch in [ + ( + user, + patch_data, + # random number of field updates + list( + random.sample( + list(USER_FIELDS_TO_SCIM.keys()), + random.randint(1, len(USER_FIELDS_TO_SCIM.keys())), # noqa: S311 + ) + ), + ) + for user, patch_data in zip(users_to_patch, user_patch_data) + ] + ] + delete_operations = [ + _delete_operation(user, bulk_id_gen) for user in users_to_delete + ] + + operations = [ + *post_operations, + *patch_operations, + *put_operations, + *delete_operations, + ] + random.shuffle(operations) + + return SimpleNamespace( + existing_users=existing_users, + remaining_users=remaining_users, + post_operations=post_operations, + patch_operations=patch_operations, + put_operations=put_operations, + delete_operations=delete_operations, + operations=operations, + ) + + +def test_bulk_post(scim_client, bulk_test_data): + """Verify that bulk operations work as expected""" + user_count = User.objects.count() + + resp = scim_client.post( + reverse("ol-scim:bulk"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [constants.SchemaURI.BULK_REQUEST], + "Operations": [ + operation.payload for operation in bulk_test_data.operations + ], + } + ), + ) + + assert resp.status_code == HTTPStatus.OK + + # singular user is the staff user + assert User.objects.count() == user_count + len(bulk_test_data.post_operations) + + results_by_bulk_id = { + op_result["bulkId"]: op_result for op_result in resp.json()["Operations"] + } + + for operation in bulk_test_data.operations: + assert ( + results_by_bulk_id[operation.payload["bulkId"]] + == operation.expected_response + ) + + if operation in bulk_test_data.delete_operations: + user = User.objects.get(id=operation.user.id) + assert not user.is_active + else: + if operation in bulk_test_data.post_operations: + user = User.objects.get(username=operation.expected_user_state.username) + else: + user = User.objects.get(id=operation.user.id) + + for key, key_type in USER_FIELD_TYPES.items(): + attr_getter = operator.attrgetter(key) + + actual_value = attr_getter(user) + expected_value = attr_getter(operation.expected_user_state) + + if key_type is bool or key_type is None: + assert actual_value is expected_value + else: + assert actual_value == expected_value + + +@pytest.mark.parametrize( + ("sort_by", "sort_order"), + [ + (None, None), + ("email", None), + ("email", "ascending"), + ("email", "descending"), + ("username", None), + ("username", "ascending"), + ("username", "descending"), + ], +) +@pytest.mark.parametrize("count", [None, 100, 500]) +def test_user_search(scim_client, sort_by, sort_order, count): + """Test the user search endpoint""" + large_user_set = UserFactory.create_batch(1100) + search_users = large_user_set[:1000] + emails = [user.email for user in search_users] + + expected = search_users + + effective_count = count or 50 + effective_sort_order = sort_order or "ascending" + + if sort_by is not None: + expected = sorted( + expected, + # postgres sort is case-insensitive + key=lambda user: getattr(user, sort_by).lower(), + reverse=effective_sort_order == "descending", + ) + + for page in range(int(len(emails) / effective_count)): + start_index = page * effective_count # zero based index + resp = scim_client.post( + reverse("ol-scim:users-search"), + content_type="application/scim+json", + data=json.dumps( + { + "schemas": [djs_constants.SchemaURI.SERACH_REQUEST], + "filter": " OR ".join([f'email EQ "{email}"' for email in emails]), + "startIndex": start_index + 1, # SCIM API is 1-based index + **({"sortBy": sort_by} if sort_by is not None else {}), + **({"sortOrder": sort_order} if sort_order is not None else {}), + **({"count": count} if count is not None else {}), + } + ), + ) + + expected_in_resp = expected[start_index : start_index + effective_count] + + assert resp.status_code == HTTPStatus.OK, f"Got error: {resp.content}" + assert resp.json() == { + "totalResults": len(emails), + "itemsPerPage": effective_count, + "startIndex": start_index + 1, + "schemas": [djs_constants.SchemaURI.LIST_RESPONSE], + "Resources": [ + { + "id": user.profile.scim_id, + "active": user.is_active, + "userName": user.username, + "displayName": user.profile.name, + "emails": [{"value": user.email, "primary": True}], + "externalId": str(user.profile.scim_external_id), + "name": { + "givenName": user.first_name, + "familyName": user.last_name, + }, + "meta": { + "resourceType": "User", + "location": f"https://localhost/scim/v2/Users/{user.profile.scim_id}", + "lastModified": user.profile.updated_at.isoformat( + timespec="milliseconds" + ), + "created": user.date_joined.isoformat(timespec="milliseconds"), + }, + "groups": [], + "schemas": [djs_constants.SchemaURI.USER], + } + for user in expected_in_resp + ], + } diff --git a/uv.lock b/uv.lock index 9887b33f..c2a83c90 100644 --- a/uv.lock +++ b/uv.lock @@ -21,11 +21,21 @@ members = [ "mitol-django-olposthog", "mitol-django-openedx", "mitol-django-payment-gateway", + "mitol-django-scim", "mitol-django-transcoding", "mitol-django-uvtestapp", "ol-django", ] +[[package]] +name = "anys" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/f2/bdeab37937c956b15ec2c5bdd297c5ebb70094d34e16b26c4ab368228895/anys-0.3.1.tar.gz", hash = "sha256:5bcda88fcc490eda840a247c8c18b7a350051f530c288ad530e94c8d139602de", size = 14712 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/0e/46dd264dabc38f7ac05944d4a359cb262bf09126e5e23e5d915b45639b44/anys-0.3.1-py3-none-any.whl", hash = "sha256:a7f5353eb061e97cdbbfc41249a397d13fc7e558310ec23cece891d6f10a0a6d", size = 8906 }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -497,6 +507,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475 }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -575,6 +594,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/b8/1d02e873ebca6ecb3d42fc55e4130daa7c839df79b9ad5c454f6e3361827/django_redis-5.0.0-py3-none-any.whl", hash = "sha256:97739ca9de3f964c51412d1d7d8aecdfd86737bb197fce6e1ff12620c63c97ee", size = 29882 }, ] +[[package]] +name = "django-scim2" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "scim2-filter-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/59/da3061607de7be41d7e5bf484acb13510d631f8fcfcecd9f5e02dc5bf4f1/django_scim2-0.19.1.tar.gz", hash = "sha256:8126111160e76a880f6699babc5259f0345c9316c8018ce5dcc3f7579ccb9e89", size = 26988 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/24/ca2cab008049239585434f7451a42757d7d258bf741316fd16587e564b74/django_scim2-0.19.1-py3-none-any.whl", hash = "sha256:845eaa64e72e4ddfe2aa54d21865721f8c1d59ecd9c06e72341eeb03ed6ba94e", size = 32072 }, +] + [[package]] name = "django-stubs" version = "5.1.0" @@ -1471,6 +1503,27 @@ requires-dist = [ { name = "mitol-django-common", editable = "src/common" }, ] +[[package]] +name = "mitol-django-scim" +version = "0.0.0" +source = { editable = "src/scim" } +dependencies = [ + { name = "django" }, + { name = "django-scim2" }, + { name = "django-stubs" }, + { name = "mitol-django-common" }, + { name = "strenum", marker = "python_full_version < '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=3.0" }, + { name = "django-scim2", specifier = ">=0.19.1" }, + { name = "django-stubs", specifier = ">=1.13.1" }, + { name = "mitol-django-common", editable = "src/common" }, + { name = "strenum", marker = "python_full_version < '3.11'", specifier = ">=0.4.15" }, +] + [[package]] name = "mitol-django-transcoding" version = "2025.2.25" @@ -1590,17 +1643,20 @@ dependencies = [ { name = "mitol-django-olposthog" }, { name = "mitol-django-openedx" }, { name = "mitol-django-payment-gateway" }, + { name = "mitol-django-scim" }, { name = "mitol-django-transcoding" }, { name = "mitol-django-uvtestapp" }, ] [package.dev-dependencies] dev = [ + { name = "anys" }, { name = "bumpver" }, { name = "click" }, { name = "click-log" }, { name = "cloup" }, { name = "coverage" }, + { name = "deepmerge" }, { name = "dj-database-url" }, { name = "django-stubs", extra = ["compatible-mypy"] }, { name = "factory-boy" }, @@ -1639,17 +1695,20 @@ requires-dist = [ { name = "mitol-django-olposthog", editable = "src/olposthog" }, { name = "mitol-django-openedx", editable = "src/openedx" }, { name = "mitol-django-payment-gateway", editable = "src/payment_gateway" }, + { name = "mitol-django-scim", editable = "src/scim" }, { name = "mitol-django-transcoding", editable = "src/transcoding" }, { name = "mitol-django-uvtestapp", editable = "src/uvtestapp" }, ] [package.metadata.requires-dev] dev = [ + { name = "anys", specifier = ">=0.3.1" }, { name = "bumpver" }, { name = "click" }, { name = "click-log" }, { name = "cloup" }, { name = "coverage", specifier = "<=7.6.1" }, + { name = "deepmerge", specifier = ">=2.0" }, { name = "dj-database-url" }, { name = "django-stubs", extras = ["compatible-mypy"] }, { name = "factory-boy", specifier = "~=3.2" }, @@ -2230,6 +2289,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/ac/e7dc469e49048dc57f62e0c555d2ee3117fa30813d2a1a2962cce3a2a82a/s3transfer-0.11.2-py3-none-any.whl", hash = "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc", size = 84151 }, ] +[[package]] +name = "scim2-filter-parser" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/c0/2f4d5caee8faa8f0d0979584c4aab95fa69d626d6f9d211dd6bb7089bc2f/scim2_filter_parser-0.7.0.tar.gz", hash = "sha256:1e11dbe2e186fc1be6d93732b467a3bbaa9deff272dfeb3a0540394cfab7030c", size = 21358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/54/b54961bfc5018fa593758c439fe0d4a22fbadfabff49a7559850af9a79e1/scim2_filter_parser-0.7.0-py3-none-any.whl", hash = "sha256:a74f90a2d52a77e0f1bc4d77e84b79f88749469f6f7192d64a4f92e4fe50ab69", size = 23409 }, +] + [[package]] name = "scriv" version = "1.5.1" @@ -2279,6 +2350,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, ] +[[package]] +name = "sly" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864 }, +] + [[package]] name = "smmap" version = "5.0.1" @@ -2363,6 +2443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/50/70762bdb23f6c2b746b90661f461d33c4913a22a46bb5265b10947e85ffb/stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78", size = 49661 }, ] +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851 }, +] + [[package]] name = "toml" version = "0.10.2"