diff --git a/.circleci/config.yml b/.circleci/config.yml index 05db471ac..beff4919e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,10 +3,10 @@ version: 2 jobs: test: machine: - image: ubuntu-2204:2024.01.2 + image: ubuntu-2404:2025.09.1 + resource_class: large steps: - checkout - - run: # NOTE: To connect to this, use an SSH tunnel in front, like so.. # @@ -22,54 +22,73 @@ jobs: # x11vnc -forever -nopw background: true - - run: + - run: name: "Setup: Copy environment variables" command: cp .env_circleci .env - - run: name: "Setup: Create directories for MinIO (cannot be made by docker for some reason)" command: | - sudo mkdir -p var/minio/public - sudo mkdir -p var/minio/private + mkdir -p var/minio/public + mkdir -p var/minio/private - run: - name: "Docker: Build containers and collect static files" + name: "Setup: Prepare the playwright environment" command: | - docker compose -f docker-compose.yml -f docker-compose.selenium.yml up -d - docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django python manage.py collectstatic --noinput - - - run: - name: "Lint: Check code style with flake8" - command: docker-compose exec django flake8 src/ - - - - run: + cd tests + apt update && apt upgrade -y + curl -LsSf https://astral.sh/uv/install.sh | sh + $HOME/.local/bin/uv sync --frozen + $HOME/.local/bin/uv run playwright install + - run: name: "Docker: Pull required images" # not available without "not e2e" tests as they pull ahead of time command: | - docker pull codalab/codalab-legacy:py37 - docker pull codalab/codalab-legacy:py3 - docker pull vergilgxw/autotable:v2 + docker pull codalab/codalab-legacy:py37 + docker pull codalab/codalab-legacy:py3 + docker pull vergilgxw/autotable:v2 + background: true + + - run: + name: "Docker: Build containers and collect static files" + command: | + docker compose up -d + docker compose exec django python manage.py collectstatic --noinput + docker compose exec django python manage.py migrate + docker compose exec django python ./manage.py createsuperuser --no-input + + - run: + name: "Get compute worker, site worker and django logs" + command: | + mkdir dockerLogs + docker compose logs -f site_worker compute_worker django > dockerLogs/django_workers.log + background: true + + - run: + name: "Lint: Check code style with flake8" + command: docker compose exec django flake8 src/ - run: name: "Tests: Run unit/integration tests (excluding e2e)" - command: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/ -m "not e2e" + command: docker compose exec django py.test src/ -m "not e2e" + # We give the name of the test files manually because we need test_auth.py to be run before the others for state.json file to be created + # CI="true" to skip some tests that fail in the CI for now - run: name: "Tests: Run end-to-end (E2E) tests" - command: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/tests/functional/ -m e2e - no_output_timeout: 60m + command: | + cd tests && CI="true" $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py + no_output_timeout: 30m # Example to run specific set of tests (for debugging individual tests from a batch of tests) # - run: - # name: e2e tests - competitions - # command: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/tests/functional/test_competitions.py -m e2e - # no_output_timeout: 60m - - + # name: "Tests: Run end-to-end (E2E) tests" + # command: cd tests && $HOME/.local/bin/uv run pytest test_auth.py test_competition.py + # no_output_timeout: 30m + - store_artifacts: + path: tests/test-results - store_artifacts: - path: artifacts/ + path: dockerLogs/ workflows: version: 2 diff --git a/.env_circleci b/.env_circleci index 3037e07af..f181e9c01 100644 --- a/.env_circleci +++ b/.env_circleci @@ -32,3 +32,9 @@ AWS_STORAGE_PRIVATE_BUCKET_NAME=private # NOTE! port 9000 here should match $MINIO_PORT AWS_S3_ENDPOINT_URL=http://172.17.0.1:9000/ AWS_QUERYSTRING_AUTH=False +DJANGO_SUPERUSER_PASSWORD=codabench +DJANGO_SUPERUSER_EMAIL=test@test.com +DJANGO_SUPERUSER_USERNAME=codabench +DOMAIN_NAME=localhost:80 +TLS_EMAIL=your@email.com +SUBMISSIONS_API_URL=http://django:8000/api \ No newline at end of file diff --git a/.env_sample b/.env_sample index 8902c5223..8d8629a3c 100644 --- a/.env_sample +++ b/.env_sample @@ -45,6 +45,8 @@ SELENIUM_HOSTNAME=selenium #DEFAULT_FROM_EMAIL="Codabench " #SERVER_EMAIL=noreply@example.com +# Contact Email +CONTACT_EMAIL=info@codabench.org # ----------------------------------------------------------------------------- # Storage diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 31aa14768..67d2a9ebd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,21 +3,20 @@ ## 1. Being a Codabench user. - Create a user account on https://codalab.lisn.fr and on https://codabench.org. -- Register on https://codabench.org to this existing competition (IRIS-tuto) https://www.codabench.org/competitions/1115/ and make a submission (from https://github.com/codalab/competition-examples/tree/master/codabench/iris): sample_result_submission and sample_code_submission. See https://github.com/codalab/codabench/wiki/User_Participating-in-a-Competition -- Create your own private competition (from https://github.com/codalab/competition-examples/tree/master/codabench/ ). See https://github.com/codalab/codabench/wiki/Getting-started-with-Codabench +- Register on https://codabench.org to this existing competition (IRIS-tuto) https://www.codabench.org/competitions/1115/ and make a submission (from https://github.com/codalab/competition-examples/tree/master/codabench/iris): sample_result_submission and sample_code_submission. See https://docs.codabench.org/latest/Participants/User_Participating-in-a-Competition/ +- Create your own private competition (from https://github.com/codalab/competition-examples/tree/master/codabench/ ). See https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Getting-started-with-Codabench/ ## 2. Setting a local instance of Codabench. -- Follow the tutorial in codabench wiki: https://github.com/codalab/codabench/wiki/Codabench-Installation. According to your hosting OS, you might have to tune your environment file a bit. Try without enabling the SSL protocol (doing so, you don't need a domain name for the server). Try using the embedded Minio storage solution instead of a private cloud storage. -- If needed, you can also look into https://github.com/codalab/codabench/wiki/How-to-deploy-Codabench-on-your-server +- Follow the tutorial in codabench docs: https://docs.codabench.org/latest/Developers_and_Administrators/Codabench-Installation/. According to your hosting OS, you might have to tune your environment file a bit. Try without enabling the SSL protocol (doing so, you don't need a domain name for the server). Try using the embedded Minio storage solution instead of a private cloud storage. +- If needed, you can also look into https://docs.codabench.org/latest/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server/ ## 3. Using one's local instance - Create your own competition and play with it. You can look at the output logs of each different docker container. -- Setting you as an admin of your platform (https://github.com/codalab/codabench/wiki/Administrator-procedures#give-superuser-privileges-to-an-user) and visit the Django Admin menu: https://github.com/codalab/codabench/wiki/Administrator-procedures#give-superuser-privileges-to-an-user - +- Setting you as an admin of your platform (https://docs.codabench.org/latest/Developers_and_Administrators/Administrator-procedures/#give-superuser-privileges-to-a-user) and visit the Django Admin menu: https://docs.codabench.org/latest/Developers_and_Administrators/Administrator-procedures/#give-superuser-privileges-to-a-user ## 4. Setting an autonomous computer-worker on your PC -- Configure and launch the docker container: https://github.com/codalab/codabench/wiki/Compute-Worker-Management---Setup -- Create a private queue on your new own competition on the production server codabench.org: https://github.com/codalab/codabench/wiki/Queue-Management#create-queue +- Configure and launch the docker container: https://docs.codabench.org/latest/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup/ +- Create a private queue on your new own competition on the production server codabench.org: https://docs.codabench.org/latest/Organizers/Running_a_benchmark/Queue-Management/#create-queue - Assign your own compute-worker to this private queue instead of the default queue. diff --git a/.github/workflows/tests.yml.DISABLED b/.github/workflows/tests.yml.DISABLED new file mode 100644 index 000000000..91e5ee27a --- /dev/null +++ b/.github/workflows/tests.yml.DISABLED @@ -0,0 +1,63 @@ +name: build_and_test +on: [push] +jobs: + build: + name: Build necessary services + # runs-on: self-hosted + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v5 + - name: "Setup: Copy environment variables" + run: cp .env_circleci .env + - name: "Setup: Create directories for MinIO (cannot be made by docker for some reason)" + run: | + mkdir -p var/minio/public + mkdir -p var/minio/private + - name: "Setup: Prepare the playwright environment" + run: | + cd playwrightPython + curl -LsSf https://astral.sh/uv/install.sh | sh + $HOME/.local/bin/uv sync + $HOME/.local/bin/uv run playwright install + - name: "Docker: Build containers and collect static files" + run: | + docker compose -f docker-compose.yml -f docker-compose.selenium.yml up -d + docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django python manage.py collectstatic --noinput + docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django python manage.py migrate + docker compose -f docker-compose.yml exec django python ./manage.py createsuperuser --no-input + - name: "Docker: Pull required images" + run: | + docker pull codalab/codalab-legacy:py37 + docker pull codalab/codalab-legacy:py3 + linter: + name: Flake8 linter + runs-on: self-hosted + needs: [build] + steps: + - name: "Lint: Check code style with flake8" + run: docker compose exec django flake8 src/ + unit_tests: + name: Unit test with Selenium + runs-on: self-hosted + needs: [linter,build] + steps: + - name: "Tests: Run unit/integration tests (excluding e2e)" + run: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/ -m "not e2e" + e2e: + name: End to End tests with Playwright + runs-on: self-hosted + needs: [linter,build] + steps: + - name: "Tests: Run end-to-end (E2E) tests" + run: cd playwrightPython && $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py + cleanup: + name: Cleanup + runs-on: self-hosted + if: ${{ always() }} + needs: [unit_tests,e2e,linter] + steps: + - name: Cleanup + run: | + docker compose -f docker-compose.yml -f docker-compose.selenium.yml down --rmi all + rm -rf ${{ github.workspace }}/* \ No newline at end of file diff --git a/README.md b/README.md index c35898589..86e77be79 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ To see Codabench in action, visit [codabench.org](https://www.codabench.org/). ## Documentation -- [Codabench Wiki](https://github.com/codalab/codabench/wiki) +- [Codabench Docs](https://docs.codabench.org) ## Quick installation (for Linux) @@ -30,7 +30,7 @@ You can now login as username "admin" with password "admin" at http://localhost/ If you ever need to reset the database, use the script `./reset_db.sh` -For more information about installation, checkout [Codabench Basic Installation Guide](https://github.com/codalab/codabench/wiki/Codabench-Installation) and [How to Deploy Server](https://github.com/codalab/codabench/wiki/How-to-deploy-Codabench-on-your-server). +For more information about installation, checkout [Codabench Basic Installation Guide](https://docs.codabench.org/latest/Developers_and_Administrators/Codabench-Installation/) and [How to Deploy Server](https://docs.codabench.org/latest/Developers_and_Administrators/How-to-deploy-Codabench-on-your-server/). ## License diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 8f01c5dca..f47fb1758 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -32,7 +32,9 @@ from celery import signals import logging logger = logging.getLogger(__name__) -from logs_loguru import configure_logging +from logs_loguru import configure_logging,colorize_run_args +import json + # ----------------------------------------------- # Celery + Rabbit MQ @@ -115,7 +117,7 @@ class ExecutionTimeLimitExceeded(Exception): # ----------------------------------------------------------------------------- @shared_task(name="compute_worker_run") def run_wrapper(run_args): - logger.info(f"Received run arguments: {run_args}") + logger.info(f"Received run arguments: \n {colorize_run_args(json.dumps(run_args))}") run = Run(run_args) try: diff --git a/docker-compose.selenium.yml b/docker-compose.selenium.yml deleted file mode 100644 index d1c62894d..000000000 --- a/docker-compose.selenium.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: '3' -services: - django: - environment: - - SELENIUM_HOSTNAME=selenium - - SUBMISSIONS_API_URL=http://django:36475/api - - WEBSOCKET_ALLOWED_ORIGINS=* - ports: - - 36475:36475 - - selenium: - image: selenium/standalone-firefox:120.0 - volumes: - - ./src/tests/functional/test_files:/test_files/ - - ./artifacts:/artifacts/:z - ports: - - 4444:4444 - - 5900:5900 diff --git a/docs/example_scripts/README.md b/docs/example_scripts/README.md index 0ff861397..fd7311e46 100644 --- a/docs/example_scripts/README.md +++ b/docs/example_scripts/README.md @@ -4,4 +4,4 @@ These scripts have been built solely to improve users' understanding of the API and expressly not as utility scripts. They can serve as starting places, but they ought not be solely relied upon for automation. -They exist here for easy testing of robot submissions outlined [here](https://github.com/codalab/competitions-v2/wiki/Robot-submissions). +They exist here for easy testing of robot submissions outlined [here](https://docs.codabench.org/latest/Developers_and_Administrators/Robot-submissions/). diff --git a/documentation/README.md b/documentation/README.md index efd3cdad7..8fc6cd6bd 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -3,13 +3,13 @@ Welcome to the Codabench Documentation. You can access the documentation generated from this folder [here](https://codalab.org/codabench/latest/) -If you want to contribute to the wiki, you can create a Pull Request modifying the files you want (located in `docs/`) while adding a quick explanation on what you have changed and why. +If you want to contribute to the docs, you can create a Pull Request modifying the files you want (located in `docs/`) while adding a quick explanation on what you have changed and why. -When creating a Pull Request to modify the core code of Codabench, you can also include the wiki modification by modifying the relevant files. Once the Pull Request is merged, the wiki will automatically be updated with your changes (`dev` tag when it's merged into develop, and the `latest` version of the wiki once it's merged in master) +When creating a Pull Request to modify the core code of Codabench, you can also include the docs modification by modifying the relevant files. Once the Pull Request is merged, the docs will automatically be updated with your changes (`dev` tag when it's merged into develop, and the `latest` version of the docs once it's merged in master) ## How to build -To build the wiki locally, you will have to first install [uv](https://github.com/astral-sh/uv) +To build the docs locally, you will have to first install [uv](https://github.com/astral-sh/uv) Once that is done, you can run the following commands (while inside this folder): @@ -18,12 +18,12 @@ uv sync # You only need to run this once, it will download all the necessary pyt PDF=1 uv run mkdocs serve -a localhost:8888 # This will build the site and serve it on localhost:8888 ``` -Open [localhost:8888](http://localhost:8888/) in your browser and you will see the wiki. Every changes you make will rebuild the documentation. +Open [localhost:8888](http://localhost:8888/) in your browser and you will see the docs. Every changes you make will rebuild the documentation. You can remove the `PDF=1` environement variable if you want to speed up the build process, but you will not generate the related PDF. ### Versioning -We use the [mike](https://github.com/jimporter/mike) plugin to preserve multiple version of the wiki. +We use the [mike](https://github.com/jimporter/mike) plugin to preserve multiple version of the docs. To use it, you can run the following command: ```bash @@ -36,4 +36,4 @@ Check the official Github page of the plugin for more information on how it work Images and assets are saved in the `_attachments` folder closest to the documentation file that calls for the image. If an image is used in multiple different places, then it should be put in `_attachements` folder in the `docs/` root directory. ## Github workflow -We have Github workflows set up to automatically rebuild the wiki when the `develop` branch receives changes, and when a new tag is created for the `master` branch. \ No newline at end of file +We have Github workflows set up to automatically rebuild the docs when the `develop` branch receives changes, and when a new tag is created for the `master` branch. \ No newline at end of file diff --git a/documentation/docs/Contribute/contributing.md b/documentation/docs/Contribute/contributing.md index d88ed4695..30be82e01 100644 --- a/documentation/docs/Contribute/contributing.md +++ b/documentation/docs/Contribute/contributing.md @@ -6,7 +6,7 @@ ## Setting up a local instance of Codabench -- Follow the tutorial in codabench [wiki](../Developers_and_Administrators/Codabench-Installation.md). According to your hosting OS, you might have to tune your environment file a bit. Try without enabling the SSL protocol (doing so, you don't need a domain name for the server). Try using the embedded Minio storage solution instead of a private cloud storage. +- Follow the tutorial in codabench [Docs](../Developers_and_Administrators/Codabench-Installation.md). According to your hosting OS, you might have to tune your environment file a bit. Try without enabling the SSL protocol (doing so, you don't need a domain name for the server). Try using the embedded Minio storage solution instead of a private cloud storage. - If needed, you can also look into [How to deploy Codabench on your server](../Developers_and_Administrators/How-to-deploy-Codabench-on-your-server.md) ### Using your local instance diff --git a/documentation/docs/Developers_and_Administrators/Adding-e2e-tests.md b/documentation/docs/Developers_and_Administrators/Adding-e2e-tests.md new file mode 100644 index 000000000..2d30a674d --- /dev/null +++ b/documentation/docs/Developers_and_Administrators/Adding-e2e-tests.md @@ -0,0 +1,63 @@ +# To run the tests locally +Install uv : [https://docs.astral.sh/uv/getting-started/installation/](https://docs.astral.sh/uv/getting-started/installation/) + +Run the following commands: +```bash +cd tests +uv sync --frozen +uv run playwright install +docker compose exec -e DJANGO_SUPERUSER_PASSWORD=codabench django python manage.py createsuperuser --username codabench --email codabench@test.mail --no-input +uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py +``` + +# Adding Tests +First, read the [documentation](https://playwright.dev/python/docs/writing-tests) on Playwright if you haven't used the tool before. +Since we are using pytest, you should also try to get more familiar with it by reading some of its [documentation](https://docs.pytest.org/en/stable/getting-started.html). + + +Once you are done, you can start adding tests. Playwright allows us to generate code with the following command : +```bash +uv run playwright codegen -o test.py +``` + +This will open two windows: +- A window containing the generated code +- A browser that is used by playwright to generate the code. Every action you take there will generate new lines of the code. + +Once you are done, close the browser and open the file that playwright created containing the code it generated. Make sure to test it to make it sure it works. + +Since we are passing custom commands to pytest, we need to remove some of the generated lines. Spawning a new browser and/or context will make them not take into the commands we have added in the `pytest.ini` file : +```python title="Original file created by codegen" +from playwright.sync_api import Playwright, sync_playwright, expect + + +def run(playwright: Playwright) -> None: + browser = playwright.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + page.goto("http://localhost/") + page.get_by_text("Benchmarks/Competitions Datasets Login Sign-up").click() + page.get_by_role("link", name="Login").click() + + page.close() + + # --------------------- + context.close() + browser.close() + + +with sync_playwright() as playwright: + run(playwright) +``` + +The previous file becomes : +```python title="Modified file" +from playwright.sync_api import Page, expect + + +def test_run(page: Page) -> None: + page.goto("http://localhost/") + page.get_by_text("Benchmarks/Competitions Datasets Login Sign-up").click() + page.get_by_role("link", name="Login").click() +``` + diff --git a/documentation/docs/Developers_and_Administrators/Administrator-procedures.md b/documentation/docs/Developers_and_Administrators/Administrator-procedures.md index a8b0c1083..216477ee4 100644 --- a/documentation/docs/Developers_and_Administrators/Administrator-procedures.md +++ b/documentation/docs/Developers_and_Administrators/Administrator-procedures.md @@ -118,6 +118,15 @@ Select it, select the `Delete selected users` action and click on `Go`: ![](_attachments/c0fdd7ff-0c46-4bae-b9e2-7d4e0b774ec2_17534366434447145.jpg) +#### Ban/Unban a user + +Go to `Users` in the `django admin`: + +![Django Admin Users](_attachments/users-django-admin.png) + +Search for user using the search bar, or use the filter on the right side. Click on the username of the user to open user details, scroll down to find `Is Banned`. Check/uncheck this option to toggle the banned status. + + ## RabbitMQ Management The RabbitMQ management tool allows you to see the status of various queues, virtual hosts, and jobs. By default, you can access it at: `http://:15672/`. The username/password is your RabbitMQ `.env` settings for username and password. The port is hard-set in `docker-compose.yml` to 15672, but you can always change this if needed. For more information, see: diff --git a/documentation/docs/Developers_and_Administrators/Running-tests.md b/documentation/docs/Developers_and_Administrators/Running-tests.md index a1b18e0b6..687e8aba9 100644 --- a/documentation/docs/Developers_and_Administrators/Running-tests.md +++ b/documentation/docs/Developers_and_Administrators/Running-tests.md @@ -2,14 +2,11 @@ # Without "end to end" tests $ docker compose exec django py.test -m "not e2e" -# "End to end tests" (a shell script to launch a selenium docker container) -$ ./run_selenium_tests.sh - -# If you are on Mac OSX it is easy to watch these tests, no need to install -# anything just do: -$ open vnc://0.0.0.0:5900 - -# And login with password "secret" +# Playwright tests (make sure to install uv first: https://docs.astral.sh/uv/getting-started/installation/) +uv sync --frozen +uv run playwright install +docker compose exec -e DJANGO_SUPERUSER_PASSWORD=codabench django python manage.py createsuperuser --username codabench --email codabench@test.mail --no-input +uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py ``` ## CircleCI @@ -17,7 +14,7 @@ $ open vnc://0.0.0.0:5900 To simulate the tests run by CircleCI locally, run the following command: ```sh -docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/ -m "not e2e" +docker compose -f docker-compose.yml exec django py.test src/ -m "not e2e" ``` ## Example competitions diff --git a/documentation/docs/Developers_and_Administrators/_attachments/users-django-admin.png b/documentation/docs/Developers_and_Administrators/_attachments/users-django-admin.png new file mode 100644 index 000000000..8ddfb0ec4 Binary files /dev/null and b/documentation/docs/Developers_and_Administrators/_attachments/users-django-admin.png differ diff --git a/documentation/docs/Newsletters_Archive/CodaLab-in-2024.md b/documentation/docs/Newsletters_Archive/CodaLab-in-2024.md index 5b9178981..f99b8788a 100644 --- a/documentation/docs/Newsletters_Archive/CodaLab-in-2024.md +++ b/documentation/docs/Newsletters_Archive/CodaLab-in-2024.md @@ -19,7 +19,7 @@ Contributors community is very active with **143 pull requests** this year. Sinc ## Introducing Codabench [Codabench](https://codabench.org/), the modernized version of [CodaLab](https://codalab.lisn.fr/), was released in summer 2023, and [presented at JCAD days](https://www.canal-u.tv/chaines/jcad/codalab-competitions-and-codabench-open-source-platforms-to-organize-scientific) in November 2024! Codabench platform software is now concentrating all development effort of the community. In addition to CodaLab features, it offers improved performance, live logs, more transparency, data-centric benchmarks and more! -We warmly encourage you to use [codabench.org](https://codabench.org/) for all your new competitions and benchmarks. Note that CodaLab bundles are compatible with Codabench, easing the transition, as explained in the following Wiki page: [How to transition from CodaLab to Codabench](../Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md) +We warmly encourage you to use [codabench.org](https://codabench.org/) for all your new competitions and benchmarks. Note that CodaLab bundles are compatible with Codabench, easing the transition, as explained in the following docs page: [How to transition from CodaLab to Codabench](../Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md) CodaLab and Codabench are hosted on servers located at [Paris-Saclay university](https://www.universite-paris-saclay.fr/), maintained by [LISN lab](http://lisn.upsaclay.fr/). diff --git a/documentation/docs/Organizers/Benchmark_Creation/Competition-Bundle-Structure.md b/documentation/docs/Organizers/Benchmark_Creation/Competition-Bundle-Structure.md index 08ea6b511..8270594f5 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/Competition-Bundle-Structure.md +++ b/documentation/docs/Organizers/Benchmark_Creation/Competition-Bundle-Structure.md @@ -75,8 +75,8 @@ leaderboard: ## Competition YAML -The `competition.yaml` file is the most important file in the bundle. It's what Codabench looks for to figure out the structure and layout of your competition, along with additional details. For more information on setting up a `competition.yaml` see the wiki page here: -[Competition YAML](https://github.com/codalab/competitions-v2/wiki/Yaml-Structure) +The `competition.yaml` file is the most important file in the bundle. It's what Codabench looks for to figure out the structure and layout of your competition, along with additional details. For more information on setting up a `competition.yaml` see the docs page here: +[Competition YAML](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Yaml-Structure/) ## Data Types And Their Role: diff --git a/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation:-Bundle.md b/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation-Bundle.md similarity index 88% rename from documentation/docs/Organizers/Benchmark_Creation/Competition-Creation:-Bundle.md rename to documentation/docs/Organizers/Benchmark_Creation/Competition-Creation-Bundle.md index 95f2fc6fa..91a318d38 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation:-Bundle.md +++ b/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation-Bundle.md @@ -1,4 +1,4 @@ -This page is relatively simple. It's where you submit a completed competition bundle to Codabench, in order for it to be processed into a competition instance. For more information on competition bundles, see this link here: [Competition Bundle Structure](https://github.com/codalab/competitions-v2/wiki/Competition-Bundle-Structure). +This page is relatively simple. It's where you submit a completed competition bundle to Codabench, in order for it to be processed into a competition instance. For more information on competition bundles, see this link here: [Competition Bundle Structure](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Bundle-Structure/). ![image](../../_attachments/71213494-82863c80-2268-11ea-8efc-27c51795a23e_17528513091588552.png) @@ -8,4 +8,4 @@ To begin, just click the paper clip icon, or the bar next to it. It should open ## Backward compatibility -If you previously used [CodaLab Competitions](https://github.com/codalab/codalab-competitions), note that Codabench is compatible with CodaLab bundles. \ No newline at end of file +If you previously used [CodaLab Competitions](https://github.com/codalab/codalab-competitions), note that Codabench is compatible with CodaLab bundles. diff --git a/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation:-Form.md b/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation-Form.md similarity index 100% rename from documentation/docs/Organizers/Benchmark_Creation/Competition-Creation:-Form.md rename to documentation/docs/Organizers/Benchmark_Creation/Competition-Creation-Form.md diff --git a/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation.md b/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation.md index cc1a4d977..1e04f9e65 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation.md +++ b/documentation/docs/Organizers/Benchmark_Creation/Competition-Creation.md @@ -3,13 +3,13 @@ Competition creation can be done two ways. Through the online form on Codalab, o ## Bundle Upload For more information on Bundle Upload see here: -[Competition Creation: Bundle](https://github.com/codalab/competitions-v2/wiki/Competition-Creation:-Bundle) +[Competition Creation: Bundle](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Creation-Bundle/) For more information on Competition Bundle Structure, see here: -[Competition Bundle Structure](https://github.com/codalab/competitions-v2/wiki/Competition-Bundle-Structure) +[Competition Bundle Structure](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Bundle-Structure/) ## GUI creation For more information on GUI creation see here: -[Competition Creation: Form](https://github.com/codalab/competitions-v2/wiki/Competition-Creation:-Form) \ No newline at end of file +[Competition Creation: Form](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Creation-Form/) diff --git a/documentation/docs/Organizers/Benchmark_Creation/Dataset-competition-creation-and-participate-instruction.md b/documentation/docs/Organizers/Benchmark_Creation/Dataset-competition-creation-and-participate-instruction.md index 2446d71d1..647143b14 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/Dataset-competition-creation-and-participate-instruction.md +++ b/documentation/docs/Organizers/Benchmark_Creation/Dataset-competition-creation-and-participate-instruction.md @@ -5,7 +5,7 @@ This page focuses on how to create a dataset contest via bundle and make submiss The brief process can be summarized in the following diagram![](../../_attachments/0_17528513120435548.png) There are two main parts: -- the contest organizer creates the dataset competition by uploading a bundle (For more information on how to create a contest via bundle, and the definition of bundle, you can refer to this link [Competition-Creation:-Bundle](https://github.com/codalab/competitions-v2/wiki/Competition-Creation:-Bundle)) +- the contest organizer creates the dataset competition by uploading a bundle (For more information on how to create a contest via bundle, and the definition of bundle, you can refer to this link [Competition-Creation-Bundle](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Creation-Bundle/)) - Competition participant submission dataset diff --git a/documentation/docs/Organizers/Benchmark_Creation/Getting-started-with-Codabench.md b/documentation/docs/Organizers/Benchmark_Creation/Getting-started-with-Codabench.md index d30d2ad34..a0025ca21 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/Getting-started-with-Codabench.md +++ b/documentation/docs/Organizers/Benchmark_Creation/Getting-started-with-Codabench.md @@ -1,6 +1,6 @@ [Codabench](https://codabench.org) is an upgraded version of the [CodaLab Competitions](https://codalab.lisn.fr/) platform, allowing you to create either **competitions** or **benchmarks**. A benchmark is essentially an **ever-lasting competition** with **multiple tasks**, for which a participant can make **multiple entries** in the result table. -This getting started tutorial shows a **simple example** of how to create a competition. Advanced users should check [fancier examples](https://github.com/codalab/competition-examples/tree/master/codabench) and [the full documentation](https://github.com/codalab/codabench/wiki). If you simply wish to participate in a benchmark or competition, go to [Participating in a benchmark](../../Participants/User_Participating-in-a-Competition.md). +This getting started tutorial shows a **simple example** of how to create a competition. Advanced users should check [fancier examples](https://github.com/codalab/competition-examples/tree/master/codabench) and [the full documentation](https://docs.codabench.org). If you simply wish to participate in a benchmark or competition, go to [Participating in a benchmark](../../Participants/User_Participating-in-a-Competition.md). ## Getting ready @@ -34,4 +34,4 @@ This getting started tutorial shows a **simple example** of how to create a comp You are done with this simple tutorial. Next, check the more [advanced tutorial](Advanced-Tutorial.md). -You can also check out this blog post: [How to create your first benchmark on Codabench](https://medium.com/@adrienpavao/how-to-create-your-first-benchmark-on-codabench-910e2aee130c). \ No newline at end of file +You can also check out this blog post: [How to create your first benchmark on Codabench](https://medium.com/@adrienpavao/how-to-create-your-first-benchmark-on-codabench-910e2aee130c). diff --git a/documentation/docs/Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md b/documentation/docs/Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md index 0390d7247..7db06835f 100644 --- a/documentation/docs/Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md +++ b/documentation/docs/Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md @@ -56,5 +56,5 @@ If you don’t have any previous competition, and want to learn how to create on ## Concluding remarks -Codabench, the new version of the competition and benchmark platform CodaLab, was launched on August 2023 and is already receiving great attention. For users accustomed to CodaLab, the transition to Codabench is quick and easy. Indeed, competition bundles are back-compatible, and all that is required is to create an account on Codabench. To go further, you can refer to [Codabench’s Wiki](https://wiki.codabench.org). +Codabench, the new version of the competition and benchmark platform CodaLab, was launched on August 2023 and is already receiving great attention. For users accustomed to CodaLab, the transition to Codabench is quick and easy. Indeed, competition bundles are back-compatible, and all that is required is to create an account on Codabench. To go further, you can refer to [Codabench’s Docs](https://docs.codabench.org). diff --git a/documentation/docs/Organizers/Running_a_benchmark/Competition-Management-&-List.md b/documentation/docs/Organizers/Running_a_benchmark/Competition-Management-&-List.md index 9d6b1e752..e4e3237da 100644 --- a/documentation/docs/Organizers/Running_a_benchmark/Competition-Management-&-List.md +++ b/documentation/docs/Organizers/Running_a_benchmark/Competition-Management-&-List.md @@ -5,11 +5,11 @@ It will also show you how to track the competitions you are currently in. ![v2_labeled_comp_list](../../_attachments/70932628-08974e80-1fef-11ea-9303-734d098df784_17528513089370105.png) ## Competition create button (Form) -This button will take you to the wizard/form for creating competitions. This will allow you to walk through each step of creating a competition using our creation/edit form. For more information on this form/wizard, please see the following link: [Competition Creation: Form](../Benchmark_Creation/Competition-Creation:-Form.md) +This button will take you to the wizard/form for creating competitions. This will allow you to walk through each step of creating a competition using our creation/edit form. For more information on this form/wizard, please see the following link: [Competition Creation: Form](../Benchmark_Creation/Competition-Creation-Form.md) ## Competition create button (Upload) -This button will take you to the upload page for competition bundles. Here you will be able to upload a competition bundle, and if it is validated and processed successfully, you should see a link to your new competition. For more information on this page, please see the following link: [Competition Creation: Bundle](../Benchmark_Creation/Competition-Creation:-Bundle.md) +This button will take you to the upload page for competition bundles. Here you will be able to upload a competition bundle, and if it is validated and processed successfully, you should see a link to your new competition. For more information on this page, please see the following link: [Competition Creation: Bundle](../Benchmark_Creation/Competition-Creation-Bundle.md) ## Competitions I'm running tab This should be the default selection for the tab navigation at the top. Having this selected will show you all the competitions you currently run/manage, and the available actions for them. @@ -21,7 +21,7 @@ Clicking on this tab will change the main view of the page. You should now see a This button will publish your competition in order to make it publicly available. If your competition is already published, this button will appear green and be used to remove your competition from public availability (It will not be deleted). By default, if your competition is un-published, it appears grey. ## Edit competition button -This button will take you to the wizard/form for editing competitions. For more information on the competition edit form, please see the link [here](../Benchmark_Creation/Competition-Creation:-Form.md) +This button will take you to the wizard/form for editing competitions. For more information on the competition edit form, please see the link [here](../Benchmark_Creation/Competition-Creation-Form.md) ## Delete competition button Deletes your competition. There will be a confirmation dialogue before deletion. We cannot recover deleted competitions. diff --git a/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md b/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md index 3c01367ba..e4366c71c 100644 --- a/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md +++ b/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md @@ -49,7 +49,7 @@ BROKER_USE_SSL=True ``` !!! note - - The broker URL is a unique identifier of the job queue that the worker should listen to. To create a queue or obtain the broker URL of an existing queue, you can refer to [Queue Management](Queue-Management.md) wiki page. + - The broker URL is a unique identifier of the job queue that the worker should listen to. To create a queue or obtain the broker URL of an existing queue, you can refer to [Queue Management](Queue-Management.md) docs page. - `/codabench` -- this path needs to be volumed into `/codabench` on the worker, as you can see below. You can select another location if convenient. diff --git a/documentation/docs/Organizers/Running_a_benchmark/Resource-Management.md b/documentation/docs/Organizers/Running_a_benchmark/Resource-Management.md index 4fd14c77d..21115b07c 100644 --- a/documentation/docs/Organizers/Running_a_benchmark/Resource-Management.md +++ b/documentation/docs/Organizers/Running_a_benchmark/Resource-Management.md @@ -29,7 +29,7 @@ You can click on a dataset/program and make it public or private. This is useful ![](_attachments/99ec6090-86bd-4425-8f12-46115e09409d_1753436708048147.jpg) -For a general breakdown of the roles of different types of datasets, see this link: [Competition Bundle Structure: Data types and their role](https://github.com/codalab/competitions-v2/wiki/Competition-Bundle-Structure#data-types-and-their-role). +For a general breakdown of the roles of different types of datasets, see this link: [Competition Bundle Structure: Data types and their role](https://docs.codabench.org/latest/Organizers/Benchmark_Creation/Competition-Bundle-Structure/#data-types-and-their-role). diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 111dd6258..f4dc0de87 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -1,6 +1,6 @@ ## Documentation -Welcome to the Codabench wiki! +Welcome to the Codabench docs! Codabench is a platform allowing you to flexibly specify a benchmark. First you define tasks, e.g. datasets and metrics of success, then you specify the API for submissions of code (algorithms), add some documentation pages, and "CLICK!" your benchmark is created, ready to accept submissions of new algorithms. Participant results get appended to an ever-growing leaderboard. @@ -14,7 +14,7 @@ You may also create inverted benchmarks in which the role of datasets and algori [Compute Worker Setup](Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md) [Administrative Procedures](Developers_and_Administrators/Administrator-procedures.md) -!!! tip "Use the top bar or the search functionality to navigate the wiki!" +!!! tip "Use the top bar or the search functionality to navigate the docs!" ## Useful links [Governance Document](https://github.com/codalab/codalab-competitions/wiki/Community-Governance) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 054a30ae9..f7d45dc9c 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: Codabench Wiki +site_name: Codabench Docs repo_url: https://github.com/codalab/codabench copyright: Apache-2.0 edit_uri: edit/develop/documentation/docs @@ -69,8 +69,8 @@ plugins: download_link: header author: Codabench Team copyright: Apache-2.0 - cover_subtitle: Codabench Wiki PDF - output_path: codabench-wiki.pdf + cover_subtitle: Codabench Docs PDF + output_path: codabench-docs.pdf toc_level: 3 # Version docs (with git) @@ -115,8 +115,8 @@ nav: - Advanced Tutorial: Organizers/Benchmark_Creation/Advanced-Tutorial.md - How to Transition from Codalab to Codabench?: Organizers/Benchmark_Creation/How-to-transition-from-CodaLab-to-Codabench.md - Competition Creation: Organizers/Benchmark_Creation/Competition-Creation.md - - Competition Creation Form: Organizers/Benchmark_Creation/Competition-Creation:-Form.md - - Competition Creation Bundle: Organizers/Benchmark_Creation/Competition-Creation:-Bundle.md + - Competition Creation Form: Organizers/Benchmark_Creation/Competition-Creation-Form.md + - Competition Creation Bundle: Organizers/Benchmark_Creation/Competition-Creation-Bundle.md - Competition YAML Structure: Organizers/Benchmark_Creation/Competition-Bundle-Structure.md - YAML Structure: Organizers/Benchmark_Creation/Yaml-Structure.md - Competition Docker Image: Organizers/Benchmark_Creation/Competition-docker-image.md @@ -144,6 +144,7 @@ nav: - Backups - Automating Creation and Restoring: Developers_and_Administrators/Creating-and-Restoring-from-Backup.md - Submission Process Overview: Developers_and_Administrators/Submission-Process-Overview.md - Robot Submissions: Developers_and_Administrators/Robot-submissions.md + - Adding Tests: Developers_and_Administrators/Adding-e2e-tests.md - Running Tests: Developers_and_Administrators/Running-tests.md - Automation: Developers_and_Administrators/Automating-with-Selenium.md - Manual Validation: Developers_and_Administrators/Manual-validation.md diff --git a/poetry.lock b/poetry.lock index 63a3a8d59..66e21bc40 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2819,20 +2819,6 @@ files = [ [package.dependencies] botocore = ">=1.3.0,<2.0.0" -[[package]] -name = "selenium" -version = "3.141.0" -description = "Python bindings for Selenium" -optional = false -python-versions = "*" -files = [ - {file = "selenium-3.141.0-py2.py3-none-any.whl", hash = "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c"}, - {file = "selenium-3.141.0.tar.gz", hash = "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d"}, -] - -[package.dependencies] -urllib3 = "*" - [[package]] name = "service-identity" version = "24.2.0" @@ -3416,4 +3402,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "3.9.20" -content-hash = "347ce2833f20dc87ee1b4bc722de0b57e5292a3e7cefe700356e14577188d593" +content-hash = "674c359c2a2797ee0a112e0a38f98a55a7558404906d27d39e9a6d56bd1fd883" diff --git a/pyproject.toml b/pyproject.toml index a7c7c2e2e..441c831dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ flake8 = "3.8.4" pytest = "6.2.1" pytest-django = "4.1.0" pytest-pythonpath = "0.7.3" -selenium = "3.141.0" jinja2 = "3.1.4" requests = "2.32.2" drf-extra-fields = "3.0.2" diff --git a/reset_db.sh b/reset_db.sh index 5daf2f10b..a932e3616 100755 --- a/reset_db.sh +++ b/reset_db.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -docker-compose exec db bash -c " +docker compose exec db bash -c " echo 'dropping database' dropdb --if-exists -U \$DB_USERNAME \$DB_NAME && echo \$DB_PASSWORD && @@ -10,7 +10,7 @@ createdb -U \$DB_USERNAME \$DB_NAME && echo 'create successful' exit" && -docker-compose exec django bash -c " +docker compose exec django bash -c " python manage.py migrate && python manage.py generate_data exit" diff --git a/src/apps/api/tests/test_banned_user.py b/src/apps/api/tests/test_banned_user.py new file mode 100644 index 000000000..5467753c9 --- /dev/null +++ b/src/apps/api/tests/test_banned_user.py @@ -0,0 +1,64 @@ +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from django.urls import reverse + +User = get_user_model() + + +class BlockBannedUsersMiddlewareTests(TestCase): + + def setUp(self): + self.client = Client() + + # Normal user (not banned) + self.user = User.objects.create_user( + username="normaluser", + email="normal@example.com", + password="password123", + is_banned=False + ) + + # Banned user + self.banned_user = User.objects.create_user( + username="banneduser", + email="banned@example.com", + password="password123", + is_banned=True + ) + + def test_banned_user_sees_banned_page(self): + """Banned user visiting a normal page should see banned.html""" + self.client.login(username="banneduser", password="password123") + response = self.client.get(reverse("pages:home")) + + self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, "banned.html") + self.assertContains(response, "You are banned", status_code=403) + + def test_normal_user_can_access_page(self): + """Normal user should access pages normally""" + self.client.login(username="normaluser", password="password123") + response = self.client.get(reverse("pages:home")) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "You are banned") + + def test_banned_user_api_request_returns_json(self): + """Banned user hitting API should get JSON error, not HTML page""" + self.client.login(username="banneduser", password="password123") + response = self.client.get(reverse("user_quota")) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response["Content-Type"], "application/json") + self.assertJSONEqual( + response.content, + {"error": "You are banned from using Codabench"} + ) + + def test_normal_user_api_access(self): + """Normal user should get valid API response""" + self.client.login(username="normaluser", password="password123") + response = self.client.get(reverse("user_quota")) + + self.assertNotEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index 5b52b4b95..b116184d7 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -3,7 +3,14 @@ from django.test import TestCase from rest_framework.test import APITestCase from datasets.models import Data -from factories import UserFactory, DataFactory +from factories import ( + UserFactory, + DataFactory, + CompetitionFactory, + PhaseFactory, + TaskFactory, + SubmissionFactory +) from utils.data import pretty_bytes, gb_to_bytes from unittest.mock import patch @@ -306,3 +313,87 @@ def test_cannot_create_dataset_unauthenticated(self): 'file_size': 1234, }) self.assertEqual(resp.status_code, 403) + + +class DatasetDeleteTests(APITestCase): + def setUp(self): + self.user = UserFactory(username='user', password='user') + self.other_user = UserFactory(username='other', password='other') + self.client.login(username='user', password='user') + + self.dataset1 = DataFactory(created_by=self.user, name='dataset1') + self.dataset2 = DataFactory(created_by=self.user, name='dataset2') + self.other_dataset = DataFactory(created_by=self.other_user, name='other_dataset') + + def test_delete_own_dataset_success(self): + """User can delete their own dataset.""" + url = reverse("data-detail", args=[self.dataset1.pk]) + resp = self.client.delete(url) + self.assertEqual(resp.status_code, 204) + self.assertFalse(Data.objects.filter(pk=self.dataset1.pk).exists()) + + def test_cannot_delete_others_dataset(self): + """User cannot delete someone else’s dataset.""" + url = reverse("data-detail", args=[self.other_dataset.pk]) + resp = self.client.delete(url) + self.assertEqual(resp.status_code, 404) + self.assertTrue(Data.objects.filter(pk=self.other_dataset.pk).exists()) + + def test_cannot_delete_dataset_in_use(self): + """If dataset is in use by a competition, it cannot be deleted.""" + # Set up in use dataset + in_use_dataset = DataFactory(type=Data.INPUT_DATA, created_by=self.user, name="in_use_dataset") + task = TaskFactory(input_data=in_use_dataset) + phase = PhaseFactory() + phase.tasks.add(task) + competition = CompetitionFactory(created_by=self.user) + competition.phases.set([phase]) + + url = reverse("data-detail", args=[in_use_dataset.pk]) + resp = self.client.delete(url) + + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.data["error"], "Cannot delete dataset: dataset is in use") + self.assertTrue(Data.objects.filter(pk=in_use_dataset.pk).exists()) + + def test_bulk_delete_success(self): + """Multiple datasets deleted successfully.""" + ids = [self.dataset1.pk, self.dataset2.pk] + resp = self.client.post(reverse("data-delete-many"), ids, format="json") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data["detail"], "Datasets deleted successfully") + self.assertFalse(Data.objects.filter(pk__in=ids).exists()) + + def test_bulk_delete_with_errors(self): + """Bulk delete should fail entirely if one dataset is not deletable.""" + # include one dataset from another user + ids = [self.dataset1.pk, self.other_dataset.pk] + resp = self.client.post(reverse("data-delete-many"), ids, format="json") + + # Since one dataset is not deletable, expect a 400 response + self.assertEqual(resp.status_code, 400) + self.assertIn("other_dataset", resp.data) + self.assertEqual(resp.data["other_dataset"], "Cannot delete a dataset that is not yours") + + # None should be deleted since the operation failed + self.assertTrue(Data.objects.filter(pk=self.dataset1.pk).exists()) + self.assertTrue(Data.objects.filter(pk=self.other_dataset.pk).exists()) + + def test_cannot_delete_dataset_associated_with_a_submission_in_competition(self): + """If a dataset is a submission linked to a competition phase, it cannot be deleted.""" + # Setup a submission dataset + phase = PhaseFactory() + competition = CompetitionFactory(created_by=self.user) + competition.phases.set([phase]) + submission_dataset = DataFactory(type=Data.SUBMISSION, created_by=self.user, name="submission_dataset") + SubmissionFactory(owner=self.user, phase=phase, data=submission_dataset) + + url = reverse("data-detail", args=[submission_dataset.pk]) + resp = self.client.delete(url) + + self.assertEqual(resp.status_code, 400) + self.assertEqual( + resp.data["error"], + "Cannot delete submission: submission belongs to an existing competition. Please visit the competition and delete your submission from there." + ) + self.assertTrue(Data.objects.filter(pk=submission_dataset.pk).exists()) diff --git a/src/apps/api/tests/test_submissions.py b/src/apps/api/tests/test_submissions.py index a12e3ef13..f498b1608 100644 --- a/src/apps/api/tests/test_submissions.py +++ b/src/apps/api/tests/test_submissions.py @@ -22,6 +22,7 @@ def setUp(self): self.collaborator = UserFactory(username='collab', password='collab') self.comp = CompetitionFactory(created_by=self.creator, collaborators=[self.collaborator]) self.phase = PhaseFactory(competition=self.comp) + self.leaderboard = LeaderboardFactory() # Extra dummy user to test permissions, they shouldn't have access to many things self.other_user = UserFactory(username='other_user', password='other') @@ -41,7 +42,16 @@ def setUp(self): phase=self.phase, owner=self.participant, status=Submission.SUBMITTED, - secret='7df3600c-1234-5678-bbc8-bbe91f42d875' + secret='7df3600c-1234-5678-bbc8-bbe91f42d875', + leaderboard=None + ) + + # add submission with that is on the leaderboard + self.leaderboard_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.SUBMITTED, + leaderboard=self.leaderboard ) def test_can_make_submission_checks_if_you_are_participant(self): @@ -95,27 +105,23 @@ def test_cannot_delete_submission_you_didnt_create(self): # As anonymous user resp = self.client.delete(url) assert resp.status_code == 403 - assert resp.data["detail"] == "Cannot interact with submission you did not make" + assert resp.data["detail"] == "You do not have permission to delete this submission!" # As regular user self.client.force_login(self.other_user) resp = self.client.delete(url) assert resp.status_code == 403 - assert resp.data["detail"] == "Cannot interact with submission you did not make" + assert resp.data["detail"] == "You do not have permission to delete this submission!" + def test_can_delete_submission_you_created(self): + url = reverse('submission-detail', args=(self.existing_submission.pk,)) # As user who made submission self.client.force_login(self.participant) resp = self.client.delete(url) assert resp.status_code == 204 assert not Submission.objects.filter(pk=self.existing_submission.pk).exists() - # As superuser (re-making submission since it has been destroyed) - self.existing_submission = SubmissionFactory( - phase=self.phase, - owner=self.participant, - status=Submission.SUBMITTED, - secret='7df3600c-1234-5678-90c8-bbe91f42d875' - ) + def test_super_user_can_delete_submission_you_created(self): url = reverse('submission-detail', args=(self.existing_submission.pk,)) self.client.force_login(self.superuser) @@ -123,6 +129,23 @@ def test_cannot_delete_submission_you_didnt_create(self): assert resp.status_code == 204 assert not Submission.objects.filter(pk=self.existing_submission.pk).exists() + def test_super_user_can_delete_leaderboard_submission_you_created(self): + url = reverse('submission-detail', args=(self.leaderboard_submission.pk,)) + + self.client.force_login(self.superuser) + resp = self.client.delete(url) + assert resp.status_code == 204 + assert not Submission.objects.filter(pk=self.leaderboard_submission.pk).exists() + + def test_cannot_delete_leaderboard_submission_you_created(self): + url = reverse('submission-detail', args=(self.leaderboard_submission.pk,)) + + self.client.force_login(self.participant) + resp = self.client.delete(url) + + assert resp.status_code == 403 + assert resp.data["detail"] == "You cannot delete a leaderboard submission!" + def test_cannot_get_details_of_submission_unless_creator_collab_or_superuser(self): url = reverse('submission-get-details', args=(self.existing_submission.pk,)) diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 09eeb2491..4113eb754 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -227,9 +227,20 @@ def create(self, request, *args, **kwargs): leaderboard.is_valid() leaderboard.save() leaderboard_id = leaderboard["id"].value + + # Set leaderboard id, starting kit and public data for phases for phase in data['phases']: phase['leaderboard'] = leaderboard_id + try: + phase['public_data'] = Data.objects.filter(key=phase['public_data']['value'])[0].id + except TypeError: + phase['public_data'] = None + try: + phase['starting_kit'] = Data.objects.filter(key=phase['starting_kit']['value'])[0].id + except TypeError: + phase['starting_kit'] = None + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) @@ -728,12 +739,12 @@ def rerun_submissions(self, request, pk): # Codabemch public queue if comp.queue is None: can_re_run_submissions = False - error_message = f"You cannot rerun more than {settings.RERUN_SUBMISSION_LIMIT} submissions on Codabench public queue! Contact us on `info@codalab.org` to request a rerun." + error_message = f"You cannot rerun more than {settings.RERUN_SUBMISSION_LIMIT} submissions on Codabench public queue! Contact us on `{settings.CONTACT_EMAIL}` to request a rerun." # Other queue where user is not owner and not organizer elif request.user != comp.queue.owner and request.user not in comp.queue.organizers.all(): can_re_run_submissions = False - error_message = f"You cannot rerun more than {settings.RERUN_SUBMISSION_LIMIT} submissions on a queue which is not yours! Contact us on `info@codalab.org` to request a rerun." + error_message = f"You cannot rerun more than {settings.RERUN_SUBMISSION_LIMIT} submissions on a queue which is not yours! Contact us on `{settings.CONTACT_EMAIL}` to request a rerun." # User can rerun submissions where he is owner or organizer else: diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index ae17dda07..0c95a8a3c 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -144,17 +144,13 @@ def create(self, request, *args, **kwargs): return Response(context, status=status.HTTP_201_CREATED, headers=headers) def destroy(self, request, *args, **kwargs): - # TODO: Confirm this has a test - instance = self.get_object() - - error = self.check_delete_permissions(request, instance) - + dataset = self.get_object() + error = self.check_delete_permissions(request, dataset) if error: return Response( {'error': error}, status=status.HTTP_400_BAD_REQUEST ) - return super().destroy(request, *args, **kwargs) @action(detail=False, methods=('POST',)) diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index b14ea9050..1b89ec6ae 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -198,11 +198,25 @@ def create(self, request, *args, **kwargs): return super(SubmissionViewSet, self).create(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): + """ + - If user is neither owner nor admin, user cannot delete the submission + - If a user is not admin and is owner of a submission and submission is on the leaderboard, user cannot delete the submission + - In rest of the cases i.e. user is admin/super user or user is owner of the submisison and submission is not on the leaderboard, user can delete the submisison + """ submission = self.get_object() - if request.user != submission.owner and not self.has_admin_permission(request.user, submission): - raise PermissionDenied("Cannot interact with submission you did not make") + is_owner = request.user == submission.owner + is_super_user_or_competition_admin = self.has_admin_permission(request.user, submission) + + # If user is neither owner nor super user/admin return permission denied + if not is_owner and not is_super_user_or_competition_admin: + raise PermissionDenied("You do not have permission to delete this submission!") + + # If user is not admin, is owner and submission is on the leaderboard return permission denied + if not is_super_user_or_competition_admin and is_owner and submission.leaderboard: + raise PermissionDenied("You cannot delete a leaderboard submission!") + # Otherwise, delete the submission self.perform_destroy(submission) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/apps/chahub/__init__.py b/src/apps/chahub/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/apps/chahub/models.py b/src/apps/chahub/models.py deleted file mode 100644 index dc49920bb..000000000 --- a/src/apps/chahub/models.py +++ /dev/null @@ -1,143 +0,0 @@ -import hashlib -import json - -from django.conf import settings -from django.db import models - -from chahub.tasks import send_to_chahub, delete_from_chahub - -import logging -logger = logging.getLogger(__name__) - - -class ChaHubModelManager(models.Manager): - def get_queryset(self): - return super().get_queryset().filter(deleted=False) - - def all_objects(self): - return super().get_queryset() - - -class ChaHubSaveMixin(models.Model): - """Helper mixin for saving model data to ChaHub. - - To use: - 1) Override `get_chahub_endpoint()` to return the endpoint on ChaHub API for this model - 2) Override `get_chahub_data()` to return a dictionary to send to ChaHub - 3) Override `get_whitelist()` to return a whitelist of fields to send to ChaHub if obj not public - 4) Be sure to call `self.clean_private_data()` inside `get_chahub_data` - 5) Override `get_chahub_is_valid()` to return True/False on whether or not the object is ready to send to ChaHub - 6) Data is sent on `save()` and `chahub_timestamp` timestamp is set - - To update remove the `chahub_timestamp` timestamp and call `save()`""" - # Timestamp set whenever a successful update happens - chahub_timestamp = models.DateTimeField(null=True, blank=True) - - # A hash of the last json information that was sent to avoid sending duplicate information - chahub_data_hash = models.TextField(null=True, blank=True) - - # If sending to chahub fails, we may need a retry. Signal that by setting this attribute to True - chahub_needs_retry = models.BooleanField(default=False) - - # Set to true if celery attempt at deletion does not get a 204 resp from chahub, so we can retry later - deleted = models.BooleanField(default=False) - - objects = ChaHubModelManager() - - class Meta: - abstract = True - - @property - def app_label(self): - return f'{self.__class__._meta.app_label}.{self.__class__.__name__}' - - def get_whitelist(self): - """Override this to set the return the whitelisted fields for private data - Example: - return ['remote_id', 'is_public'] - """ - raise NotImplementedError() - - # ------------------------------------------------------------------------- - # METHODS TO OVERRIDE WHEN USING THIS MIXIN! - # ------------------------------------------------------------------------- - @staticmethod - def get_chahub_endpoint(): - """Override this to return the endpoint URL for this resource - - Example: - # If the endpoint is chahub.org/api/v1/competitions/ then... - return "competitions/" - """ - raise NotImplementedError() - - def get_chahub_data(self): - """Override this to return a dictionary with data to send to chahub - - Example: - return {"name": self.name} - """ - raise NotImplementedError() - - def get_chahub_is_valid(self): - """Override this to validate the specific model before it's sent - - Example: - return comp.is_published - """ - # By default, always push - return True - - def clean_private_data(self, data): - """Override this to clean up any data that should not be sent to chahub if the object is not public""" - if hasattr(self, 'is_public'): - public = self.is_public - elif hasattr(self, 'published'): - public = self.published - else: - # assume data is good to push to chahub if there is no field saying otherwise - public = True - if not public: - for key in data.keys(): - if key not in self.get_whitelist(): - data[key] = None - return data - - # Regular methods - def save(self, send=True, *args, **kwargs): - # We do a save here to give us an ID for generating URLs and such - super().save(*args, **kwargs) - - # making sure get whitelist was implemented, making sure we don't send to chahub without cleaning our data - self.get_whitelist() - - if getattr(settings, 'IS_TESTING', False) and not getattr(settings, 'PYTEST_FORCE_CHAHUB', False): - # For tests let's just assume Chahub isn't available - # We can mock proper responses - return None - - # Make sure we're not sending these in tests - if settings.CHAHUB_API_URL and send: - is_valid = self.get_chahub_is_valid() - logger.info(f"ChaHub :: {self.__class__.__name__}({self.pk}) is_valid = {is_valid}") - - if is_valid: - data = [self.clean_private_data(self.get_chahub_data())] - - data_hash = hashlib.md5(json.dumps(data).encode('utf-8')).hexdigest() - # Send to chahub if we haven't yet, we have new data - if not self.chahub_timestamp or self.chahub_data_hash != data_hash: - send_to_chahub.apply_async((self.app_label, self.pk, data, data_hash)) - elif self.chahub_needs_retry: - # This is NOT valid but also marked as need retry, unmark need retry until this is valid again - logger.warning('ChaHub :: This is invalid but marked for retry. Clearing retry until valid again.') - self.chahub_needs_retry = False - super().save() - - def delete(self, send=True, *args, **kwargs): - if settings.CHAHUB_API_URL and send: - self.deleted = True - self.save(send=False) - delete_from_chahub.apply_async((self.app_label, self.pk)) - else: - super().delete(*args, **kwargs) diff --git a/src/apps/chahub/tasks.py b/src/apps/chahub/tasks.py deleted file mode 100644 index 36dcfd643..000000000 --- a/src/apps/chahub/tasks.py +++ /dev/null @@ -1,164 +0,0 @@ -import json - -import requests -from django.utils import timezone - -from celery_config import app -from django.apps import apps -from django.conf import settings -from apps.chahub.utils import ChahubException -import logging -logger = logging.getLogger(__name__) - - -def _send(endpoint, data): - url = f"{settings.CHAHUB_API_URL}{endpoint}" - headers = { - 'Content-type': 'application/json', - 'X-CHAHUB-API-KEY': settings.CHAHUB_API_KEY, - } - logger.info(f"ChaHub :: Sending to ChaHub ({url}) the following data: \n{data}") - return requests.post(url=url, data=json.dumps(data), headers=headers) - - -def get_obj(app_label, pk, include_deleted=False): - Model = apps.get_model(app_label) - - try: - if include_deleted: - obj = Model.objects.all_objects().get(pk=pk) - else: - obj = Model.objects.get(pk=pk) - except Model.DoesNotExist: - raise ChahubException(f"Could not find {app_label} with pk: {pk}") - return obj - - -@app.task(queue='site-worker') -def send_to_chahub(app_label, pk, data, data_hash): - """ - Does a post request to the specified API endpoint on chahub with the inputted data. - """ - if not settings.CHAHUB_API_URL: - raise ChahubException("CHAHUB_API_URL env var required to send to Chahub") - if not settings.CHAHUB_API_KEY: - raise ChahubException("No ChaHub API Key provided") - - obj = get_obj(app_label, pk) - - try: - resp = _send(obj.get_chahub_endpoint(), data) - except requests.exceptions.RequestException: - resp = None - - if resp and resp.status_code in (200, 201): - logger.info(f"ChaHub :: Received response {resp.status_code} {resp.content}") - obj.chahub_timestamp = timezone.now() - obj.chahub_data_hash = data_hash - obj.chahub_needs_retry = False - else: - status = getattr(resp, 'status_code', 'N/A') - body = getattr(resp, 'content', 'N/A') - logger.info(f"ChaHub :: Error sending to chahub, status={status}, body={body}") - obj.chahub_needs_retry = True - obj.save(send=False) - - -@app.task(queue='site-worker') -def delete_from_chahub(app_label, pk): - if not settings.CHAHUB_API_URL: - raise ChahubException("CHAHUB_API_URL env var required to send to Chahub") - if not settings.CHAHUB_API_KEY: - raise ChahubException("No ChaHub API Key provided") - - obj = get_obj(app_label, pk, include_deleted=True) - - url = f"{settings.CHAHUB_API_URL}{obj.get_chahub_endpoint()}{pk}/" - logger.info(f"ChaHub :: Sending to ChaHub ({url}) delete message") - - headers = {'X-CHAHUB-API-KEY': settings.CHAHUB_API_KEY} - - try: - resp = requests.delete(url=url, headers=headers) - except requests.exceptions.RequestException: - resp = None - - if resp and resp.status_code == 204: - logger.info(f"ChaHub :: Received response {resp.status_code} {resp.content}") - obj.delete(send=False) - else: - status = getattr(resp, 'status_code', 'N/A') - body = getattr(resp, 'content', 'N/A') - logger.error(f"ChaHub :: Error sending to chahub, status={status}, body={body}") - obj.chahub_needs_retry = True - obj.save(send=False) - - -def batch_send_to_chahub(model, limit=None, retry_only=False): - qs = model.objects.all() - if retry_only: - qs = qs.filter(chahub_needs_retry=True) - if limit is not None: - qs = qs[:limit] - - endpoint = model.get_chahub_endpoint() - data = [obj.get_chahub_data() for obj in qs if obj.get_chahub_is_valid()] - if not data: - logger.warning(f'Nothing to send to Chahub at {endpoint}') - return - try: - logger.info(f"Sending all data to Chahub at {endpoint}") - resp = _send(endpoint=endpoint, data=data) - logger.info(f"Response Status Code: {resp.status_code}") - if resp.status_code != 201: - logger.warning(f'ChaHub Response Content: {resp.content}') - except ChahubException: - logger.warning("There was a problem reaching Chahub. Retry again later") - - -def chahub_is_up(): - if not settings.CHAHUB_API_URL: - return False - - logger.info("Checking whether ChaHub is online before sending retries") - try: - response = requests.get(settings.CHAHUB_API_URL) - if response.ok: - logger.info("ChaHub is online") - return True - else: - logger.warning("Bad Status from ChaHub") - return False - except requests.exceptions.RequestException: - # This base exception works for HTTP errors, Connection errors, etc. - logger.error("Request Exception trying to access ChaHub") - return False - - -def get_chahub_models(): - from chahub.models import ChaHubSaveMixin - return ChaHubSaveMixin.__subclasses__() - - -@app.task(queue='site-worker') -def do_chahub_retries(limit=None): - if not chahub_is_up(): - return - chahub_models = get_chahub_models() - logger.info(f'Retrying for ChaHub models: {chahub_models}') - for model in chahub_models: - batch_send_to_chahub(model, retry_only=True, limit=limit) - obj_to_be_deleted = model.objects.all_objects().filter(deleted=True) - if limit is not None: - obj_to_be_deleted = obj_to_be_deleted[:limit] - for obj in obj_to_be_deleted: - # TODO: call celery task here, instead of abstracting. - obj.delete() - - -@app.task(queue='site-worker') -def send_everything_to_chahub(limit=None): - if not chahub_is_up(): - return - for model in get_chahub_models(): - batch_send_to_chahub(model, limit=limit) diff --git a/src/apps/chahub/tests/__init__.py b/src/apps/chahub/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/apps/chahub/tests/test_chahub_mixin.py b/src/apps/chahub/tests/test_chahub_mixin.py deleted file mode 100644 index 7b408e75b..000000000 --- a/src/apps/chahub/tests/test_chahub_mixin.py +++ /dev/null @@ -1,98 +0,0 @@ -from chahub.tests.utils import ChaHubTestCase -from competitions.models import Submission -from factories import UserFactory, CompetitionFactory, DataFactory, SubmissionFactory, PhaseFactory, \ - CompetitionParticipantFactory -from profiles.models import User - - -class SubmissionMixinTests(ChaHubTestCase): - def setUp(self): - self.user = UserFactory() - self.comp = CompetitionFactory(published=True) - self.participant = CompetitionParticipantFactory(user=self.user, competition=self.comp) - self.phase = PhaseFactory(competition=self.comp) - self.data = DataFactory() - # Calling this after initial setup so we don't turn on FORCE_CHAHUB and try and send all our setup objects - super().setUp() - self.submission = SubmissionFactory.build( - owner=self.user, - phase=self.phase, - data=self.data, - participant=self.participant, - status='Finished', - is_public=True, - leaderboard=None - ) - - def test_submission_save_sends_to_chahub(self): - resp = self.mock_chahub_save(self.submission) - assert resp.called - - def test_submission_save_not_sending_duplicate_data(self): - resp1 = self.mock_chahub_save(self.submission) - assert resp1.called - self.submission = Submission.objects.get(id=self.submission.id) - resp2 = self.mock_chahub_save(self.submission) - assert not resp2.called - - def test_submission_save_sends_updated_data(self): - resp1 = self.mock_chahub_save(self.submission) - assert resp1.called - self.phase.index += 1 - resp2 = self.mock_chahub_save(self.submission) - assert resp2.called - - # def test_invalid_submission_not_sent(self): - # self.submission.status = "Running" - # self.submission.is_public = False - # resp1 = self.mock_chahub_save(self.submission) - # assert not resp1.called - # self.submission = Submission.objects.get(id=self.submission.id) - # self.submission.status = "Finished" - # resp2 = self.mock_chahub_save(self.submission) - # assert resp2.called - - # def test_retrying_invalid_submission_wont_retry_again(self): - # self.submission.status = "Running" - # self.submission.chahub_needs_retry = True - # resp = self.mock_chahub_save(self.submission) - # assert not resp.called - # assert not Submission.objects.get(id=self.submission.id).chahub_needs_retry - - def test_valid_submission_marked_for_retry_sent_and_needs_retry_unset(self): - # Mark submission for retry - self.submission.chahub_needs_retry = True - resp = self.mock_chahub_save(self.submission) - assert resp.called - assert not Submission.objects.get(id=self.submission.id).chahub_needs_retry - - -class ProfileMixinTests(ChaHubTestCase): - def setUp(self): - self.user = UserFactory.build(username='admin') # create a user but don't save until later in the mock - super().setUp() - - def test_profile_save_not_sending_on_blacklisted_data_update(self): - resp1 = self.mock_chahub_save(self.user) - assert resp1.called - self.user = User.objects.get(id=self.user.id) - self.user.password = 'this_is_different' # Not using user.set_password() to control when the save happens - resp2 = self.mock_chahub_save(self.user) - assert not resp2.called - - -class CompetitionMixinTests(ChaHubTestCase): - def setUp(self): - self.comp = CompetitionFactory(published=False) - PhaseFactory(competition=self.comp) - super().setUp() - - def test_unpublished_comp_doesnt_send_private_data(self): - resp = self.mock_chahub_save(self.comp) - # Gross traversal through call args to get the data passed to _send - assert resp.called - data = resp.call_args[0][1][0] - whitelist = self.comp.get_whitelist() - for key, value in data.items(): - if key not in whitelist: - assert value is None diff --git a/src/apps/chahub/tests/test_chahub_tasks.py b/src/apps/chahub/tests/test_chahub_tasks.py deleted file mode 100644 index cd8e6836e..000000000 --- a/src/apps/chahub/tests/test_chahub_tasks.py +++ /dev/null @@ -1,37 +0,0 @@ -from chahub.tests.utils import ChaHubTestCase -from factories import UserFactory, CompetitionFactory, DataFactory, SubmissionFactory, PhaseFactory, \ - CompetitionParticipantFactory - - -class ChaHubDoRetriesTests(ChaHubTestCase): - def setUp(self): - for _ in range(5): - user = UserFactory(chahub_needs_retry=True) - comp = CompetitionFactory(chahub_needs_retry=True, published=True) - participant = CompetitionParticipantFactory(competition=comp, user=user, status='approved') - phase = PhaseFactory(competition=comp) - DataFactory(chahub_needs_retry=True, is_public=True, upload_completed_successfully=True) - SubmissionFactory( - chahub_needs_retry=True, - status="Finished", - phase=phase, - is_public=True, - participant=participant - ) - super().setUp() - - def test_do_retries_picks_up_all_expected_items(self): - resp = self.mock_retries() - # Should call once each for Users, Comps, Datasets, Submissions - assert resp.call_count == 4 - for call in resp.call_args_list: - # Should get passed a batch of data that is 5 long - assert len(call[1]['data']) == 5 - - def test_do_retries_limit_will_limit_number_of_retries(self): - resp = self.mock_retries(limit=2) - # Should call once each for Users, Comps, Datasets, Submissions - assert resp.call_count == 4 - for call in resp.call_args_list: - # Should get passed a batch of data that is 2 long, matching the limit - assert len(call[1]['data']) == 2 diff --git a/src/apps/chahub/tests/utils.py b/src/apps/chahub/tests/utils.py deleted file mode 100644 index 0a8501303..000000000 --- a/src/apps/chahub/tests/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest import mock - -from django.conf import settings -from django.http.response import HttpResponseBase -from django.test import TestCase - -from chahub.tasks import do_chahub_retries - - -class ChaHubTestResponse(HttpResponseBase): - @property - def ok(self): - return self.status_code < 400 - - -class ChaHubTestCase(TestCase): - def setUp(self): - settings.PYTEST_FORCE_CHAHUB = True - # set the url to localhost for tests - settings.CHAHUB_API_URL = 'http://localhost/' - settings.CHAHUB_API_KEY = 'asdf' - - def tearDown(self): - settings.PYTEST_FORCE_CHAHUB = False - settings.CHAHUB_API_URL = None - - def mock_chahub_save(self, obj): - with mock.patch('chahub.tasks._send') as chahub_mock: - chahub_mock.return_value = ChaHubTestResponse(status=201) - chahub_mock.return_value.content = '' - obj.save() - return chahub_mock - - def mock_retries(self, limit=None): - with mock.patch('apps.chahub.tasks.requests.get') as chahub_get_mock: - # This checks that ChaHub is up, mock this so the task doesn't bail - chahub_get_mock.return_value = ChaHubTestResponse(status=200) - with mock.patch('chahub.tasks._send') as send_to_chahub_mock: - send_to_chahub_mock.return_value = ChaHubTestResponse(status=201) - send_to_chahub_mock.return_value.content = '' - do_chahub_retries(limit=limit) - return send_to_chahub_mock diff --git a/src/apps/chahub/utils.py b/src/apps/chahub/utils.py deleted file mode 100644 index 98a65962d..000000000 --- a/src/apps/chahub/utils.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.conf import settings - -import requests -import json - -import logging -logger = logging.getLogger(__name__) - - -class ChahubException(Exception): - pass - - -def send_to_chahub(endpoint, data): - """ - Does a post request to the specified API endpoint on chahub with the inputted data. - :param endpoint: String designating which API endpoint; IE: 'producers/' - :param data: Dictionary containing data we are sending away to the endpoint. - :return: - """ - if not endpoint: - raise ChahubException("No ChaHub API endpoint given") - if not settings.CHAHUB_API_URL: - raise ChahubException("CHAHUB_API_URL env var required to send to Chahub") - - url = f"{settings.CHAHUB_API_URL}{endpoint}" - - logger.info(f"ChaHub :: Sending to ChaHub ({url}) the following data: \n{data}") - try: - headers = { - 'Content-type': 'application/json', - 'X-CHAHUB-API-KEY': settings.CHAHUB_API_KEY, - } - return requests.post(url=url, data=json.dumps(data), headers=headers) - except requests.ConnectionError: - raise ChahubException('Connection Error with ChaHub') diff --git a/src/apps/competitions/migrations/0059_auto_20250623_1341.py b/src/apps/competitions/migrations/0059_auto_20250623_1341.py new file mode 100644 index 000000000..235a0014c --- /dev/null +++ b/src/apps/competitions/migrations/0059_auto_20250623_1341.py @@ -0,0 +1,77 @@ +# Generated by Django 2.2.28 on 2025-06-23 13:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0058_phase_hide_prediction_output'), + ] + + operations = [ + migrations.RemoveField( + model_name='competition', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='competition', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='competition', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='competition', + name='deleted', + ), + migrations.RemoveField( + model_name='competitionparticipant', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='competitionparticipant', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='competitionparticipant', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='competitionparticipant', + name='deleted', + ), + migrations.RemoveField( + model_name='phase', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='phase', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='phase', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='phase', + name='deleted', + ), + migrations.RemoveField( + model_name='submission', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='submission', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='submission', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='submission', + name='deleted', + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 15cb1846b..60fc0ac0b 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -4,7 +4,6 @@ import botocore.exceptions from django.conf import settings -from django.contrib.sites.models import Site from django.contrib.postgres.fields import JSONField from django.core.files.base import ContentFile from django.db import models @@ -14,7 +13,6 @@ from decimal import Decimal from celery_config import app, app_for_vhost -from chahub.models import ChaHubSaveMixin from leaderboards.models import SubmissionScore from profiles.models import User, Organization from utils.data import PathWrapper @@ -27,7 +25,7 @@ logger = logging.getLogger(__name__) -class Competition(ChaHubSaveMixin, models.Model): +class Competition(models.Model): COMPETITION = "competition" BENCHMARK = "benchmark" @@ -206,46 +204,6 @@ def update_phase_statuses(self): def get_absolute_url(self): return reverse('competitions:detail', kwargs={'pk': self.pk}) - @staticmethod - def get_chahub_endpoint(): - return "competitions/" - - def get_chahub_is_valid(self): - has_phases = self.phases.exists() - upload_finished = all([c.status == CompetitionCreationTaskStatus.FINISHED for c in - self.creation_statuses.all()]) if self.creation_statuses.exists() else True - return has_phases and upload_finished - - def get_whitelist(self): - return [ - 'remote_id', - 'participants', - 'phases', - 'published', - ] - - def get_chahub_data(self): - data = { - 'created_by': self.created_by.username, - 'creator_id': self.created_by.pk, - 'created_when': self.created_when.isoformat(), - 'title': self.title, - 'url': f'http://{Site.objects.get_current().domain}{self.get_absolute_url()}', - 'remote_id': self.pk, - 'published': self.published, - 'participants': [p.get_chahub_data() for p in self.participants.all()], - 'phases': [phase.get_chahub_data(send_competition_id=False) for phase in self.phases.all()], - } - start = getattr(self.phases.order_by('index').first(), 'start', None) - data['start'] = start.isoformat() if start is not None else None - end = getattr(self.phases.order_by('index').last(), 'end', None) - data['end'] = end.isoformat() if end is not None else None - if self.logo: - data['logo_url'] = self.logo.url - data['logo'] = self.logo.url - - return self.clean_private_data(data) - def make_logo_icon(self): if self.logo: # Read the content of the logo file @@ -318,7 +276,7 @@ def __str__(self): return f"pk: {self.pk} ({self.status})" -class Phase(ChaHubSaveMixin, models.Model): +class Phase(models.Model): PREVIOUS = "Previous" CURRENT = "Current" NEXT = "Next" @@ -388,30 +346,6 @@ def can_user_make_submissions(self, user): return False, 'Reached maximum allowed submissions for this phase' return True, None - @staticmethod - def get_chahub_endpoint(): - return 'phases/' - - def get_whitelist(self): - return ['remote_id', 'published', 'tasks', 'index', 'status', 'competition_remote_id'] - - def get_chahub_data(self, send_competition_id=True): - data = { - 'remote_id': self.pk, - 'published': self.published, - 'status': self.status, - 'index': self.index, - 'start': self.start.isoformat(), - 'end': self.end.isoformat() if self.end else None, - 'name': self.name, - 'description': self.description, - 'is_active': self.is_active, - 'tasks': [task.get_chahub_data() for task in self.tasks.all()] - } - if send_competition_id: - data['competition_remote_id'] = self.competition.pk - return self.clean_private_data(data) - @property def is_active(self): """ Returns true when this phase of the competition is on-going. """ @@ -488,7 +422,7 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) -class Submission(ChaHubSaveMixin, models.Model): +class Submission(models.Model): NONE = "None" SUBMITTING = "Submitting" SUBMITTED = "Submitted" @@ -779,37 +713,8 @@ def on_leaderboard(self): on_leaderboard = bool(self.children.first().leaderboard) return on_leaderboard - @staticmethod - def get_chahub_endpoint(): - return "submissions/" - - def get_whitelist(self): - return [ - 'remote_id', - 'is_public', - 'competition', - 'phase_index', - 'data', - ] - - def get_chahub_data(self): - data = { - "remote_id": self.id, - "is_public": self.is_public, - "competition": self.phase.competition_id, - "phase_index": self.phase.index, - "owner": self.owner.id, - "participant_name": self.owner.username, - "submitted_at": self.created_when.isoformat(), - "data": self.data.get_chahub_data(), - } - return self.clean_private_data(data) - - def get_chahub_is_valid(self): - return self.status == self.FINISHED - -class CompetitionParticipant(ChaHubSaveMixin, models.Model): +class CompetitionParticipant(models.Model): UNKNOWN = 'unknown' DENIED = 'denied' APPROVED = 'approved' @@ -833,25 +738,6 @@ class Meta: def __str__(self): return f"({self.id}) - User: {self.user.username} in Competition: {self.competition.title}" - @staticmethod - def get_chahub_endpoint(): - return 'participants/' - - def get_whitelist(self): - return [ - 'remote_id', - 'competition_id' - ] - - def get_chahub_data(self): - data = { - 'remote_id': self.pk, - 'user': self.user.id, - 'status': self.status, - 'competition_id': self.competition_id - } - return self.clean_private_data(data) - def save(self, *args, **kwargs): # Determine if this is a new participant (no existing record in DB) is_new = self.pk is None diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 4edf66592..f3f577139 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -467,8 +467,6 @@ def _get_error_string(error_dict): status.status = CompetitionCreationTaskStatus.FINISHED status.resulting_competition = competition status.save() - # call again, to make sure phases get sent to chahub - competition.save() logger.info("Competition saved!") status.dataset.name += f" - {competition.title}" status.dataset.save() diff --git a/src/apps/datasets/migrations/0011_auto_20250623_1341.py b/src/apps/datasets/migrations/0011_auto_20250623_1341.py new file mode 100644 index 000000000..ef8022e4f --- /dev/null +++ b/src/apps/datasets/migrations/0011_auto_20250623_1341.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.28 on 2025-06-23 13:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0010_auto_20250218_1100'), + ] + + operations = [ + migrations.RemoveField( + model_name='data', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='data', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='data', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='data', + name='deleted', + ), + ] diff --git a/src/apps/datasets/migrations/0013_merge_0011_auto_20250623_1341_0012_delete_datagroup.py b/src/apps/datasets/migrations/0013_merge_0011_auto_20250623_1341_0012_delete_datagroup.py new file mode 100644 index 000000000..a36c2abd6 --- /dev/null +++ b/src/apps/datasets/migrations/0013_merge_0011_auto_20250623_1341_0012_delete_datagroup.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2025-12-10 13:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0011_auto_20250623_1341'), + ('datasets', '0012_delete_datagroup'), + ] + + operations = [ + ] diff --git a/src/apps/datasets/models.py b/src/apps/datasets/models.py index 633ec5ac5..668e93977 100644 --- a/src/apps/datasets/models.py +++ b/src/apps/datasets/models.py @@ -3,14 +3,12 @@ import botocore.exceptions from django.conf import settings -from django.contrib.sites.models import Site from django.db import models from django.db.models import Q from django.urls import reverse from django.utils.timezone import now from decimal import Decimal -from chahub.models import ChaHubSaveMixin from utils.data import PathWrapper from utils.storage import BundleStorage from competitions.models import Competition @@ -20,7 +18,7 @@ logger = logging.getLogger(__name__) -class Data(ChaHubSaveMixin, models.Model): +class Data(models.Model): """Data models are unqiue based on name + created_by. If no name is given, then there is no uniqueness to enforce""" # It's useful to have these defaults map to the YAML names for these, like `scoring_program` @@ -113,32 +111,3 @@ def in_use(self): def __str__(self): return f'{self.name}({self.id})' - - @staticmethod - def get_chahub_endpoint(): - return "datasets/" - - def get_chahub_is_valid(self): - if not self.was_created_by_competition: - return self.upload_completed_successfully - else: - return True - - def get_whitelist(self): - return ['remote_id', 'is_public'] - - def get_chahub_data(self): - ssl = settings.SECURE_SSL_REDIRECT - site = Site.objects.get_current().domain - return self.clean_private_data({ - 'creator_id': self.created_by.id, - 'remote_id': self.pk, - 'created_by': str(self.created_by.username), - 'created_when': self.created_when.isoformat(), - 'name': self.name, - 'type': self.type, - 'description': self.description, - 'key': str(self.key), - 'is_public': self.is_public, - 'download_url': f'http{"s" if ssl else ""}://{site}{self.get_download_url()}' - }) diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index 162d47fdf..38df37527 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -5,6 +5,7 @@ from competitions.models import Submission from announcements.models import Announcement, NewsPost +from django.conf import settings from django.shortcuts import render from utils.data import pretty_bytes @@ -20,6 +21,7 @@ def get_context_data(self, *args, **kwargs): news_posts = NewsPost.objects.all().order_by('-id') context['news_posts'] = news_posts + context['CONTACT_EMAIL'] = settings.CONTACT_EMAIL return context diff --git a/src/apps/profiles/admin.py b/src/apps/profiles/admin.py index cb2c5bee3..eafc21cdf 100644 --- a/src/apps/profiles/admin.py +++ b/src/apps/profiles/admin.py @@ -8,8 +8,8 @@ class UserAdmin(admin.ModelAdmin): change_form_template = "admin/auth/user/change_form.html" change_list_template = "admin/auth/user/change_list.html" search_fields = ['username', 'email'] - list_filter = ['is_staff', 'is_superuser', 'is_deleted', 'is_bot'] - list_display = ['username', 'email', 'is_staff', 'is_superuser'] + list_filter = ['is_staff', 'is_superuser', 'is_deleted', 'is_bot', 'is_banned'] + list_display = ['username', 'email', 'is_staff', 'is_superuser', 'is_banned'] class DeletedUserAdmin(admin.ModelAdmin): diff --git a/src/apps/profiles/migrations/0003_auto_20191122_1942.py b/src/apps/profiles/migrations/0003_auto_20191122_1942.py index ec2474036..2bfd8a549 100644 --- a/src/apps/profiles/migrations/0003_auto_20191122_1942.py +++ b/src/apps/profiles/migrations/0003_auto_20191122_1942.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AlterModelManagers( name='user', managers=[ - ('objects', profiles.models.ChaHubUserManager()), + ('objects', profiles.models.CodabenchUserManager()), ], ), migrations.AddField( diff --git a/src/apps/profiles/migrations/0017_auto_20250623_1341.py b/src/apps/profiles/migrations/0017_auto_20250623_1341.py new file mode 100644 index 000000000..e9601bfdb --- /dev/null +++ b/src/apps/profiles/migrations/0017_auto_20250623_1341.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.28 on 2025-06-23 13:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0016_deleteduser_user_id'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ], + ), + migrations.RemoveField( + model_name='user', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='user', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='user', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='user', + name='deleted', + ), + ] diff --git a/src/apps/profiles/migrations/0017_user_is_banned.py b/src/apps/profiles/migrations/0017_user_is_banned.py new file mode 100644 index 000000000..0f266d30d --- /dev/null +++ b/src/apps/profiles/migrations/0017_user_is_banned.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2025-12-08 18:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0016_deleteduser_user_id'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_banned', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/profiles/migrations/0018_auto_20250623_1719.py b/src/apps/profiles/migrations/0018_auto_20250623_1719.py new file mode 100644 index 000000000..25d07fb3b --- /dev/null +++ b/src/apps/profiles/migrations/0018_auto_20250623_1719.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.28 on 2025-06-23 17:19 + +from django.db import migrations +import profiles.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0017_auto_20250623_1341'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', profiles.models.CodabenchUserManager()), + ], + ), + ] diff --git a/src/apps/profiles/migrations/0019_merge_0017_user_is_banned_0018_auto_20250623_1719.py b/src/apps/profiles/migrations/0019_merge_0017_user_is_banned_0018_auto_20250623_1719.py new file mode 100644 index 000000000..3b9cf2e15 --- /dev/null +++ b/src/apps/profiles/migrations/0019_merge_0017_user_is_banned_0018_auto_20250623_1719.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2025-12-10 13:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0017_user_is_banned'), + ('profiles', '0018_auto_20250623_1719'), + ] + + operations = [ + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 3a660440d..95ff20b34 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -3,7 +3,6 @@ from django.contrib.auth.models import PermissionsMixin, AbstractBaseUser, UserManager from django.db import models from django.utils.timezone import now -from chahub.models import ChaHubSaveMixin from django.utils.text import slugify from utils.data import PathWrapper from django.urls import reverse @@ -25,9 +24,9 @@ ] -class ChaHubUserManager(UserManager): +class CodabenchUserManager(UserManager): def get_queryset(self): - return super().get_queryset().filter(deleted=False) + return super().get_queryset().filter() def all_objects(self): return super().get_queryset() @@ -43,7 +42,7 @@ def __str__(self): return f"{self.username} ({self.email})" -class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): +class User(AbstractBaseUser, PermissionsMixin): # Social needs the below setting. Username is not really set to UID. USERNAME_FIELD = 'username' EMAIL_FIELD = 'email' @@ -101,12 +100,15 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): is_bot = models.BooleanField(default=False) # Required for social auth and such to create users - objects = ChaHubUserManager() + objects = CodabenchUserManager() # Soft deletion is_deleted = models.BooleanField(default=False) deleted_at = models.DateTimeField(null=True, blank=True) + # Ban + is_banned = models.BooleanField(default=False) + def save(self, *args, **kwargs): self.slug = slugify(self.username, allow_unicode=True) super().save(*args, **kwargs) @@ -124,35 +126,6 @@ def __str__(self): def slug_url(self): return reverse('profiles:user_profile', args=[self.slug]) - @staticmethod - def get_chahub_endpoint(): - return "profiles/" - - def get_whitelist(self): - # all chahub data is ok to send - pass - - def clean_private_data(self, data): - # overriding this to filter out blacklist data from above, just to make _sure_ we don't send that info - return {k: v for k, v in data.items() if k not in PROFILE_DATA_BLACKLIST} - - def get_chahub_data(self): - data = { - 'email': self.email, - 'username': self.username, - 'remote_id': self.pk, - 'details': { - "is_active": self.is_active, - "last_login": self.last_login.isoformat() if self.last_login else None, - "date_joined": self.date_joined.isoformat() if self.date_joined else None, - } - } - return self.clean_private_data(data) - - def get_chahub_is_valid(self): - # By default, always push - return True - def get_used_storage_space(self, binary=False): """ Function to calculate storage used by a user diff --git a/src/apps/profiles/pipeline.py b/src/apps/profiles/pipeline.py deleted file mode 100644 index b14ea63ca..000000000 --- a/src/apps/profiles/pipeline.py +++ /dev/null @@ -1,24 +0,0 @@ -from profiles.models import GithubUserInfo - - -def user_details(user, **kwargs): - """Update user details using data from provider.""" - backend = kwargs.get('backend') - - if user: - if backend and backend.name == 'chahub': - if kwargs.get('details', {}).get('github_info'): - github_info = kwargs['details'].pop('github_info', None) - if github_info and github_info.get('uid'): - obj, created = GithubUserInfo.objects.update_or_create( - uid=github_info.pop('uid'), - defaults=github_info, - ) - user.github_info = obj - user.save() - if created: - print("New github user info created for user: {}".format(user.username)) - if not created: - print("We updated existing info for user: {}".format(user.username)) - else: - pass diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 366fdf298..333a96e15 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -33,8 +33,6 @@ class LoginView(auth_views.LoginView): def get_context_data(self, *args, **kwargs): context = super(LoginView, self).get_context_data(*args, **kwargs) - # "http://localhost:8888/profiles/signup?next=http://localhost/social/login/chahub" - context['chahub_signup_url'] = "{}/profiles/signup?next={}/social/login/chahub".format(settings.SOCIAL_AUTH_CHAHUB_BASE_URL, settings.SITE_DOMAIN) return context @@ -96,7 +94,7 @@ def activateEmail(request, user, to_email): mail_subject = 'Activate your user account.' message = render_to_string('profiles/emails/template_activate_account.html', { 'username': user.username, - 'domain': get_current_site(request).domain, + 'domain': settings.DOMAIN_NAME, 'uid': urlsafe_base64_encode(force_bytes(user.pk)), 'token': account_activation_token.make_token(user), 'protocol': 'https' if request.is_secure() else 'http' @@ -199,10 +197,6 @@ def sign_up(request): return redirect('accounts:login') context = {} - context['chahub_signup_url'] = "{}/profiles/signup?next={}/social/login/chahub".format( - settings.SOCIAL_AUTH_CHAHUB_BASE_URL, - settings.SITE_DOMAIN - ) if request.method == 'POST': form = SignUpForm(request.POST) if form.is_valid(): @@ -266,10 +260,6 @@ def log_in(request): next = request.GET.get('next', None) context = {} - context['chahub_signup_url'] = "{}/profiles/signup?next={}/social/login/chahub".format( - settings.SOCIAL_AUTH_CHAHUB_BASE_URL, - settings.SITE_DOMAIN - ) if request.method == 'POST': form = LoginForm(request.POST) @@ -357,7 +347,7 @@ class CustomPasswordResetView(auth_views.PasswordResetView): We have to use app:view_name syntax in templates like " {% url 'accounts:password_reset_confirm'%} " Therefore we need to tell this view to find the right success_url with that syntax or django won't be able to find the view. - 3. from_email: We want to set the from_email to info@codalab.org - may eventually put in .env file. + 3. from_email: We want to use SERVER_EMAIL already set in the .env # The other commented sections are the defaults for other attributes in auth_views.PasswordResetView. They are in here in case someone wants to customize in the future. All attributes show up in the order shown in the docs. diff --git a/src/apps/tasks/migrations/0005_auto_20250623_1341.py b/src/apps/tasks/migrations/0005_auto_20250623_1341.py new file mode 100644 index 000000000..a4ffc9cf2 --- /dev/null +++ b/src/apps/tasks/migrations/0005_auto_20250623_1341.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.28 on 2025-06-23 13:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0004_task_shared_with'), + ] + + operations = [ + migrations.RemoveField( + model_name='solution', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='solution', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='solution', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='solution', + name='deleted', + ), + migrations.RemoveField( + model_name='task', + name='chahub_data_hash', + ), + migrations.RemoveField( + model_name='task', + name='chahub_needs_retry', + ), + migrations.RemoveField( + model_name='task', + name='chahub_timestamp', + ), + migrations.RemoveField( + model_name='task', + name='deleted', + ), + ] diff --git a/src/apps/tasks/models.py b/src/apps/tasks/models.py index c93d2b60a..e54ed75e6 100644 --- a/src/apps/tasks/models.py +++ b/src/apps/tasks/models.py @@ -4,10 +4,8 @@ from django.db import models from django.utils.timezone import now -from chahub.models import ChaHubSaveMixin - -class Task(ChaHubSaveMixin, models.Model): +class Task(models.Model): name = models.CharField(max_length=256) description = models.TextField(null=True, blank=True) key = models.UUIDField(default=uuid.uuid4, blank=True, unique=True) @@ -31,43 +29,8 @@ def _validated(self): # TODO: Should only include submissions that are successful, not any! return self.solutions.filter(md5__in=self.phases.values_list('submissions__md5', flat=True)).exists() - @staticmethod - def get_chahub_endpoint(): - return 'tasks/' - - def get_whitelist(self): - return [ - 'remote_id', - 'is_public', - 'solutions', - 'ingestion_program', - 'input_data', - 'reference_data', - 'scoring_program', - ] - - def get_chahub_data(self, include_solutions=True): - data = { - 'remote_id': self.pk, - 'created_by': self.created_by.username, - 'creator_id': self.created_by.pk, - 'created_when': self.created_when.isoformat(), - 'name': self.name, - 'description': self.description, - 'key': str(self.key), - 'is_public': self.is_public, - 'ingestion_program': self.ingestion_program.get_chahub_data() if self.ingestion_program else None, - 'input_data': self.input_data.get_chahub_data() if self.input_data else None, - 'ingestion_only_during_scoring': self.ingestion_only_during_scoring, - 'reference_data': self.reference_data.get_chahub_data() if self.reference_data else None, - 'scoring_program': self.scoring_program.get_chahub_data() if self.scoring_program else None, - } - if include_solutions: - data['solutions'] = [solution.get_chahub_data(include_tasks=False) for solution in self.solutions.all()] - return self.clean_private_data(data) - -class Solution(ChaHubSaveMixin, models.Model): +class Solution(models.Model): name = models.CharField(max_length=256) description = models.TextField(null=True, blank=True) key = models.UUIDField(default=uuid.uuid4, blank=True, unique=True) @@ -79,27 +42,3 @@ class Solution(ChaHubSaveMixin, models.Model): def __str__(self): return f"Solution - {self.name} - ({self.id})" - - @staticmethod - def get_chahub_endpoint(): - return 'solutions/' - - def get_whitelist(self): - return [ - 'remote_id', - 'is_public', - 'data', - 'tasks', - ] - - def get_chahub_data(self, include_tasks=True): - data = { - 'remote_id': self.pk, - 'name': self.name, - 'description': self.description, - 'key': str(self.key), - 'data': self.data.get_chahub_data(), # Todo: Make sure data is public if solution is public - } - if include_tasks: - data['tasks'] = [task.get_chahub_data(include_solutions=False) for task in self.tasks.all()] - return self.clean_private_data(data) diff --git a/src/middleware.py b/src/middleware.py new file mode 100644 index 000000000..1905a0f2c --- /dev/null +++ b/src/middleware.py @@ -0,0 +1,19 @@ +from django.shortcuts import render +from django.http import JsonResponse + + +class BlockBannedUsersMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if user.is_authenticated and user.is_banned: + # For api paths return json response + # For normal paths show banned page + if request.path.startswith("/api/"): + return JsonResponse({"error": "You are banned from using Codabench"}, status=403) + else: + return render(request, "banned.html", status=403) + + return self.get_response(request) diff --git a/src/settings/base.py b/src/settings/base.py index 9db7a91ec..505329d73 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -49,12 +49,11 @@ 'redis', ) OUR_APPS = ( - 'chahub', + 'profiles', 'analytics', 'competitions', 'datasets', 'pages', - 'profiles', 'leaderboards', 'tasks', 'commands', @@ -68,6 +67,7 @@ MIDDLEWARE = ( 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -76,7 +76,8 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 'corsheaders.middleware.CorsMiddleware', # BB - 'django.middleware.common.CommonMiddleware' + 'django.middleware.common.CommonMiddleware', + 'middleware.BlockBannedUsersMiddleware' ) ROOT_URLCONF = 'urls' @@ -120,7 +121,6 @@ # ============================================================================= AUTHENTICATION_BACKENDS = ( 'social_core.backends.github.GithubOAuth2', - 'utils.oauth_backends.ChahubOAuth2', 'django.contrib.auth.backends.ModelBackend', 'django_su.backends.SuBackend', 'profiles.backends.EmailAuthenticationBackend', @@ -135,7 +135,6 @@ 'social_core.pipeline.social_auth.load_extra_data', # 'social_core.pipeline.user.user_details', 'social_core.pipeline.social_auth.associate_by_email', - 'profiles.pipeline.user_details', ) # Github @@ -143,11 +142,6 @@ SOCIAL_AUTH_GITHUB_SECRET = os.environ.get('SOCIAL_AUTH_GITHUB_SECRET') SOCIAL_AUTH_GITHUB_SCOPE = ['user'] -# Codalab Example settings -SOCIAL_AUTH_CHAHUB_BASE_URL = os.environ.get('SOCIAL_AUTH_CHAHUB_BASE_URL', 'asdfasdfasfd') -SOCIAL_AUTH_CHAHUB_KEY = os.environ.get('SOCIAL_AUTH_CHAHUB_KEY', 'asdfasdfasfd') -SOCIAL_AUTH_CHAHUB_SECRET = os.environ.get('SOCIAL_AUTH_CHAHUB_SECRET', 'asdfasdfasfdasdfasdf') - # Generic SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy' SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage' @@ -316,7 +310,6 @@ def setup_celery_logging(**kwargs): # This will configure the logger with Loguru, allowing us to chose between log levels (DEBUG INFO etc) and if the logs are serialized or not (JSON format) configure_logging(os.environ.get("LOG_LEVEL", "INFO"), os.environ.get("SERIALIZED", 'false')) - # ============================================================================= # Channels # ============================================================================= @@ -459,13 +452,7 @@ def setup_celery_logging(**kwargs): DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'Codabench ') SERVER_EMAIL = os.environ.get('SERVER_EMAIL', 'noreply@codabench.org') -# ============================================================================= -# Chahub -# ============================================================================= -CHAHUB_API_URL = os.environ.get('CHAHUB_API_URL') -CHAHUB_API_KEY = os.environ.get('CHAHUB_API_KEY') -CHAHUB_PRODUCER_ID = os.environ.get('CHAHUB_PRODUCER_ID') - +CONTACT_EMAIL = os.environ.get('CONTACT_EMAIL', 'info@codabench.org') # Django-Su (User impersonation) SU_LOGIN_CALLBACK = 'profiles.admin.su_login_callback' diff --git a/src/settings/logs_loguru.py b/src/settings/logs_loguru.py index bf4e02f66..28b2cf075 100644 --- a/src/settings/logs_loguru.py +++ b/src/settings/logs_loguru.py @@ -2,14 +2,16 @@ import logging import sys import os - +import re +import json +from loguru._better_exceptions import ExceptionFormatter +import loguru from loguru import logger + # ----------------------------------------------------------------------------- # This file will allow us to replace the Django default logger with loguru # ----------------------------------------------------------------------------- - - class InterceptHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: # Get corresponding Loguru level if it exists. @@ -29,10 +31,265 @@ def emit(self, record: logging.LogRecord) -> None: frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +# Helps colorize json logs +def colorize_run_args(json_str): + """ + Apply colorization to a run_args in compute workers and celery + """ + # Define color codes + reset = "\033[0m" + green = "\033[32m" + cyan = "\033[36m" + yellow = "\033[33m" + magenta = "\033[35m" + + lineskip = "\n" + # Colorize json + # Yellow by default + # Magenta for numbers + # Cyan for docker images + # Green for True/False + json_str = re.sub( + r'("detailed_results_url": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("docker_image": ")(.*?)(",)', rf"\1{cyan}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("execution_time_limit":)(.*?)(,)', + rf"\1{magenta}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("id":)(.*?)(,)', rf"\1{magenta}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("ingestion_only_during_scoring": )(.*?)(,)', + rf"\1{green}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("is_scoring": )(.*?)(,)', rf"\1{green}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("prediction_result": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("program_data": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("reference_data": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("scoring_ingestion_stderr": ")(.*?)(")', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("scoring_ingestion_stdout": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("scoring_result": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("scoring_stderr": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("scoring_stdout": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("secret": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("submissions_api_url": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("user_pk":)(.*?)(,)', rf"\1{magenta}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("ingestion_only_during_scoring": ")(.*?)(,)', + rf"\1{green}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("prediction_ingestion_stderr": ")(.*?)(")', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("ingestion_program": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("input_data": ")(.*?)(",)', rf"\1{yellow}\2{reset}\3{lineskip}", json_str + ) + json_str = re.sub( + r'("prediction_stdout": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("prediction_stderr": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + json_str = re.sub( + r'("prediction_ingestion_stdout": ")(.*?)(",)', + rf"\1{yellow}\2{reset}\3{lineskip}", + json_str, + ) + + return json_str + + +def colorize_json_string(json_str): + """ + Apply colorization to a JSON string after it's been serialized. + Colorize message based on the color of the level. + """ + # Define color codes + reset = "\033[0m" + green = "\033[32m" # For timestamp and success level + cyan = "\033[34m" # For DEBUG level and paths + white = "\033[0m" # For INFO level + yellow = "\033[33m" # For WARNING level + red = "\033[31m" # For ERROR level + white_on_red = "\033[37;41m" # For CRITICAL level + + # Find and colorize the timestamp + json_str = re.sub(r'("time": ")([^"]+)(")', rf"\1{green}\2{reset}\3", json_str) + + # Extract the level before colorizing to determine message color + level_match = re.search(r'"level": "([^"]+)"', json_str) + level_color = white # Default color + + if level_match: + level = level_match.group(1) + if level == "DEBUG": + level_color = cyan + elif level == "INFO": + level_color = white + elif level == "WARNING": + level_color = yellow + elif level == "ERROR": + level_color = red + elif level == "SUCCESS": + level_color = green + elif level == "CRITICAL": + level_color = white_on_red + + # Find and colorize the log level + json_str = re.sub(r'("level": ")DEBUG(")', rf"\1{cyan}DEBUG{reset}\2", json_str) + json_str = re.sub(r'("level": ")INFO(")', rf"\1{white}INFO{reset}\2", json_str) + json_str = re.sub( + r'("level": ")WARNING(")', rf"\1{yellow}WARNING{reset}\2", json_str + ) + json_str = re.sub(r'("level": ")ERROR(")', rf"\1{red}ERROR{reset}\2", json_str) + json_str = re.sub( + r'("level": ")SUCCESS(")', rf"\1{green}SUCCESS{reset}\2", json_str + ) + json_str = re.sub( + r'("level": ")CRITICAL(")', rf"\1{white_on_red}CRITICAL{reset}\2", json_str + ) + + # Find and colorize the message using the level color + json_str = re.sub( + r'("message": ")(.*?)(")', rf"\1{level_color}\2{reset}\3", json_str + ) + + # Find and colorize the path + json_str = re.sub(r'("path": ")(.*?)(")', rf"\1{cyan}\2{reset}\3", json_str) + + # Find and colorize exceptions + json_str = re.sub(r'("type": ")(.*?)(")', rf"\1{red}\2{reset}\3", json_str) + json_str = re.sub(r'("value": ")(.*?)(")', rf"\1{red}\2{reset}\3", json_str) + + return json_str + + +def serialize(record): + """Serialize with datetime, path info, and apply colorization to the JSON string.""" + # Extract datetime + timestamp = record["time"].isoformat(" ", "seconds") + + # Extract file path, module, function and line info + module_name = record["name"] + function_name = record["function"] + line_number = record["line"] + + path_info = f"{module_name}:{function_name}:{line_number}" + + # Get log level + level = record["level"].name + + # Extract other info + error: loguru.RecordException = record["exception"] + error_by_default = sys.exc_info() # logger.error + show_exception_value: bool = record["extra"].get("show_exception_value", True) + extra = record["extra"].copy() + + # Process exception info + if error: # only set when exception. + exc_type, exc_value, exc_tb = error.type, error.value, error.traceback + + # Use ExceptionFormatter directly with the specific error components + formatter = ExceptionFormatter(backtrace=True, diagnose=True, colorize=True) + formatted_traceback = formatter.format_exception(exc_type, exc_value, exc_tb) + + exception = { + "type": exc_type.__name__, + "value": str(exc_value).strip("'") if show_exception_value else None, + "traceback": "".join(formatted_traceback), + } + elif error_by_default[0]: # whenever error occurs + _type, _value, _ = sys.exc_info() + exception = { + "type": _type.__name__, + "value": str(_value).strip("'") if show_exception_value else None, + "traceback": None, + } + else: + exception = None + + # Prepare data for serialization + to_serialize = { + "time": timestamp, + "level": level, + "path": path_info, + "message": record["message"], + "exception": exception, + } + + # Add other extra fields + for key, value in extra.items(): + if key not in ("serialized", "show_exception_value"): + to_serialize[key] = value + + # Convert to JSON string + json_str = json.dumps(to_serialize) + record["extra"]["serialized"] = colorize_json_string(json_str) + # Colorize the JSON string + return "{extra[serialized]}\n" -def configure_logging(log_level=os.environ.get("LOG_LEVEL", "INFO"), serialized=os.environ.get("SERIALIZED", "false")): +def configure_logging( + log_level=os.environ.get("LOG_LEVEL", "INFO"), + serialized=os.environ.get("SERIALIZED", "false"), +): # We can change the level per module here. uvicorn.protolcs is set to warning, otherwise we would get logs that Caddy is already getting # You can get the name of modules you want to add from the logs if log_level.upper() == "INFO": @@ -42,14 +299,24 @@ def configure_logging(log_level=os.environ.get("LOG_LEVEL", "INFO"), serialized= level_per_module = { "": log_level.upper(), - "uvicorn.protocols.http": moduleLogLevel.upper() + "uvicorn.protocols.http": moduleLogLevel.upper(), } # Remove default logger configuration then configure logger logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) - if serialized.lower() == 'true': + if serialized.lower() == "true": logger.remove() - logger.add(sys.stderr, format="{time:MMMM D, YYYY > HH:mm:ss!UTC} | {level} | {message}", serialize=True, filter=level_per_module) + # logger.add(sys.stderr, format="{time:MMMM D, YYYY > HH:mm:ss!UTC} | {level} | {message}", serialize=True, filter=level_per_module) + logger.add( + sys.stderr, + colorize=True, + serialize=False, + backtrace=True, + diagnose=True, + level=log_level.upper(), + format=serialize, + filter=level_per_module, + ) else: logger.remove() logger.add(sys.stderr, colorize=True, filter=level_per_module) diff --git a/src/settings/test.py b/src/settings/test.py index 21c5ababc..ec5963f3d 100644 --- a/src/settings/test.py +++ b/src/settings/test.py @@ -24,7 +24,3 @@ SELENIUM_HOSTNAME = os.environ.get("SELENIUM_HOSTNAME", "localhost") IS_TESTING = True - -CHAHUB_API_URL = None -CHAHUB_API_KEY = None -CHAHUB_PRODUCER_ID = None diff --git a/src/static/img/banned.png b/src/static/img/banned.png new file mode 100644 index 000000000..034e2db41 Binary files /dev/null and b/src/static/img/banned.png differ diff --git a/src/static/riot/competitions/competition_list.tag b/src/static/riot/competitions/competition_list.tag index 5787aede5..7d7b0f720 100644 --- a/src/static/riot/competitions/competition_list.tag +++ b/src/static/riot/competitions/competition_list.tag @@ -8,7 +8,7 @@ Benchmarks I'm In diff --git a/src/static/riot/competitions/detail/_tabs.tag b/src/static/riot/competitions/detail/_tabs.tag index 02dffe668..a791d05dd 100644 --- a/src/static/riot/competitions/detail/_tabs.tag +++ b/src/static/riot/competitions/detail/_tabs.tag @@ -10,7 +10,7 @@ Forum @@ -50,7 +50,7 @@
- +
@@ -202,7 +202,7 @@ is_admin="{competition.admin}">
-
+ @@ -267,7 +267,7 @@ }) // Need code for public_data and starting_kit at phase level - if(self.competition.participant_status === 'approved'){ + if(self.competition.participant_status === 'approved'){ _.forEach(phase.tasks, task => { _.forEach(task.solutions, solution => { soln = { @@ -310,8 +310,8 @@ }) // loop over competition phases to mark if phase has started or ended self.competition.phases.forEach(function (phase, index) { - - phase_ended = false + + phase_ended = false phase_started = false // check if phase has started @@ -343,20 +343,20 @@ }) self.competition.is_admin = CODALAB.state.user.has_competition_admin_privileges(competition) - + // Find current phase and set selected phase index to its id self.selected_phase_index = _.get(_.find(self.competition.phases, {'status': 'Current'}), 'id') - // If no Current phase in this competition + // If no Current phase in this competition // Find Final phase and set selected phase index to its id if (self.selected_phase_index == null) { self.selected_phase_index = _.get(_.find(self.competition.phases, {is_final_phase: true}), 'id') } - // If no Final phase in this competition + // If no Final phase in this competition // Find the last phase and set selected phase index to its id if (self.selected_phase_index == null) { - self.selected_phase_index = self.competition.phases[self.competition.phases.length - 1].id; + self.selected_phase_index = self.competition.phases[self.competition.phases.length - 1].id; } self.phase_selected(_.find(self.competition.phases, {id: self.selected_phase_index})) @@ -375,7 +375,7 @@ rendered_content.forEach(node => { $(`#page_${index}`)[0].appendChild(node.cloneNode(true)); // Append each node }); - + }) if(self.competition_has_no_terms_page()){ const rendered_content = renderMarkdownWithLatex(self.competition.terms) diff --git a/src/static/riot/competitions/editor/_competition_details.tag b/src/static/riot/competitions/editor/_competition_details.tag index 4cd12eeb6..199edd404 100644 --- a/src/static/riot/competitions/editor/_competition_details.tag +++ b/src/static/riot/competitions/editor/_competition_details.tag @@ -137,7 +137,7 @@