diff --git a/.gitea/actions/setup-envipy/action.yaml b/.gitea/actions/setup-envipy/action.yaml new file mode 100644 index 00000000..064d1e8e --- /dev/null +++ b/.gitea/actions/setup-envipy/action.yaml @@ -0,0 +1,94 @@ +name: 'Setup enviPy Environment' +description: 'Shared setup for enviPy CI - installs dependencies and prepares environment' + +inputs: + skip-frontend: + description: 'Skip frontend build steps (pnpm, tailwind)' + required: false + default: 'false' + skip-playwright: + description: 'Skip Playwright installation' + required: false + default: 'false' + ssh-private-key: + description: 'SSH private key for git access' + required: true + run-migrations: + description: 'Run Django migrations after setup' + required: false + default: 'true' + +runs: + using: "composite" + steps: + - name: Install system tools via apt + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client openjdk-11-jre-headless + + - name: Setup ssh + shell: bash + run: | + mkdir -p ~/.ssh + echo "${{ inputs.ssh-private-key }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts + eval $(ssh-agent -s) + ssh-add ~/.ssh/id_ed25519 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Setup Python venv + shell: bash + run: | + uv sync --locked --all-extras --dev + + - name: Install Playwright + if: inputs.skip-playwright == 'false' + shell: bash + run: | + source .venv/bin/activate + playwright install --with-deps + + - name: Build Frontend + if: inputs.skip-frontend == 'false' + shell: bash + run: | + uv run python scripts/pnpm_wrapper.py install + cat << 'EOF' > pnpm-workspace.yaml + onlyBuiltDependencies: + - '@parcel/watcher' + - '@tailwindcss/oxide' + EOF + uv run python scripts/pnpm_wrapper.py run build + + - name: Wait for Postgres + shell: bash + run: | + until pg_isready -h postgres -U ${{ env.POSTGRES_USER }}; do + echo "Waiting for postgres..." + sleep 2 + done + echo "Postgres is ready!" + + - name: Run Django Migrations + if: inputs.run-migrations == 'true' + shell: bash + run: | + source .venv/bin/activate + python manage.py migrate --noinput diff --git a/.gitea/workflows/api-ci.yaml b/.gitea/workflows/api-ci.yaml new file mode 100644 index 00000000..1550cba0 --- /dev/null +++ b/.gitea/workflows/api-ci.yaml @@ -0,0 +1,86 @@ +name: API CI + +on: + pull_request: + branches: + - develop + paths: + - 'epapi/**' + - 'epdb/models.py' # API depends on models + - 'epdb/logic.py' # API depends on business logic + - 'tests/fixtures/**' # API tests use fixtures + workflow_dispatch: + +jobs: + api-tests: + if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }} + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + ports: + - ${{ vars.POSTGRES_PORT}}:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + RUNNER_TOOL_CACHE: /toolcache + EP_DATA_DIR: /opt/enviPy/ + ALLOWED_HOSTS: 127.0.0.1,localhost + DEBUG: True + LOG_LEVEL: INFO + MODEL_BUILDING_ENABLED: True + APPLICABILITY_DOMAIN_ENABLED: True + ENVIFORMER_PRESENT: True + ENVIFORMER_DEVICE: cpu + FLAG_CELERY_PRESENT: False + PLUGINS_ENABLED: True + SERVER_URL: http://localhost:8000 + ADMIN_APPROVAL_REQUIRED: True + REGISTRATION_MANDATORY: True + LOG_DIR: '' + # DB + POSTGRES_SERVICE_NAME: postgres + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_PORT: 5432 + # SENTRY + SENTRY_ENABLED: False + # MS ENTRA + MS_ENTRA_ENABLED: False + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Use shared setup action - skips frontend builds for API-only tests + - name: Setup enviPy Environment + uses: ./.gitea/actions/setup-envipy + with: + skip-frontend: 'true' + skip-playwright: 'true' + ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }} + run-migrations: 'true' + + - name: Run API tests + run: | + source .venv/bin/activate + python manage.py test epapi -v 2 + + - name: Test API endpoints availability + run: | + source .venv/bin/activate + python manage.py runserver 0.0.0.0:8000 & + SERVER_PID=$! + sleep 5 + curl -f http://localhost:8000/api/v1/docs || echo "API docs not available" + kill $SERVER_PID diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5e897309..dc9f997f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -64,64 +64,17 @@ jobs: MS_ENTRA_ENABLED: False steps: - - name: Checkout repository uses: actions/checkout@v4 - - name: Install system tools via apt - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client redis-tools openjdk-11-jre-headless - - - name: Setup ssh - run: | - echo "${{ secrets.ENVIPY_CI_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts - eval $(ssh-agent -s) - ssh-add ~/.ssh/id_ed25519 - - - name: Install pnpm - uses: pnpm/action-setup@v4 + # Use shared setup action - includes all dependencies and migrations + - name: Setup enviPy Environment + uses: ./.gitea/actions/setup-envipy with: - version: 10 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "pnpm" - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - - name: Setup venv - run: | - uv sync --locked --all-extras --dev - source .venv/bin/activate - playwright install --with-deps - - - name: Run PNPM Commands - run: | - uv run python scripts/pnpm_wrapper.py install - cat << 'EOF' > pnpm-workspace.yaml - onlyBuiltDependencies: - - '@parcel/watcher' - - '@tailwindcss/oxide' - EOF - uv run python scripts/pnpm_wrapper.py run build - - - name: Wait for services - run: | - until pg_isready -h postgres -U postgres; do sleep 2; done - # until redis-cli -h redis ping; do sleep 2; done - - - name: Run Django Migrations - run: | - source .venv/bin/activate - python manage.py migrate --noinput + skip-frontend: 'false' + skip-playwright: 'false' + ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }} + run-migrations: 'true' - name: Run frontend tests run: | diff --git a/.gitignore b/.gitignore index 0a113012..037efa4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,375 @@ -*.pyc + + + +### Python ### + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py db.sqlite3 -.idea/ +db.sqlite3-journal static/admin/ static/django_extensions/ + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments .env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ +*.code-workspace + +# Ruff stuff: +.ruff_cache/ + +# UV cache +.uv-cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +### Agents ### +.claude/ +.codex/ +.cursor/ +.github/prompts/ +.junie/ +.windsurf/ + +AGENTS.md +CLAUDE.md +GEMINI.md +.aider.* + +### Node.js ### + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +### Custom ### + debug.log scratches/ test-results/ - data/ +*.arff -.DS_Store - -node_modules/ +# Auto generated static/css/output.css -*.code-workspace -.vscode/ -/pnpm-workspace.yaml +# macOS system files +.DS_Store +.Trashes +._* diff --git a/envipath/api.py b/envipath/api.py index 0e9bd46d..abed5e8d 100644 --- a/envipath/api.py +++ b/envipath/api.py @@ -1,4 +1,4 @@ -from epdb.api import router as epdb_app_router +from epapi.v1.router import router as v1_router # Refactored API from epdb.api_v2 from epdb.legacy_api import router as epdb_legacy_app_router from ninja import NinjaAPI @@ -8,5 +8,5 @@ api_v1 = NinjaAPI(title="API V1 Docs", urls_namespace="api-v1") api_legacy = NinjaAPI(title="Legacy API Docs", urls_namespace="api-legacy") # Add routers -api_v1.add_router("/", epdb_app_router) +api_v1.add_router("/", v1_router) api_legacy.add_router("/", epdb_legacy_app_router) diff --git a/envipath/settings.py b/envipath/settings.py index 3cac2c1a..2051da51 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -48,6 +48,7 @@ INSTALLED_APPS = [ "django_extensions", "oauth2_provider", # Custom + "epapi", # API endpoints (v1, etc.) "epdb", # "migration", ] @@ -198,6 +199,12 @@ if not os.path.exists(LOG_DIR): os.mkdir(LOG_DIR) PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins") + +API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50)) +PAGINATION_MAX_PER_PAGE_SIZE = int( + os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100) +) # Ninja override + if not os.path.exists(PLUGIN_DIR): os.mkdir(PLUGIN_DIR) @@ -355,6 +362,7 @@ FLAGS = { # -> /password_reset/done is covered as well LOGIN_EXEMPT_URLS = [ "/register", + "/api/v1/", # Let API handle its own authentication "/api/legacy/", "/o/token/", "/o/userinfo/", diff --git a/epapi/__init__.py b/epapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epapi/apps.py b/epapi/apps.py new file mode 100644 index 00000000..7eb9b30a --- /dev/null +++ b/epapi/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EpapiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "epapi" diff --git a/epapi/migrations/__init__.py b/epapi/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epapi/tests/__init__.py b/epapi/tests/__init__.py new file mode 100644 index 00000000..6c89b242 --- /dev/null +++ b/epapi/tests/__init__.py @@ -0,0 +1 @@ +# Tests for epapi app diff --git a/epapi/tests/v1/__init__.py b/epapi/tests/v1/__init__.py new file mode 100644 index 00000000..b2f65908 --- /dev/null +++ b/epapi/tests/v1/__init__.py @@ -0,0 +1 @@ +# Tests for epapi v1 API diff --git a/epapi/tests/v1/test_api_permissions.py b/epapi/tests/v1/test_api_permissions.py new file mode 100644 index 00000000..200a761f --- /dev/null +++ b/epapi/tests/v1/test_api_permissions.py @@ -0,0 +1,532 @@ +from django.test import TestCase, tag + +from epdb.logic import GroupManager, PackageManager, UserManager +from epdb.models import ( + Compound, + GroupPackagePermission, + Permission, + UserPackagePermission, +) + + +@tag("api", "end2end") +class APIPermissionTestBase(TestCase): + """ + Base class for API permission tests. + + Sets up common test data: + - user1: Owner of packages + - user2: User with various permissions + - user3: User with no permissions + - reviewed_package: Public package (reviewed=True) + - unreviewed_package_owned: Unreviewed package owned by user1 + - unreviewed_package_read: Unreviewed package with READ permission for user2 + - unreviewed_package_write: Unreviewed package with WRITE permission for user2 + - unreviewed_package_all: Unreviewed package with ALL permission for user2 + - unreviewed_package_no_access: Unreviewed package with no permissions for user2/user3 + - group_package: Unreviewed package accessible via group permission + - test_group: Group containing user2 + """ + + @classmethod + def setUpTestData(cls): + # Create users + cls.user1 = UserManager.create_user( + "permission-user1", + "permission-user1@envipath.com", + "SuperSafe", + set_setting=False, + add_to_group=False, + is_active=True, + ) + cls.user2 = UserManager.create_user( + "permission-user2", + "permission-user2@envipath.com", + "SuperSafe", + set_setting=False, + add_to_group=False, + is_active=True, + ) + cls.user3 = UserManager.create_user( + "permission-user3", + "permission-user3@envipath.com", + "SuperSafe", + set_setting=False, + add_to_group=False, + is_active=True, + ) + + # Delete default packages to ensure clean test data + for user in [cls.user1, cls.user2, cls.user3]: + default_pkg = user.default_package + user.default_package = None + user.save() + if default_pkg: + default_pkg.delete() + + # Create reviewed package (public) + cls.reviewed_package = PackageManager.create_package( + cls.user1, "Reviewed Package", "Public package" + ) + cls.reviewed_package.reviewed = True + cls.reviewed_package.save() + + # Create unreviewed packages with various permissions + cls.unreviewed_package_owned = PackageManager.create_package( + cls.user1, "User1 Owned Package", "Owned by user1" + ) + + cls.unreviewed_package_read = PackageManager.create_package( + cls.user1, "User2 Read Package", "User2 has READ permission" + ) + UserPackagePermission.objects.create( + user=cls.user2, package=cls.unreviewed_package_read, permission=Permission.READ[0] + ) + + cls.unreviewed_package_write = PackageManager.create_package( + cls.user1, "User2 Write Package", "User2 has WRITE permission" + ) + UserPackagePermission.objects.create( + user=cls.user2, package=cls.unreviewed_package_write, permission=Permission.WRITE[0] + ) + + cls.unreviewed_package_all = PackageManager.create_package( + cls.user1, "User2 All Package", "User2 has ALL permission" + ) + UserPackagePermission.objects.create( + user=cls.user2, package=cls.unreviewed_package_all, permission=Permission.ALL[0] + ) + + cls.unreviewed_package_no_access = PackageManager.create_package( + cls.user1, "No Access Package", "No permissions for user2/user3" + ) + + # Create group and group package + cls.test_group = GroupManager.create_group( + cls.user1, "Test Group", "Group for permission testing" + ) + cls.test_group.user_member.add(cls.user2) + cls.test_group.save() + + cls.group_package = PackageManager.create_package( + cls.user1, "Group Package", "Accessible via group permission" + ) + GroupPackagePermission.objects.create( + group=cls.test_group, package=cls.group_package, permission=Permission.READ[0] + ) + + # Create test compounds in each package + cls.reviewed_compound = Compound.create( + cls.reviewed_package, "C", "Reviewed Compound", "Test compound" + ) + cls.owned_compound = Compound.create( + cls.unreviewed_package_owned, "CC", "Owned Compound", "Test compound" + ) + cls.read_compound = Compound.create( + cls.unreviewed_package_read, "CCC", "Read Compound", "Test compound" + ) + cls.write_compound = Compound.create( + cls.unreviewed_package_write, "CCCC", "Write Compound", "Test compound" + ) + cls.all_compound = Compound.create( + cls.unreviewed_package_all, "CCCCC", "All Compound", "Test compound" + ) + cls.no_access_compound = Compound.create( + cls.unreviewed_package_no_access, "CCCCCC", "No Access Compound", "Test compound" + ) + cls.group_compound = Compound.create( + cls.group_package, "CCCCCCC", "Group Compound", "Test compound" + ) + + +@tag("api", "end2end") +class PackageListPermissionTest(APIPermissionTestBase): + """ + Test permissions for /api/v1/packages/ endpoint. + + Special case: This endpoint allows anonymous access (auth=None) + """ + + ENDPOINT = "/api/v1/packages/" + + def test_anonymous_user_sees_only_reviewed_packages(self): + """Anonymous users should only see reviewed packages.""" + self.client.logout() + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Should only see reviewed package + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_package.uuid)) + self.assertEqual(payload["items"][0]["review_status"], "reviewed") + + def test_authenticated_user_sees_all_readable_packages(self): + """Authenticated users see reviewed + packages they have access to.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2 should see: + # - reviewed_package (public) + # - unreviewed_package_read (READ permission) + # - unreviewed_package_write (WRITE permission) + # - unreviewed_package_all (ALL permission) + # - group_package (via group membership) + # Total: 5 packages + self.assertEqual(payload["total_items"], 5) + + visible_uuids = {item["uuid"] for item in payload["items"]} + expected_uuids = { + str(self.reviewed_package.uuid), + str(self.unreviewed_package_read.uuid), + str(self.unreviewed_package_write.uuid), + str(self.unreviewed_package_all.uuid), + str(self.group_package.uuid), + } + self.assertEqual(visible_uuids, expected_uuids) + + def test_owner_sees_all_owned_packages(self): + """Package owner sees all packages they created.""" + self.client.force_login(self.user1) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user1 owns all packages + # Total: 7 packages (all packages created in setUpTestData) + self.assertEqual(payload["total_items"], 7) + + def test_filter_by_review_status_true(self): + """Filter to show only reviewed packages.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT, {"review_status": True}) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Only reviewed_package + self.assertEqual(payload["total_items"], 1) + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_filter_by_review_status_false(self): + """Filter to show only unreviewed packages.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT, {"review_status": False}) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2's accessible unreviewed packages: 4 + self.assertEqual(payload["total_items"], 4) + self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"])) + + def test_anonymous_filter_unreviewed_returns_empty(self): + """Anonymous users get no results when filtering for unreviewed.""" + self.client.logout() + response = self.client.get(self.ENDPOINT, {"review_status": False}) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 0) + + +@tag("api", "end2end") +class GlobalCompoundListPermissionTest(APIPermissionTestBase): + """ + Test permissions for /api/v1/compounds/ endpoint. + + This endpoint requires authentication. + """ + + ENDPOINT = "/api/v1/compounds/" + + def test_anonymous_user_cannot_access(self): + """Anonymous users should get 401 Unauthorized.""" + self.client.logout() + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 401) + + def test_authenticated_user_sees_compounds_from_readable_packages(self): + """Authenticated users see compounds from packages they can read.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2 should see compounds from: + # - reviewed_package (public) + # - unreviewed_package_read (READ permission) + # - unreviewed_package_write (WRITE permission) + # - unreviewed_package_all (ALL permission) + # - group_package (via group membership) + # Total: 5 compounds + self.assertEqual(payload["total_items"], 5) + + visible_uuids = {item["uuid"] for item in payload["items"]} + expected_uuids = { + str(self.reviewed_compound.uuid), + str(self.read_compound.uuid), + str(self.write_compound.uuid), + str(self.all_compound.uuid), + str(self.group_compound.uuid), + } + self.assertEqual(visible_uuids, expected_uuids) + + def test_user_without_permission_cannot_see_compound(self): + """User without permission to package cannot see its compounds.""" + self.client.force_login(self.user3) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user3 should only see compounds from reviewed_package + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_compound.uuid)) + + def test_owner_sees_all_compounds(self): + """Package owner sees all compounds they created.""" + self.client.force_login(self.user1) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user1 owns all packages, so sees all compounds + self.assertEqual(payload["total_items"], 7) + + def test_read_permission_allows_viewing(self): + """READ permission allows viewing compounds.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Check that read_compound is included + uuids = [item["uuid"] for item in payload["items"]] + self.assertIn(str(self.read_compound.uuid), uuids) + + def test_write_permission_allows_viewing(self): + """WRITE permission also allows viewing compounds.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Check that write_compound is included + uuids = [item["uuid"] for item in payload["items"]] + self.assertIn(str(self.write_compound.uuid), uuids) + + def test_all_permission_allows_viewing(self): + """ALL permission allows viewing compounds.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Check that all_compound is included + uuids = [item["uuid"] for item in payload["items"]] + self.assertIn(str(self.all_compound.uuid), uuids) + + def test_group_permission_allows_viewing(self): + """Group membership grants access to group-permitted packages.""" + self.client.force_login(self.user2) + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # Check that group_compound is included + uuids = [item["uuid"] for item in payload["items"]] + self.assertIn(str(self.group_compound.uuid), uuids) + + +@tag("api", "end2end") +class PackageScopedCompoundListPermissionTest(APIPermissionTestBase): + """ + Test permissions for /api/v1/package/{uuid}/compound/ endpoint. + + This endpoint requires authentication AND package access. + """ + + def test_anonymous_user_cannot_access_reviewed_package(self): + """Anonymous users should get 401 even for reviewed packages.""" + self.client.logout() + endpoint = f"/api/v1/package/{self.reviewed_package.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 401) + + def test_authenticated_user_can_access_reviewed_package(self): + """Authenticated users can access reviewed packages.""" + self.client.force_login(self.user3) + endpoint = f"/api/v1/package/{self.reviewed_package.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_compound.uuid)) + + def test_user_can_access_package_with_read_permission(self): + """User with READ permission can access package-scoped endpoint.""" + self.client.force_login(self.user2) + endpoint = f"/api/v1/package/{self.unreviewed_package_read.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.read_compound.uuid)) + + def test_user_can_access_package_with_write_permission(self): + """User with WRITE permission can access package-scoped endpoint.""" + self.client.force_login(self.user2) + endpoint = f"/api/v1/package/{self.unreviewed_package_write.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.write_compound.uuid)) + + def test_user_can_access_package_with_all_permission(self): + """User with ALL permission can access package-scoped endpoint.""" + self.client.force_login(self.user2) + endpoint = f"/api/v1/package/{self.unreviewed_package_all.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.all_compound.uuid)) + + def test_user_cannot_access_package_without_permission(self): + """User without permission gets 403 Forbidden.""" + self.client.force_login(self.user2) + endpoint = f"/api/v1/package/{self.unreviewed_package_no_access.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 403) + + def test_nonexistent_package_returns_404(self): + """Request for non-existent package returns 404.""" + self.client.force_login(self.user2) + fake_uuid = "00000000-0000-0000-0000-000000000000" + endpoint = f"/api/v1/package/{fake_uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 404) + + def test_owner_can_access_owned_package(self): + """Package owner can access their package.""" + self.client.force_login(self.user1) + endpoint = f"/api/v1/package/{self.unreviewed_package_owned.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.owned_compound.uuid)) + + def test_group_member_can_access_group_package(self): + """Group member can access package via group permission.""" + self.client.force_login(self.user2) + endpoint = f"/api/v1/package/{self.group_package.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 200) + payload = response.json() + + self.assertEqual(payload["total_items"], 1) + self.assertEqual(payload["items"][0]["uuid"], str(self.group_compound.uuid)) + + def test_non_group_member_cannot_access_group_package(self): + """Non-group member cannot access package with only group permission.""" + self.client.force_login(self.user3) + endpoint = f"/api/v1/package/{self.group_package.uuid}/compound/" + response = self.client.get(endpoint) + + self.assertEqual(response.status_code, 403) + + +@tag("api", "end2end") +class MultiResourcePermissionTest(APIPermissionTestBase): + """ + Test that permission system works consistently across all resource types. + + Tests a sample of other endpoints to ensure permission logic is consistent. + """ + + def test_rules_endpoint_respects_permissions(self): + """Rules endpoint uses same permission logic.""" + from epdb.models import SimpleAmbitRule + + # Create rule in no-access package + rule = SimpleAmbitRule.create( + self.unreviewed_package_no_access, "Test Rule", "Test", "[C:1]>>[C:1]O" + ) + + self.client.force_login(self.user2) + response = self.client.get("/api/v1/rules/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2 should not see the rule from no_access_package + rule_uuids = [item["uuid"] for item in payload["items"]] + self.assertNotIn(str(rule.uuid), rule_uuids) + + def test_reactions_endpoint_respects_permissions(self): + """Reactions endpoint uses same permission logic.""" + from epdb.models import Reaction + + # Create reaction in no-access package + reaction = Reaction.create( + self.unreviewed_package_no_access, "Test Reaction", "Test", ["C"], ["CO"] + ) + + self.client.force_login(self.user2) + response = self.client.get("/api/v1/reactions/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2 should not see the reaction from no_access_package + reaction_uuids = [item["uuid"] for item in payload["items"]] + self.assertNotIn(str(reaction.uuid), reaction_uuids) + + def test_pathways_endpoint_respects_permissions(self): + """Pathways endpoint uses same permission logic.""" + from epdb.models import Pathway + + # Create pathway in no-access package + pathway = Pathway.objects.create( + package=self.unreviewed_package_no_access, name="Test Pathway", description="Test" + ) + + self.client.force_login(self.user2) + response = self.client.get("/api/v1/pathways/") + + self.assertEqual(response.status_code, 200) + payload = response.json() + + # user2 should not see the pathway from no_access_package + pathway_uuids = [item["uuid"] for item in payload["items"]] + self.assertNotIn(str(pathway.uuid), pathway_uuids) diff --git a/epapi/tests/v1/test_contract_get_entities.py b/epapi/tests/v1/test_contract_get_entities.py new file mode 100644 index 00000000..3bdeed59 --- /dev/null +++ b/epapi/tests/v1/test_contract_get_entities.py @@ -0,0 +1,477 @@ +from django.test import TestCase, tag + +from epdb.logic import PackageManager, UserManager +from epdb.models import Compound, Reaction, Pathway, EPModel, SimpleAmbitRule, Scenario + + +class BaseTestAPIGetPaginated: + """ + Mixin class for API pagination tests. + + Subclasses must inherit from both this class and TestCase, e.g.: + class MyTest(BaseTestAPIGetPaginated, TestCase): + ... + + Subclasses must define: + - resource_name: Singular name (e.g., "compound") + - resource_name_plural: Plural name (e.g., "compounds") + - global_endpoint: Global listing endpoint (e.g., "/api/v1/compounds/") + - package_endpoint_template: Template for package-scoped endpoint or None + - total_reviewed: Number of reviewed items to create + - total_unreviewed: Number of unreviewed items to create + - create_reviewed_resource(cls, package, idx): Factory method + - create_unreviewed_resource(cls, package, idx): Factory method + """ + + # Configuration to be overridden by subclasses + resource_name = None + resource_name_plural = None + global_endpoint = None + package_endpoint_template = None + total_reviewed = 50 + total_unreviewed = 20 + default_page_size = 50 + max_page_size = 100 + + @classmethod + def setUpTestData(cls): + # Create test user + cls.user = UserManager.create_user( + f"{cls.resource_name}-user", + f"{cls.resource_name}-user@envipath.com", + "SuperSafe", + set_setting=False, + add_to_group=False, + is_active=True, + ) + + # Delete the auto-created default package to ensure clean test data + default_pkg = cls.user.default_package + cls.user.default_package = None + cls.user.save() + default_pkg.delete() + + # Create reviewed package + cls.reviewed_package = PackageManager.create_package( + cls.user, "Reviewed Package", f"Reviewed package for {cls.resource_name} tests" + ) + cls.reviewed_package.reviewed = True + cls.reviewed_package.save() + + # Create unreviewed package + cls.unreviewed_package = PackageManager.create_package( + cls.user, "Draft Package", f"Unreviewed package for {cls.resource_name} tests" + ) + + # Create reviewed resources + for idx in range(cls.total_reviewed): + cls.create_reviewed_resource(cls.reviewed_package, idx) + + # Create unreviewed resources + for idx in range(cls.total_unreviewed): + cls.create_unreviewed_resource(cls.unreviewed_package, idx) + + # Set up package-scoped endpoints if applicable + if cls.package_endpoint_template: + cls.reviewed_package_endpoint = cls.package_endpoint_template.format( + uuid=cls.reviewed_package.uuid + ) + cls.unreviewed_package_endpoint = cls.package_endpoint_template.format( + uuid=cls.unreviewed_package.uuid + ) + + @classmethod + def create_reviewed_resource(cls, package, idx): + """ + Create a single reviewed resource. + Must be implemented by subclass. + + Args: + package: The package to create the resource in + idx: Index of the resource (0-based) + """ + raise NotImplementedError(f"{cls.__name__} must implement create_reviewed_resource()") + + @classmethod + def create_unreviewed_resource(cls, package, idx): + """ + Create a single unreviewed resource. + Must be implemented by subclass. + + Args: + package: The package to create the resource in + idx: Index of the resource (0-based) + """ + raise NotImplementedError(f"{cls.__name__} must implement create_unreviewed_resource()") + + def setUp(self): + self.client.force_login(self.user) + + def test_requires_session_authentication(self): + """Test that the global endpoint requires authentication.""" + self.client.logout() + response = self.client.get(self.global_endpoint) + self.assertEqual(response.status_code, 401) + + def test_global_listing_uses_default_page_size(self): + """Test that the global endpoint uses default pagination settings.""" + response = self.client.get(self.global_endpoint, {"review_status": True}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["page"], 1) + self.assertEqual(payload["page_size"], self.default_page_size) + self.assertEqual(payload["total_items"], self.total_reviewed) + + # Verify only reviewed items are returned + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_can_request_later_page(self): + """Test that pagination works for later pages.""" + if self.total_reviewed <= self.default_page_size: + self.skipTest( + f"Not enough items to test pagination " + f"({self.total_reviewed} <= {self.default_page_size})" + ) + + response = self.client.get(self.global_endpoint, {"page": 2}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["page"], 2) + + # Calculate expected items on page 2 + expected_items = min(self.default_page_size, self.total_reviewed - self.default_page_size) + self.assertEqual(len(payload["items"]), expected_items) + + # Verify only reviewed items are returned + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_page_size_is_capped(self): + """Test that page size is capped at the maximum.""" + if self.total_reviewed <= self.max_page_size: + self.skipTest( + f"Not enough items to test page size cap " + f"({self.total_reviewed} <= {self.max_page_size})" + ) + + response = self.client.get(self.global_endpoint, {"page_size": 150}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["page_size"], self.max_page_size) + self.assertEqual(len(payload["items"]), self.max_page_size) + + def test_package_endpoint_for_reviewed_package(self): + """Test the package-scoped endpoint for reviewed packages.""" + if not self.package_endpoint_template: + self.skipTest("No package endpoint for this resource") + + response = self.client.get(self.reviewed_package_endpoint) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["total_items"], self.total_reviewed) + + # Verify only reviewed items are returned + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_package_endpoint_for_unreviewed_package(self): + """Test the package-scoped endpoint for unreviewed packages.""" + if not self.package_endpoint_template: + self.skipTest("No package endpoint for this resource") + + response = self.client.get(self.unreviewed_package_endpoint) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["total_items"], self.total_unreviewed) + + # Verify only unreviewed items are returned + self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"])) + + +@tag("api", "end2end") +class PackagePaginationAPITest(TestCase): + ENDPOINT = "/api/v1/packages/" + + @classmethod + def setUpTestData(cls): + cls.user = UserManager.create_user( + "package-user", + "package-user@envipath.com", + "SuperSafe", + set_setting=False, + add_to_group=False, + is_active=True, + ) + + # Delete the auto-created default package to ensure clean test data + default_pkg = cls.user.default_package + cls.user.default_package = None + cls.user.save() + default_pkg.delete() + + # Create reviewed packages + cls.total_reviewed = 25 + for idx in range(cls.total_reviewed): + package = PackageManager.create_package( + cls.user, f"Reviewed Package {idx:03d}", "Reviewed package for tests" + ) + package.reviewed = True + package.save() + + # Create unreviewed packages + cls.total_unreviewed = 15 + for idx in range(cls.total_unreviewed): + PackageManager.create_package( + cls.user, f"Draft Package {idx:03d}", "Unreviewed package for tests" + ) + + def setUp(self): + self.client.force_login(self.user) + + def test_anonymous_can_access_reviewed_packages(self): + self.client.logout() + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 200) + + payload = response.json() + # Anonymous users can only see reviewed packages + self.assertEqual(payload["total_items"], self.total_reviewed) + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_listing_uses_default_page_size(self): + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["page"], 1) + self.assertEqual(payload["page_size"], 50) + self.assertEqual(payload["total_items"], self.total_reviewed + self.total_unreviewed) + + def test_reviewed_filter_true(self): + response = self.client.get(self.ENDPOINT, {"review_status": True}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["total_items"], self.total_reviewed) + self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"])) + + def test_reviewed_filter_false(self): + response = self.client.get(self.ENDPOINT, {"review_status": False}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + self.assertEqual(payload["total_items"], self.total_unreviewed) + self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"])) + + def test_reviewed_filter_false_anonymous(self): + self.client.logout() + response = self.client.get(self.ENDPOINT, {"review_status": False}) + self.assertEqual(response.status_code, 200) + + payload = response.json() + # Anonymous users cannot access unreviewed packages + self.assertEqual(payload["total_items"], 0) + + +@tag("api", "end2end") +class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Compound pagination tests using base class.""" + + resource_name = "compound" + resource_name_plural = "compounds" + global_endpoint = "/api/v1/compounds/" + package_endpoint_template = "/api/v1/package/{uuid}/compound/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + simple_smiles = ["C", "CC", "CCC", "CCCC", "CCCCC"] + smiles = simple_smiles[idx % len(simple_smiles)] + ("O" * (idx // len(simple_smiles))) + return Compound.create( + package, + smiles, + f"Reviewed Compound {idx:03d}", + "Compound for pagination tests", + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + simple_smiles = ["C", "CC", "CCC", "CCCC", "CCCCC"] + smiles = simple_smiles[idx % len(simple_smiles)] + ("N" * (idx // len(simple_smiles))) + return Compound.create( + package, + smiles, + f"Draft Compound {idx:03d}", + "Compound for pagination tests", + ) + + +@tag("api", "end2end") +class RulePaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Rule pagination tests using base class.""" + + resource_name = "rule" + resource_name_plural = "rules" + global_endpoint = "/api/v1/rules/" + package_endpoint_template = "/api/v1/package/{uuid}/rule/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + # Create unique SMIRKS by combining chain length and functional group variations + # This ensures each idx gets a truly unique SMIRKS pattern + carbon_chain = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx) + smirks = f"[{carbon_chain}:1]>>[{carbon_chain}:1]O" + return SimpleAmbitRule.create( + package, + f"Reviewed Rule {idx:03d}", + f"Rule {idx} for pagination tests", + smirks, + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + # Create unique SMIRKS by varying the carbon chain length + carbon_chain = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx) + smirks = f"[{carbon_chain}:1]>>[{carbon_chain}:1]N" + return SimpleAmbitRule.create( + package, + f"Draft Rule {idx:03d}", + f"Rule {idx} for pagination tests", + smirks, + ) + + +@tag("api", "end2end") +class ReactionPaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Reaction pagination tests using base class.""" + + resource_name = "reaction" + resource_name_plural = "reactions" + global_endpoint = "/api/v1/reactions/" + package_endpoint_template = "/api/v1/package/{uuid}/reaction/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + # Generate unique SMILES with growing chain lengths to avoid duplicates + # Each idx gets a unique chain length + educt_smiles = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx) + product_smiles = educt_smiles + "O" + return Reaction.create( + package=package, + name=f"Reviewed Reaction {idx:03d}", + description="Reaction for pagination tests", + educts=[educt_smiles], + products=[product_smiles], + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + # Generate unique SMILES with growing chain lengths to avoid duplicates + # Each idx gets a unique chain length + educt_smiles = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx) + product_smiles = educt_smiles + "N" + return Reaction.create( + package=package, + name=f"Draft Reaction {idx:03d}", + description="Reaction for pagination tests", + educts=[educt_smiles], + products=[product_smiles], + ) + + +@tag("api", "end2end") +class PathwayPaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Pathway pagination tests using base class.""" + + resource_name = "pathway" + resource_name_plural = "pathways" + global_endpoint = "/api/v1/pathways/" + package_endpoint_template = "/api/v1/package/{uuid}/pathway/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + return Pathway.objects.create( + package=package, + name=f"Reviewed Pathway {idx:03d}", + description="Pathway for pagination tests", + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + return Pathway.objects.create( + package=package, + name=f"Draft Pathway {idx:03d}", + description="Pathway for pagination tests", + ) + + +@tag("api", "end2end") +class ModelPaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Model pagination tests using base class.""" + + resource_name = "model" + resource_name_plural = "models" + global_endpoint = "/api/v1/models/" + package_endpoint_template = "/api/v1/package/{uuid}/model/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + return EPModel.objects.create( + package=package, + name=f"Reviewed Model {idx:03d}", + description="Model for pagination tests", + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + return EPModel.objects.create( + package=package, + name=f"Draft Model {idx:03d}", + description="Model for pagination tests", + ) + + +@tag("api", "end2end") +class ScenarioPaginationAPITest(BaseTestAPIGetPaginated, TestCase): + """Scenario pagination tests using base class.""" + + resource_name = "scenario" + resource_name_plural = "scenarios" + global_endpoint = "/api/v1/scenarios/" + package_endpoint_template = "/api/v1/package/{uuid}/scenario/" + total_reviewed = 125 + total_unreviewed = 35 + + @classmethod + def create_reviewed_resource(cls, package, idx): + return Scenario.create( + package, + f"Reviewed Scenario {idx:03d}", + "Scenario for pagination tests", + "2025-01-01", + "lab", + [], + ) + + @classmethod + def create_unreviewed_resource(cls, package, idx): + return Scenario.create( + package, + f"Draft Scenario {idx:03d}", + "Scenario for pagination tests", + "2025-01-01", + "field", + [], + ) diff --git a/epapi/v1/__init__.py b/epapi/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epapi/v1/auth.py b/epapi/v1/auth.py new file mode 100644 index 00000000..72569ce0 --- /dev/null +++ b/epapi/v1/auth.py @@ -0,0 +1,8 @@ +from ninja.security import HttpBearer +from ninja.errors import HttpError + + +class BearerTokenAuth(HttpBearer): + def authenticate(self, request, token): + # FIXME: placeholder; implement it in O(1) time + raise HttpError(401, "Invalid or expired token") diff --git a/epapi/v1/dal.py b/epapi/v1/dal.py new file mode 100644 index 00000000..94a23345 --- /dev/null +++ b/epapi/v1/dal.py @@ -0,0 +1,95 @@ +from django.db.models import Model +from epdb.logic import PackageManager +from epdb.models import CompoundStructure, User, Package, Compound +from uuid import UUID + +from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError + + +def get_compound_or_error(user, compound_uuid: UUID): + """ + Get compound by UUID with permission check. + """ + try: + compound = Compound.objects.get(uuid=compound_uuid) + package = compound.package + except Compound.DoesNotExist: + raise EPAPINotFoundError(f"Compound with UUID {compound_uuid} not found") + + # FIXME: optimize package manager to exclusively work with UUIDs + if not user or user.is_anonymous or not PackageManager.readable(user, package): + raise EPAPIPermissionDeniedError("Insufficient permissions to access this compound.") + + return compound + + +def get_package_or_error(user, package_uuid: UUID): + """ + Get package by UUID with permission check. + """ + + # FIXME: update package manager with custom exceptions to avoid manual checks here + try: + package = Package.objects.get(uuid=package_uuid) + except Package.DoesNotExist: + raise EPAPINotFoundError(f"Package with UUID {package_uuid} not found") + + # FIXME: optimize package manager to exclusively work with UUIDs + if not user or user.is_anonymous or not PackageManager.readable(user, package): + raise EPAPIPermissionDeniedError("Insufficient permissions to access this package.") + + return package + + +def get_user_packages_qs(user: User | None): + """Get all packages readable by the user.""" + if not user or user.is_anonymous: + return PackageManager.get_reviewed_packages() + return PackageManager.get_all_readable_packages(user, include_reviewed=True) + + +def get_user_entities_qs(model_class: Model, user: User | None): + """Build queryset for reviewed package entities.""" + + if not user or user.is_anonymous: + return model_class.objects.filter(package__reviewed=True).select_related("package") + + qs = model_class.objects.filter( + package__in=PackageManager.get_all_readable_packages(user, include_reviewed=True) + ).select_related("package") + return qs + + +def get_package_scoped_entities_qs( + model_class: Model, package_uuid: UUID, user: User | None = None +): + """Build queryset for specific package entities.""" + package = get_package_or_error(user, package_uuid) + qs = model_class.objects.filter(package=package).select_related("package") + return qs + + +def get_user_structures_qs(user: User | None): + """Build queryset for structures accessible to the user (via compound->package).""" + + if not user or user.is_anonymous: + return CompoundStructure.objects.filter(compound__package__reviewed=True).select_related( + "compound__package" + ) + + qs = CompoundStructure.objects.filter( + compound__package__in=PackageManager.get_all_readable_packages(user, include_reviewed=True) + ).select_related("compound__package") + return qs + + +def get_package_compound_scoped_structure_qs( + package_uuid: UUID, compound_uuid: UUID, user: User | None = None +): + """Build queryset for specific package compound structures.""" + + get_package_or_error(user, package_uuid) + compound = get_compound_or_error(user, compound_uuid) + + qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package") + return qs diff --git a/epapi/v1/endpoints/__init__.py b/epapi/v1/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/epapi/v1/endpoints/compounds.py b/epapi/v1/endpoints/compounds.py new file mode 100644 index 00000000..dd7e153f --- /dev/null +++ b/epapi/v1/endpoints/compounds.py @@ -0,0 +1,41 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import Compound +from ..pagination import EnhancedPageNumberPagination +from ..schemas import CompoundOutSchema, ReviewStatusFilter +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/compounds/", response=EnhancedPageNumberPagination.Output[CompoundOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_compounds(request): + """ + List all compounds from reviewed packages. + """ + return get_user_entities_qs(Compound, request.user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/compound/", + response=EnhancedPageNumberPagination.Output[CompoundOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_compounds(request, package_uuid: UUID): + """ + List all compounds for a specific package. + """ + user = request.user + return get_package_scoped_entities_qs(Compound, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/models.py b/epapi/v1/endpoints/models.py new file mode 100644 index 00000000..1ca6cc1e --- /dev/null +++ b/epapi/v1/endpoints/models.py @@ -0,0 +1,41 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import EPModel +from ..pagination import EnhancedPageNumberPagination +from ..schemas import ModelOutSchema, ReviewStatusFilter +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/models/", response=EnhancedPageNumberPagination.Output[ModelOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_models(request): + """ + List all models from reviewed packages. + """ + return get_user_entities_qs(EPModel, request.user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/model/", + response=EnhancedPageNumberPagination.Output[ModelOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_models(request, package_uuid: UUID): + """ + List all models for a specific package. + """ + user = request.user + return get_package_scoped_entities_qs(EPModel, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/packages.py b/epapi/v1/endpoints/packages.py new file mode 100644 index 00000000..7f4265ee --- /dev/null +++ b/epapi/v1/endpoints/packages.py @@ -0,0 +1,27 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +import logging + +from ..dal import get_user_packages_qs +from ..pagination import EnhancedPageNumberPagination +from ..schemas import PackageOutSchema, SelfReviewStatusFilter + +router = Router() +logger = logging.getLogger(__name__) + + +@router.get("/packages/", response=EnhancedPageNumberPagination.Output[PackageOutSchema], auth=None) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=SelfReviewStatusFilter, +) +def list_all_packages(request): + """ + List packages accessible to the user. + + """ + user = request.user + qs = get_user_packages_qs(user) + return qs.order_by("name").all() diff --git a/epapi/v1/endpoints/pathways.py b/epapi/v1/endpoints/pathways.py new file mode 100644 index 00000000..5f704ae0 --- /dev/null +++ b/epapi/v1/endpoints/pathways.py @@ -0,0 +1,42 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import Pathway +from ..pagination import EnhancedPageNumberPagination +from ..schemas import PathwayOutSchema, ReviewStatusFilter +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/pathways/", response=EnhancedPageNumberPagination.Output[PathwayOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_pathways(request): + """ + List all pathways from reviewed packages. + """ + user = request.user + return get_user_entities_qs(Pathway, user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/pathway/", + response=EnhancedPageNumberPagination.Output[PathwayOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_pathways(request, package_uuid: UUID): + """ + List all pathways for a specific package. + """ + user = request.user + return get_package_scoped_entities_qs(Pathway, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/reactions.py b/epapi/v1/endpoints/reactions.py new file mode 100644 index 00000000..d43623c9 --- /dev/null +++ b/epapi/v1/endpoints/reactions.py @@ -0,0 +1,42 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import Reaction +from ..pagination import EnhancedPageNumberPagination +from ..schemas import ReactionOutSchema, ReviewStatusFilter +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/reactions/", response=EnhancedPageNumberPagination.Output[ReactionOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_reactions(request): + """ + List all reactions from reviewed packages. + """ + user = request.user + return get_user_entities_qs(Reaction, user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/reaction/", + response=EnhancedPageNumberPagination.Output[ReactionOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_reactions(request, package_uuid: UUID): + """ + List all reactions for a specific package. + """ + user = request.user + return get_package_scoped_entities_qs(Reaction, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/rules.py b/epapi/v1/endpoints/rules.py new file mode 100644 index 00000000..31808b74 --- /dev/null +++ b/epapi/v1/endpoints/rules.py @@ -0,0 +1,42 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import Rule +from ..pagination import EnhancedPageNumberPagination +from ..schemas import ReviewStatusFilter, RuleOutSchema +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/rules/", response=EnhancedPageNumberPagination.Output[RuleOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_rules(request): + """ + List all rules from reviewed packages. + """ + user = request.user + return get_user_entities_qs(Rule, user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/rule/", + response=EnhancedPageNumberPagination.Output[RuleOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_rules(request, package_uuid: UUID): + """ + List all rules for a specific package. + """ + user = request.user + return get_package_scoped_entities_qs(Rule, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/scenarios.py b/epapi/v1/endpoints/scenarios.py new file mode 100644 index 00000000..bb1c33b4 --- /dev/null +++ b/epapi/v1/endpoints/scenarios.py @@ -0,0 +1,36 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from epdb.models import Scenario +from ..pagination import EnhancedPageNumberPagination +from ..schemas import ReviewStatusFilter, ScenarioOutSchema +from ..dal import get_user_entities_qs, get_package_scoped_entities_qs + +router = Router() + + +@router.get("/scenarios/", response=EnhancedPageNumberPagination.Output[ScenarioOutSchema]) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_all_scenarios(request): + user = request.user + return get_user_entities_qs(Scenario, user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/scenario/", + response=EnhancedPageNumberPagination.Output[ScenarioOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=ReviewStatusFilter, +) +def list_package_scenarios(request, package_uuid: UUID): + user = request.user + return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all() diff --git a/epapi/v1/endpoints/structure.py b/epapi/v1/endpoints/structure.py new file mode 100644 index 00000000..68a0d6c7 --- /dev/null +++ b/epapi/v1/endpoints/structure.py @@ -0,0 +1,50 @@ +from django.conf import settings as s +from ninja import Router +from ninja_extra.pagination import paginate +from uuid import UUID + +from ..pagination import EnhancedPageNumberPagination +from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter +from ..dal import ( + get_user_structures_qs, + get_package_compound_scoped_structure_qs, +) + +router = Router() + + +@router.get( + "/structures/", response=EnhancedPageNumberPagination.Output[CompoundStructureOutSchema] +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=StructureReviewStatusFilter, +) +def list_all_structures(request): + """ + List all structures from all packages. + """ + user = request.user + return get_user_structures_qs(user).order_by("name").all() + + +@router.get( + "/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/", + response=EnhancedPageNumberPagination.Output[CompoundStructureOutSchema], +) +@paginate( + EnhancedPageNumberPagination, + page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE, + filter_schema=StructureReviewStatusFilter, +) +def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID): + """ + List all structures for a specific package and compound. + """ + user = request.user + return ( + get_package_compound_scoped_structure_qs(package_uuid, compound_uuid, user) + .order_by("name") + .all() + ) diff --git a/epapi/v1/errors.py b/epapi/v1/errors.py new file mode 100644 index 00000000..864e3337 --- /dev/null +++ b/epapi/v1/errors.py @@ -0,0 +1,28 @@ +from ninja.errors import HttpError + + +class EPAPIError(HttpError): + status_code: int = 500 + + def __init__(self, message: str) -> None: + super().__init__(status_code=self.status_code, message=message) + + @classmethod + def from_exception(cls, exc: Exception): + return cls(message=str(exc)) + + +class EPAPIUnauthorizedError(EPAPIError): + status_code = 401 + + +class EPAPIPermissionDeniedError(EPAPIError): + status_code = 403 + + +class EPAPINotFoundError(EPAPIError): + status_code = 404 + + +class EPAPIValidationError(EPAPIError): + status_code = 422 diff --git a/epapi/v1/pagination.py b/epapi/v1/pagination.py new file mode 100644 index 00000000..7d4fc234 --- /dev/null +++ b/epapi/v1/pagination.py @@ -0,0 +1,60 @@ +import math +from typing import Any, Generic, List, TypeVar + +from django.db.models import QuerySet +from ninja import Schema +from ninja.pagination import PageNumberPagination + +T = TypeVar("T") + + +class EnhancedPageNumberPagination(PageNumberPagination): + class Output(Schema, Generic[T]): + items: List[T] + page: int + page_size: int + total_items: int + total_pages: int + + def paginate_queryset( + self, + queryset: QuerySet, + pagination: PageNumberPagination.Input, + **params: Any, + ) -> Any: + page_size = self._get_page_size(pagination.page_size) + offset = (pagination.page - 1) * page_size + total_items = self._items_count(queryset) + total_pages = math.ceil(total_items / page_size) if page_size > 0 else 0 + + return { + "items": queryset[offset : offset + page_size], + "page": pagination.page, + "page_size": page_size, + "total_items": total_items, + "total_pages": total_pages, + } + + async def apaginate_queryset( + self, + queryset: QuerySet, + pagination: PageNumberPagination.Input, + **params: Any, + ) -> Any: + page_size = self._get_page_size(pagination.page_size) + offset = (pagination.page - 1) * page_size + total_items = await self._aitems_count(queryset) + total_pages = math.ceil(total_items / page_size) if page_size > 0 else 0 + + if isinstance(queryset, QuerySet): + items = [obj async for obj in queryset[offset : offset + page_size]] + else: + items = queryset[offset : offset + page_size] + + return { + "items": items, + "page": pagination.page, + "page_size": page_size, + "total_items": total_items, + "total_pages": total_pages, + } diff --git a/epapi/v1/router.py b/epapi/v1/router.py new file mode 100644 index 00000000..a5cd2dea --- /dev/null +++ b/epapi/v1/router.py @@ -0,0 +1,22 @@ +from ninja import Router +from ninja.security import SessionAuth +from .auth import BearerTokenAuth +from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure + +# Main router with authentication +router = Router( + auth=[ + SessionAuth(), + BearerTokenAuth(), + ] +) + +# Include all endpoint routers +router.add_router("", packages.router) +router.add_router("", scenarios.router) +router.add_router("", compounds.router) +router.add_router("", rules.router) +router.add_router("", reactions.router) +router.add_router("", pathways.router) +router.add_router("", models.router) +router.add_router("", structure.router) diff --git a/epapi/v1/schemas.py b/epapi/v1/schemas.py new file mode 100644 index 00000000..b5620d86 --- /dev/null +++ b/epapi/v1/schemas.py @@ -0,0 +1,104 @@ +from ninja import FilterSchema, FilterLookup, Schema +from typing import Annotated, Optional +from uuid import UUID + + +# Filter schema for query parameters +class ReviewStatusFilter(FilterSchema): + """Filter schema for review_status query parameter.""" + + review_status: Annotated[Optional[bool], FilterLookup("package__reviewed")] = None + + +class SelfReviewStatusFilter(FilterSchema): + """Filter schema for review_status query parameter on self-reviewed entities.""" + + review_status: Annotated[Optional[bool], FilterLookup("reviewed")] = None + + +class StructureReviewStatusFilter(FilterSchema): + """Filter schema for review_status on structures (via compound->package).""" + + review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None + + +# Base schema for all package-scoped entities +class PackageEntityOutSchema(Schema): + """Base schema for entities belonging to a package.""" + + uuid: UUID + url: str = "" + name: str + description: str + review_status: str = "" + package: str = "" + + @staticmethod + def resolve_url(obj): + return obj.url + + @staticmethod + def resolve_package(obj): + return obj.package.url + + @staticmethod + def resolve_review_status(obj): + return "reviewed" if obj.package.reviewed else "unreviewed" + + +# All package-scoped entities inherit from base +class ScenarioOutSchema(PackageEntityOutSchema): + pass + + +class CompoundOutSchema(PackageEntityOutSchema): + pass + + +class RuleOutSchema(PackageEntityOutSchema): + pass + + +class ReactionOutSchema(PackageEntityOutSchema): + pass + + +class PathwayOutSchema(PackageEntityOutSchema): + pass + + +class ModelOutSchema(PackageEntityOutSchema): + pass + + +class CompoundStructureOutSchema(PackageEntityOutSchema): + compound: str = "" + + @staticmethod + def resolve_compound(obj): + return obj.compound.url + + @staticmethod + def resolve_package(obj): + return obj.compound.package.url + + @staticmethod + def resolve_review_status(obj): + return "reviewed" if obj.compound.package.reviewed else "unreviewed" + + +# Package is special (no package FK) +class PackageOutSchema(Schema): + uuid: UUID + url: str = "" + name: str + description: str + review_status: str = "" + + @staticmethod + def resolve_url(obj): + return obj.url + + @staticmethod + def resolve_review_status(obj): + return "reviewed" if obj.reviewed else "unreviewed" diff --git a/epdb/logic.py b/epdb/logic.py index 729b9eea..08f2adca 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -444,6 +444,7 @@ class PackageManager(object): if PackageManager.readable(user, p): return p else: + # FIXME: use custom exception to be translatable to 403 in API raise ValueError( "Insufficient permissions to access Package with ID {}".format(package_id) ) diff --git a/epdb/views.py b/epdb/views.py index 917ae6da..039cb85a 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -474,20 +474,15 @@ def packages(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Packages" - - context["object_type"] = "package" context["meta"]["current_package"] = context["meta"]["user"].default_package - context["meta"]["can_edit"] = True - reviewed_package_qs = Package.objects.filter(reviewed=True).order_by("created") - unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user).order_by( - "name" - ) + # Context for paginated template + context["entity_type"] = "package" + context["api_endpoint"] = "/api/v1/packages/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "packages" - context["reviewed_objects"] = reviewed_package_qs - context["unreviewed_objects"] = unreviewed_package_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/packages_paginated.html", context) elif request.method == "POST": if hidden := request.POST.get("hidden", None): @@ -533,29 +528,16 @@ def compounds(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Compounds" - - context["object_type"] = "compound" context["meta"]["current_package"] = context["meta"]["user"].default_package - reviewed_compound_qs = Compound.objects.none() + # Context for paginated template + context["entity_type"] = "compound" + context["api_endpoint"] = "/api/v1/compounds/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_mode"] = "tabbed" + context["list_title"] = "compounds" - for p in PackageManager.get_reviewed_packages(): - reviewed_compound_qs |= Compound.objects.filter(package=p) - - reviewed_compound_qs = reviewed_compound_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": True} - for pw in reviewed_compound_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_compound_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/compounds_paginated.html", context) elif request.method == "POST": # delegate to default package @@ -571,32 +553,19 @@ def rules(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Rules" - - context["object_type"] = "rule" context["meta"]["current_package"] = context["meta"]["user"].default_package context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Rule": s.SERVER_URL + "/rule"}, ] - reviewed_rule_qs = Rule.objects.none() - for p in PackageManager.get_reviewed_packages(): - reviewed_rule_qs |= Rule.objects.filter(package=p) + # Context for paginated template + context["entity_type"] = "rule" + context["api_endpoint"] = "/api/v1/rules/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "rules" - reviewed_rule_qs = reviewed_rule_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": True} - for pw in reviewed_rule_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_rule_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/rules_paginated.html", context) elif request.method == "POST": # delegate to default package @@ -612,32 +581,19 @@ def reactions(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Reactions" - - context["object_type"] = "reaction" context["meta"]["current_package"] = context["meta"]["user"].default_package context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Reaction": s.SERVER_URL + "/reaction"}, ] - reviewed_reaction_qs = Reaction.objects.none() - for p in PackageManager.get_reviewed_packages(): - reviewed_reaction_qs |= Reaction.objects.filter(package=p).order_by("name") + # Context for paginated template + context["entity_type"] = "reaction" + context["api_endpoint"] = "/api/v1/reactions/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "reactions" - reviewed_reaction_qs = reviewed_reaction_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": True} - for pw in reviewed_reaction_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_reaction_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/reactions_paginated.html", context) elif request.method == "POST": # delegate to default package @@ -653,33 +609,19 @@ def pathways(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Pathways" - - context["object_type"] = "pathway" context["meta"]["current_package"] = context["meta"]["user"].default_package context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Pathway": s.SERVER_URL + "/pathway"}, ] - reviewed_pathway_qs = Pathway.objects.none() + # Context for paginated template + context["entity_type"] = "pathway" + context["api_endpoint"] = "/api/v1/pathways/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "pathways" - for p in PackageManager.get_reviewed_packages(): - reviewed_pathway_qs |= Pathway.objects.filter(package=p).order_by("name") - - reviewed_pathway_qs = reviewed_pathway_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": True} - for pw in reviewed_pathway_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_pathway_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/pathways_paginated.html", context) elif request.method == "POST": # delegate to default package @@ -703,25 +645,13 @@ def scenarios(request): {"Scenario": s.SERVER_URL + "/scenario"}, ] - reviewed_scenario_qs = Scenario.objects.none() + # Context for paginated template + context["entity_type"] = "scenario" + context["api_endpoint"] = "/api/v1/scenarios/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "scenarios" - for p in PackageManager.get_reviewed_packages(): - reviewed_scenario_qs |= Scenario.objects.filter(package=p).order_by("name") - - reviewed_scenario_qs = reviewed_scenario_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": s.name, "url": s.url, "reviewed": True} - for s in reviewed_scenario_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_scenario_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/scenarios_paginated.html", context) elif request.method == "POST": # delegate to default package @@ -736,42 +666,28 @@ def models(request): if request.method == "GET": context = get_base_context(request) context["title"] = "enviPath - Models" - - context["object_type"] = "model" context["meta"]["current_package"] = context["meta"]["user"].default_package context["breadcrumbs"] = [ {"Home": s.SERVER_URL}, {"Model": s.SERVER_URL + "/model"}, ] + # Keep model_types for potential modal/action use context["model_types"] = { "ML Relative Reasoning": "ml-relative-reasoning", "Rule Based Relative Reasoning": "rule-based-relative-reasoning", "EnviFormer": "enviformer", } - for k, v in s.CLASSIFIER_PLUGINS.items(): context["model_types"][v.display()] = k - reviewed_model_qs = EPModel.objects.none() + # Context for paginated template + context["entity_type"] = "model" + context["api_endpoint"] = "/api/v1/models/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "models" - for p in PackageManager.get_reviewed_packages(): - reviewed_model_qs |= EPModel.objects.filter(package=p).order_by("name") - - reviewed_model_qs = reviewed_model_qs.order_by("name") - - if request.GET.get("all"): - return JsonResponse( - { - "objects": [ - {"name": pw.name, "url": pw.url, "reviewed": True} - for pw in reviewed_model_qs - ] - } - ) - - context["reviewed_objects"] = reviewed_model_qs - return render(request, "collections/objects_list.html", context) + return render(request, "collections/models_paginated.html", context) elif request.method == "POST": current_user = _anonymous_or_real(request) @@ -848,6 +764,10 @@ def package_models(request, package_uuid): context["meta"]["current_package"] = current_package context["object_type"] = "model" context["breadcrumbs"] = breadcrumbs(current_package, "model") + context["entity_type"] = "model" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "models" reviewed_model_qs = EPModel.objects.none() unreviewed_model_qs = EPModel.objects.none() @@ -869,9 +789,6 @@ def package_models(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_model_qs - context["unreviewed_objects"] = unreviewed_model_qs - context["model_types"] = { "ML Relative Reasoning": "mlrr", "Rule Based Relative Reasoning": "rbrr", @@ -884,7 +801,7 @@ def package_models(request, package_uuid): for k, v in s.CLASSIFIER_PLUGINS.items(): context["model_types"][v.display()] = k - return render(request, "collections/objects_list.html", context) + return render(request, "collections/models_paginated.html", context) elif request.method == "POST": log_post_params(request) @@ -1242,6 +1159,11 @@ def package_compounds(request, package_uuid): context["meta"]["current_package"] = current_package context["object_type"] = "compound" context["breadcrumbs"] = breadcrumbs(current_package, "compound") + context["entity_type"] = "compound" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_mode"] = "tabbed" + context["list_title"] = "compounds" reviewed_compound_qs = Compound.objects.none() unreviewed_compound_qs = Compound.objects.none() @@ -1267,10 +1189,7 @@ def package_compounds(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_compound_qs - context["unreviewed_objects"] = unreviewed_compound_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/compounds_paginated.html", context) elif request.method == "POST": compound_name = request.POST.get("compound-name") @@ -1389,19 +1308,17 @@ def package_compound_structures(request, package_uuid, compound_uuid): context["breadcrumbs"] = breadcrumbs( current_package, "compound", current_compound, "structure" ) + context["entity_type"] = "structure" + context["page_title"] = f"{current_compound.name} - Structures" + context["api_endpoint"] = ( + f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/" + ) + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["compound"] = current_compound + context["list_mode"] = "combined" + context["list_title"] = "structures" - reviewed_compound_structure_qs = CompoundStructure.objects.none() - unreviewed_compound_structure_qs = CompoundStructure.objects.none() - - if current_package.reviewed: - reviewed_compound_structure_qs = current_compound.structures.order_by("name") - else: - unreviewed_compound_structure_qs = current_compound.structures.order_by("name") - - context["reviewed_objects"] = reviewed_compound_structure_qs - context["unreviewed_objects"] = unreviewed_compound_structure_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/structures_paginated.html", context) elif request.method == "POST": structure_name = request.POST.get("structure-name") @@ -1548,6 +1465,10 @@ def package_rules(request, package_uuid): context["meta"]["current_package"] = current_package context["object_type"] = "rule" context["breadcrumbs"] = breadcrumbs(current_package, "rule") + context["entity_type"] = "rule" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "rules" reviewed_rule_qs = Rule.objects.none() unreviewed_rule_qs = Rule.objects.none() @@ -1569,10 +1490,7 @@ def package_rules(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_rule_qs - context["unreviewed_objects"] = unreviewed_rule_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/rules_paginated.html", context) elif request.method == "POST": log_post_params(request) @@ -1750,11 +1668,15 @@ def package_reactions(request, package_uuid): if request.method == "GET": context = get_base_context(request) - context["title"] = f"enviPath - {current_package.name} - {current_package.name} - Reactions" + context["title"] = f"enviPath - {current_package.name} - Reactions" context["meta"]["current_package"] = current_package context["object_type"] = "reaction" context["breadcrumbs"] = breadcrumbs(current_package, "reaction") + context["entity_type"] = "reaction" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "reactions" reviewed_reaction_qs = Reaction.objects.none() unreviewed_reaction_qs = Reaction.objects.none() @@ -1780,10 +1702,7 @@ def package_reactions(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_reaction_qs - context["unreviewed_objects"] = unreviewed_reaction_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/reactions_paginated.html", context) elif request.method == "POST": reaction_name = request.POST.get("reaction-name") @@ -1902,6 +1821,10 @@ def package_pathways(request, package_uuid): context["meta"]["current_package"] = current_package context["object_type"] = "pathway" context["breadcrumbs"] = breadcrumbs(current_package, "pathway") + context["entity_type"] = "pathway" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "pathways" reviewed_pathway_qs = Pathway.objects.none() unreviewed_pathway_qs = Pathway.objects.none() @@ -1925,10 +1848,7 @@ def package_pathways(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_pathway_qs - context["unreviewed_objects"] = unreviewed_pathway_qs - - return render(request, "collections/objects_list.html", context) + return render(request, "collections/pathways_paginated.html", context) elif request.method == "POST": log_post_params(request) @@ -2465,6 +2385,10 @@ def package_scenarios(request, package_uuid): context["meta"]["current_package"] = current_package context["object_type"] = "scenario" context["breadcrumbs"] = breadcrumbs(current_package, "scenario") + context["entity_type"] = "scenario" + context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/" + context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE + context["list_title"] = "scenarios" reviewed_scenario_qs = Scenario.objects.none() unreviewed_scenario_qs = Scenario.objects.none() @@ -2490,9 +2414,6 @@ def package_scenarios(request, package_uuid): } ) - context["reviewed_objects"] = reviewed_scenario_qs - context["unreviewed_objects"] = unreviewed_scenario_qs - from envipy_additional_information import ( SEDIMENT_ADDITIONAL_INFORMATION, SLUDGE_ADDITIONAL_INFORMATION, @@ -2527,7 +2448,7 @@ def package_scenarios(request, package_uuid): context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION - return render(request, "collections/objects_list.html", context) + return render(request, "collections/scenarios_paginated.html", context) elif request.method == "POST": log_post_params(request) diff --git a/pyproject.toml b/pyproject.toml index 3a0879dd..0a91c442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ dependencies = [ "django>=5.2.1", "django-extensions>=4.1", "django-model-utils>=5.0.0", - "django-ninja>=1.4.1", + "django-ninja>=1.4.5", + "django-ninja-extra>=0.30.6", "django-oauth-toolkit>=3.0.1", "django-polymorphic>=4.1.0", "enviformer", @@ -47,6 +48,7 @@ dev = [ "ruff>=0.13.3", "pytest-playwright>=0.7.1", "pytest-django>=4.11.1", + "pytest-cov>=7.0.0", ] [tool.ruff] @@ -121,3 +123,22 @@ collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help ] } frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" } + +[tool.pytest.ini_options] +addopts = "--verbose --capture=no --durations=10" +testpaths = ["tests", "*/tests"] +pythonpath = ["."] +norecursedirs = [ + "env", + "venv", + "envipy-plugins", + "envipy-additional-information", + "envipy-ambit", + "enviformer", +] +markers = [ + "api: API tests", + "frontend: Frontend tests", + "end2end: End-to-end tests", + "slow: Slow tests", +] diff --git a/static/css/input.css b/static/css/input.css index 00694158..2407b62e 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -36,24 +36,17 @@ @import "./daisyui-theme.css"; /* Loading Spinner - Benzene Ring */ -.loading-spinner { +.benzene-spinner { display: flex; justify-content: center; align-items: center; padding: 2rem; } -.loading-spinner svg { +.benzene-spinner svg { width: 48px; height: 48px; - animation: spin 2s linear infinite; -} - -.loading-spinner .hexagon, -.loading-spinner .double-bonds { - fill: none; - stroke: currentColor; - stroke-width: 2; + animation: spin 3s linear infinite; } @keyframes spin { diff --git a/static/js/alpine/pagination.js b/static/js/alpine/pagination.js index 31696518..e87687b6 100644 --- a/static/js/alpine/pagination.js +++ b/static/js/alpine/pagination.js @@ -5,31 +5,26 @@ */ document.addEventListener('alpine:init', () => { - Alpine.data('paginatedList', (initialItems = [], options = {}) => ({ - allItems: initialItems, - filteredItems: [], + Alpine.data('remotePaginatedList', (options = {}) => ({ + items: [], currentPage: 1, + totalPages: 0, + totalItems: 0, perPage: options.perPage || 50, - searchQuery: '', + endpoint: options.endpoint || '', isReviewed: options.isReviewed || false, instanceId: options.instanceId || Math.random().toString(36).substring(2, 9), + isLoading: false, + error: null, init() { - this.filteredItems = this.allItems; - }, - - get totalPages() { - return Math.ceil(this.filteredItems.length / this.perPage); + if (this.endpoint) { + this.fetchPage(1); + } }, get paginatedItems() { - const start = (this.currentPage - 1) * this.perPage; - const end = start + this.perPage; - return this.filteredItems.slice(start, end); - }, - - get totalItems() { - return this.filteredItems.length; + return this.items; }, get showingStart() { @@ -38,36 +33,65 @@ document.addEventListener('alpine:init', () => { }, get showingEnd() { - return Math.min(this.currentPage * this.perPage, this.totalItems); + if (this.totalItems === 0) return 0; + return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems); }, - search(query) { - this.searchQuery = query.toLowerCase(); - if (this.searchQuery === '') { - this.filteredItems = this.allItems; - } else { - this.filteredItems = this.allItems.filter(item => - item.name.toLowerCase().includes(this.searchQuery) - ); + async fetchPage(page) { + if (!this.endpoint) { + return; + } + + this.isLoading = true; + this.error = null; + + try { + const url = new URL(this.endpoint, window.location.origin); + // Preserve existing query parameters and add pagination params + url.searchParams.set('page', page.toString()); + url.searchParams.set('page_size', this.perPage.toString()); + + const response = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`Failed to load ${this.endpoint} (status ${response.status})`); + } + + const data = await response.json(); + this.items = data.items || []; + this.totalItems = data.total_items || 0; + this.totalPages = data.total_pages || 0; + this.currentPage = data.page || page; + this.perPage = data.page_size || this.perPage; + + // Dispatch event for parent components (e.g., tab count updates) + this.$dispatch('items-loaded', { totalItems: this.totalItems }); + } catch (err) { + console.error(err); + this.error = `Unable to load ${this.endpoint}. Please try again.`; + } finally { + this.isLoading = false; } - this.currentPage = 1; }, nextPage() { if (this.currentPage < this.totalPages) { - this.currentPage++; + this.fetchPage(this.currentPage + 1); } }, prevPage() { if (this.currentPage > 1) { - this.currentPage--; + this.fetchPage(this.currentPage - 1); } }, goToPage(page) { if (page >= 1 && page <= this.totalPages) { - this.currentPage = page; + this.fetchPage(page); } }, @@ -76,54 +100,43 @@ document.addEventListener('alpine:init', () => { const total = this.totalPages; const current = this.currentPage; - // Handle empty case if (total === 0) { return pages; } if (total <= 7) { - // Show all pages if 7 or fewer for (let i = 1; i <= total; i++) { pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` }); } } else { - // More than 7 pages - show first, last, and sliding window around current - // Always show first page pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` }); - // Determine the start and end of the middle range - let rangeStart, rangeEnd; + let rangeStart; + let rangeEnd; if (current <= 4) { - // Near the beginning: show pages 2-5 rangeStart = 2; rangeEnd = 5; } else if (current >= total - 3) { - // Near the end: show last 4 pages before the last page rangeStart = total - 4; rangeEnd = total - 1; } else { - // In the middle: show current page and one on each side rangeStart = current - 1; rangeEnd = current + 1; } - // Add ellipsis before range if there's a gap if (rangeStart > 2) { pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` }); } - // Add pages in the range for (let i = rangeStart; i <= rangeEnd; i++) { pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` }); } - // Add ellipsis after range if there's a gap if (rangeEnd < total - 1) { pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` }); } - // Always show last page pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` }); } diff --git a/templates/actions/collections/compound.html b/templates/actions/collections/compound.html deleted file mode 100644 index 9133ba9a..00000000 --- a/templates/actions/collections/compound.html +++ /dev/null @@ -1,10 +0,0 @@ -{% if meta.can_edit %} -
No {{ empty_text|default:"items" }} found.
++ A compound stores the structure of a molecule and can include + meta-information. +
+ + Learn more >> + +{% endblock description %} diff --git a/templates/collections/models_paginated.html b/templates/collections/models_paginated.html new file mode 100644 index 00000000..b98ef561 --- /dev/null +++ b/templates/collections/models_paginated.html @@ -0,0 +1,32 @@ +{% extends "collections/paginated_base.html" %} + +{% block page_title %}Models{% endblock %} + +{% block action_button %} + {% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %} + + {% endif %} +{% endblock action_button %} + +{% block action_modals %} + {% if meta.enabled_features.MODEL_BUILDING %} + {% include "modals/collections/new_model_modal.html" %} + {% endif %} +{% endblock action_modals %} + +{% block description %} +A model applies machine learning to limit the combinatorial explosion.
+ + Learn more >> + +{% endblock description %} diff --git a/templates/collections/objects_list.html b/templates/collections/objects_list.html index 43111fae..92c661df 100644 --- a/templates/collections/objects_list.html +++ b/templates/collections/objects_list.html @@ -4,7 +4,8 @@ {# Serialize objects data for Alpine pagination #} {# prettier-ignore-start #} - {# FIXME: This is a hack to get the objects data into the JavaScript code. #} +{# FIXME: This is a hack to get the objects data into the JavaScript code. #} +{% if object_type != 'scenario' %} - {# prettier-ignore-end #} +{% endif %} +{# prettier-ignore-end #} - {% if object_type != 'package' %} -- A package contains pathways, rules, etc. and can reflect specific - experimental conditions. - Learn more >> -
- {% elif object_type == 'compound' %} -- A compound stores the structure of a molecule and can include - meta-information. - Learn more >> -
- {% elif object_type == 'structure' %} -- The structures stored in this compound - Learn more >> -
- {% elif object_type == 'rule' %} -- A rule describes a biotransformation reaction template that is - defined as SMIRKS. - Learn more >> -
- {% elif object_type == 'reaction' %} -- A reaction is a specific biotransformation from educt compounds to - product compounds. - Learn more >> -
- {% elif object_type == 'pathway' %} -- A pathway displays the (predicted) biodegradation of a compound as - graph. - Learn more >> -
- {% elif object_type == 'node' %} + {% if object_type == 'node' %}
Nodes represent the (predicted) compounds in a graph.
{% elif object_type == 'edge' %}
- Edges represent the links between Nodes in a graph
+ Edges represent the links between nodes in a graph.
Learn more >>
- A scenario contains meta-information that can be attached to other
- data (compounds, rules, ..).
- Learn more >>
-
- A model applies machine learning to limit the combinatorial
- explosion.
- Learn more >>
-
- A setting includes configuration parameters for pathway
- predictions.
- Learn more >>
-
- Register now to create own packages and to submit and manage your
- data.
- Learn more >>
-
- Users can team up in groups to share packages.
- Learn more >>
-
- Nothing found. There are two possible reasons:
1.
- There is no content yet.
2. You have no reading
- permissions.
Please be sure you have at least reading
- permissions.
+ Nothing found. There are two possible reasons:
+ 1. There is no content yet.
+ 2. You have no reading permissions.
+ Please ensure you have at least reading permissions.