├── .gitattributes
├── .github
└── workflows
│ ├── run-on-windows.yml
│ └── run-tests.yml
├── .gitignore
├── COMMERCIAL-LICENSE.txt
├── CONTRIBUTOR_LICENSE_AGREEMENT.md
├── LICENSE.txt
├── MANIFEST.in
├── README.md
├── docker-compose-https.yml
├── docker-compose.yml
├── docker
├── init-letsencrypt.sh
├── mercury
│ ├── Dockerfile
│ └── entrypoint.sh
└── nginx
│ ├── Dockerfile
│ ├── default.conf
│ └── pro
│ └── default.conf
├── frontend
├── .gitignore
├── package.json
├── public
│ ├── favicon-old.ico
│ ├── favicon.ico
│ ├── index.html
│ ├── jupyter-additional.css
│ ├── jupyter-syntax.css
│ ├── jupyter-theme-light.css
│ ├── manifest.json
│ ├── mercury_black_logo.svg
│ ├── mercury_logo.svg
│ └── robots.txt
├── src
│ ├── Root.tsx
│ ├── Routes.tsx
│ ├── components
│ │ ├── AutoRefresh.tsx
│ │ ├── DefaultLogo.tsx
│ │ ├── FileItem.tsx
│ │ ├── FilesView.tsx
│ │ ├── Footer.tsx
│ │ ├── HomeNavBar.tsx
│ │ ├── LoginButton.tsx
│ │ ├── MadeWithDiv.tsx
│ │ ├── MainView.tsx
│ │ ├── NavBar.tsx
│ │ ├── ProFeatureAlert.tsx
│ │ ├── RequireAuth.tsx
│ │ ├── RestAPIView.tsx
│ │ ├── RunButton.tsx
│ │ ├── SelectExecutionHistory.tsx
│ │ ├── ShareDialog.tsx
│ │ ├── SideBar.tsx
│ │ ├── StatusBar.tsx
│ │ ├── UserButton.tsx
│ │ ├── WaitPDFExport.tsx
│ │ └── WindowDimensions.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── react-app-env.d.ts
│ ├── reportWebVitals.ts
│ ├── rootReducer.ts
│ ├── setupTests.ts
│ ├── slices
│ │ ├── appSlice.ts
│ │ ├── authSlice.ts
│ │ ├── notebooksSlice.ts
│ │ ├── sitesSlice.ts
│ │ ├── tasksSlice.ts
│ │ ├── versionSlice.ts
│ │ └── wsSlice.tsx
│ ├── store.ts
│ ├── utils.ts
│ ├── views
│ │ ├── AccountView.tsx
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── AppView.tsx
│ │ ├── HomeView.tsx
│ │ ├── LoginView.tsx
│ │ ├── LostConnection.tsx
│ │ ├── NotebookNotFoundView.tsx
│ │ ├── OpenAPIView.tsx
│ │ ├── SiteAccessForbiddenView.tsx
│ │ ├── SiteLoadingView.tsx
│ │ ├── SiteNetworkErrorView.tsx
│ │ ├── SiteNotFoundView.tsx
│ │ ├── SiteNotReadyView.tsx
│ │ └── SitePleaseRefreshView.tsx
│ ├── websocket
│ │ └── Provider.tsx
│ └── widgets
│ │ ├── Button.tsx
│ │ ├── Checkbox.tsx
│ │ ├── File.tsx
│ │ ├── Markdown.tsx
│ │ ├── Numeric.tsx
│ │ ├── Range.tsx
│ │ ├── Select.tsx
│ │ ├── Slider.tsx
│ │ ├── Text.tsx
│ │ └── Types.tsx
├── tsconfig.json
└── yarn.lock
├── mercury
├── .gitignore
├── __init__.py
├── apps
│ ├── __init__.py
│ ├── accounts
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── fields.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_apikey.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── tasks.py
│ │ ├── templatetags
│ │ │ ├── __init__.py
│ │ │ └── replace.py
│ │ ├── tests
│ │ │ ├── __init__.py
│ │ │ ├── test_accounts.py
│ │ │ ├── test_apikey.py
│ │ │ ├── test_invitations.py
│ │ │ ├── test_secrets.py
│ │ │ ├── test_sites.py
│ │ │ └── test_subscription.py
│ │ ├── urls.py
│ │ └── views
│ │ │ ├── __init__.py
│ │ │ ├── accounts.py
│ │ │ ├── apikey.py
│ │ │ ├── invitations.py
│ │ │ ├── permissions.py
│ │ │ ├── secrets.py
│ │ │ ├── sites.py
│ │ │ ├── subscription.py
│ │ │ └── utils.py
│ ├── nb
│ │ ├── __init__.py
│ │ ├── exporter.py
│ │ ├── nbrun.py
│ │ ├── tests.py
│ │ ├── tests
│ │ │ ├── __init__.py
│ │ │ └── test_nbrun.py
│ │ └── utils.py
│ ├── nbworker
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── nb.py
│ │ ├── rest.py
│ │ ├── tests.py
│ │ ├── utils.py
│ │ └── ws.py
│ ├── notebooks
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── fixtures
│ │ │ ├── simple_notebook.ipynb
│ │ │ └── third_notebook.ipynb
│ │ ├── management
│ │ │ ├── __init__.py
│ │ │ └── commands
│ │ │ │ ├── __init__.py
│ │ │ │ ├── add.py
│ │ │ │ ├── delete.py
│ │ │ │ ├── list.py
│ │ │ │ └── watch.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── slides_themes.py
│ │ ├── tasks.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── storage
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_useruploadedfile.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── s3utils.py
│ │ ├── serializers.py
│ │ ├── storage.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ ├── utils.py
│ │ └── views
│ │ │ ├── __init__.py
│ │ │ ├── dashboardfiles.py
│ │ │ ├── notebookfiles.py
│ │ │ ├── stylefiles.py
│ │ │ └── workerfiles.py
│ ├── tasks
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── clean_service.py
│ │ ├── export_pdf.py
│ │ ├── export_png.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_restapitask.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── notify.py
│ │ ├── serializers.py
│ │ ├── tasks.py
│ │ ├── tasks_export.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── workers
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── constants.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_machine.py
│ │ │ ├── 0003_worker_run_by_workersession.py
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── serializers.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ ├── utils.py
│ │ └── views.py
│ └── ws
│ │ ├── __init__.py
│ │ ├── apps.py
│ │ ├── client.py
│ │ ├── middleware.py
│ │ ├── migrations
│ │ └── __init__.py
│ │ ├── routing.py
│ │ ├── tasks.py
│ │ ├── tests.py
│ │ ├── utils.py
│ │ └── worker.py
├── demo.py
├── manage.py
├── mercury.py
├── requirements.txt
├── server
│ ├── __init__.py
│ ├── asgi.py
│ ├── celery.py
│ ├── settings.py
│ ├── urls.py
│ ├── views.py
│ └── wsgi.py
├── templates
│ └── account
│ │ └── email
│ │ ├── email_confirmation_message.txt
│ │ └── password_reset_key_message.txt
└── widgets
│ ├── __init__.py
│ ├── apiresponse.py
│ ├── app.py
│ ├── button.py
│ ├── chat.py
│ ├── checkbox.py
│ ├── confetti.py
│ ├── file.py
│ ├── in_mercury.py
│ ├── json.py
│ ├── manager.py
│ ├── md.py
│ ├── multiselect.py
│ ├── note.py
│ ├── numberbox.py
│ ├── numeric.py
│ ├── outputdir.py
│ ├── pdf.py
│ ├── range.py
│ ├── select.py
│ ├── slider.py
│ ├── stop.py
│ ├── table.py
│ ├── text.py
│ └── user.py
├── pyproject.toml
├── scripts
├── dual_pack_mercury.sh
└── pack_mercury.sh
├── setup-https.sh
└── setup.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.css linguist-detectable=false
2 |
--------------------------------------------------------------------------------
/.github/workflows/run-on-windows.yml:
--------------------------------------------------------------------------------
1 | name: Run on Windows
2 |
3 | on:
4 | schedule:
5 | - cron: '0 8 * * 1'
6 | # run workflow manually
7 | workflow_dispatch:
8 |
9 | jobs:
10 | build:
11 | runs-on: windows-latest
12 | strategy:
13 | matrix:
14 | python-version: [3.8]
15 |
16 | steps:
17 | - uses: conda-incubator/setup-miniconda@v2
18 | with:
19 | activate-environment: test
20 | auto-update-conda: false
21 | python-version: ${{ matrix.python-version }}
22 | - name: Activate conda and check versions
23 | run: |
24 | conda create --name testenv python=3.8
25 | conda activate testenv
26 | conda --version
27 | python --version
28 | - name: Install Mercury
29 | run: conda install -c conda-forge -n testenv mljar-mercury
30 | - name: Run Mercury server
31 | run: mercury run
32 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | python-version: [3.8]
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Set up Python ${{ matrix.python-version }}
16 | uses: actions/setup-python@v2
17 | with:
18 | python-version: ${{ matrix.python-version }}
19 | - name: Install dependencies
20 | run: |
21 | sudo apt-get update
22 | python -m pip install --upgrade pip
23 | ./scripts/pack_mercury.sh
24 | pip install --upgrade setuptools
25 | cd mercury
26 | pip install -U -r requirements.txt
27 | - name: Test backend
28 | run: |
29 | cd mercury
30 | python manage.py test apps
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info*
2 | dist/
3 | .env
--------------------------------------------------------------------------------
/CONTRIBUTOR_LICENSE_AGREEMENT.md:
--------------------------------------------------------------------------------
1 | Mercury Contributor License Agreement
2 |
3 | I disavow any rights or claims to any changes submitted to the Mercury project and assign the copyright of those changes to MLJAR Sp. z o.o.
4 |
5 | As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim.
6 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include mercury/frontend-dist/ *
2 | recursive-include mercury/frontend-single-site-dist/ *
3 | include mercury/requirements.txt
4 | include README.md
5 |
--------------------------------------------------------------------------------
/docker-compose-https.yml:
--------------------------------------------------------------------------------
1 | version: '2'
2 |
3 | services:
4 | nginx:
5 | restart: unless-stopped
6 | build:
7 | context: .
8 | dockerfile: ./docker/nginx/Dockerfile
9 | ports:
10 | - 80:80
11 | - 443:443
12 | volumes:
13 | - static_volume:/app/mercury/django_static
14 | - media_volume:/app/mercury/media
15 | - ./docker/nginx/pro:/etc/nginx/conf.d
16 | - ./docker/nginx/certbot/conf:/etc/letsencrypt
17 | - ./docker/nginx/certbot/www:/var/www/certbot
18 | depends_on:
19 | - mercury
20 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
21 | certbot:
22 | image: certbot/certbot
23 | restart: unless-stopped
24 | volumes:
25 | - ./docker/nginx/certbot/conf:/etc/letsencrypt
26 | - ./docker/nginx/certbot/www:/var/www/certbot
27 | entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
28 |
29 | mercury:
30 | restart: unless-stopped
31 | build:
32 | context: .
33 | dockerfile: ./docker/mercury/Dockerfile
34 | args:
35 | GITHUB_TOKEN: ${GITHUB_TOKEN}
36 | entrypoint: /app/docker/mercury/entrypoint.sh
37 | volumes:
38 | - ${NOTEBOOKS_PATH}:/app/notebooks
39 | - static_volume:/app/mercury/django_static
40 | - media_volume:/app/mercury/media
41 | expose:
42 | - 9000
43 | environment:
44 | DEBUG: ${DEBUG}
45 | SERVE_STATIC: ${SERVE_STATIC}
46 | DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME}
47 | DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD}
48 | DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL}
49 | SECRET_KEY: ${SECRET_KEY}
50 | ALLOWED_HOSTS: ${ALLOWED_HOSTS}
51 | WELCOME: ${WELCOME}
52 | EMAIL_HOST: ${EMAIL_HOST}
53 | EMAIL_HOST_USER: ${EMAIL_HOST_USER}
54 | EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
55 | EMAIL_PORT: ${EMAIL_PORT}
56 | DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL}
57 | DJANGO_DB: postgresql
58 | POSTGRES_HOST: db
59 | POSTGRES_NAME: postgres
60 | POSTGRES_USER: postgres
61 | POSTGRES_PASSWORD: postgres
62 | POSTGRES_PORT: 5432
63 | depends_on:
64 | - db
65 | db:
66 | image: postgres:13.0-alpine
67 | restart: unless-stopped
68 | volumes:
69 | - postgres_data:/var/lib/postgresql/data/
70 | environment:
71 | POSTGRES_DB: postgres
72 | POSTGRES_USER: postgres
73 | POSTGRES_PASSWORD: postgres
74 |
75 | volumes:
76 | static_volume: {}
77 | media_volume: {}
78 | postgres_data: {}
79 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2'
2 |
3 | services:
4 | nginx:
5 | restart: unless-stopped
6 | build:
7 | context: .
8 | dockerfile: ./docker/nginx/Dockerfile
9 | ports:
10 | - 80:80
11 | volumes:
12 | - static_volume:/app/mercury/django_static
13 | - media_volume:/app/mercury/media
14 | - ./docker/nginx:/etc/nginx/conf.d
15 | depends_on:
16 | - mercury
17 | command: "/bin/sh -c 'nginx -g \"daemon off;\"'"
18 | mercury:
19 | restart: unless-stopped
20 | build:
21 | context: .
22 | dockerfile: ./docker/mercury/Dockerfile
23 | entrypoint: /app/docker/mercury/entrypoint.sh
24 | volumes:
25 | - ${NOTEBOOKS_PATH}:/app/notebooks
26 | - static_volume:/app/mercury/django_static
27 | - media_volume:/app/mercury/media
28 | expose:
29 | - 9000
30 | environment:
31 | DEBUG: ${DEBUG}
32 | SERVE_STATIC: ${SERVE_STATIC}
33 | DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME}
34 | DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD}
35 | DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL}
36 | SECRET_KEY: ${SECRET_KEY}
37 | ALLOWED_HOSTS: ${ALLOWED_HOSTS}
38 | WELCOME: ${WELCOME}
39 | EMAIL_HOST: ${EMAIL_HOST}
40 | EMAIL_HOST_USER: ${EMAIL_HOST_USER}
41 | EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
42 | EMAIL_PORT: ${EMAIL_PORT}
43 | DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL}
44 | DJANGO_DB: postgresql
45 | POSTGRES_HOST: db
46 | POSTGRES_NAME: postgres
47 | POSTGRES_USER: postgres
48 | POSTGRES_PASSWORD: postgres
49 | POSTGRES_PORT: 5432
50 | MERCURY_VERBOSE: ${MERCURY_VERBOSE}
51 | DJANGO_LOG_LEVEL: ${DJANGO_LOG_LEVEL}
52 | depends_on:
53 | - db
54 | db:
55 | image: postgres:13.0-alpine
56 | restart: unless-stopped
57 | volumes:
58 | - postgres_data:/var/lib/postgresql/data/
59 | environment:
60 | POSTGRES_DB: postgres
61 | POSTGRES_USER: postgres
62 | POSTGRES_PASSWORD: postgres
63 |
64 | volumes:
65 | static_volume: {}
66 | media_volume: {}
67 | postgres_data: {}
68 |
--------------------------------------------------------------------------------
/docker/init-letsencrypt.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if ! [ -x "$(command -v docker-compose)" ]; then
4 | echo 'Error: docker-compose is not installed.' >&2
5 | exit 1
6 | fi
7 |
8 | domains=({{your_domain}} www.{{your_domain}})
9 | rsa_key_size=4096
10 | data_path="./docker/nginx/certbot"
11 | email="" # Adding a valid address is strongly recommended
12 | staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits
13 |
14 | if [ -d "$data_path" ]; then
15 | read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
16 | if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
17 | exit
18 | fi
19 | fi
20 |
21 |
22 | if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
23 | echo "### Downloading recommended TLS parameters ..."
24 | mkdir -p "$data_path/conf"
25 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
26 | curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
27 | echo
28 | fi
29 |
30 | echo "### Creating dummy certificate for $domains ..."
31 | path="/etc/letsencrypt/live/$domains"
32 | mkdir -p "$data_path/conf/live/$domains"
33 | docker-compose -f docker-compose-https.yml run --rm --entrypoint "\
34 | openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
35 | -keyout '$path/privkey.pem' \
36 | -out '$path/fullchain.pem' \
37 | -subj '/CN=localhost'" certbot
38 | echo
39 |
40 |
41 | echo "### Starting nginx ..."
42 | docker-compose -f docker-compose-https.yml up --force-recreate -d nginx
43 | echo
44 |
45 | echo "### Deleting dummy certificate for $domains ..."
46 | docker-compose -f docker-compose-https.yml run --rm --entrypoint "\
47 | rm -Rf /etc/letsencrypt/live/$domains && \
48 | rm -Rf /etc/letsencrypt/archive/$domains && \
49 | rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot
50 | echo
51 |
52 |
53 | echo "### Requesting Let's Encrypt certificate for $domains ..."
54 | #Join $domains to -d args
55 | domain_args=""
56 | for domain in "${domains[@]}"; do
57 | domain_args="$domain_args -d $domain"
58 | done
59 |
60 | # Select appropriate email arg
61 | case "$email" in
62 | "") email_arg="--register-unsafely-without-email" ;;
63 | *) email_arg="--email $email" ;;
64 | esac
65 |
66 | # Enable staging mode if needed
67 | if [ $staging != "0" ]; then staging_arg="--staging"; fi
68 |
69 | docker-compose -f docker-compose-https.yml run --rm --entrypoint "\
70 | certbot certonly --webroot -w /var/www/certbot \
71 | $staging_arg \
72 | $email_arg \
73 | $domain_args \
74 | --rsa-key-size $rsa_key_size \
75 | --agree-tos \
76 | --force-renewal" certbot
77 | echo
78 |
79 | echo "### Reloading nginx ..."
80 | docker-compose -f docker-compose-https.yml exec nginx nginx -s reload
81 |
--------------------------------------------------------------------------------
/docker/mercury/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:20.04
2 |
3 | RUN apt-get update && \
4 | apt-get install -y software-properties-common && \
5 | add-apt-repository ppa:deadsnakes/ppa && \
6 | apt-get update && \
7 | apt-get install -y python3.10 python3.10-dev python3-pip python-is-python3 git gconf-service \
8 | libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 \
9 | libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 \
10 | libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 \
11 | libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 \
12 | lsb-release xdg-utils wget libcairo-gobject2 libxinerama1 libgtk2.0-0 libpangoft2-1.0-0 libthai0 libpixman-1-0 \
13 | libxcb-render0 libharfbuzz0b libdatrie1 libgraphite2-3 libgbm1 \
14 | libpq-dev libarchive13
15 |
16 | # Install Miniconda using official installer script
17 | RUN wget -qO /tmp/miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh && \
18 | bash /tmp/miniconda.sh -b -p /opt/conda && \
19 | rm /tmp/miniconda.sh
20 |
21 | ENV PATH="/opt/conda/bin:${PATH}"
22 |
23 | WORKDIR /app
24 | ADD ./mercury/requirements.txt /app/mercury/
25 |
26 | RUN conda install --yes mamba -c conda-forge
27 | RUN mamba install --yes python=3.10 --file mercury/requirements.txt -c conda-forge
28 | RUN mamba install --yes gunicorn psycopg2 daphne -c conda-forge
29 | RUN mamba install --yes mercury -c conda-forge
30 |
31 | ADD ./mercury/templates /app/mercury/templates
32 | ADD ./mercury/server /app/mercury/server
33 | ADD ./mercury/apps /app/mercury/apps
34 | ADD ./mercury/*.py /app/mercury/
35 | ADD ./mercury/widgets /app/mercury/widgets
36 | ADD ./docker /app/docker
37 |
38 | RUN chmod +x /app/docker/mercury/entrypoint.sh
39 |
--------------------------------------------------------------------------------
/docker/mercury/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | until cd /app/mercury
4 | do
5 | echo "Waiting for server volume..."
6 | done
7 |
8 |
9 | until python manage.py migrate
10 | do
11 | echo "Waiting for db to be ready..."
12 | sleep 2
13 | done
14 |
15 | # path with jupyter
16 | export PATH="$HOME/.local/bin:$PATH"
17 |
18 | echo "Docker: Add requirements for notebooks"
19 | REQ=/app/notebooks/requirements.txt
20 | if test -f "$REQ"; then
21 | pip install -r $REQ
22 | fi
23 |
24 | echo "Docker list files"
25 | ls -al /app/notebooks/
26 | ls -al /app/
27 | echo "Docker: Add notebooks"
28 | for filepath in /app/notebooks/*.ipynb; do
29 | echo "Docker: Add " $filepath
30 | python manage.py add $filepath
31 | done
32 |
33 | echo "Docker: Collect statics, for Admin Panel, DRF views"
34 | python manage.py collectstatic --noinput
35 |
36 | echo "Docker: Try to create super user, if doesnt exist"
37 | python manage.py createsuperuser --noinput
38 |
39 | echo "Docker: Start worker and beat service"
40 | celery -A server worker --loglevel=info -P gevent --concurrency 4 -E -Q celery,ws &
41 | celery -A server beat --loglevel=error --max-interval 60 &
42 |
43 | echo "Docker: Start daphne server"
44 | daphne server.asgi:application --bind 0.0.0.0 --port 9000
45 |
46 | #gunicorn server.wsgi --bind 0.0.0.0:8000 --workers 4 --threads 4
47 |
48 | # for debug
49 | #python manage.py runserver 0.0.0.0:9000
--------------------------------------------------------------------------------
/docker/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14.16.0-alpine as build
2 |
3 | WORKDIR /app/frontend
4 | COPY ./frontend/package.json ./
5 | COPY ./frontend/yarn.lock ./
6 | RUN yarn install --frozen-lockfile
7 | COPY ./frontend/src ./src
8 | COPY ./frontend/public ./public
9 | COPY ./frontend/tsconfig.json ./tsconfig.json
10 |
11 | RUN yarn build
12 |
13 | # The second stage
14 | # Copy React static files and start nginx
15 | FROM nginx:stable-alpine
16 | COPY --from=build /app/frontend/build /usr/share/nginx/html
17 |
--------------------------------------------------------------------------------
/docker/nginx/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name _;
4 | server_tokens off;
5 | access_log off;
6 | client_max_body_size 20M;
7 |
8 | location / {
9 | root /usr/share/nginx/html;
10 | index index.html index.htm;
11 | try_files $uri $uri/ /index.html;
12 | }
13 |
14 | location /api {
15 | try_files $uri @proxy_api;
16 | }
17 | location /admin {
18 | try_files $uri @proxy_api;
19 | }
20 |
21 | location @proxy_api {
22 | proxy_set_header Host $http_host;
23 | proxy_redirect off;
24 | proxy_pass http://mercury:9000;
25 | }
26 |
27 | location /ws {
28 | try_files $uri @proxy_ws;
29 | }
30 |
31 | location @proxy_ws {
32 | proxy_http_version 1.1;
33 | proxy_set_header Upgrade $http_upgrade;
34 | proxy_set_header Connection "upgrade";
35 | proxy_redirect off;
36 | proxy_pass http://mercury:9000;
37 | }
38 |
39 | location /django_static/ {
40 | autoindex on;
41 | alias /app/mercury/django_static/;
42 | }
43 |
44 | location /media/ {
45 | autoindex on;
46 | alias /app/mercury/media/;
47 | }
48 |
49 | }
--------------------------------------------------------------------------------
/docker/nginx/pro/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name {{your_domain}};
4 | server_tokens off;
5 |
6 |
7 | location /.well-known/acme-challenge/ {
8 | root /var/www/certbot;
9 | }
10 |
11 | location / {
12 | return 301 https://$host$request_uri;
13 | }
14 | }
15 |
16 | server {
17 | listen 443 ssl;
18 | server_name {{your_domain}};
19 | server_tokens off;
20 |
21 | ssl_certificate /etc/letsencrypt/live/{{your_domain}}/fullchain.pem;
22 | ssl_certificate_key /etc/letsencrypt/live/{{your_domain}}/privkey.pem;
23 | include /etc/letsencrypt/options-ssl-nginx.conf;
24 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
25 |
26 | client_max_body_size 20M;
27 |
28 | location / {
29 | root /usr/share/nginx/html;
30 | index index.html index.htm;
31 | try_files $uri $uri/ /index.html;
32 | }
33 |
34 | location /api {
35 | try_files $uri @proxy_api;
36 | }
37 | location /admin {
38 | try_files $uri @proxy_api;
39 | }
40 |
41 | location @proxy_api {
42 | proxy_set_header X-Forwarded-Proto https;
43 | proxy_set_header X-Url-Scheme $scheme;
44 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
45 | proxy_set_header Host $http_host;
46 | proxy_redirect off;
47 | proxy_pass http://mercury:9000;
48 | }
49 |
50 | location /ws {
51 | try_files $uri @proxy_ws;
52 | }
53 |
54 | location @proxy_ws {
55 | proxy_http_version 1.1;
56 | proxy_set_header Upgrade $http_upgrade;
57 | proxy_set_header Connection "upgrade";
58 | proxy_redirect off;
59 | proxy_pass http://mercury:9000;
60 | }
61 |
62 | location /django_static/ {
63 | autoindex on;
64 | alias /app/mercury/django_static/;
65 | }
66 |
67 | location /media/ {
68 | autoindex on;
69 | alias /app/mercury/media/;
70 | }
71 | }
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "2.4.3",
4 | "private": true,
5 | "dependencies": {
6 | "@popperjs/core": "^2.11.0",
7 | "@reduxjs/toolkit": "^1.6.2",
8 | "@testing-library/jest-dom": "^5.11.4",
9 | "@testing-library/react": "^11.1.0",
10 | "@testing-library/user-event": "^12.1.10",
11 | "@types/jest": "^26.0.15",
12 | "@types/node": "^12.0.0",
13 | "@types/react": "^17.0.0",
14 | "@types/react-dom": "^17.0.0",
15 | "@types/react-numeric-input": "^2.2.4",
16 | "@types/react-router-dom": "^5.3.2",
17 | "axios": "^1.3.4",
18 | "bootstrap": "^5.1.3",
19 | "dangerously-set-html-content": "^1.0.12",
20 | "filepond": "^4.30.3",
21 | "filepond-plugin-file-validate-size": "^2.2.5",
22 | "filepond-plugin-image-exif-orientation": "^1.0.11",
23 | "filepond-plugin-image-preview": "^4.6.10",
24 | "font-awesome": "^4.7.0",
25 | "history": "^5.1.0",
26 | "js-file-download": "^0.4.12",
27 | "popper": "^1.0.1",
28 | "react": "^17.0.2",
29 | "react-block-ui": "^1.3.5",
30 | "react-dom": "^17.0.2",
31 | "react-filepond": "^7.1.1",
32 | "react-hot-loader": "4.13.0",
33 | "react-markdown": "^8.0.0",
34 | "react-range": "^1.8.11",
35 | "react-redux": "^7.2.6",
36 | "react-router-dom": "6.8.1",
37 | "react-scripts": "4.0.3",
38 | "react-select": "^5.2.1",
39 | "react-toastify": "^8.1.1",
40 | "react-use-websocket": "^4.2.0",
41 | "redux": "^4.1.2",
42 | "redux-thunk": "^2.4.1",
43 | "rehype-highlight": "^5.0.2",
44 | "rehype-raw": "^6.1.1",
45 | "remark-emoji": "^3.0.2",
46 | "remark-gfm": "^3.0.1",
47 | "typescript": "^4.1.2",
48 | "uuidv4": "^6.2.12",
49 | "web-vitals": "^1.0.1"
50 | },
51 | "scripts": {
52 | "start": "REACT_APP_LOCAL_URL=\"\" react-scripts start",
53 | "build": "REACT_APP_LOCAL_URL=\"\" react-scripts build",
54 | "local-build": "REACT_APP_LOCAL_URL=\"/static\" react-scripts build && mv build/ ../mercury/frontend-dist",
55 | "local-single-site-build": "REACT_APP_LOCAL_URL=\"/static\" react-scripts build && mv build/ ../mercury/frontend-single-site-dist",
56 | "test": "react-scripts test",
57 | "eject": "react-scripts eject"
58 | },
59 | "eslintConfig": {
60 | "extends": [
61 | "react-app",
62 | "react-app/jest"
63 | ]
64 | },
65 | "browserslist": {
66 | "production": [
67 | ">0.2%",
68 | "not dead",
69 | "not op_mini all"
70 | ],
71 | "development": [
72 | "last 1 chrome version",
73 | "last 1 firefox version",
74 | "last 1 safari version"
75 | ]
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/frontend/public/favicon-old.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/frontend/public/favicon-old.ico
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
16 |
20 |
29 |
30 |
31 |
36 |
41 |
42 |
47 | Mercury - Turn Jupyter Notebook to Web App
48 |
49 |
50 |
51 |
52 |
53 |
90 |
91 |
92 | You need to enable JavaScript to run this app.
93 |
94 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Mercury",
3 | "name": "Mercury: Easily share your Python notebooks",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/src/Root.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Provider } from "react-redux";
3 | import { History } from "history";
4 | import { Store } from "./store";
5 | import Routes from "./Routes";
6 |
7 | type Props = {
8 | store: Store;
9 | history: History;
10 | };
11 | const Root = ({ store, history }: Props) => (
12 |
13 |
14 |
15 | );
16 |
17 | export default Root;
18 |
--------------------------------------------------------------------------------
/frontend/src/Routes.tsx:
--------------------------------------------------------------------------------
1 | /* eslint react/jsx-props-no-spreading: off */
2 | import React, { ReactNode, useEffect } from "react";
3 | import { useDispatch } from "react-redux";
4 | import {
5 | BrowserRouter as Router,
6 | Routes,
7 | Route,
8 | Outlet,
9 | } from "react-router-dom";
10 |
11 | import { setToken, setUsername } from "./slices/authSlice";
12 | // import { fetchVersion } from "./slices/versionSlice";
13 | import { getSessionId } from "./utils";
14 | import MainApp from "./views/App";
15 | import AccountView from "./views/AccountView";
16 | import HomeView from "./views/HomeView";
17 | import LoginView from "./views/LoginView";
18 | import { fetchSite } from "./slices/sitesSlice";
19 | import RequireAuth from "./components/RequireAuth";
20 | import WebSocketProvider from "./websocket/Provider";
21 | import OpenAPIView from "./views/OpenAPIView";
22 | type Props = {
23 | children: ReactNode;
24 | };
25 |
26 | function App(props: Props) {
27 | const { children } = props;
28 | return <>{children}>;
29 | }
30 |
31 | function AppLayout() {
32 | return (
33 |
34 | <>
35 |
36 | >
37 |
38 | );
39 | }
40 |
41 | export default function AppRoutes() {
42 | const dispatch = useDispatch();
43 |
44 | useEffect(() => {
45 | getSessionId(true);
46 | // dispatch(fetchVersion());
47 | if (localStorage.getItem("token")) {
48 | dispatch(setToken(localStorage.getItem("token")));
49 | }
50 | if (localStorage.getItem("username")) {
51 | dispatch(setUsername(localStorage.getItem("username")));
52 | }
53 |
54 | dispatch(fetchSite());
55 | // eslint-disable-next-line react-hooks/exhaustive-deps
56 | }, []);
57 |
58 | return (
59 |
60 |
61 |
62 | }>
63 | } />
64 |
68 |
69 |
70 | }
71 | />
72 | } />
73 | } />
74 |
75 | } />
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/frontend/src/components/AutoRefresh.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { fetchCurrentTask } from "../slices/tasksSlice";
4 |
5 | import { fetchNotebook, getSelectedNotebook } from "../slices/notebooksSlice";
6 |
7 | type Props = {
8 | siteId: number;
9 | notebookId: number;
10 | };
11 |
12 | export default function AutoRefresh({ siteId, notebookId }: Props) {
13 | const dispatch = useDispatch();
14 | const notebook = useSelector(getSelectedNotebook);
15 |
16 | useEffect(() => {
17 | setTimeout(() => {
18 | dispatch(fetchNotebook(siteId, notebookId, true));
19 | dispatch(fetchCurrentTask(notebookId));
20 | }, 60000); // every 1 minute
21 | }, [dispatch, siteId, notebookId, notebook]);
22 |
23 | return
;
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/components/DefaultLogo.tsx:
--------------------------------------------------------------------------------
1 | export default process.env.PUBLIC_URL +
2 | process.env.REACT_APP_LOCAL_URL +
3 | "/mercury_logo.svg";
4 |
--------------------------------------------------------------------------------
/frontend/src/components/FileItem.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import fileDownload from "js-file-download";
3 |
4 | type Props = {
5 | fname: string;
6 | downloadLink: string;
7 | firstItem: boolean;
8 | lastItem: boolean;
9 | };
10 |
11 | export default function FileItem({
12 | fname,
13 | downloadLink,
14 | firstItem,
15 | lastItem,
16 | }: Props) {
17 | const handleDownload = (url: string, filename: string) => {
18 | let token = axios.defaults.headers.common["Authorization"];
19 |
20 | if (url.includes("s3.amazonaws.com")) {
21 | // we cant do requests to s3 with auth token
22 | // we need to remove auth token before request
23 | delete axios.defaults.headers.common["Authorization"];
24 | }
25 |
26 | axios
27 | .get(url, {
28 | responseType: "blob",
29 | })
30 | .then((res) => {
31 | fileDownload(res.data, filename);
32 | });
33 |
34 | if (url.includes("s3.amazonaws.com")) {
35 | // after request we set token back
36 | axios.defaults.headers.common["Authorization"] = token;
37 | }
38 | };
39 |
40 | return (
41 |
52 |
{" "}
57 | {fname}
58 |
59 | handleDownload(downloadLink, fname!)}
64 | >
65 | Download
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/frontend/src/components/FilesView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { useDispatch } from "react-redux";
4 | import axios from "axios";
5 | import BlockUi from "react-block-ui";
6 | import { setView } from "../slices/appSlice";
7 | import FileItem from "./FileItem";
8 |
9 | type FilesViewProps = {
10 | files: string[];
11 | filesState: string;
12 | waiting: boolean;
13 | };
14 |
15 | export default function FilesView({
16 | files,
17 | filesState,
18 | waiting,
19 | }: FilesViewProps) {
20 | const dispatch = useDispatch();
21 |
22 | let filesLinks = [];
23 |
24 | let myFiles = [...files];
25 |
26 | myFiles.sort();
27 |
28 | for (let f of myFiles) {
29 | let fname = f.split("/").pop();
30 | fname = fname?.split("?")[0];
31 |
32 | if (f && fname) {
33 | let downloadLink = `${axios.defaults.baseURL}${f}`;
34 | if (f.includes("s3.amazonaws.com")) {
35 | downloadLink = f;
36 | }
37 | filesLinks.push(
38 |
44 | );
45 | }
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 | Output
53 | Files
54 |
55 |
56 |
57 | {filesState === "loaded" && filesLinks}
58 | {filesState === "loaded" && filesLinks.length === 0 && (
59 |
No files available for download
60 | )}
61 | {filesState === "unknown" && (
62 |
Please run the notebook to produce output files ...
63 | )}
64 | {filesState === "loading" &&
Loading files please wait ...
}
65 | {filesState === "error" && (
66 |
67 | There was an error during loading files. Please try to run the
68 | app again or contact the administrator.
69 |
70 | )}
71 |
72 |
73 |
74 |
75 | {
79 | dispatch(setView("app"));
80 | }}
81 | >
82 | Back to App
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | type FooterProps = {
4 | footerText: string;
5 | };
6 |
7 | export default function Footer({ footerText }: FooterProps) {
8 | return (
9 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/src/components/HomeNavBar.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { Link } from "react-router-dom";
4 | import LoginButton from "./LoginButton";
5 | import UserButton from "./UserButton";
6 |
7 | type NavBarProps = {
8 | isSitePublic: boolean;
9 | username: string;
10 | logoSrc: string;
11 | navbarColor: string;
12 | };
13 |
14 | export default function NavBar({
15 | isSitePublic,
16 | username,
17 | logoSrc,
18 | navbarColor,
19 | }: NavBarProps) {
20 | let headerBgClass = "";
21 | let headerStyle = {};
22 | if (navbarColor === "") {
23 | headerBgClass = "bg-dark";
24 | } else {
25 | headerStyle = {
26 | backgroundColor: navbarColor,
27 | };
28 | }
29 |
30 | return (
31 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/frontend/src/components/LoginButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | export default function LoginButton() {
5 | return (
6 |
7 |
8 | Log in
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/src/components/MadeWithDiv.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function MadeWithDiv() {
4 | return (
5 |
6 |
7 |
8 | {" "}
9 | created with {" "}
10 |
11 |
12 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/components/NavBar.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { Link } from "react-router-dom";
4 | import LoginButton from "./LoginButton";
5 | import UserButton from "./UserButton";
6 |
7 | type NavBarProps = {
8 | isSitePublic: boolean;
9 | username: string;
10 | logoSrc: string;
11 | navbarColor: string;
12 | };
13 |
14 | export default function NavBar({
15 | isSitePublic,
16 | username,
17 | logoSrc,
18 | navbarColor,
19 | }: NavBarProps) {
20 |
21 | let headerBgClass = "";
22 | let headerStyle = {};
23 | if (navbarColor === "") {
24 | headerBgClass = "bg-dark";
25 | } else {
26 | headerStyle = {
27 | backgroundColor: navbarColor,
28 | };
29 | }
30 |
31 | return (
32 |
36 |
37 | {logoSrc !== "" && logoSrc !== "loading" && (
38 |
43 | )}
44 |
45 |
46 | {!isSitePublic && username === "" && }
47 | {username !== "" && }
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/components/ProFeatureAlert.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | type ProFeatureProps = {
5 | featureName: string;
6 | };
7 |
8 | export default function ProFeatureAlert({ featureName }: ProFeatureProps) {
9 | return (
10 |
11 |
12 |
13 | This is a Pro
14 | feature{" "}
15 |
16 | You are using an open-source version of the Mercury framework. The{' '}
17 | {featureName} is a Pro feature available only for commercial users.
18 | Please consider purchasing the Mercury commercial license. It is
19 | perpetual and comes with additional features, dedicated support, and
20 | allows white-labeling. You can learn more about available licenses on
21 | our{" "}
22 |
23 | website
24 |
25 | .
26 |
27 |
28 |
29 |
30 |
Back to home
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/components/RequireAuth.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { useLocation, Navigate } from "react-router-dom";
4 | import { getToken } from "../slices/authSlice";
5 | import {
6 | fetchSite,
7 | getSiteStatus,
8 | isPublic,
9 | SiteStatus,
10 | } from "../slices/sitesSlice";
11 | import LostConnection from "../views/LostConnection";
12 | import SiteAccessForbiddenView from "../views/SiteAccessForbiddenView";
13 | import SiteLoadingView from "../views/SiteLoadingView";
14 | import SiteNetworkErrorView from "../views/SiteNetworkErrorView";
15 | import SiteNotFoundView from "../views/SiteNotFoundView";
16 | import SitePleaseRefreshView from "../views/SitePleaseRefreshView";
17 | import SiteNotReadyView from "../views/SiteNotReadyView";
18 | import NotebookNotFoundView from "../views/NotebookNotFoundView";
19 |
20 | export default function RequireAuth({ children }: { children: JSX.Element }) {
21 | const token = useSelector(getToken);
22 | const isPublicSite = useSelector(isPublic);
23 | let location = useLocation();
24 | const dispatch = useDispatch();
25 | const siteStatus = useSelector(getSiteStatus);
26 |
27 | useEffect(() => {
28 | dispatch(fetchSite());
29 | }, [dispatch]);
30 |
31 | if (siteStatus === SiteStatus.Unknown) {
32 | return ;
33 | } else if (siteStatus === SiteStatus.NotFound) {
34 | return ;
35 | } else if (siteStatus === SiteStatus.NotReady) {
36 | return ;
37 | } else if (siteStatus === SiteStatus.AccessForbidden) {
38 | return ;
39 | } else if (siteStatus === SiteStatus.NetworkError) {
40 | return ;
41 | } else if (siteStatus === SiteStatus.PleaseRefresh) {
42 | return ;
43 | } else if (siteStatus === SiteStatus.LostConnection) {
44 | window.location.reload();
45 | return ;
46 | } else if (siteStatus === SiteStatus.NotebookNotFound) {
47 | return ;
48 | }
49 |
50 | if (!isPublicSite && !token) {
51 | return ;
52 | }
53 |
54 | return children;
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/components/RestAPIView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React, { useEffect, useState } from "react";
3 | import axios from "axios";
4 | import { IWidget } from "../widgets/Types";
5 | import { useSelector } from "react-redux";
6 | import { getWidgetsValues } from "../slices/notebooksSlice";
7 |
8 | type Props = {
9 | slug: string;
10 | widgetsParams: Record;
11 | notebookPath: string;
12 | columnsWidth: number;
13 | taskSessionId: string | undefined;
14 | };
15 |
16 | export default function RestAPIView({
17 | slug,
18 | widgetsParams,
19 | notebookPath,
20 | columnsWidth,
21 | taskSessionId,
22 | }: Props) {
23 | const [response, setResponse] = useState(
24 | JSON.stringify({ msg: "Example output" })
25 | );
26 | const widgetsValues = useSelector(getWidgetsValues);
27 |
28 | let examplePostData = {} as Record<
29 | string,
30 | | string
31 | | number
32 | | null
33 | | undefined
34 | | boolean
35 | | [number, number]
36 | | string[]
37 | | unknown
38 | >;
39 | for (let [key, widgetParams] of Object.entries(widgetsParams)) {
40 | if (widgetParams.input) {
41 | examplePostData[key] = widgetsValues[key];
42 | }
43 | }
44 |
45 | async function fetchResponse() {
46 | try {
47 | const { data } = await axios.get(`get/${taskSessionId}`);
48 | setResponse(JSON.stringify(data));
49 | } catch (error) {}
50 | }
51 |
52 | useEffect(() => {
53 | if (taskSessionId) {
54 | fetchResponse();
55 | }
56 | // eslint-disable-next-line react-hooks/exhaustive-deps
57 | }, [taskSessionId, notebookPath]);
58 |
59 | let sessionId = "id-with-some-random-string";
60 | if (taskSessionId) {
61 | sessionId = taskSessionId;
62 | }
63 |
64 | let postRequest = `curl -X POST -H "Content-Type: application/json" -d '${JSON.stringify(
65 | examplePostData
66 | )}' ${axios.defaults.baseURL}/run/${slug}`;
67 | return (
68 |
78 |
Notebook as REST API
79 |
80 | This notebook can be executed as REST API. Please see the examples below
81 | on how to access the notebook.
82 |
83 |
84 |
85 |
POST request to execute the notebook
86 |
92 | The above request should return a JSON with `id`. The `id` should be
93 | used in the GET request to fetch the result.
94 |
95 | Example response:
96 |
{`{"id": "${sessionId}"}`}
97 |
98 |
99 |
GET request to get execution result in JSON
100 |
106 |
107 |
108 |
109 |
Response
110 |
{response}
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/frontend/src/components/RunButton.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { WorkerState } from "../slices/wsSlice";
4 |
5 | type RunButtonProps = {
6 | runNb: () => void;
7 | waiting: boolean;
8 | workerState: WorkerState;
9 | };
10 |
11 | export default function RunButton({
12 | runNb,
13 | waiting,
14 | workerState,
15 | }: RunButtonProps) {
16 | return (
17 | {
22 | runNb();
23 | }}
24 | disabled={
25 | waiting ||
26 | // !allFilesUploaded() ||
27 | workerState !== WorkerState.Running
28 | }
29 | >
30 | {workerState === WorkerState.Running && (
31 |
32 | Run
33 |
34 | )}
35 | {workerState === WorkerState.Busy && (
36 |
37 |
45 |
49 | {" "}
50 | Busy
51 |
52 | )}
53 | {workerState !== WorkerState.Busy &&
54 | workerState !== WorkerState.Running && Waiting ... }
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/src/components/SelectExecutionHistory.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React, { useEffect } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import Select from "react-select";
5 | import {
6 | getCurrentTask,
7 | getExecutionHistory,
8 | getHistoricTask,
9 | getShowCurrent,
10 | //setHistoricTask,
11 | } from "../slices/tasksSlice";
12 | //import { setWidgetValue } from "./Widgets/widgetsSlice";
13 | //import { setWidgetValue } from "../slices/notebooksSlice";
14 |
15 | type SingleOption = { value: string; label: string };
16 |
17 | type Props = {
18 | disabled: boolean;
19 | displayNb: (taskId: number) => void;
20 | };
21 |
22 | export default function SelectExecutionHistory({ disabled, displayNb }: Props) {
23 | const dispatch = useDispatch();
24 | const executionHistory = useSelector(getExecutionHistory);
25 | const historicTask = useSelector(getHistoricTask);
26 | const currentTask = useSelector(getCurrentTask);
27 | const showCurrent = useSelector(getShowCurrent);
28 |
29 | useEffect(() => {
30 | /*
31 | if (executionHistory.length > 0) {
32 | let lastHistoricTask = executionHistory[executionHistory.length - 1];
33 |
34 | if (!historicTask.id && currentTask.id === lastHistoricTask.id) {
35 | for (let [key, value] of Object.entries(
36 | JSON.parse(currentTask.params)
37 | )) {
38 | if (value !== null) {
39 | dispatch(setWidgetValue({ key, value }));
40 | }
41 | }
42 | }
43 | }*/
44 | }, [dispatch, executionHistory, historicTask, currentTask]);
45 |
46 | if (executionHistory.length === 0) return
;
47 |
48 | const selectStyles = {
49 | menu: (base: any) => ({
50 | ...base,
51 | zIndex: 100,
52 | }),
53 | };
54 |
55 | let selectedValue: SingleOption = { value: "", label: "" };
56 |
57 | let count = 1;
58 | let options: { value: string; label: string }[] = executionHistory.map(
59 | (run) => {
60 | let choice = `Run ${count}`;
61 | count += 1;
62 | if (!showCurrent) {
63 | selectedValue = { value: `${count - 2}`, label: choice };
64 | }
65 | return { value: `${count - 2}`, label: choice };
66 | }
67 | );
68 | options.push({ value: "current", label: "Current" });
69 | if (showCurrent) {
70 | selectedValue = { value: "current", label: "Current" };
71 | }
72 |
73 | return (
74 |
75 |
76 | Execution history
77 | {
86 | if (e) {
87 | if (e.value === "current") {
88 | displayNb(0);
89 | } else {
90 | let historical = executionHistory[parseInt(e.value)];
91 | console.log(historical.id);
92 | console.log(historical.params);
93 |
94 | displayNb(historical.id);
95 |
96 | }
97 | // let historical = executionHistory[parseInt(e.value)];
98 | // dispatch(setHistoricTask(historical));
99 | // for (let [key, value] of Object.entries(
100 | // JSON.parse(historical.params)
101 | // )) {
102 | // if (value !== null) {
103 | // dispatch(setWidgetValue({ key, value }));
104 | // }
105 | // }
106 | }
107 | }}
108 | />
109 |
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/frontend/src/components/UserButton.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { useDispatch } from "react-redux";
4 | import { Link, useNavigate } from "react-router-dom";
5 | import { logout } from "../slices/authSlice";
6 |
7 | type UserButtonProps = {
8 | username: string;
9 | };
10 |
11 | export default function UserButton({ username }: UserButtonProps) {
12 | const dispatch = useDispatch();
13 | const navigate = useNavigate();
14 | return (
15 |
16 |
17 |
28 |
29 |
33 |
34 |
35 | Account
36 |
37 |
38 |
39 |
40 |
41 |
42 | dispatch(logout(navigate))}
46 | >
47 | Log out
48 |
49 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/components/WaitPDFExport.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { toast } from "react-toastify";
4 | import { getExportingToPDF, getExportToPDFCounter, getExportToPDFJobId, getPDF, stopPDFExport } from "../slices/tasksSlice";
5 |
6 | export default function WaitPDFExport() {
7 | const dispatch = useDispatch();
8 | const counter = useSelector(getExportToPDFCounter);
9 | const jobId = useSelector(getExportToPDFJobId);
10 | const exportingPDF = useSelector(getExportingToPDF);
11 |
12 | useEffect(() => {
13 | if(jobId === '') {
14 | return;
15 | }
16 | if(!exportingPDF) {
17 | return;
18 | }
19 | // raise error after 2 minutes of waiting ...
20 | if (counter < 120) {
21 | setTimeout(() => {
22 | dispatch(getPDF(jobId));
23 | }, 1000); // every 1 second
24 | } else {
25 | dispatch(stopPDFExport());
26 | toast.error("Problem with PDF export. Please try again later or ask your admin for help.", { autoClose: 6000 })
27 | }
28 | }, [dispatch, counter, jobId, exportingPDF]);
29 |
30 | return
;
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/components/WindowDimensions.tsx:
--------------------------------------------------------------------------------
1 | // code from https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs
2 | import { useState, useEffect } from "react";
3 |
4 | export function getWindowDimensions() {
5 | const { innerWidth: width, innerHeight: height } = window;
6 | return {
7 | width,
8 | height,
9 | };
10 | }
11 |
12 | export default function useWindowDimensions() {
13 | const [windowDimensions, setWindowDimensions] = useState(
14 | getWindowDimensions()
15 | );
16 |
17 | useEffect(() => {
18 | function handleResize() {
19 | setWindowDimensions(getWindowDimensions());
20 | }
21 |
22 | window.addEventListener("resize", handleResize);
23 | return () => window.removeEventListener("resize", handleResize);
24 | }, []);
25 |
26 | return windowDimensions;
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
15 | html {
16 | position: relative;
17 | min-height: 100%;
18 | overscroll-behavior: none;
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "react-dom";
3 | import axios from "axios";
4 | import Root from "./Root";
5 | import { history, configuredStore } from "./store";
6 |
7 | import { ToastContainer } from "react-toastify";
8 | import "react-toastify/dist/ReactToastify.css";
9 | import "bootstrap/dist/js/bootstrap.min.js";
10 | import "bootstrap/dist/css/bootstrap.css";
11 | import "font-awesome/css/font-awesome.min.css";
12 | import "react-block-ui/style.css";
13 | import "filepond/dist/filepond.min.css";
14 | import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css";
15 | import "./index.css";
16 |
17 | const store = configuredStore();
18 |
19 | if (process.env.REACT_APP_SERVER_URL) {
20 | axios.defaults.baseURL = process.env.REACT_APP_SERVER_URL;
21 | } else {
22 | if (window.location.origin === "http://localhost:3000") {
23 | axios.defaults.baseURL = "http://127.0.0.1:8000";
24 | } else {
25 | axios.defaults.baseURL = window.location.origin;
26 | }
27 | }
28 |
29 | if (window.location.origin.endsWith("hf.space")) {
30 | axios.defaults.baseURL = window.location.origin;
31 | }
32 |
33 | // in the case of some special params in the url
34 | axios.defaults.baseURL = axios.defaults.baseURL.split("+")[0];
35 | axios.defaults.baseURL = axios.defaults.baseURL.split("?")[0];
36 | axios.defaults.baseURL = axios.defaults.baseURL.split("#")[0];
37 |
38 |
39 | document.addEventListener("DOMContentLoaded", () =>
40 | render(
41 |
42 |
43 |
51 |
,
52 | document.getElementById("root")
53 | )
54 | );
55 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/frontend/src/rootReducer.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import { combineReducers } from 'redux';
3 | import { History } from 'history';
4 | import notebooksReducer from './slices/notebooksSlice';
5 | import tasksReducer from './slices/tasksSlice';
6 | import versionReducer from './slices/versionSlice';
7 | import appReducer from './slices/appSlice';
8 | import authReducer from "./slices/authSlice";
9 | import wsReducer from "./slices/wsSlice";
10 | import sitesReducer from "./slices/sitesSlice";
11 |
12 | export default function createRootReducer(history: History) {
13 | return combineReducers({
14 | notebooks: notebooksReducer,
15 | tasks: tasksReducer,
16 | // widgets: widgetsReducer,
17 | version: versionReducer,
18 | app: appReducer,
19 | auth: authReducer,
20 | ws: wsReducer,
21 | sites: sitesReducer,
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/frontend/src/slices/versionSlice.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import {
3 | createSlice,
4 | PayloadAction,
5 | AnyAction,
6 | Dispatch,
7 | } from '@reduxjs/toolkit';
8 | import axios from 'axios';
9 |
10 | import { RootState } from '../store';
11 | import { setToken, setUsername } from './authSlice';
12 |
13 |
14 | const initialState = {
15 | fetchingIsPro: true,
16 | isPro: false,
17 | welcome: ""
18 | };
19 |
20 | const versionSlice = createSlice({
21 | name: 'version',
22 | initialState,
23 | reducers: {
24 | setVersion(state, action: PayloadAction<{ isPro: boolean }>) {
25 | const { isPro } = action.payload;
26 | state.isPro = isPro;
27 | state.fetchingIsPro = false;
28 | },
29 | setWelcome(state, action: PayloadAction) {
30 | state.welcome = action.payload;
31 | }
32 | },
33 | });
34 |
35 | export default versionSlice.reducer;
36 |
37 | export const {
38 | setVersion,
39 | setWelcome,
40 | } = versionSlice.actions;
41 |
42 | export const getIsPro = (state: RootState) => state.version.isPro;
43 | export const getFetchingIsPro = (state: RootState) => state.version.fetchingIsPro;
44 | export const getWelcome = (state: RootState) => state.version.welcome;
45 |
46 | export const fetchVersion =
47 | () =>
48 | async (dispatch: Dispatch) => {
49 |
50 | try {
51 | const url = '/api/v1/version/';
52 | const { data } = await axios.get(url);
53 | dispatch(setVersion(data));
54 | } catch (error) {
55 | console.log(`Problem during loading Mercury version. ${error}`);
56 | if (axios.isAxiosError(error)) {
57 | if (error.response?.status === 401) {
58 | // clear auth data
59 | dispatch(setToken(null));
60 | dispatch(setUsername(null));
61 | window.location.reload();
62 | }
63 | }
64 | }
65 | };
66 |
67 |
68 | export const fetchWelcome =
69 | (siteId: number) =>
70 | async (dispatch: Dispatch) => {
71 |
72 | try {
73 | const url = `/api/v1/${siteId}/welcome/`;
74 | const { data } = await axios.get(url);
75 | dispatch(setWelcome(data.msg));
76 | } catch (error) {
77 | console.log(`Problem during loading Mercury welcome message. ${error}`);
78 | }
79 |
80 | };
81 |
82 |
--------------------------------------------------------------------------------
/frontend/src/slices/wsSlice.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import { createSlice, PayloadAction } from "@reduxjs/toolkit";
3 | import { RootState } from "../store";
4 |
5 | export enum WebSocketState {
6 | Connecting = "Connecting",
7 | Connected = "Connected",
8 | Unknown = "Unknown",
9 | Disconnected = "Disconnected",
10 | }
11 |
12 | export enum WorkerState {
13 | Unknown = "Unknown",
14 | Starting = "Starting",
15 | Running = "Running",
16 | Missing = "Missing",
17 | Busy = "Busy",
18 | Queued = "Queued",
19 | MaxRunTimeReached = "MaxRunTimeReached",
20 | MaxIdleTimeReached = "MaxIdleTimeReached",
21 | UsageLimitReached = "UsageLimitReached",
22 | //InstallPackages = "InstallPackages",
23 | }
24 |
25 | const initialState = {
26 | webSocketState: WebSocketState.Unknown,
27 | workerState: WorkerState.Unknown,
28 | workerId: undefined as undefined | number,
29 | notebookSrc: "",
30 | tryConnectCount: 0,
31 | };
32 |
33 | const wsSlice = createSlice({
34 | name: "ws",
35 | initialState,
36 | reducers: {
37 | setWebSocketState(state, action: PayloadAction) {
38 | state.webSocketState = action.payload;
39 | },
40 | setWorkerState(state, action: PayloadAction) {
41 | state.workerState = action.payload;
42 | },
43 | setWorkerId(state, action: PayloadAction) {
44 | state.workerId = action.payload;
45 | },
46 | setNotebookSrc(state, action: PayloadAction) {
47 | state.notebookSrc = action.payload;
48 | },
49 | increaseTryConnectCount(state) {
50 | state.tryConnectCount += 1;
51 | },
52 | resetTryConnectCount(state) {
53 | state.tryConnectCount = 0;
54 | },
55 | },
56 | });
57 |
58 | export default wsSlice.reducer;
59 |
60 | export const {
61 | setWebSocketState,
62 | setWorkerState,
63 | setWorkerId,
64 | setNotebookSrc,
65 | increaseTryConnectCount,
66 | resetTryConnectCount,
67 | } = wsSlice.actions;
68 |
69 | export const getWebSocketState = (state: RootState) => state.ws.webSocketState;
70 | export const getWorkerState = (state: RootState) => state.ws.workerState;
71 | export const getWorkerId = (state: RootState) => state.ws.workerId;
72 | export const getNotebookSrc = (state: RootState) => state.ws.notebookSrc;
73 | export const getTryConnectCount = (state: RootState) =>
74 | state.ws.tryConnectCount;
75 |
76 | export const runNotebook = (widgets_params: string) => {
77 | return {
78 | purpose: "run-notebook",
79 | widgets: widgets_params,
80 | };
81 | };
82 |
83 | export const saveNotebook = () => {
84 | return {
85 | purpose: "save-notebook",
86 | };
87 | };
88 |
89 | export const displayNotebook = (taskId: number) => {
90 | return {
91 | purpose: "display-notebook",
92 | taskId,
93 | };
94 | };
95 |
96 | export const downloadHTML = () => {
97 | return {
98 | purpose: "download-html",
99 | };
100 | };
101 |
102 | export const downloadPDF = () => {
103 | return {
104 | purpose: "download-pdf",
105 | };
106 | };
107 |
--------------------------------------------------------------------------------
/frontend/src/store.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-cycle */
2 | import { configureStore, getDefaultMiddleware, Action } from '@reduxjs/toolkit';
3 | import { createBrowserHistory } from 'history';
4 |
5 | import { ThunkAction } from 'redux-thunk';
6 | import createRootReducer from './rootReducer';
7 |
8 | export const history = createBrowserHistory();
9 | const rootReducer = createRootReducer(history);
10 | export type RootState = ReturnType;
11 |
12 | const middleware = [...getDefaultMiddleware()]; // , router];
13 |
14 | export const configuredStore = (initialState?: RootState) => {
15 | // Create Store
16 | const store = configureStore({
17 | reducer: rootReducer,
18 | middleware,
19 | preloadedState: initialState,
20 | });
21 | return store;
22 | };
23 | export type Store = ReturnType;
24 | export type AppThunk = ThunkAction>;
25 |
--------------------------------------------------------------------------------
/frontend/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid';
2 | import axios from "axios";
3 | import fileDownload from "js-file-download";
4 | import { toast } from 'react-toastify';
5 |
6 | export const getSessionId = (forceReload: boolean = false): string => {
7 | var sessionId = sessionStorage.getItem("sessionId");
8 | if (sessionId === null || forceReload === true) {
9 | sessionId = uuidv4();
10 | sessionStorage.setItem("sessionId", sessionId);
11 | }
12 | return sessionId;
13 | }
14 |
15 | export const setAxiosAuthToken = (token: string | null) => {
16 | if (typeof token !== "undefined" && token) {
17 | // Apply for every request
18 | axios.defaults.headers.common["Authorization"] = "Token " + token;
19 | } else {
20 | // Delete auth header
21 | delete axios.defaults.headers.common["Authorization"];
22 | }
23 | };
24 |
25 | export const handleDownload = (url: string, filename: string) => {
26 | axios
27 | .get(url, {
28 | responseType: "blob",
29 | })
30 | .then((res) => {
31 | fileDownload(res.data, filename);
32 | })
33 | .catch((err) => {
34 | toast.error(`Error during ${filename} download`)
35 | })
36 | };
--------------------------------------------------------------------------------
/frontend/src/views/App.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 | import AppView from "./AppView";
3 |
4 | export default function MyApp() {
5 | const { slug } = useParams<{ slug: string }>();
6 | const { embed } = useParams<{ embed: string }>();
7 | const displayEmbed = !!(embed && embed === "embed");
8 |
9 | return (
10 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/views/LostConnection.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function LostConnection() {
5 | return (
6 |
7 |
15 |
Lost connection
16 |
17 | App lost connection to the server. Please try again in a moment or
18 | contact administrator.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/views/NotebookNotFoundView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function NotebookNotFoundView() {
5 | return (
6 |
7 |
15 |
Notebook not found
16 |
17 | We can't find your notebook. Please double check the URL address and
18 | permissions.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/views/SiteAccessForbiddenView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 | import { Link } from "react-router-dom";
4 |
5 | export default function SiteAccessForbiddenView() {
6 | return (
7 |
8 |
16 |
Access forbidden
17 |
18 | Please login to access site.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/views/SiteLoadingView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function SiteLoadingView() {
5 | return (
6 |
7 |
15 |
Please wait. Loading site ...
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/views/SiteNetworkErrorView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function SiteNetworkErrorView() {
5 | return (
6 |
7 |
15 |
Network Error
16 |
17 | Please check if you have internet connection and server is running. In
18 | case of problems, please contact administrator.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/views/SiteNotFoundView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function SiteNotFoundView() {
5 | return (
6 |
7 |
15 |
Site does not exist
16 |
17 | We can't find site you are looking for. Please make sure that URL
18 | address is correct.
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/views/SiteNotReadyView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function SiteNotReadyView() {
5 | return (
6 |
7 |
15 |
Site not ready
16 |
17 | Your site is not ready yet. Please refresh page in a while or check
18 | the dashboard.
19 |
20 |
window.location.reload()}
23 | >
24 | Refresh
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/views/SitePleaseRefreshView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | export default function SitePleaseRefreshView() {
5 | return (
6 |
7 |
15 |
Please refresh
16 |
Please try to refresh the website ...
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/widgets/Button.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { setWidgetValue } from "../slices/notebooksSlice";
4 |
5 | type ButtonProps = {
6 | widgetKey: string;
7 | label: string | null;
8 | style: string;
9 | value: string | boolean | null;
10 | disabled: boolean;
11 | hidden: boolean;
12 | runNb: () => void;
13 | };
14 |
15 | export default function ButtonWidget({
16 | widgetKey,
17 | label,
18 | style,
19 | value,
20 | disabled,
21 | hidden,
22 | runNb,
23 | }: ButtonProps) {
24 | const dispatch = useDispatch();
25 |
26 | let selectedClass = "btn-primary";
27 | if (style === "success") {
28 | selectedClass = "btn-success";
29 | } else if (style === "danger") {
30 | selectedClass = "btn-danger";
31 | } else if (style === "info") {
32 | selectedClass = "btn-info";
33 | } else if (style === "warning") {
34 | selectedClass = "btn-warning";
35 | }
36 |
37 | useEffect(() => {
38 | if (value) {
39 | runNb();
40 | }
41 | // eslint-disable-next-line react-hooks/exhaustive-deps
42 | }, [value]);
43 |
44 | return (
45 |
46 | {
51 | dispatch(
52 | setWidgetValue({
53 | key: widgetKey,
54 | value: true,
55 | })
56 | );
57 | }}
58 | disabled={disabled}
59 | >
60 | {label}
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/src/widgets/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React, { useEffect, useState } from "react";
3 | import { useDispatch } from "react-redux";
4 | import { useSearchParams } from "react-router-dom";
5 |
6 | import {
7 | setUrlValuesUsed,
8 | setWidgetUrlValue,
9 | setWidgetValue,
10 | } from "../slices/notebooksSlice";
11 |
12 | type CheckboxProps = {
13 | widgetKey: string;
14 | label: string | null;
15 | value: boolean | null;
16 | disabled: boolean;
17 | hidden: boolean;
18 | runNb: () => void;
19 | url_key: string;
20 | };
21 |
22 | export default function CheckboxWidget({
23 | widgetKey,
24 | label,
25 | value,
26 | disabled,
27 | hidden,
28 | runNb,
29 | url_key,
30 | }: CheckboxProps) {
31 | const dispatch = useDispatch();
32 | const [updated, userInteraction] = useState(false);
33 |
34 | const [searchParams] = useSearchParams();
35 |
36 | useEffect(() => {
37 | if (url_key !== undefined && url_key !== "") {
38 | const urlValue = searchParams.get(url_key)?.toLowerCase();
39 |
40 | if (
41 | !updated &&
42 | urlValue !== undefined &&
43 | (urlValue === "true" || urlValue === "false")
44 | ) {
45 | dispatch(
46 | setWidgetUrlValue({
47 | key: widgetKey,
48 | value: urlValue === "true",
49 | })
50 | );
51 | dispatch(setUrlValuesUsed(true));
52 | }
53 | }
54 | }, [dispatch, searchParams, updated, url_key, widgetKey]);
55 |
56 | useEffect(() => {
57 | if (updated) {
58 | runNb();
59 | }
60 | // eslint-disable-next-line react-hooks/exhaustive-deps
61 | }, [value]);
62 |
63 | return (
64 |
65 | {
71 | userInteraction(true);
72 | dispatch(setWidgetValue({ key: widgetKey, value: !value }));
73 | }}
74 | checked={value != null ? value : false}
75 | />
76 |
81 | {label}
82 |
83 |
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/src/widgets/Markdown.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React from "react";
3 |
4 | import ReactMarkdown from "react-markdown";
5 | import rehypeHighlight from "rehype-highlight";
6 | import remarkGfm from "remark-gfm";
7 | import emoji from "remark-emoji";
8 | import rehypeRaw from "rehype-raw";
9 |
10 | type MarkdownProps = {
11 | value: string;
12 | disabled: boolean;
13 | };
14 |
15 | export default function MarkdownWidget({ value, disabled }: MarkdownProps) {
16 | return (
17 |
21 |
24 | {value}
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/mercury/.gitignore:
--------------------------------------------------------------------------------
1 | *.sqlite3
2 | celerybeat-schedule
3 | *.sqlite
4 | *.pyc
5 | *__pycache__*
6 | media/
7 | venv/
8 | menv/
9 | frontend-dist/
10 | frontend-single-site-dist/
11 | *.egg-info*
12 | static/
13 | dist/
14 | django_static/
15 | uploads-temp/
16 | uploads/
17 |
--------------------------------------------------------------------------------
/mercury/__init__.py:
--------------------------------------------------------------------------------
1 |
2 | __version__ = "2.4.3"
3 |
4 | from mercury.mercury import *
5 |
--------------------------------------------------------------------------------
/mercury/apps/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/accounts/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from apps.accounts.models import Membership, Site
4 |
5 |
6 | class SiteModelAdmin(admin.ModelAdmin):
7 | list_display = ("id", "title", "slug", "created_by")
8 |
9 | class Meta:
10 | model = Site
11 |
12 |
13 | admin.site.register(Site, SiteModelAdmin)
14 |
15 |
16 | class MembershipModelAdmin(admin.ModelAdmin):
17 | list_display = ("id", "user", "host", "rights")
18 |
19 | class Meta:
20 | model = Site
21 |
22 |
23 | admin.site.register(Membership, MembershipModelAdmin)
24 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class AccountsConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.accounts"
7 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/fields.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.utils.timezone import now
3 |
4 |
5 | class AutoCreatedField(models.DateTimeField):
6 | """
7 | A DateTimeField that automatically populates itself at
8 | object creation.
9 | By default, sets editable=False, default=datetime.now.
10 | """
11 |
12 | def __init__(self, *args, **kwargs):
13 | kwargs.setdefault("editable", False)
14 | kwargs.setdefault("default", now)
15 | super(AutoCreatedField, self).__init__(*args, **kwargs)
16 |
17 |
18 | class AutoLastModifiedField(AutoCreatedField):
19 | """
20 | A DateTimeField that updates itself on each save() of the model.
21 | By default, sets editable=False and default=datetime.now.
22 | """
23 |
24 | def pre_save(self, model_instance, add):
25 | value = now()
26 | setattr(model_instance, self.attname, value)
27 | return value
28 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/migrations/0002_apikey.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.7 on 2024-05-09 09:02
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("accounts", "0001_initial"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="ApiKey",
18 | fields=[
19 | (
20 | "key",
21 | models.CharField(max_length=40, primary_key=True, serialize=False),
22 | ),
23 | ("created_at", models.DateTimeField(auto_now_add=True)),
24 | (
25 | "user",
26 | models.ForeignKey(
27 | on_delete=django.db.models.deletion.CASCADE,
28 | to=settings.AUTH_USER_MODEL,
29 | ),
30 | ),
31 | ],
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/accounts/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/serializers.py:
--------------------------------------------------------------------------------
1 | from dj_rest_auth.serializers import UserDetailsSerializer
2 | from rest_framework import serializers
3 |
4 | from apps.accounts.models import Invitation, Membership, Secret, Site, UserProfile
5 |
6 |
7 | class UserProfileSerializer(serializers.ModelSerializer):
8 | class Meta:
9 | model = UserProfile
10 | fields = ("info",)
11 |
12 |
13 | class UserSerializer(UserDetailsSerializer):
14 | profile = UserProfileSerializer()
15 |
16 | class Meta(UserDetailsSerializer.Meta):
17 | fields = UserDetailsSerializer.Meta.fields + ("profile",)
18 |
19 |
20 | class SiteSerializer(serializers.ModelSerializer):
21 | created_by = UserDetailsSerializer(many=False, read_only=True)
22 |
23 | class Meta:
24 | model = Site
25 | read_only_fields = (
26 | "id",
27 | "created_at",
28 | "created_by",
29 | "updated_at",
30 | "status",
31 | )
32 | fields = (
33 | "id",
34 | "created_at",
35 | "created_by",
36 | "updated_at",
37 | "title",
38 | "slug",
39 | "domain",
40 | "custom_domain",
41 | "share",
42 | "welcome",
43 | "active",
44 | "status",
45 | "info",
46 | "domain",
47 | )
48 |
49 |
50 | class MembershipSerializer(serializers.ModelSerializer):
51 | user = UserDetailsSerializer(many=False, read_only=True)
52 | created_by = UserDetailsSerializer(many=False, read_only=True)
53 |
54 | class Meta:
55 | model = Membership
56 | read_only_fields = ("id", "created_at", "created_by", "updated_at")
57 | fields = read_only_fields + ("rights", "user")
58 |
59 |
60 | class InvitationSerializer(serializers.ModelSerializer):
61 | created_by = UserDetailsSerializer(many=False, read_only=True)
62 |
63 | class Meta:
64 | model = Invitation
65 | read_only_fields = ("id", "invited", "created_at", "created_by", "rights")
66 | fields = read_only_fields
67 |
68 |
69 | class SecretSerializer(serializers.ModelSerializer):
70 | created_by = UserDetailsSerializer(many=False, read_only=True)
71 |
72 | class Meta:
73 | model = Secret
74 | read_only_fields = ("id", "name", "created_at", "created_by")
75 | fields = read_only_fields
76 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/tasks.py:
--------------------------------------------------------------------------------
1 | from allauth.account.admin import EmailAddress
2 | from celery import shared_task
3 |
4 | from django.conf import settings
5 | from django.core.mail import send_mail
6 |
7 | from apps.accounts.models import Invitation, Membership, Site, SiteStatus
8 | from apps.notebooks.models import Notebook
9 | from apps.notebooks.tasks import task_init_notebook
10 | from apps.storage.models import UploadedFile
11 | from apps.storage.s3utils import S3
12 | from apps.storage.utils import get_site_bucket_key
13 |
14 |
15 | @shared_task(bind=True)
16 | def task_init_site(self, job_params):
17 | site = Site.objects.get(pk=job_params["site_id"])
18 | site.status = SiteStatus.INITIALIZING
19 | site.save()
20 |
21 | files = UploadedFile.objects.filter(hosted_on=site)
22 |
23 | any_notebooks = len([f for f in files if f.filetype == "ipynb"]) > 0
24 |
25 | if any_notebooks:
26 | # clear previous notebooks
27 | # just delete them
28 | Notebook.objects.filter(hosted_on=site).delete()
29 | s3 = S3()
30 | for f in files:
31 | print(f"Download {f.filepath}")
32 | s3.download_file(f.filepath, f.filename)
33 | if f.filetype == "ipynb":
34 | task_init_notebook(
35 | f.filename,
36 | bucket_key=get_site_bucket_key(site, ""),
37 | site=site,
38 | user=f.created_by,
39 | )
40 |
41 | site.status = SiteStatus.READY
42 | site.save()
43 |
44 |
45 | def get_app_address(site):
46 | subdomain = site.slug
47 | domain = site.domain
48 | custom_domain = site.custom_domain
49 |
50 | if custom_domain is not None and custom_domain != "":
51 | return custom_domain
52 |
53 | return f"https://{subdomain}.{domain}"
54 |
55 |
56 | @shared_task(bind=True)
57 | def task_send_invitation(self, job_params):
58 | invitation_id = job_params["invitation_id"]
59 | invitation = Invitation.objects.get(pk=invitation_id)
60 |
61 | from_address = EmailAddress.objects.get(user=invitation.created_by, primary=True)
62 | invited_by = invitation.created_by
63 |
64 | send_mail(
65 | "Mercury Invitation",
66 | f"""Hi,
67 |
68 | User {invited_by.username} invites you to {invitation.rights.lower()} web app at {get_app_address(invitation.hosted_on)}.
69 |
70 | Please create a new account at https://cloud.runmercury.com to access app.
71 |
72 | Thank you!
73 | Mercury Team
74 | """,
75 | settings.DEFAULT_FROM_EMAIL,
76 | [invitation.invited],
77 | fail_silently=False,
78 | )
79 |
80 |
81 | @shared_task(bind=True)
82 | def task_send_new_member(self, job_params):
83 | membership_id = job_params["membership_id"]
84 | membership = Membership.objects.get(pk=membership_id)
85 |
86 | from_address = EmailAddress.objects.get(user=membership.created_by, primary=True)
87 | invited_by = membership.created_by
88 |
89 | send_mail(
90 | "Mercury Invitation",
91 | f"""Hi,
92 |
93 | User {invited_by.username} invites you to {membership.rights.lower()} web app at {get_app_address(membership.host)}.
94 |
95 | Thank you!
96 | Mercury Team
97 | """,
98 | settings.DEFAULT_FROM_EMAIL,
99 | [membership.user.email],
100 | fail_silently=False,
101 | )
102 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/accounts/templatetags/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/templatetags/replace.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 | @register.filter
5 | def replace(value, arg):
6 | """
7 | Replacing filter
8 | Use `{{ "aaa"|replace:"a|b" }}`
9 | """
10 | if len(arg.split('|')) != 2:
11 | return value
12 |
13 | what, to = arg.split('|')
14 | return value.replace(what, to)
--------------------------------------------------------------------------------
/mercury/apps/accounts/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/accounts/tests/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/tests/test_apikey.py:
--------------------------------------------------------------------------------
1 | # Please run tests with below command
2 | # python manage.py test apps.accounts.tests.test_apikey
3 |
4 | from allauth.account.admin import EmailAddress
5 | from django.contrib.auth.models import User
6 |
7 | from rest_framework.test import APITestCase
8 |
9 | from apps.accounts.models import Site, ApiKey
10 |
11 |
12 | class ApiKeyTestCase(APITestCase):
13 | register_url = "/api/v1/auth/register/"
14 | login_url = "/api/v1/auth/login/"
15 | get_api_key_url = "/api/v1/auth/api-key"
16 | regenerate_api_key_url = "/api/v1/auth/regenerate-api-key"
17 |
18 | def setUp(self):
19 | self.user1_params = {
20 | "username": "user1", # it is optional to pass username
21 | "email": "piotr@example.com",
22 | "password": "verysecret",
23 | }
24 | self.user = User.objects.create_user(
25 | username=self.user1_params["username"],
26 | email=self.user1_params["email"],
27 | password=self.user1_params["password"],
28 | )
29 | EmailAddress.objects.create(
30 | user=self.user, email=self.user.email, verified=True, primary=True
31 | )
32 | self.site = Site.objects.create(
33 | title="First site", slug="first-site", created_by=self.user
34 | )
35 |
36 | def test_get_api_key(self):
37 | # login
38 | response = self.client.post(self.login_url, self.user1_params)
39 | token = response.json()["key"]
40 | headers = {"HTTP_AUTHORIZATION": "Token " + token}
41 |
42 | self.assertEqual(len(ApiKey.objects.all()), 0)
43 |
44 | response = self.client.post(
45 | self.get_api_key_url,
46 | )
47 | self.assertEqual(response.status_code, 401)
48 |
49 | response = self.client.post(
50 | self.get_api_key_url, **headers
51 | )
52 | self.assertEqual(response.status_code, 200)
53 | self.assertTrue("apiKey" in response.json())
54 | # there should 1 api key for each use
55 | response2 = self.client.post(
56 | self.get_api_key_url, **headers
57 | )
58 | self.assertEqual(len(ApiKey.objects.all()), 1)
59 | # and it should be unchanged
60 | self.assertEqual(response.json()["apiKey"], response2.json()["apiKey"])
61 |
62 |
63 | def test_regenerate_api_key(self):
64 | # login
65 | response = self.client.post(self.login_url, self.user1_params)
66 | token = response.json()["key"]
67 | headers = {"HTTP_AUTHORIZATION": "Token " + token}
68 | self.assertEqual(len(ApiKey.objects.all()), 0)
69 | response = self.client.post(
70 | self.get_api_key_url, **headers
71 | )
72 | self.assertEqual(len(ApiKey.objects.all()), 1)
73 | self.assertEqual(response.status_code, 200)
74 | self.assertTrue("apiKey" in response.json())
75 | # regenerate
76 | response2 = self.client.post(
77 | self.regenerate_api_key_url, **headers
78 | )
79 | # there should 1 api key for each use
80 | response3 = self.client.post(
81 | self.get_api_key_url, **headers
82 | )
83 | print(ApiKey.objects.all())
84 | self.assertEqual(len(ApiKey.objects.all()), 1)
85 | # after regenerate there should be change
86 | self.assertNotEqual(response.json()["apiKey"], response2.json()["apiKey"])
87 | # but in next request it should be unchanged
88 | self.assertEqual(response2.json()["apiKey"], response3.json()["apiKey"])
89 |
90 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/tests/test_subscription.py:
--------------------------------------------------------------------------------
1 | # Please run tests with below command
2 | # python manage.py test apps.accounts
3 | import os
4 | from datetime import datetime
5 |
6 | from allauth.account.admin import EmailAddress
7 | from django.contrib.auth.models import User
8 | from django.core import mail
9 | from django.utils.timezone import make_aware
10 | from rest_framework import status
11 | from rest_framework.authtoken.models import Token
12 | from rest_framework.test import APITestCase
13 |
14 | from apps.accounts.models import Membership, Secret, Site
15 | from apps.notebooks.models import Notebook
16 | from apps.workers.models import Worker
17 |
18 |
19 | # tests are disabled because they use live paddle api
20 |
21 | # class SubscriptionTestCase(APITestCase):
22 | # register_url = "/api/v1/auth/register/"
23 | # verify_email_url = "/api/v1/auth/register/verify-email/"
24 | # login_url = "/api/v1/auth/login/"
25 | # user_details_url = "/api/v1/auth/user/"
26 |
27 | # subscription_url = "/api/v1/subscription"
28 |
29 | # def setUp(self):
30 | # #
31 | # # first user
32 | # #
33 | # self.user1_params = {
34 | # "username": "user1", # it is optional to pass username
35 | # "email": "piotr@example.com",
36 | # "password": "verysecret",
37 | # }
38 | # self.user = User.objects.create_user(
39 | # username=self.user1_params["username"],
40 | # email=self.user1_params["email"],
41 | # password=self.user1_params["password"],
42 | # )
43 | # EmailAddress.objects.create(
44 | # user=self.user, email=self.user.email, verified=True, primary=True
45 | # )
46 | # #
47 | # # second user
48 | # #
49 | # self.user2_params = {
50 | # "username": "user2", # it is optional to pass username
51 | # "email": "piotr2@example.com",
52 | # "password": "verysecret2",
53 | # }
54 | # self.user2 = User.objects.create_user(
55 | # username=self.user2_params["username"],
56 | # email=self.user2_params["email"],
57 | # password=self.user2_params["password"],
58 | # )
59 | # EmailAddress.objects.create(
60 | # user=self.user2, email=self.user2.email, verified=True, primary=True
61 | # )
62 |
63 | # def test_check_subscription(self):
64 | # # login as second user to get token
65 | # response = self.client.post(self.login_url, self.user1_params)
66 | # token = response.json()["key"]
67 | # headers = {"HTTP_AUTHORIZATION": "Token " + token}
68 |
69 | # new_data = {"action": "check", "checkoutId": "206702119-chre59f1e77e6ca-132bd39c55"}
70 | # response = self.client.post(self.subscription_url, new_data, **headers)
71 | # print(response)
72 |
73 | # user = User.objects.get(pk=1)
74 | # print(user.profile.info)
75 |
76 |
77 | # def test_is_active(self):
78 | # # login as second user to get token
79 | # response = self.client.post(self.login_url, self.user1_params)
80 | # token = response.json()["key"]
81 | # headers = {"HTTP_AUTHORIZATION": "Token " + token}
82 |
83 | # new_data = {"action": "check", "checkoutId": "206702119-chre59f1e77e6ca-132bd39c55"}
84 | # response = self.client.post(self.subscription_url, new_data, **headers)
85 |
86 | # user = User.objects.get(pk=1)
87 | # print(user.profile.info)
88 |
89 | # new_data = {"action": "is_active"}
90 | # response = self.client.post(self.subscription_url, new_data, **headers)
91 | # print(response)
92 | # print(response.json())
93 |
94 | # user = User.objects.get(pk=1)
95 | # print(user.profile.info)
96 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import include
2 | from django.urls import path, re_path
3 | from django.views.generic.base import TemplateView
4 | from rest_framework.routers import DefaultRouter
5 |
6 | from apps.accounts.views.sites import GetSiteView, InitializeSite, SiteViewSet
7 |
8 | from apps.accounts.views.accounts import MembershipViewSet, DeleteAccount
9 | from apps.accounts.views.invitations import (
10 | DeleteInvitation,
11 | InviteView,
12 | ListInvitations,
13 | )
14 | from apps.accounts.views.secrets import (
15 | AddSecret,
16 | DeleteSecret,
17 | ListSecrets,
18 | WorkerListSecrets,
19 | )
20 | from apps.accounts.views.subscription import SubscriptionView
21 |
22 | from apps.accounts.views.apikey import (GetApiKey, RegenerateApiKey)
23 |
24 |
25 | router = DefaultRouter()
26 | router.register(r"api/v1/sites", SiteViewSet, basename="sites")
27 | router.register(r"api/v1/(?P.+)/members", MembershipViewSet, basename="sites")
28 |
29 | accounts_urlpatterns = router.urls
30 |
31 | accounts_urlpatterns += [
32 | path("api/v1/auth/", include("dj_rest_auth.urls")),
33 | path("api/v1/auth/register/", include("dj_rest_auth.registration.urls")),
34 | # path to set verify email in the frontend
35 | # fronted will do POST request to server with key
36 | # this is empty view, just to make reverse works
37 | re_path(
38 | r"^verify-email/(?P[-:\w]+)/$",
39 | TemplateView.as_view(),
40 | name="account_confirm_email",
41 | ),
42 | # path to set password reset in the frontend
43 | # fronted will do POST request to server with uid and token
44 | # this is empty view, just to make reverse works
45 | re_path(
46 | r"^reset-password/(?P[-:\w]+)/(?P[-:\w]+)/$",
47 | TemplateView.as_view(),
48 | name="password_reset_confirm",
49 | ),
50 | # sites
51 | re_path("api/v1/get-site/(?P.+)/", GetSiteView.as_view()),
52 | re_path("api/v1/init-site/(?P.+)/", InitializeSite.as_view()),
53 | # invitations
54 | re_path("api/v1/(?P.+)/invite", InviteView.as_view()),
55 | re_path("api/v1/(?P.+)/list-invitations", ListInvitations.as_view()),
56 | re_path(
57 | "api/v1/(?P.+)/delete-invitation/(?P.+)",
58 | DeleteInvitation.as_view(),
59 | ),
60 | # secrets
61 | re_path("api/v1/(?P.+)/add-secret", AddSecret.as_view()),
62 | re_path("api/v1/(?P.+)/list-secrets", ListSecrets.as_view()),
63 | re_path(
64 | "api/v1/(?P.+)/delete-secret/(?P.+)", DeleteSecret.as_view()
65 | ),
66 | re_path(
67 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/worker-secrets",
68 | WorkerListSecrets.as_view(),
69 | ),
70 | re_path(
71 | "api/v1/subscription",
72 | SubscriptionView.as_view(),
73 | ),
74 | re_path("api/v1/auth/delete-account/", DeleteAccount.as_view()),
75 | # api keys
76 | re_path("api/v1/auth/api-key", GetApiKey.as_view()),
77 | re_path("api/v1/auth/regenerate-api-key", RegenerateApiKey.as_view()),
78 | ]
79 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/accounts/views/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/accounts.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.db import transaction
3 | from rest_framework import permissions, viewsets
4 | from rest_framework.exceptions import APIException
5 |
6 | from apps.accounts.models import Membership, Site
7 | from apps.accounts.serializers import MembershipSerializer
8 |
9 | from apps.accounts.views.permissions import HasEditRights
10 |
11 | import json
12 | from rest_framework import permissions, status
13 | from rest_framework.response import Response
14 | from rest_framework.views import APIView
15 | from rest_framework.serializers import ValidationError
16 |
17 | from apps.accounts.serializers import UserSerializer
18 |
19 |
20 | class MembershipViewSet(viewsets.ModelViewSet):
21 | serializer_class = MembershipSerializer
22 | permission_classes = [permissions.IsAuthenticated, HasEditRights]
23 |
24 | def get_queryset(self):
25 | return Membership.objects.filter(host__id=self.kwargs["site_id"])
26 |
27 | def perform_create(self, serializer):
28 | try:
29 | # create a database instance
30 | with transaction.atomic():
31 | site = Site.objects.get(pk=self.kwargs.get("site_id"))
32 | user = User.objects.get(pk=self.request.data.get("user_id"))
33 | instance = serializer.save(
34 | host=site, user=user, created_by=self.request.user
35 | )
36 | instance.save()
37 | except Exception as e:
38 | raise APIException(str(e))
39 |
40 |
41 | class DeleteAccount(APIView):
42 | permission_classes = [permissions.IsAuthenticated]
43 |
44 | def post(self, request, format=None):
45 | user = request.user
46 |
47 | info = json.loads(user.profile.info)
48 | plan = info.get("plan", "starter")
49 | if plan != "starter":
50 | return Response(
51 | data={"msg": "Please cancel subscription"},
52 | status=status.HTTP_403_FORBIDDEN,
53 | )
54 |
55 | # remove other stuff ...
56 | print("TODO: remove all files ...")
57 |
58 | user.delete()
59 |
60 | return Response(status=status.HTTP_204_NO_CONTENT)
61 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/apikey.py:
--------------------------------------------------------------------------------
1 | from rest_framework import permissions, status
2 | from rest_framework.response import Response
3 | from rest_framework.views import APIView
4 | from apps.accounts.models import ApiKey
5 |
6 |
7 | class GetApiKey(APIView):
8 | permission_classes = [permissions.IsAuthenticated]
9 |
10 | def post(self, request, format=None):
11 | api_key, _ = ApiKey.objects.get_or_create(user=request.user)
12 | return Response({"apiKey": api_key.key}, status=status.HTTP_200_OK)
13 |
14 |
15 | class RegenerateApiKey(APIView):
16 | permission_classes = [permissions.IsAuthenticated]
17 |
18 | def post(self, request, format=None):
19 | ApiKey.objects.filter(user=request.user).update(key=ApiKey.generate_key())
20 | api_key = ApiKey.objects.get(user=request.user)
21 | return Response({"apiKey": api_key.key}, status=status.HTTP_200_OK)
22 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/permissions.py:
--------------------------------------------------------------------------------
1 | from rest_framework import permissions
2 |
3 | from apps.accounts.models import Membership, Site, ApiKey
4 |
5 |
6 | class HasEditRights(permissions.BasePermission):
7 | def has_permission(self, request, view):
8 | site_id = view.kwargs.get("site_id")
9 | if site_id is None: # just in case
10 | return False
11 | return (
12 | Membership.objects.filter(
13 | host__id=site_id, user=request.user, rights=Membership.EDIT
14 | )
15 | or Site.objects.get(pk=site_id).created_by == request.user
16 | )
17 |
18 | def has_object_permission(self, request, view, obj):
19 | return (
20 | Membership.objects.filter(
21 | host=obj.host, user=request.user, rights=Membership.EDIT
22 | )
23 | or obj.host.created_by == request.user
24 | )
25 |
26 |
27 | def apiKeyToUser(request):
28 | try:
29 | token = request.META.get("HTTP_AUTHORIZATION", "")
30 | if "Bearer" in token:
31 | apiKey = token.split(" ")[1]
32 | keys = ApiKey.objects.filter(key=apiKey)
33 | if keys:
34 | request.user = keys[0].user
35 | except Exception:
36 | pass
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/secrets.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from cryptography.fernet import Fernet
4 | from rest_framework import permissions, status
5 | from rest_framework.exceptions import APIException
6 | from rest_framework.response import Response
7 | from rest_framework.views import APIView
8 |
9 | from apps.accounts.models import Secret, Site
10 | from apps.accounts.views.permissions import HasEditRights
11 | from apps.notebooks.models import Notebook
12 | from apps.workers.models import Worker
13 | from apps.accounts.serializers import SecretSerializer
14 |
15 |
16 | class AddSecret(APIView):
17 | permission_classes = [permissions.IsAuthenticated, HasEditRights]
18 |
19 | def post(self, request, site_id, format=None):
20 | site = Site.objects.get(pk=site_id)
21 |
22 | name = request.data.get("name")
23 | secret = request.data.get("secret")
24 |
25 | # print(Fernet.generate_key())
26 |
27 | f = Fernet(
28 | os.environ.get(
29 | "FERNET_KEY", "ZpojyumLN_yNMwhZH21pXmHA3dgB74Tlcx9lb3wAtmE="
30 | ).encode()
31 | )
32 |
33 | Secret.objects.create(
34 | name=name,
35 | token=f.encrypt(secret.encode()).decode(),
36 | created_by=request.user,
37 | hosted_on=site,
38 | )
39 |
40 | return Response(status=status.HTTP_201_CREATED)
41 |
42 |
43 | class ListSecrets(APIView):
44 | permission_classes = [permissions.IsAuthenticated, HasEditRights]
45 |
46 | def get(self, request, site_id, format=None):
47 | secrets = Secret.objects.filter(hosted_on__id=site_id)
48 |
49 | return Response(
50 | SecretSerializer(secrets, many=True).data, status=status.HTTP_200_OK
51 | )
52 |
53 |
54 | class WorkerListSecrets(APIView):
55 | def get(self, request, session_id, worker_id, notebook_id, format=None):
56 | try:
57 | w = Worker.objects.get(
58 | pk=worker_id, session_id=session_id, notebook__id=notebook_id
59 | )
60 | nb = Notebook.objects.get(pk=notebook_id)
61 | secrets = Secret.objects.filter(hosted_on=nb.hosted_on)
62 | data = []
63 |
64 | f = Fernet(
65 | os.environ.get(
66 | "FERNET_KEY", "ZpojyumLN_yNMwhZH21pXmHA3dgB74Tlcx9lb3wAtmE="
67 | ).encode()
68 | )
69 |
70 | for secret in secrets:
71 | data += [
72 | {
73 | "name": secret.name,
74 | "secret": f.decrypt(secret.token.encode()).decode(),
75 | }
76 | ]
77 |
78 | return Response(data, status=status.HTTP_200_OK)
79 | except Exception as e:
80 | return Response(status=status.HTTP_404_NOT_FOUND)
81 |
82 |
83 | class DeleteSecret(APIView):
84 | permission_classes = [permissions.IsAuthenticated, HasEditRights]
85 |
86 | def delete(self, request, site_id, secret_id, format=None):
87 | try:
88 | secret = Secret.objects.get(pk=secret_id, hosted_on__id=site_id)
89 | secret.delete()
90 | return Response(status=status.HTTP_204_NO_CONTENT)
91 | except Exception as e:
92 | raise APIException(str(e))
93 |
--------------------------------------------------------------------------------
/mercury/apps/accounts/views/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import uuid
3 |
4 | from django.template.defaultfilters import slugify
5 |
6 |
7 | PLAN_KEY = "plan"
8 | PLAN_STARTER = "starter"
9 | PLAN_PRO = "pro"
10 | PLAN_BUSINESS = "business"
11 |
12 | IDLE_TIME = {PLAN_STARTER: 5 * 60, PLAN_PRO: 30 * 60, PLAN_BUSINESS: 60 * 60}
13 |
14 |
15 | def is_cloud_version():
16 | return os.environ.get("MERCURY_CLOUD", "0") == "1"
17 |
18 |
19 | def get_idle_time(owner):
20 | if not is_cloud_version():
21 | return 24 * 60 * 60
22 | plan = PLAN_STARTER
23 | try:
24 | plan = owner.plan
25 | except Exception as e:
26 | pass
27 | return IDLE_TIME.get(plan, 5 * 60)
28 |
29 |
30 | def get_max_run_time(owner):
31 | return get_idle_time(owner)
32 |
33 |
34 | def some_random_slug():
35 | h = uuid.uuid4().hex.replace("-", "")
36 | return h[:8]
37 |
38 |
39 | def get_slug(slug, title):
40 | new_slug = slugify(slug)
41 | if new_slug == "":
42 | new_slug = slugify(title)
43 | if new_slug == "":
44 | new_slug = some_random_slug()
45 | return new_slug
46 |
--------------------------------------------------------------------------------
/mercury/apps/nb/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/nb/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/nb/tests.py:
--------------------------------------------------------------------------------
1 | import os
2 | import tempfile
3 |
4 | import nbformat as nbf
5 | from django.test import Client, TestCase
6 | from execnb.nbio import dict2nb, read_nb
7 |
8 | from apps.nb.exporter import Exporter
9 | from apps.nb.nbrun import NbRun
10 |
11 | # python manage.py test apps
12 | from apps.nb.utils import one_cell_notebook, test_notebook
13 | from apps.notebooks.models import Notebook
14 | from apps.notebooks.tasks import task_init_notebook
15 |
16 | # python manage.py test apps.nb
17 |
18 |
19 | class NbTestCase(TestCase):
20 | def test_run_notebook(self):
21 | nb = test_notebook(code=["print(12)"])
22 | nb = dict2nb(nb)
23 | nbrun = NbRun()
24 | nbrun.run_notebook(nb)
25 | self.assertTrue("12" in "".join(nb.cells[0].outputs[0].text))
26 |
27 | def test_run_notebook(self):
28 | nb = test_notebook(code=["print(12)"])
29 | nb = dict2nb(nb)
30 | nbrun = NbRun()
31 | nbrun.run_notebook(nb)
32 | self.assertTrue("12" in "".join(nb.cells[0].outputs[0].text))
33 |
34 | def test_export_slides(self):
35 | nb = test_notebook(markdown=["# wow"])
36 | nb.cells[0]["metadata"] = {"slideshow": {"slide_type": "slide"}}
37 | nb = dict2nb(nb)
38 | nbrun = NbRun(is_presentation=True, reveal_theme="simple")
39 | nbrun.run_notebook(nb)
40 | body = nbrun.export_html(nb)
41 | self.assertTrue(len(body) > 0)
42 |
--------------------------------------------------------------------------------
/mercury/apps/nb/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/nb/tests/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/nb/tests/test_nbrun.py:
--------------------------------------------------------------------------------
1 | # Please run tests with below command
2 | # python manage.py test apps.nb.tests.test_nbrun
3 | import os
4 |
5 | from rest_framework.authtoken.models import Token
6 | from rest_framework.test import APITestCase
7 |
8 | from execnb.nbio import dict2nb
9 | from apps.nb.nbrun import NbRun
10 | from apps.nb.utils import one_cell_notebook, test_notebook
11 |
12 |
13 | class NbRunTestCase(APITestCase):
14 | def setUp(self):
15 | pass
16 |
17 | def test_iframe_in_output(self):
18 | fname = "test-iframe-output.html"
19 | with open(fname, "w") as fout:
20 | fout.write("test")
21 |
22 | nbrun = NbRun()
23 |
24 | code = f"""from IPython.display import IFrame
25 | IFrame(src="{fname}", width=100, height=100)"""
26 |
27 | nb = one_cell_notebook(code)
28 | nb = dict2nb(nb)
29 |
30 | nbrun.run_cell(nb.cells[0])
31 | self.assertTrue(fname not in nbrun.export_html(nb, full_header=False))
32 |
33 | os.remove(fname)
34 |
35 | def test_stop_on_error(self):
36 | nb = test_notebook(code=["2+2", "print(a)", "print(1)"])
37 | nb = dict2nb(nb)
38 |
39 | nbrun = NbRun(stop_on_error=False)
40 | nbrun.run_notebook(nb)
41 | self.assertTrue(len(nb.cells[2].outputs) > 0)
42 |
43 | nbrun = NbRun(stop_on_error=True)
44 | nbrun.run_notebook(nb)
45 | self.assertTrue(len(nb.cells[2].outputs) == 0)
46 |
--------------------------------------------------------------------------------
/mercury/apps/nb/utils.py:
--------------------------------------------------------------------------------
1 | import nbformat as nbf
2 |
3 |
4 | def test_notebook(markdown=[], code=[]):
5 | nb = nbf.v4.new_notebook()
6 | nb["cells"] = []
7 | for m in markdown:
8 | nb["cells"] += [nbf.v4.new_markdown_cell(m)]
9 | for c in code:
10 | nb["cells"] += [nbf.v4.new_code_cell(c)]
11 |
12 | return nb
13 |
14 |
15 | def one_cell_notebook(code=""):
16 | nb = nbf.v4.new_notebook()
17 | nb["cells"] = [nbf.v4.new_code_cell(code)]
18 | return nb
19 |
--------------------------------------------------------------------------------
/mercury/apps/nbworker/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/nbworker/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/nbworker/__main__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import signal
3 | import sys
4 | import time
5 | import logging
6 |
7 | CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8 | BACKEND_DIR = os.path.join(CURRENT_DIR, "..")
9 | sys.path.insert(0, BACKEND_DIR)
10 |
11 | LOG_LEVEL = (
12 | logging.ERROR
13 | if os.environ.get("DJANGO_LOG_LEVEL", "ERROR") == "ERROR"
14 | else logging.INFO
15 | )
16 |
17 | logging.basicConfig(
18 | # filename="nbworker.log", filemode="w",
19 | format="NB %(asctime)s %(message)s",
20 | level=LOG_LEVEL,
21 | )
22 | log = logging.getLogger(__name__)
23 | logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
24 |
25 | from apps.nbworker.nb import NBWorker
26 | from apps.nbworker.rest import RESTClient
27 | from apps.nbworker.utils import stop_event
28 |
29 | if len(sys.argv) != 5:
30 | log.error("Wrong number of input parameters")
31 | sys.exit(0)
32 |
33 | notebook_id = int(sys.argv[1])
34 | session_id = sys.argv[2]
35 | worker_id = int(sys.argv[3])
36 | server_url = sys.argv[4]
37 |
38 |
39 | if os.environ.get("MERCURY_SERVER_URL") is None:
40 | os.environ["MERCURY_SERVER_URL"] = server_url.replace("ws://", "http://").replace(
41 | "wss://", "https://"
42 | )
43 |
44 |
45 | def signal_handler(signal, frame):
46 | global stop_event
47 | log.info("\nBye bye!")
48 | stop_event.set()
49 | RESTClient.delete_worker_in_db(session_id, worker_id, notebook_id)
50 | sys.exit(1)
51 |
52 |
53 | signal.signal(signal.SIGINT, signal_handler)
54 |
55 |
56 | RECONNECT_WAIT_TIME = 10
57 | CONNECT_MAX_TRIES = 2
58 |
59 |
60 | if __name__ == "__main__":
61 | log.info(f"Start NBWorker with arguments {sys.argv}")
62 | for _ in range(CONNECT_MAX_TRIES):
63 | nb_worker = NBWorker(
64 | f"{server_url}/ws/worker/{notebook_id}/{session_id}/{worker_id}/",
65 | notebook_id,
66 | session_id,
67 | worker_id,
68 | )
69 | if nb_worker.is_task_mode():
70 | break
71 | else:
72 | time.sleep(RECONNECT_WAIT_TIME)
73 |
--------------------------------------------------------------------------------
/mercury/apps/nbworker/utils.py:
--------------------------------------------------------------------------------
1 | import threading
2 | from enum import Enum
3 |
4 | stop_event = threading.Event()
5 |
6 |
7 | class Purpose(str, Enum):
8 | WorkerPing = "worker-ping"
9 | WorkerState = "worker-state"
10 | InitNotebook = "init-notebook"
11 | RunNotebook = "run-notebook"
12 | SaveNotebook = "save-notebook"
13 | SavedNotebook = "saved-notebook"
14 | DisplayNotebook = "display-notebook"
15 | ClearSession = "clear-session"
16 | CloseWorker = "close-worker"
17 |
18 | ExecutedNotebook = "executed-notebook"
19 | UpdateWidgets = "update-widgets"
20 | HideWidgets = "hide-widgets"
21 | InitWidgets = "init-widgets"
22 | UpdateTitle = "update-title"
23 | UpdateShowCode = "update-show-code"
24 |
25 | DownloadHTML = "download-html"
26 | DownloadPDF = "download-pdf"
27 |
--------------------------------------------------------------------------------
/mercury/apps/nbworker/ws.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import sys
4 | from queue import Queue
5 |
6 | import websocket
7 |
8 | from apps.nbworker.rest import RESTClient
9 | from apps.nbworker.utils import Purpose, stop_event
10 | from apps.storage.storage import StorageManager
11 |
12 | log = logging.getLogger(__name__)
13 |
14 |
15 | class WSClient(RESTClient):
16 | def __init__(self, ws_address, notebook_id, session_id, worker_id):
17 | super(WSClient, self).__init__(notebook_id, session_id, worker_id)
18 |
19 | self.sm = StorageManager(self.session_id, self.worker_id, self.notebook_id)
20 |
21 | self.ws_address = ws_address
22 |
23 | self.connect(ws_address)
24 |
25 | self.queue = Queue()
26 |
27 | self.msg_counter = 0
28 |
29 | def connect(self, ws_address):
30 | try:
31 | log.info(f"WS connect to {ws_address}")
32 | self.ws = websocket.WebSocketApp(
33 | ws_address,
34 | on_open=lambda ws: self.on_open(ws),
35 | on_close=lambda ws, close_status_code, close_msg: self.on_close(
36 | ws, close_status_code, close_msg
37 | ),
38 | on_error=lambda ws, msg: self.on_error(ws, msg),
39 | on_pong=lambda ws, msg: self.on_pong(ws, msg),
40 | on_message=lambda ws, msg: self.on_message(ws, msg),
41 | )
42 | except Exception:
43 | log.exception("Exception when WS connect")
44 |
45 | def on_open(self, ws):
46 | log.info("Open ws connection")
47 | self.queue.put(json.dumps({"purpose": Purpose.InitNotebook}))
48 | # just check if exists
49 | self.worker_exists()
50 |
51 | def on_close(self, ws, close_status_code, close_msg):
52 | global stop_event
53 | stop_event.set()
54 | self.sm.delete_worker_output_dir()
55 | log.info(f"WS close connection, status={close_status_code}, msg={close_msg}")
56 |
57 | def on_pong(self, wsapp, msg):
58 | log.info("WS on_pong")
59 | if self.is_worker_stale():
60 | self.delete_worker()
61 | log.info(f"Worker id={self.worker_id} is stale, quit")
62 | sys.exit(1)
63 |
64 | def on_error(self, ws, msg):
65 | log.info(f"WS on_error, {msg}")
66 |
67 | def on_message(self, ws, msg):
68 | log.info(f"WS on_message {msg}")
69 |
70 | json_data = json.loads(msg)
71 | if json_data.get("purpose", "") == Purpose.WorkerPing:
72 | self.worker_pong()
73 | else:
74 | self.queue.put(msg)
75 |
76 | self.msg_counter += 1
77 |
78 | def send_state(self):
79 | try:
80 | log.info(f"Send state {self.worker_state()}")
81 | msg = {
82 | "purpose": Purpose.WorkerState,
83 | "state": self.worker_state(),
84 | "workerId": self.worker_id,
85 | }
86 | self.ws.send(json.dumps(msg))
87 | except Exception as e:
88 | log.exception("Exception when send state")
89 |
90 | def update_worker_state(self, new_state):
91 | self.set_worker_state(new_state)
92 | self.send_state()
93 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/notebooks/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/notebooks/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from apps.notebooks.models import Notebook
4 |
5 |
6 | class NotebookModelAdmin(admin.ModelAdmin):
7 | list_display = ("id", "title", "slug", "state", "path")
8 |
9 | class Meta:
10 | model = Notebook
11 |
12 |
13 | admin.site.register(Notebook, NotebookModelAdmin)
14 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class NotebooksConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.notebooks"
7 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/notebooks/management/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/notebooks/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/notebooks/management/commands/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/notebooks/management/commands/add.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.core.management.base import BaseCommand, CommandError
4 |
5 | from apps.notebooks.models import Notebook
6 | from apps.notebooks.tasks import task_init_notebook
7 |
8 |
9 | class Command(BaseCommand):
10 | help = "Add a new notebook"
11 |
12 | def add_arguments(self, parser):
13 | parser.add_argument("notebook_path", help="Path to notebook")
14 |
15 | def handle(self, *args, **options):
16 | notebook_path = options["notebook_path"]
17 | notebook_id = self.notebook_id_available(notebook_path)
18 |
19 | action = "added"
20 | if notebook_id is None:
21 | self.stdout.write(self.style.HTTP_INFO(f"Initialize {notebook_path}"))
22 | else:
23 | action = "updated"
24 | self.stdout.write(
25 | self.style.HTTP_INFO(f"The notebook {notebook_path} will be updated")
26 | )
27 |
28 | notebook_id = task_init_notebook(
29 | options["notebook_path"], notebook_id=notebook_id
30 | )
31 |
32 | self.stdout.write(
33 | self.style.SUCCESS(f"Successfully {action} a notebook (id:{notebook_id})")
34 | )
35 |
36 | def notebook_id_available(self, notebook_path):
37 | notebook = Notebook.objects.filter(path=os.path.abspath(notebook_path)).first()
38 | if notebook is not None:
39 | return notebook.id
40 | return None
41 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/management/commands/delete.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.core.management.base import BaseCommand, CommandError
4 |
5 | from apps.notebooks.models import Notebook
6 |
7 |
8 | class Command(BaseCommand):
9 | help = "Delete a new notebook"
10 |
11 | def add_arguments(self, parser):
12 | parser.add_argument("notebook_path", help="Path to notebook")
13 |
14 | def handle(self, *args, **options):
15 | notebook_path = options["notebook_path"]
16 | notebook_path = os.path.abspath(notebook_path)
17 | self.stdout.write(self.style.HTTP_INFO(f"Try to delete {notebook_path}"))
18 | notebooks_deleted_cnt, _ = Notebook.objects.filter(path=notebook_path).delete()
19 | if notebooks_deleted_cnt:
20 | self.stdout.write(self.style.SUCCESS("Successfully deleted a notebook"))
21 | else:
22 | self.stdout.write(
23 | self.style.NOTICE("No notebooks with provided path deleted")
24 | )
25 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/management/commands/list.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.core.management.base import BaseCommand, CommandError
4 |
5 | from apps.notebooks.models import Notebook
6 |
7 |
8 | class Command(BaseCommand):
9 | help = "List all notebooks"
10 |
11 | def handle(self, *args, **options):
12 | notebooks = Notebook.objects.all()
13 | if not notebooks:
14 | self.stdout.write(self.style.NOTICE("No notebooks available"))
15 | else:
16 | self.stdout.write(self.style.SUCCESS("Notebooks list:"))
17 | for n in notebooks:
18 | print(f"(id:{n.id}) {n.title} from {n.path}")
19 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2023-03-28 08:50
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 | initial = True
10 |
11 | dependencies = [
12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 | ("accounts", "0001_initial"),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name="Notebook",
19 | fields=[
20 | (
21 | "id",
22 | models.BigAutoField(
23 | auto_created=True,
24 | primary_key=True,
25 | serialize=False,
26 | verbose_name="ID",
27 | ),
28 | ),
29 | ("created_at", models.DateTimeField(auto_now_add=True)),
30 | ("file_updated_at", models.DateTimeField(blank=True)),
31 | ("title", models.CharField(max_length=512)),
32 | ("slug", models.CharField(blank=True, max_length=512)),
33 | ("path", models.CharField(max_length=1024)),
34 | (
35 | "image_path",
36 | models.CharField(blank=True, default="", max_length=1024),
37 | ),
38 | ("params", models.TextField(blank=True)),
39 | ("state", models.CharField(blank=True, max_length=128)),
40 | ("default_view_path", models.CharField(blank=True, max_length=1024)),
41 | ("output", models.CharField(blank=True, max_length=128)),
42 | ("format", models.CharField(blank=True, max_length=1024)),
43 | ("schedule", models.CharField(blank=True, max_length=128)),
44 | ("notify", models.TextField(blank=True)),
45 | ("errors", models.TextField(blank=True)),
46 | (
47 | "created_by",
48 | models.ForeignKey(
49 | on_delete=django.db.models.deletion.CASCADE,
50 | to=settings.AUTH_USER_MODEL,
51 | ),
52 | ),
53 | (
54 | "hosted_on",
55 | models.ForeignKey(
56 | on_delete=django.db.models.deletion.CASCADE, to="accounts.site"
57 | ),
58 | ),
59 | ],
60 | ),
61 | ]
62 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/notebooks/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/notebooks/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.db import models
3 |
4 | from apps.accounts.models import Site
5 |
6 |
7 | class Notebook(models.Model):
8 | created_at = models.DateTimeField(auto_now_add=True)
9 | file_updated_at = models.DateTimeField(blank=True)
10 | title = models.CharField(max_length=512, blank=False)
11 | slug = models.CharField(max_length=512, blank=True)
12 | path = models.CharField(max_length=1024, blank=False)
13 | image_path = models.CharField(default="", max_length=1024, blank=True)
14 | params = models.TextField(blank=True)
15 | state = models.CharField(max_length=128, blank=True)
16 | default_view_path = models.CharField(max_length=1024, blank=True)
17 | output = models.CharField(max_length=128, blank=True)
18 | format = models.CharField(max_length=1024, blank=True)
19 | schedule = models.CharField(max_length=128, blank=True)
20 | notify = models.TextField(blank=True)
21 | errors = models.TextField(blank=True)
22 |
23 | created_by = models.ForeignKey(User, on_delete=models.CASCADE)
24 | hosted_on = models.ForeignKey(Site, on_delete=models.CASCADE)
25 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from apps.notebooks.models import Notebook
4 |
5 |
6 | class NotebookSerializer(serializers.ModelSerializer):
7 | class Meta:
8 | model = Notebook
9 | read_only_fields = ("id", "created_at", "file_updated_at", "hosted_on")
10 | fields = (
11 | "id",
12 | "created_at",
13 | "file_updated_at",
14 | "hosted_on",
15 | "title",
16 | "slug",
17 | "path",
18 | "params",
19 | "state",
20 | "default_view_path",
21 | "output",
22 | "format",
23 | "schedule",
24 | "errors",
25 | )
26 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/tests.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mercury/apps/notebooks/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 |
3 | from apps.notebooks.views import (
4 | GetNbIframes,
5 | ListNotebooks,
6 | RetrieveNotebook,
7 | RetrieveNotebookWithSlug,
8 | )
9 |
10 | notebooks_urlpatterns = [
11 | re_path(
12 | "api/v1/(?P.+)/notebooks/(?P.+)",
13 | RetrieveNotebook.as_view(),
14 | ),
15 | re_path(
16 | "api/v1/(?P.+)/getnb/(?P.+)",
17 | RetrieveNotebookWithSlug.as_view(),
18 | ),
19 | re_path("api/v1/(?P.+)/notebooks", ListNotebooks.as_view()),
20 | re_path("api/v1/(?P.+)/nb-iframes", GetNbIframes.as_view()),
21 | ]
22 |
--------------------------------------------------------------------------------
/mercury/apps/storage/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/storage/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/storage/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/mercury/apps/storage/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class StorageConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.storage"
7 |
--------------------------------------------------------------------------------
/mercury/apps/storage/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2023-03-28 08:50
2 |
3 | import apps.accounts.fields
4 | from django.conf import settings
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 | import django.utils.timezone
8 |
9 |
10 | class Migration(migrations.Migration):
11 | initial = True
12 |
13 | dependencies = [
14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 | ("workers", "0001_initial"),
16 | ("accounts", "0001_initial"),
17 | ]
18 |
19 | operations = [
20 | migrations.CreateModel(
21 | name="WorkerFile",
22 | fields=[
23 | (
24 | "id",
25 | models.BigAutoField(
26 | auto_created=True,
27 | primary_key=True,
28 | serialize=False,
29 | verbose_name="ID",
30 | ),
31 | ),
32 | ("filename", models.CharField(max_length=1024)),
33 | ("filepath", models.CharField(max_length=1024)),
34 | ("output_dir", models.CharField(max_length=1024)),
35 | ("local_filepath", models.CharField(max_length=1024)),
36 | (
37 | "created_at",
38 | apps.accounts.fields.AutoCreatedField(
39 | default=django.utils.timezone.now, editable=False
40 | ),
41 | ),
42 | (
43 | "created_by",
44 | models.ForeignKey(
45 | on_delete=django.db.models.deletion.CASCADE, to="workers.worker"
46 | ),
47 | ),
48 | ],
49 | ),
50 | migrations.CreateModel(
51 | name="UploadedFile",
52 | fields=[
53 | (
54 | "id",
55 | models.BigAutoField(
56 | auto_created=True,
57 | primary_key=True,
58 | serialize=False,
59 | verbose_name="ID",
60 | ),
61 | ),
62 | ("filename", models.CharField(max_length=1024)),
63 | ("filepath", models.CharField(max_length=1024)),
64 | ("filetype", models.CharField(max_length=128)),
65 | ("filesize", models.IntegerField()),
66 | (
67 | "created_at",
68 | apps.accounts.fields.AutoCreatedField(
69 | default=django.utils.timezone.now, editable=False
70 | ),
71 | ),
72 | (
73 | "created_by",
74 | models.ForeignKey(
75 | on_delete=django.db.models.deletion.CASCADE,
76 | to=settings.AUTH_USER_MODEL,
77 | ),
78 | ),
79 | (
80 | "hosted_on",
81 | models.ForeignKey(
82 | on_delete=django.db.models.deletion.CASCADE, to="accounts.site"
83 | ),
84 | ),
85 | ],
86 | ),
87 | ]
88 |
--------------------------------------------------------------------------------
/mercury/apps/storage/migrations/0002_useruploadedfile.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2023-03-29 09:06
2 |
3 | import apps.accounts.fields
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 | import django.utils.timezone
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("accounts", "0001_initial"),
12 | ("storage", "0001_initial"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="UserUploadedFile",
18 | fields=[
19 | (
20 | "id",
21 | models.BigAutoField(
22 | auto_created=True,
23 | primary_key=True,
24 | serialize=False,
25 | verbose_name="ID",
26 | ),
27 | ),
28 | ("filename", models.CharField(max_length=1024)),
29 | ("filepath", models.CharField(max_length=1024)),
30 | ("session_id", models.CharField(max_length=128)),
31 | (
32 | "created_at",
33 | apps.accounts.fields.AutoCreatedField(
34 | default=django.utils.timezone.now, editable=False
35 | ),
36 | ),
37 | (
38 | "hosted_on",
39 | models.ForeignKey(
40 | on_delete=django.db.models.deletion.CASCADE, to="accounts.site"
41 | ),
42 | ),
43 | ],
44 | ),
45 | ]
46 |
--------------------------------------------------------------------------------
/mercury/apps/storage/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/storage/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/storage/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.db import models
3 |
4 | from apps.accounts.fields import AutoCreatedField, AutoLastModifiedField
5 | from apps.accounts.models import Site
6 | from apps.workers.models import Worker
7 | from apps.notebooks.models import Notebook
8 |
9 |
10 | class UploadedFile(models.Model):
11 | """Files that are uploaded in Dashboard"""
12 |
13 | filename = models.CharField(max_length=1024, blank=False, null=False)
14 | filepath = models.CharField(max_length=1024, blank=False, null=False)
15 | filetype = models.CharField(max_length=128, blank=False, null=False)
16 | filesize = models.IntegerField(blank=False, null=False) # size in B
17 | hosted_on = models.ForeignKey(Site, on_delete=models.CASCADE)
18 | created_at = AutoCreatedField()
19 | created_by = models.ForeignKey(
20 | User,
21 | on_delete=models.CASCADE,
22 | )
23 |
24 |
25 | class WorkerFile(models.Model):
26 | """Files created in worker (saved in output dir)"""
27 |
28 | filename = models.CharField(max_length=1024, blank=False, null=False)
29 | filepath = models.CharField(max_length=1024, blank=False, null=False)
30 | output_dir = models.CharField(max_length=1024, blank=False, null=False)
31 | local_filepath = models.CharField(max_length=1024, blank=False, null=False)
32 | created_at = AutoCreatedField()
33 | created_by = models.ForeignKey(
34 | Worker,
35 | on_delete=models.CASCADE,
36 | )
37 |
38 |
39 | class UserUploadedFile(models.Model):
40 | """Files that are uploaded in notebook by users"""
41 |
42 | filename = models.CharField(max_length=1024, blank=False, null=False)
43 | filepath = models.CharField(max_length=1024, blank=False, null=False)
44 |
45 | hosted_on = models.ForeignKey(Site, on_delete=models.CASCADE)
46 | # web browser session id
47 | session_id = models.CharField(max_length=128)
48 |
49 | created_at = AutoCreatedField()
50 |
--------------------------------------------------------------------------------
/mercury/apps/storage/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from dj_rest_auth.serializers import UserDetailsSerializer
4 |
5 | from apps.storage.models import UploadedFile
6 |
7 |
8 | class UploadedFileSerializer(serializers.ModelSerializer):
9 | created_by = UserDetailsSerializer(many=False, read_only=True)
10 |
11 | class Meta:
12 | model = UploadedFile
13 | read_only_fields = ("id", "created_at", "created_by")
14 | fields = ("id", "created_at", "created_by", "filename", "filesize", "filetype")
15 |
--------------------------------------------------------------------------------
/mercury/apps/storage/tests.py:
--------------------------------------------------------------------------------
1 | # Please run tests with below command
2 | # python manage.py test apps.storage
3 |
4 | import uuid
5 |
6 | from allauth.account.admin import EmailAddress
7 | from django.contrib.auth.models import User
8 | from django.core.files.uploadedfile import SimpleUploadedFile
9 | from django.test.client import encode_multipart
10 | from django_drf_filepond.models import TemporaryUpload
11 | from rest_framework import status
12 | from rest_framework.authtoken.models import Token
13 | from rest_framework.test import APITestCase
14 |
15 | from apps.accounts.models import Site
16 | from apps.storage.s3utils import S3
17 | from mercury.apps.storage.storage import StorageManager
18 |
19 |
20 | class StorageTestCase(APITestCase):
21 | login_url = "/api/v1/auth/login/"
22 | upload_url = "/api/v1/fp/process/"
23 |
24 | def setUp(self):
25 | self.user1_params = {
26 | "username": "user1", # it is optional to pass username
27 | "email": "piotr@example.com",
28 | "password": "verysecret",
29 | }
30 | self.user = User.objects.create_user(
31 | username=self.user1_params["username"],
32 | email=self.user1_params["email"],
33 | password=self.user1_params["password"],
34 | )
35 | EmailAddress.objects.create(
36 | user=self.user, email=self.user.email, verified=True, primary=True
37 | )
38 |
39 | def test_file_upload(self):
40 | fname = "test.txt"
41 | with open(fname, "w") as fout:
42 | fout.write("test file hello")
43 |
44 | data = None
45 | with open(fname, "rb") as fin:
46 | data = fin.read()
47 |
48 | file_spec = SimpleUploadedFile(fname, data)
49 | upload_form = {"filepond": file_spec}
50 | boundary = str(uuid.uuid4()).replace("-", "")
51 |
52 | encoded_form = encode_multipart(boundary, upload_form)
53 | content_type = "multipart/form-data; boundary=%s" % (boundary)
54 |
55 | # login as first user to get token
56 | response = self.client.post(self.login_url, self.user1_params)
57 | token = response.json()["key"]
58 | headers = {"HTTP_AUTHORIZATION": "Token " + token}
59 |
60 | print("file upload", data)
61 | new_data = {"filepond": data}
62 | response = self.client.post(
63 | self.upload_url, data=encoded_form, content_type=content_type, **headers
64 | )
65 | print(response)
66 | print(response.data)
67 |
68 | tu = TemporaryUpload.objects.get(upload_id=response.data)
69 | print(tu)
70 | # tu.delete()
71 |
72 | def test_s3_live(self):
73 | fname = "test-file.txt"
74 | with open(fname, "w") as fout:
75 | fout.write("hello there")
76 |
77 | s3 = S3()
78 | bucket_key = "my-folder/test.txt"
79 | s3.upload_file(fname, bucket_key)
80 | new_fname = "test-file-2.txt"
81 | s3.download_file(bucket_key, new_fname)
82 |
83 | with open(new_fname, "r") as fin:
84 | print(fin.read())
85 |
86 | print(s3.list_files("my-folder"))
87 |
88 | s3.delete_file(bucket_key)
89 |
90 | print(s3.list_files("my-folder"))
91 |
92 | url = s3.get_presigned_url(bucket_key, "put_object")
93 | print(url)
94 |
95 | content = None
96 | with open(new_fname, "rb") as fin:
97 | content = fin.read()
98 |
99 | import requests
100 |
101 | response = requests.put(url, content)
102 | print(response)
103 |
--------------------------------------------------------------------------------
/mercury/apps/storage/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 |
3 | from apps.storage.views.dashboardfiles import (
4 | GetStorageType,
5 | DeleteFile,
6 | FileUploaded,
7 | GetUploadCountLimit,
8 | ListFiles,
9 | PresignedUrl,
10 | PresignedUrlPut,
11 | )
12 | from apps.storage.views.workerfiles import (
13 | WorkerAddFile,
14 | WorkerGetUploadedFilesUrls,
15 | WorkerPresignedUrl,
16 | )
17 | from apps.storage.views.notebookfiles import (
18 | NbPresignedUrlPut,
19 | NbFileUploaded,
20 | NbDeleteFile,
21 | WorkerGetNbFileUrl,
22 | )
23 | from apps.storage.views.stylefiles import StyleUrlPut, StyleUrlGet
24 |
25 |
26 | storage_urlpatterns = [
27 | re_path(
28 | "api/v1/storage-type",
29 | GetStorageType.as_view(),
30 | ),
31 | #
32 | # dashboard files
33 | #
34 | re_path("api/v1/(?P.+)/files", ListFiles.as_view()),
35 | re_path(
36 | "api/v1/presigned-url/(?P.+)/(?P.+)/(?P.+)",
37 | PresignedUrl.as_view(),
38 | ),
39 | re_path(
40 | "api/v1/presigned-url-put/(?P.+)/(?P.+)/(?P.+)",
41 | PresignedUrlPut.as_view(),
42 | ),
43 | re_path(
44 | "api/v1/file-uploaded",
45 | FileUploaded.as_view(),
46 | ),
47 | re_path(
48 | "api/v1/delete-file",
49 | DeleteFile.as_view(),
50 | ),
51 | re_path(
52 | "api/v1/upload-limit/(?P.+)",
53 | GetUploadCountLimit.as_view(),
54 | ),
55 | #
56 | # style files
57 | #
58 | re_path(
59 | "api/v1/style-put/(?P.+)/(?P.+)/(?P.+)",
60 | StyleUrlPut.as_view(),
61 | ),
62 | re_path(
63 | "api/v1/get-style/(?P.+)/(?P.+)",
64 | StyleUrlGet.as_view(),
65 | ),
66 | #
67 | # worker files
68 | #
69 | re_path(
70 | "api/v1/worker/presigned-url/(?P.+)/(?P.+)/(?P.+)/(?P.+)/(?P.+)/(?P.+)",
71 | WorkerPresignedUrl.as_view(),
72 | ),
73 | re_path(
74 | "api/v1/worker/add-file",
75 | WorkerAddFile.as_view(),
76 | ),
77 | re_path(
78 | "api/v1/worker/uploaded-files-urls/(?P.+)/(?P.+)/(?P.+)",
79 | WorkerGetUploadedFilesUrls.as_view(),
80 | ),
81 | # user uploaded files in notebooks
82 | re_path(
83 | "api/v1/nb-file-put/(?P.+)/(?P.+)/(?P.+)/(?P.+)",
84 | NbPresignedUrlPut.as_view(),
85 | ),
86 | re_path(
87 | "api/v1/nb-file-uploaded",
88 | NbFileUploaded.as_view(),
89 | ),
90 | re_path(
91 | "api/v1/nb-delete-file",
92 | NbDeleteFile.as_view(),
93 | ),
94 | re_path(
95 | "api/v1/worker/user-uploaded-file/(?P.+)/(?P.+)/(?P.+)/(?P.+)",
96 | WorkerGetNbFileUrl.as_view(),
97 | ),
98 | ]
99 |
--------------------------------------------------------------------------------
/mercury/apps/storage/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | from dotenv import load_dotenv
3 |
4 | load_dotenv(".env")
5 | load_dotenv("../.env")
6 |
7 | STORAGE_MEDIA = "media"
8 | STORAGE_S3 = "s3"
9 | STORAGE = STORAGE_MEDIA
10 |
11 | if os.environ.get("STORAGE", STORAGE_MEDIA) == STORAGE_S3:
12 | STORAGE = STORAGE_S3
13 |
14 | from pathlib import Path
15 |
16 | BASE_DIR = Path(__file__).resolve().parent.parent.parent
17 |
18 | MERCURY_DATA_DIR = Path(os.getenv('MERCURY_DATA_DIR', BASE_DIR))
19 |
20 | MEDIA_ROOT = str(MERCURY_DATA_DIR / "media")
21 | MEDIA_URL = "/media/"
22 |
23 |
24 | def get_bucket_key(site, user, filename):
25 | return f"site-{site.id}/user-{user.id}/{filename}"
26 |
27 |
28 | def get_site_bucket_key(site, filename):
29 | return f"site-{site.id}/files/{filename}"
30 |
31 |
32 | def get_worker_bucket_key(session_id, output_dir, filename):
33 | return f"session-{session_id}/{output_dir}/{filename}"
34 |
35 |
36 | def get_user_upload_bucket_key(site_id, session_id, filename):
37 | return f"site-{site_id}/session-{session_id}/user-input/{filename}"
38 |
--------------------------------------------------------------------------------
/mercury/apps/storage/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/storage/views/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/tasks/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/tasks/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/tasks/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 | from .models import Task
5 |
6 |
7 | class TaskModelAdmin(admin.ModelAdmin):
8 | list_display = ("id", "session_id", "notebook", "state")
9 |
10 | class Meta:
11 | model = Task
12 |
13 |
14 | admin.site.register(Task, TaskModelAdmin)
15 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TasksConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.tasks"
7 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/clean_service.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from django.utils.timezone import make_aware
4 | from django_drf_filepond.models import TemporaryUpload
5 |
6 |
7 | def clean_service():
8 | # clean temporary uploads older than 1 day
9 | # delete in DB remove files from storage as well
10 | tus = TemporaryUpload.objects.filter(
11 | uploaded__lte=make_aware(datetime.now() - timedelta(days=1))
12 | )
13 | tus.delete()
14 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2023-03-28 08:50
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 | initial = True
9 |
10 | dependencies = [
11 | ("notebooks", "0001_initial"),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Task",
17 | fields=[
18 | (
19 | "id",
20 | models.BigAutoField(
21 | auto_created=True,
22 | primary_key=True,
23 | serialize=False,
24 | verbose_name="ID",
25 | ),
26 | ),
27 | ("task_id", models.CharField(blank=True, max_length=128)),
28 | ("session_id", models.CharField(max_length=128)),
29 | ("created_at", models.DateTimeField(auto_now_add=True)),
30 | ("state", models.CharField(blank=True, max_length=128)),
31 | ("params", models.TextField(blank=True)),
32 | ("result", models.TextField(blank=True)),
33 | (
34 | "notebook",
35 | models.ForeignKey(
36 | on_delete=django.db.models.deletion.CASCADE,
37 | to="notebooks.notebook",
38 | ),
39 | ),
40 | ],
41 | ),
42 | ]
43 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/migrations/0002_restapitask.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.3 on 2024-05-07 08:11
2 |
3 | import apps.accounts.fields
4 | from django.conf import settings
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 | import django.utils.timezone
8 |
9 |
10 | class Migration(migrations.Migration):
11 |
12 | dependencies = [
13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14 | ("notebooks", "0001_initial"),
15 | ("tasks", "0001_initial"),
16 | ]
17 |
18 | operations = [
19 | migrations.CreateModel(
20 | name="RestAPITask",
21 | fields=[
22 | (
23 | "id",
24 | models.BigAutoField(
25 | auto_created=True,
26 | primary_key=True,
27 | serialize=False,
28 | verbose_name="ID",
29 | ),
30 | ),
31 | ("session_id", models.CharField(max_length=128)),
32 | ("created_at", models.DateTimeField(auto_now_add=True)),
33 | (
34 | "updated_at",
35 | apps.accounts.fields.AutoLastModifiedField(
36 | default=django.utils.timezone.now, editable=False
37 | ),
38 | ),
39 | ("state", models.CharField(blank=True, max_length=128)),
40 | ("params", models.TextField(blank=True)),
41 | ("nb_html_path", models.TextField(blank=True)),
42 | ("nb_pdf_path", models.TextField(blank=True)),
43 | ("response", models.TextField(blank=True)),
44 | (
45 | "created_by",
46 | models.ForeignKey(
47 | blank=True,
48 | null=True,
49 | on_delete=django.db.models.deletion.SET_NULL,
50 | to=settings.AUTH_USER_MODEL,
51 | ),
52 | ),
53 | (
54 | "notebook",
55 | models.ForeignKey(
56 | on_delete=django.db.models.deletion.CASCADE,
57 | to="notebooks.notebook",
58 | ),
59 | ),
60 | ],
61 | ),
62 | ]
63 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/tasks/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/tasks/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from apps.notebooks.models import Notebook
4 | from django.contrib.auth.models import User
5 | from apps.accounts.fields import AutoLastModifiedField
6 |
7 | class Task(models.Model):
8 | # task id from Celery
9 | task_id = models.CharField(max_length=128, blank=True)
10 | # web browser session id
11 | session_id = models.CharField(max_length=128)
12 | # notebook
13 | notebook = models.ForeignKey(
14 | Notebook,
15 | on_delete=models.CASCADE,
16 | )
17 | created_at = models.DateTimeField(auto_now_add=True)
18 | # state of execution, can be: CREATED, RECEIVED, DONE, ERROR
19 | state = models.CharField(max_length=128, blank=True)
20 | # input params for task
21 | params = models.TextField(blank=True)
22 | # result of execution, should contain
23 | # the path with HTML notebook
24 | result = models.TextField(blank=True)
25 |
26 | class RestAPITask(models.Model):
27 | # web browser session id
28 | session_id = models.CharField(max_length=128)
29 | # notebook
30 | notebook = models.ForeignKey(
31 | Notebook,
32 | on_delete=models.CASCADE,
33 | )
34 | created_at = models.DateTimeField(auto_now_add=True)
35 | updated_at = AutoLastModifiedField()
36 | created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
37 | # state of execution, can be: CREATED, RECEIVED, DONE, ERROR
38 | state = models.CharField(max_length=128, blank=True)
39 | # input params for task
40 | params = models.TextField(blank=True)
41 | # result of execution, should contain
42 | # the path with HTML notebook
43 | nb_html_path = models.TextField(blank=True)
44 | # PDF path
45 | nb_pdf_path = models.TextField(blank=True)
46 | # JSON response
47 | response = models.TextField(blank=True)
48 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from apps.tasks.models import Task
4 |
5 |
6 | class TaskSerializer(serializers.ModelSerializer):
7 | class Meta:
8 | model = Task
9 | read_only_fields = ("id", "created_at", "notebook_id")
10 | fields = (
11 | "id",
12 | "task_id",
13 | "session_id",
14 | "created_at",
15 | "state",
16 | "params",
17 | "result",
18 | )
19 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/tasks_export.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from celery import shared_task
4 | from django.conf import settings
5 |
6 | from apps.notebooks.models import Notebook
7 | from apps.tasks.export_pdf import to_pdf
8 |
9 |
10 | @shared_task(bind=True, ignore_result=False)
11 | def export_to_pdf(self, job_params):
12 | notebook_id = job_params.get("notebook_id")
13 | notebook_path = job_params.get("notebook_path")
14 |
15 | if notebook_id is None or notebook_path is None:
16 | raise Exception(
17 | "PDF export params validation error. Wrong notebook information."
18 | )
19 |
20 | # try to build platform independent path
21 |
22 | notebook_os_path = os.path.join(
23 | *(
24 | [settings.MEDIA_ROOT]
25 | + notebook_path.replace(settings.MEDIA_URL, "", 1).split("/")
26 | )
27 | )
28 |
29 | if not os.path.exists(notebook_os_path):
30 | raise Exception(
31 | f"PDF export notebook error. The notebook in HTML format does not exist."
32 | )
33 |
34 | notebook = Notebook.objects.get(pk=notebook_id)
35 |
36 | slides_postfix = ""
37 | if notebook.output == "slides":
38 | slides_postfix = "?print-pdf"
39 |
40 | pdf_os_path = notebook_os_path.replace(".html", ".pdf")
41 |
42 | to_pdf(notebook_os_path + slides_postfix, pdf_os_path)
43 |
44 | title = notebook.slug + ".pdf"
45 |
46 | pdf_url = notebook_path.replace(".html", ".pdf")
47 |
48 | return pdf_url, title
49 |
--------------------------------------------------------------------------------
/mercury/apps/tasks/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 | from rest_framework.routers import DefaultRouter
3 |
4 | from apps.tasks.views import (
5 | ClearTasksView,
6 | CreateRestAPITask,
7 | ExecutionHistoryView,
8 | ExportPDF,
9 | GetLastTaskView,
10 | GetPDFAddress,
11 | GetRestAPITask,
12 | ListOutputFilesView,
13 | ListWorkerOutputFilesView,
14 | TaskCreateView,
15 | ListRestAPITasks
16 | )
17 |
18 | tasks_urlpatterns = [
19 | re_path("api/v1/execute/(?P.+)", TaskCreateView.as_view()),
20 | re_path(
21 | "api/v1/latest_task/(?P.+)/(?P.+)",
22 | GetLastTaskView.as_view(),
23 | ),
24 | re_path(
25 | "api/v1/output_files/(?P.+)/(?P.+)",
26 | ListOutputFilesView.as_view(),
27 | ),
28 | re_path(
29 | "api/v1/worker-output-files/(?P.+)/(?P.+)/(?P.+)",
30 | ListWorkerOutputFilesView.as_view(),
31 | ),
32 | re_path(
33 | "api/v1/clear_tasks/(?P.+)/(?P.+)",
34 | ClearTasksView.as_view(),
35 | ),
36 | #
37 | re_path("export_pdf", ExportPDF.as_view()),
38 | re_path("get_pdf/(?P.+)", GetPDFAddress.as_view()),
39 | re_path(
40 | "api/v1/execution_history/(?P.+)/(?P.+)",
41 | ExecutionHistoryView.as_view(),
42 | ),
43 |
44 | # used by notebook as REST API
45 | re_path("api/v1/(?P.+)/run/(?P.+)", CreateRestAPITask.as_view()),
46 | re_path("api/v1/get/(?P.+)", GetRestAPITask.as_view()),
47 | re_path("api/v1/(?P.+)/(?P.+)/list-rest-tasks", ListRestAPITasks.as_view()),
48 | ]
49 |
--------------------------------------------------------------------------------
/mercury/apps/workers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/workers/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/workers/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/mercury/apps/workers/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class WorkersConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.workers"
7 |
--------------------------------------------------------------------------------
/mercury/apps/workers/constants.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class WorkerState(str, Enum):
5 | Busy = "Busy"
6 | Running = "Running"
7 | Unknown = "Unknown"
8 | MaxRunTimeReached = "MaxRunTimeReached"
9 | MaxIdleTimeReached = "MaxIdleTimeReached"
10 | InstallPackages = "InstallPackages"
11 |
12 |
13 | class MachineState(str, Enum):
14 | Pending = "Pending"
15 | Running = "Running"
16 | Stopping = "Stopping"
17 | Stopped = "Stopped"
18 | ShuttingDown = "ShuttingDown"
19 | Terminated = "Terminated"
20 |
21 |
22 | class WorkerSessionState(str, Enum):
23 | Running = "Running"
24 | Stopped = "Stopped"
25 |
--------------------------------------------------------------------------------
/mercury/apps/workers/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.5 on 2023-03-28 08:50
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 | initial = True
9 |
10 | dependencies = [
11 | ("notebooks", "0001_initial"),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Worker",
17 | fields=[
18 | (
19 | "id",
20 | models.BigAutoField(
21 | auto_created=True,
22 | primary_key=True,
23 | serialize=False,
24 | verbose_name="ID",
25 | ),
26 | ),
27 | ("machine_id", models.CharField(blank=True, max_length=128)),
28 | ("session_id", models.CharField(max_length=128)),
29 | ("state", models.CharField(blank=True, max_length=128)),
30 | ("created_at", models.DateTimeField(auto_now_add=True)),
31 | ("updated_at", models.DateTimeField(auto_now=True)),
32 | (
33 | "notebook",
34 | models.ForeignKey(
35 | on_delete=django.db.models.deletion.CASCADE,
36 | to="notebooks.notebook",
37 | ),
38 | ),
39 | ],
40 | ),
41 | ]
42 |
--------------------------------------------------------------------------------
/mercury/apps/workers/migrations/0002_machine.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.1 on 2023-06-20 12:58
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("workers", "0001_initial"),
9 | ]
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name="Machine",
14 | fields=[
15 | (
16 | "id",
17 | models.BigAutoField(
18 | auto_created=True,
19 | primary_key=True,
20 | serialize=False,
21 | verbose_name="ID",
22 | ),
23 | ),
24 | ("ipv4", models.CharField(blank=True, max_length=128)),
25 | ("state", models.CharField(blank=True, max_length=128)),
26 | ("created_at", models.DateTimeField(auto_now_add=True)),
27 | ("updated_at", models.DateTimeField(auto_now=True)),
28 | ],
29 | ),
30 | ]
31 |
--------------------------------------------------------------------------------
/mercury/apps/workers/migrations/0003_worker_run_by_workersession.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.2.1 on 2023-06-22 09:33
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("notebooks", "0001_initial"),
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("accounts", "0001_initial"),
13 | ("workers", "0002_machine"),
14 | ]
15 |
16 | operations = [
17 | migrations.AddField(
18 | model_name="worker",
19 | name="run_by",
20 | field=models.ForeignKey(
21 | blank=True,
22 | null=True,
23 | on_delete=django.db.models.deletion.SET_NULL,
24 | to=settings.AUTH_USER_MODEL,
25 | ),
26 | ),
27 | migrations.CreateModel(
28 | name="WorkerSession",
29 | fields=[
30 | (
31 | "id",
32 | models.BigAutoField(
33 | auto_created=True,
34 | primary_key=True,
35 | serialize=False,
36 | verbose_name="ID",
37 | ),
38 | ),
39 | ("ipv4", models.CharField(blank=True, max_length=128)),
40 | ("state", models.CharField(blank=True, max_length=128)),
41 | ("created_at", models.DateTimeField(auto_now_add=True)),
42 | ("updated_at", models.DateTimeField(auto_now=True)),
43 | (
44 | "notebook",
45 | models.ForeignKey(
46 | on_delete=django.db.models.deletion.CASCADE,
47 | to="notebooks.notebook",
48 | ),
49 | ),
50 | (
51 | "owned_by",
52 | models.ForeignKey(
53 | on_delete=django.db.models.deletion.CASCADE,
54 | related_name="owner",
55 | to=settings.AUTH_USER_MODEL,
56 | ),
57 | ),
58 | (
59 | "run_by",
60 | models.ForeignKey(
61 | blank=True,
62 | null=True,
63 | on_delete=django.db.models.deletion.SET_NULL,
64 | to=settings.AUTH_USER_MODEL,
65 | ),
66 | ),
67 | (
68 | "site",
69 | models.ForeignKey(
70 | on_delete=django.db.models.deletion.CASCADE, to="accounts.site"
71 | ),
72 | ),
73 | (
74 | "worker",
75 | models.ForeignKey(
76 | blank=True,
77 | null=True,
78 | on_delete=django.db.models.deletion.SET_NULL,
79 | to="workers.worker",
80 | ),
81 | ),
82 | ],
83 | ),
84 | ]
85 |
--------------------------------------------------------------------------------
/mercury/apps/workers/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/workers/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/workers/models.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from django.contrib.auth.models import User
4 | from django.db import models
5 |
6 | from apps.accounts.models import Site
7 |
8 | from apps.notebooks.models import Notebook
9 |
10 |
11 | class Worker(models.Model):
12 | """It is a task that is done by the worker"""
13 |
14 | # machine unique id
15 | machine_id = models.CharField(max_length=128, blank=True)
16 |
17 | # web browser session id
18 | session_id = models.CharField(max_length=128)
19 |
20 | # notebook
21 | notebook = models.ForeignKey(
22 | Notebook,
23 | on_delete=models.CASCADE,
24 | )
25 |
26 | state = models.CharField(max_length=128, blank=True)
27 |
28 | created_at = models.DateTimeField(auto_now_add=True)
29 |
30 | updated_at = models.DateTimeField(auto_now=True)
31 |
32 | run_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
33 |
34 |
35 | class Machine(models.Model):
36 | # machine ip v4 address
37 | ipv4 = models.CharField(max_length=128, blank=True)
38 |
39 | # state from MachineState
40 | state = models.CharField(max_length=128, blank=True)
41 |
42 | created_at = models.DateTimeField(auto_now_add=True)
43 |
44 | updated_at = models.DateTimeField(auto_now=True)
45 |
46 |
47 | class WorkerSession(models.Model):
48 | # machine ip v4 address
49 | ipv4 = models.CharField(max_length=128, blank=True)
50 |
51 | # state from WorkerSessionState
52 | state = models.CharField(max_length=128, blank=True)
53 |
54 | created_at = models.DateTimeField(auto_now_add=True)
55 |
56 | updated_at = models.DateTimeField(auto_now=True)
57 |
58 | owned_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owner")
59 |
60 | run_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
61 |
62 | site = models.ForeignKey(Site, on_delete=models.CASCADE)
63 |
64 | notebook = models.ForeignKey(Notebook, on_delete=models.CASCADE)
65 |
66 | worker = models.ForeignKey(Worker, on_delete=models.SET_NULL, null=True, blank=True)
67 |
--------------------------------------------------------------------------------
/mercury/apps/workers/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from apps.workers.models import Worker
4 |
5 |
6 | class WorkerSerializer(serializers.ModelSerializer):
7 | class Meta:
8 | model = Worker
9 | read_only_fields = ("id", "created_at", "updated_at")
10 | fields = ("id", "created_at", "updated_at", "state")
11 |
--------------------------------------------------------------------------------
/mercury/apps/workers/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 |
3 | from apps.workers.views import (
4 | DeleteWorker,
5 | GetWorker,
6 | IsWorkerStale,
7 | SetWorkerState,
8 | WorkerGetNb,
9 | WorkerUpdateNb,
10 | MachineInfo,
11 | WorkerGetOwnerAndUser,
12 | AnalyticsView,
13 | UpdateRestApiTask,
14 | )
15 |
16 | workers_urlpatterns = [
17 | re_path(
18 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/nb",
19 | WorkerGetNb.as_view(),
20 | ),
21 | re_path(
22 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/owner-and-user",
23 | WorkerGetOwnerAndUser.as_view(),
24 | ),
25 | re_path(
26 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/update-nb",
27 | WorkerUpdateNb.as_view(),
28 | ),
29 | re_path(
30 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/worker",
31 | GetWorker.as_view(),
32 | ),
33 | re_path(
34 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/is-worker-stale",
35 | IsWorkerStale.as_view(),
36 | ),
37 | re_path(
38 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/set-worker-state",
39 | SetWorkerState.as_view(),
40 | ),
41 | re_path(
42 | "api/v1/worker/(?P.+)/(?P.+)/(?P.+)/delete-worker",
43 | DeleteWorker.as_view(),
44 | ),
45 | re_path(
46 | "api/v1/machine-info",
47 | MachineInfo.as_view(),
48 | ),
49 | re_path(
50 | "api/v1/(?P.+)/analytics",
51 | AnalyticsView.as_view(),
52 | ),
53 | re_path(
54 | "api/v1/update-rest-task/(?P.+)",
55 | UpdateRestApiTask.as_view(),
56 | ),
57 |
58 | ]
59 |
--------------------------------------------------------------------------------
/mercury/apps/workers/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | log = logging.getLogger(__name__)
4 |
5 |
6 | def get_running_machines():
7 | return []
8 |
9 |
10 | def shuffle_machines(machines):
11 | return []
12 |
13 |
14 | def list_instances():
15 | pass
16 |
17 |
18 | def start_new_instance(worker_id):
19 | pass
20 |
21 |
22 | def start_sleeping_instance(instance_id):
23 | pass
24 |
25 |
26 | def terminate_instance(instance_id):
27 | pass
28 |
29 |
30 | def hibernate_instance(instance_id):
31 | pass
32 |
33 |
34 | def need_instance(worker_id):
35 | pass
36 |
37 |
38 | def scale_down():
39 | log.info("Scale instances down")
40 |
--------------------------------------------------------------------------------
/mercury/apps/ws/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/ws/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/ws/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class WsConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "apps.ws"
7 |
--------------------------------------------------------------------------------
/mercury/apps/ws/middleware.py:
--------------------------------------------------------------------------------
1 | from channels.db import database_sync_to_async
2 | from channels.middleware import BaseMiddleware
3 | from django.contrib.auth.models import AnonymousUser
4 | from rest_framework.authtoken.models import Token
5 |
6 |
7 | @database_sync_to_async
8 | def get_user(token_key):
9 | try:
10 | token = Token.objects.get(key=token_key)
11 | return token.user
12 | except Token.DoesNotExist:
13 | return AnonymousUser()
14 |
15 |
16 | class TokenAuthMiddleware(BaseMiddleware):
17 | def __init__(self, inner):
18 | super().__init__(inner)
19 |
20 | async def __call__(self, scope, receive, send):
21 | try:
22 | token_key = (
23 | dict((x.split("=") for x in scope["query_string"].decode().split("&")))
24 | ).get("token", None)
25 | except ValueError:
26 | token_key = None
27 | scope["user"] = (
28 | AnonymousUser() if token_key is None else await get_user(token_key)
29 | )
30 | return await super().__call__(scope, receive, send)
31 |
--------------------------------------------------------------------------------
/mercury/apps/ws/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/apps/ws/migrations/__init__.py
--------------------------------------------------------------------------------
/mercury/apps/ws/routing.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path
2 |
3 | from apps.ws.client import ClientProxy
4 | from apps.ws.worker import WorkerProxy
5 |
6 | websocket_urlpatterns = [
7 | re_path(
8 | r"ws/client/(?P.+)/(?P.+)/$",
9 | ClientProxy.as_asgi(),
10 | ),
11 | re_path(
12 | r"ws/worker/(?P.+)/(?P.+)/(?P.+)/$",
13 | WorkerProxy.as_asgi(),
14 | ),
15 | ]
16 |
--------------------------------------------------------------------------------
/mercury/apps/ws/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import subprocess
4 | import sys
5 | import requests
6 |
7 | from celery import shared_task
8 | from django.conf import settings
9 |
10 | from apps.workers.models import Worker
11 | from apps.ws.utils import machine_uuid
12 |
13 | from apps.workers.utils import get_running_machines, shuffle_machines, need_instance
14 |
15 |
16 | logging.basicConfig(
17 | format="WS_TASK %(asctime)s %(message)s",
18 | level=os.getenv("DJANGO_LOG_LEVEL", "ERROR"),
19 | )
20 |
21 | log = logging.getLogger(__name__)
22 |
23 |
24 | @shared_task(bind=True)
25 | def task_start_websocket_worker(self, job_params):
26 | log.info(f"NbWorkers per machine: {settings.NBWORKERS_PER_MACHINE}")
27 |
28 | if os.environ.get("MACHINE_SPELL", "") == "":
29 | machine_id = machine_uuid()
30 | workers = Worker.objects.filter(machine_id=machine_id)
31 |
32 | log.info(f"Workers count: {len(workers)} machine_id={ machine_id }")
33 |
34 | if len(workers) > settings.NBWORKERS_PER_MACHINE:
35 | log.info("Defer task start ws worker")
36 | task_start_websocket_worker.s(job_params).apply_async(countdown=15)
37 | else:
38 | directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
39 | command = [
40 | sys.executable,
41 | os.path.join(directory, "nbworker"),
42 | str(job_params["notebook_id"]),
43 | str(job_params["session_id"]),
44 | str(job_params["worker_id"]),
45 | job_params["server_url"],
46 | ]
47 | log.info("Start " + " ".join(command))
48 | worker = subprocess.Popen(command)
49 | else:
50 | machines = get_running_machines()
51 | log.info(f'Machines {machines}')
52 | machines = shuffle_machines(machines)
53 | log.info(f'Shuffled machines {machines}')
54 | workers_ips = [m.ipv4 for m in machines]
55 | all_busy = True
56 | log.info(f'Worker IPs {workers_ips}')
57 | try:
58 | for worker_ip in workers_ips:
59 | workers = Worker.objects.filter(machine_id=worker_ip)
60 | log.info(f"Job count: {len(workers)} in machine_id={ worker_ip }")
61 | if len(workers) <= settings.NBWORKERS_PER_MACHINE:
62 | notebook_id = job_params["notebook_id"]
63 | session_id = job_params["session_id"]
64 | worker_id = job_params["worker_id"]
65 | worker_url = f"http://{worker_ip}/start/{notebook_id}/{session_id}/{worker_id}"
66 | log.info(f"Try to start worker {worker_url}")
67 | response = requests.get(worker_url, timeout=5)
68 | log.info(f"Response from worker {response.status_code}")
69 | if response.status_code == 200:
70 | if response.json().get("msg", "") == "ok":
71 | all_busy = False
72 | break
73 | except Exception as e:
74 | log.error(f"Error when starting a new worker, {str(e)}")
75 |
76 | if all_busy:
77 | log.info("Defer task start ws worker")
78 | need_instance(job_params["worker_id"])
79 | task_start_websocket_worker.s(job_params).apply_async(countdown=5)
80 |
--------------------------------------------------------------------------------
/mercury/apps/ws/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # python manage.py test apps.ws -v 2
4 |
5 |
6 | class WsTestCase(TestCase):
7 | def test_ws(self):
8 | pass
9 |
--------------------------------------------------------------------------------
/mercury/apps/ws/worker.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import logging
4 |
5 | from asgiref.sync import async_to_sync
6 | from channels.generic.websocket import WebsocketConsumer
7 |
8 | from apps.workers.models import Worker, WorkerSession
9 | from apps.workers.constants import WorkerSessionState
10 | from apps.ws.utils import client_group, worker_group
11 |
12 | logging.basicConfig(
13 | format="WORKER %(asctime)s %(message)s",
14 | level=os.getenv("DJANGO_LOG_LEVEL", "ERROR"),
15 | )
16 |
17 | log = logging.getLogger(__name__)
18 |
19 |
20 | class WorkerProxy(WebsocketConsumer):
21 | def connect(self):
22 | self.notebook_id = int(self.scope["url_route"]["kwargs"]["notebook_id"])
23 | self.session_id = self.scope["url_route"]["kwargs"]["session_id"]
24 | self.worker_id = self.scope["url_route"]["kwargs"]["worker_id"]
25 |
26 | # check if there is such worker requested in database
27 | workers = Worker.objects.filter(
28 | pk=self.worker_id, notebook__id=self.notebook_id, session_id=self.session_id
29 | )
30 | if not workers:
31 | self.close()
32 |
33 | log.info(
34 | f"Worker ({self.worker_id}) connect to {self.session_id}, notebook id {self.notebook_id}"
35 | )
36 |
37 | self.client_group = client_group(self.notebook_id, self.session_id)
38 | self.worker_group = worker_group(self.notebook_id, self.session_id)
39 |
40 | async_to_sync(self.channel_layer.group_add)(
41 | self.worker_group, self.channel_name
42 | )
43 |
44 | worker = workers[len(workers) - 1]
45 | self.worker_session = WorkerSession.objects.create(
46 | ipv4="unknown",
47 | state=WorkerSessionState.Running,
48 | owned_by=worker.notebook.created_by,
49 | run_by=worker.run_by,
50 | site=worker.notebook.hosted_on,
51 | notebook=worker.notebook,
52 | worker=worker,
53 | )
54 |
55 | self.accept()
56 |
57 | def disconnect(self, close_code):
58 | self.worker_session.sate = WorkerSessionState.Stopped
59 | self.worker_session.worker = None
60 | self.worker_session.save()
61 | async_to_sync(self.channel_layer.group_discard)(
62 | self.worker_group, self.channel_name
63 | )
64 |
65 | def receive(self, text_data):
66 | json_data = json.loads(text_data)
67 | # broadcast to all clients
68 | async_to_sync(self.channel_layer.group_send)(
69 | self.client_group, {"type": "broadcast_message", "payload": json_data}
70 | )
71 |
72 | def broadcast_message(self, event):
73 | payload = event["payload"]
74 | self.send(text_data=json.dumps(payload))
75 |
--------------------------------------------------------------------------------
/mercury/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
--------------------------------------------------------------------------------
/mercury/requirements.txt:
--------------------------------------------------------------------------------
1 | django==4.2.7
2 | djangorestframework==3.14.0
3 | django-filter==21.1
4 | markdown==3.3.6
5 | celery>=5.1.2
6 | sqlalchemy==1.4.27
7 | gevent
8 | nbconvert>=7.8.0
9 | django-cors-headers==3.10.1
10 | ipython>=7.30.1
11 | ipykernel>=6.6.0
12 | psutil>=5.8.0
13 | whitenoise>=5.3.0
14 | python-dotenv>=0.19.2
15 | django-drf-filepond==0.4.1
16 | croniter>=1.3.5
17 | pyppeteer==1.0.2
18 | channels[daphne]>=4.0.0
19 | websocket-client>=1.4.2
20 | execnb
21 | ipywidgets==8.0.3 # cant update ipywidgets!
22 | dj-rest-auth[with_social]==3.0.0
23 | boto3==1.26.83
24 | cryptography
25 | pyopenssl>=23.1.1
26 | bleach>=6.0.0
27 | itables>=2.0.0
28 |
29 |
--------------------------------------------------------------------------------
/mercury/server/__init__.py:
--------------------------------------------------------------------------------
1 | from .celery import app as celery_app
2 |
3 | __all__ = ("celery_app",)
4 |
--------------------------------------------------------------------------------
/mercury/server/asgi.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from channels.auth import AuthMiddlewareStack
4 | from channels.routing import ProtocolTypeRouter, URLRouter
5 | from channels.security.websocket import AllowedHostsOriginValidator
6 | from django.core.asgi import get_asgi_application
7 |
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
9 | # Initialize Django ASGI application early to ensure the AppRegistry
10 | # is populated before importing code that may import ORM models.
11 | django_asgi_app = get_asgi_application()
12 |
13 | import apps.ws.routing
14 | from apps.ws.middleware import TokenAuthMiddleware
15 |
16 | application = ProtocolTypeRouter(
17 | {
18 | "http": django_asgi_app,
19 | "websocket": TokenAuthMiddleware( # AllowedHostsOriginValidator(
20 | AuthMiddlewareStack(URLRouter(apps.ws.routing.websocket_urlpatterns))
21 | )
22 | # ),
23 | }
24 | )
25 |
--------------------------------------------------------------------------------
/mercury/server/celery.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import uuid
4 |
5 | from celery import Celery
6 | from celery.schedules import crontab
7 | from django.conf import settings
8 | from django.db import transaction
9 |
10 | CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11 | sys.path.insert(0, CURRENT_DIR)
12 |
13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
14 |
15 | # celery -A server worker --loglevel=info -P gevent --concurrency 1 -E
16 | app = Celery("server")
17 |
18 | # Using a string here means the worker doesn't have to serialize
19 | # the configuration object to child processes.
20 | # - namespace='CELERY' means all celery-related configuration keys
21 | # should have a `CELERY_` prefix.
22 | app.config_from_object("django.conf:settings", namespace="CELERY")
23 |
24 | app.conf.timezone = settings.TIME_ZONE
25 |
26 | app.autodiscover_tasks()
27 |
28 | # workers with active websocket connection are in the separate queue
29 | app.conf.task_routes = {
30 | "apps.ws.tasks.task_start_websocket_worker": {"queue": "ws"}
31 | }
32 |
33 |
34 | @app.on_after_configure.connect
35 | def setup_periodic_tasks(sender, **kwargs):
36 | try:
37 | # add scale down task only in cloud env
38 | if os.environ.get("MERCURY_CLOUD", "0") == "1":
39 | sender.add_periodic_task(
40 | crontab(
41 | minute="*",
42 | hour="*",
43 | day_of_month="*",
44 | month_of_year="*",
45 | day_of_week="*",
46 | ),
47 | scale_down_task.s(),
48 | )
49 |
50 | # from apps.notebooks.models import Notebook
51 |
52 | # # get all notebooks with not empty schedule
53 | # notebooks = Notebook.objects.exclude(schedule__isnull=True).exclude(
54 | # schedule__exact=""
55 | # )
56 |
57 | # for n in notebooks:
58 | # schedule_str = n.schedule
59 | # schedule_arr = schedule_str.split(" ")
60 | # minute, hour, day_of_month, month, day_of_week = (
61 | # schedule_arr[0],
62 | # schedule_arr[1],
63 | # schedule_arr[2],
64 | # schedule_arr[3],
65 | # schedule_arr[4],
66 | # )
67 | # sender.add_periodic_task(
68 | # crontab(
69 | # minute=minute,
70 | # hour=hour,
71 | # day_of_month=day_of_month,
72 | # month_of_year=month,
73 | # day_of_week=day_of_week,
74 | # ),
75 | # execute_notebook.s(n.id),
76 | # )
77 | except Exception as e:
78 | print("Problem with periodic tasks setup")
79 | print(str(e))
80 |
81 |
82 | @app.task
83 | def scale_down_task():
84 | import django
85 | django.setup()
86 | from apps.workers.utils import scale_down
87 | scale_down()
88 |
89 | @app.task
90 | def execute_notebook(notebook_id):
91 | import django
92 |
93 | django.setup()
94 | from apps.notebooks.models import Notebook
95 | from apps.tasks.models import Task
96 | from apps.tasks.tasks import task_execute
97 |
98 | with transaction.atomic():
99 | task = Task(
100 | session_id=f"scheduled-{uuid.uuid4().hex[:8]}",
101 | state="CREATED",
102 | notebook=Notebook.objects.get(pk=notebook_id),
103 | params="{}",
104 | )
105 | task.save()
106 | job_params = {"db_id": task.id}
107 | transaction.on_commit(lambda: task_execute.delay(job_params))
108 |
--------------------------------------------------------------------------------
/mercury/server/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls import include
3 | from django.conf.urls.static import static
4 | from django.contrib import admin
5 | from django.shortcuts import render
6 | from django.urls import path, re_path
7 |
8 | from apps.notebooks.urls import notebooks_urlpatterns
9 | from apps.tasks.urls import tasks_urlpatterns
10 | from apps.accounts.urls import accounts_urlpatterns
11 | from apps.storage.urls import storage_urlpatterns
12 | from apps.workers.urls import workers_urlpatterns
13 | from server.views import VersionInfo, WelcomeMessage
14 |
15 | urlpatterns = []
16 |
17 | if settings.DEBUG or settings.SERVE_STATIC:
18 | # serve static file for development only!
19 | def index(request):
20 | return render(request, "index.html")
21 |
22 | # Serve static and media files from development server
23 | urlpatterns += [
24 | path("", index),
25 | re_path(r"^app", index),
26 | re_path(r"^login", index),
27 | re_path(r"^account", index),
28 | ]
29 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
30 |
31 |
32 | urlpatterns += [
33 | path("admin/", admin.site.urls),
34 | re_path(
35 | "api/v1/version",
36 | VersionInfo.as_view(),
37 | ),
38 | re_path(
39 | "api/v1/(?P.+)/welcome",
40 | WelcomeMessage.as_view(),
41 | ),
42 | re_path(r"^api/v1/fp/", include("django_drf_filepond.urls")),
43 | ]
44 |
45 | urlpatterns += tasks_urlpatterns
46 | urlpatterns += notebooks_urlpatterns
47 | urlpatterns += accounts_urlpatterns
48 | urlpatterns += storage_urlpatterns
49 | urlpatterns += workers_urlpatterns
50 |
51 | admin.site.site_header = "Mercury Admin Panel"
52 |
--------------------------------------------------------------------------------
/mercury/server/views.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.conf import settings
4 | from rest_framework.response import Response
5 | from rest_framework.views import APIView
6 |
7 | from apps.accounts.models import Site, Membership
8 |
9 |
10 | class VersionInfo(APIView):
11 | def get(self, request, format=None):
12 | return Response({"isPro": True})
13 |
14 |
15 | # it will be used as really simple cache
16 | welcome_msg = None
17 |
18 |
19 | class WelcomeMessage(APIView):
20 | def get(self, request, site_id, format=None):
21 | global welcome_msg
22 | welcome_file = os.environ.get("WELCOME")
23 | if welcome_file is not None:
24 | if welcome_msg is None:
25 | if os.path.exists(welcome_file):
26 | with open(welcome_file, encoding="utf-8", errors="ignore") as fin:
27 | welcome_msg = fin.read()
28 | else:
29 | return Response({"msg": ""})
30 | if welcome_msg is not None:
31 | # check access rights ...
32 | user = request.user
33 |
34 | site = Site.objects.get(pk=site_id)
35 | if site.share == Site.PRIVATE:
36 | if user.is_anonymous:
37 | return Response({"msg": ""})
38 | else:
39 | # logged user needs to be owner or have member rights
40 | owner = site.created_by == user
41 | member = Membership.objects.filter(user=user, host=site)
42 | if not owner and not member:
43 | return Response({"msg": ""})
44 |
45 | return Response({"msg": welcome_msg})
46 | return Response({"msg": ""})
47 |
--------------------------------------------------------------------------------
/mercury/server/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for server project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/mercury/templates/account/email/email_confirmation_message.txt:
--------------------------------------------------------------------------------
1 | {% extends "account/email/base_message.txt" %}
2 | {% load account %}
3 | {% load i18n %}
4 |
5 | {% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}You're receiving this e-mail because user {{ user_display }} has given your e-mail address to register an account on {{ site_domain }}.
6 |
7 | To confirm this is correct, go to https://cloud.runmercury.com/verify-email/{{ key }}{% endblocktrans %}{% endautoescape %}{% endblock %}
--------------------------------------------------------------------------------
/mercury/templates/account/email/password_reset_key_message.txt:
--------------------------------------------------------------------------------
1 | {% extends "account/email/base_message.txt" %}
2 | {% load i18n %}
3 | {% load replace %}
4 |
5 | {% block content %}{% autoescape off %}{% blocktrans %}You're receiving this email because you or someone else has requested a password reset for your user account.
6 | It can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %}
7 |
8 | {{ password_reset_url|replace:"api.|cloud." }}{% if username %}
9 |
10 | {% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}{% endif %}{% endautoescape %}{% endblock content %}
--------------------------------------------------------------------------------
/mercury/widgets/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mljar/mercury/c4c50fc3d2545704a0da2a690e53dd6d163b9711/mercury/widgets/__init__.py
--------------------------------------------------------------------------------
/mercury/widgets/apiresponse.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from IPython.display import display
4 |
5 | from .manager import WidgetsManager
6 | from .json import JSON
7 |
8 | class APIResponse:
9 | """
10 | The APIResponse class provides an interface for returning JSON response from notebook.
11 | Notebook can be executed with REST API and can return the JSON response.
12 |
13 | Attributes
14 | ----------
15 | response : dict, default {}
16 | JSON response from notebook.
17 |
18 | Examples
19 | --------
20 | Returning JSON response from notebook.
21 | >>> import mercury as mr
22 | >>> my_response = mr.APIResponse(response={"msg: "hello from notebook"})
23 | """
24 | def __init__(self, response={}):
25 | if not isinstance(response, dict):
26 | raise Exception("Please provide response as dict {}")
27 | self.code_uid = WidgetsManager.get_code_uid("APIResponse")
28 | self.response = response
29 | JSON(response, level=4)
30 | display(self)
31 |
32 | @property
33 | def value(self):
34 | return self.response
35 |
36 | def __str__(self):
37 | return "mercury.APIResponse"
38 |
39 | def __repr__(self):
40 | return "mercury.APIResponse"
41 |
42 | def _repr_mimebundle_(self, **kwargs):
43 | data = {}
44 |
45 | view = {
46 | "widget": "APIResponse",
47 | "value": json.dumps(self.response),
48 | "model_id": self.code_uid,
49 | "code_uid": self.code_uid,
50 | }
51 | data["application/mercury+json"] = json.dumps(view, indent=4)
52 |
53 | data["text/plain"] = "API Response"
54 |
55 | return data
56 |
--------------------------------------------------------------------------------
/mercury/widgets/chat.py:
--------------------------------------------------------------------------------
1 | from IPython.display import display, HTML
2 |
3 |
4 | def Chat(messages=[]):
5 | html = """"""
6 |
7 | left_msg = """
12 |
"""
13 | right_msg = """
18 |
"""
19 | style = left_msg
20 | for m in messages:
21 | html += style.format(m)
22 | style = left_msg if style == right_msg else right_msg
23 |
24 | html += "
"
25 |
26 | display(HTML(html))
27 |
--------------------------------------------------------------------------------
/mercury/widgets/confetti.py:
--------------------------------------------------------------------------------
1 | from IPython.display import display, HTML
2 |
3 |
4 | def Confetti():
5 | display(
6 | HTML(
7 | """
8 |
22 | """
23 | )
24 | )
25 |
--------------------------------------------------------------------------------
/mercury/widgets/in_mercury.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2023 MLJAR Sp. z o.o.
2 | #
3 | # This program is free software: you can redistribute it and/or modify
4 | # it under the terms of the GNU Affero General Public License as published by
5 | # the Free Software Foundation, either version 3 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU Affero General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU Affero General Public License
14 | # along with this program. If not, see .
15 |
16 | import os
17 |
18 |
19 | def in_mercury():
20 | """Returns True if running notebook as web app in Mercury Server"""
21 | return os.environ.get("RUN_MERCURY", "") == "1"
22 |
--------------------------------------------------------------------------------
/mercury/widgets/md.py:
--------------------------------------------------------------------------------
1 | from IPython.display import display, Markdown as md
2 |
3 |
4 | def Markdown(text="hello"):
5 | display(md(text))
6 |
--------------------------------------------------------------------------------
/mercury/widgets/note.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import ipywidgets
4 | from IPython.display import Markdown, display
5 |
6 | from .manager import WidgetException, WidgetsManager
7 |
8 |
9 | class NoteText:
10 | def __init__(self, text):
11 | self.value = text
12 |
13 |
14 | class Note:
15 | """
16 | The Note class provides an interface for adding Markdown-formatted notes within
17 | the Mercury UI sidebar.
18 |
19 | This class supports Markdown, a lightweight and easy-to-use syntax for styling
20 | all forms of writing on the Mercury platform. Users can add notes with emphasis,
21 | lists, links, and more using the standard Markdown syntax.
22 |
23 | Parameters
24 | ----------
25 | text : str, default '*Note*'
26 | The Markdown-formatted text to be displayed in the Mercury UI sidebar.
27 | If an empty string is provided, the note will display no text.
28 |
29 | Attributes
30 | ----------
31 | value : str
32 | The current Markdown text of the note. This can be set or retrieved at any time.
33 |
34 | Examples
35 | --------
36 | Adding a new Markdown note to the Mercury sidebar.
37 | >>> import mercury as mr
38 | >>> my_note = mr.Note(text="Some **Markdown** text")
39 |
40 | The note with the text "Some **Markdown** text" (with "Markdown" bolded) is now
41 | displayed in the sidebar.
42 | """
43 |
44 | def __init__(self, text="*Note*"):
45 | self.code_uid = WidgetsManager.get_code_uid("Note")
46 |
47 | if WidgetsManager.widget_exists(self.code_uid):
48 | self.note = WidgetsManager.get_widget(self.code_uid)
49 | if self.note.value != text:
50 | self.note.value = text
51 | else:
52 | self.note = NoteText(text)
53 | WidgetsManager.add_widget(self.code_uid, self.code_uid, self.note)
54 | display(self)
55 |
56 | @property
57 | def value(self):
58 | return self.note.value
59 |
60 | def __str__(self):
61 | return "mercury.Note"
62 |
63 | def __repr__(self):
64 | return "mercury.Note"
65 |
66 | def _repr_mimebundle_(self, **kwargs):
67 | data = {}
68 |
69 | view = {
70 | "widget": "Note",
71 | "value": self.note.value,
72 | "model_id": self.code_uid,
73 | "code_uid": self.code_uid,
74 | }
75 | data["application/mercury+json"] = json.dumps(view, indent=4)
76 |
77 | data["text/markdown"] = self.note.value
78 |
79 | return data
80 |
--------------------------------------------------------------------------------
/mercury/widgets/numberbox.py:
--------------------------------------------------------------------------------
1 | import os
2 | from uuid import uuid4
3 |
4 |
5 | class NumberBox:
6 | BLUE = "#00B1E4"
7 | LIGHT_BLUE = "rgba(0, 177, 228, 0.5)"
8 | RED = "#FF6384"
9 | LIGHT_RED = "rgba(255, 99, 132, 0.5)"
10 | GREEN = "#00B275"
11 | LIGHT_GREEN = "rgb(0, 178, 117, 0.5)"
12 |
13 | def __init__(
14 | self,
15 | data,
16 | title="",
17 | percent_change=None,
18 | background_color="white",
19 | border_color="lightgray",
20 | data_color="black",
21 | title_color="gray",
22 | ):
23 | self.data = data
24 | self.title = title
25 | self.blox_type = "numeric"
26 | if isinstance(self.data, list):
27 | self.blox_type = "list"
28 | self.percent_change = percent_change
29 | self.background_color = background_color
30 | self.border_color = border_color
31 | self.data_color = data_color
32 | self.title_color = title_color
33 | # position when displayed as a list
34 | self.position = None
35 |
36 | def styles(self):
37 | return """"""
49 |
50 | def _repr_html_(self):
51 | if self.blox_type == "list":
52 | bloxs = ""
53 | for i, b in enumerate(self.data):
54 | if isinstance(b, NumberBox):
55 | # we dont set position for last item
56 | # because we dont need to add margin for it
57 | if i != len(self.data) - 1:
58 | b.position = i
59 | bloxs += b._repr_html_()
60 |
61 | return f"""{self.styles()}{bloxs}
"""
62 |
63 | percent_change_html = ""
64 | if self.percent_change is not None:
65 | if self.percent_change > 0:
66 | percent_change_html = f"""
67 | +{self.percent_change}%
68 | """
69 | else:
70 | percent_change_html = f"""
71 | {self.percent_change}%
72 | """
73 | title_html = ""
74 | if self.title != "":
75 | title_html = f"""{self.title} """
76 |
77 | data_str = ""
78 | if isinstance(self.data, str):
79 | data_str = self.data
80 | else:
81 | data_str = f"{self.data:,}"
82 |
83 | margin = "0px" if self.position is None else "15px"
84 | return f"""
85 |
86 | {data_str}
87 | {percent_change_html}
88 | {title_html}
89 |
90 | """
91 |
--------------------------------------------------------------------------------
/mercury/widgets/outputdir.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import tempfile
4 |
5 | import ipywidgets
6 | from IPython.display import display
7 |
8 | from .manager import WidgetsManager
9 |
10 |
11 | class DirPath:
12 | def __init__(self, dir_path):
13 | self.value = dir_path
14 |
15 |
16 | class OutputDir:
17 | def __init__(self):
18 | self.code_uid = WidgetsManager.get_code_uid("OutputDir")
19 | if WidgetsManager.widget_exists(self.code_uid):
20 | self.dir_path = WidgetsManager.get_widget(self.code_uid)
21 | else:
22 | self.dir_path = DirPath(os.environ.get("MERCURY_OUTPUTDIR", "."))
23 | WidgetsManager.add_widget("output-dir", self.code_uid, self.dir_path)
24 | display(self)
25 |
26 | @property
27 | def path(self):
28 | return self.dir_path.value
29 |
30 | def __str__(self):
31 | return "mercury.OutputDir"
32 |
33 | def __repr__(self):
34 | return "mercury.OutputDir"
35 |
36 | def _repr_mimebundle_(self, **kwargs):
37 | data = {}
38 |
39 | view = {
40 | "widget": "OutputDir",
41 | "model_id": "output-dir",
42 | "code_uid": self.code_uid,
43 | }
44 | data["application/mercury+json"] = json.dumps(view, indent=4)
45 | data[
46 | "text/html"
47 | ] = "Output Directory This output won't appear in the web app. "
48 |
49 | return data
50 |
--------------------------------------------------------------------------------
/mercury/widgets/pdf.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from IPython.display import IFrame
3 |
4 |
5 | def PDF(file_path=None, width="100%", height=800):
6 | try:
7 | content = None
8 | with open(file_path, "rb") as fin:
9 | content = fin.read()
10 | base64_pdf = base64.b64encode(content).decode("utf-8")
11 |
12 | return IFrame(
13 | f"data:application/pdf;base64,{base64_pdf}", width=width, height=height
14 | )
15 | except Exception as e:
16 | print("Problem with displaying PDF")
17 |
--------------------------------------------------------------------------------
/mercury/widgets/stop.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | class StopExecution(Exception):
5 | def _render_traceback_(self):
6 | if os.environ.get("RUN_MERCURY") is not None:
7 | return ["StopExecution"]
8 | pass
9 |
10 |
11 | def Stop():
12 | raise StopExecution()
13 |
--------------------------------------------------------------------------------
/mercury/widgets/table.py:
--------------------------------------------------------------------------------
1 | from itables import show
2 |
3 | import ipywidgets
4 | from IPython.display import display
5 | from .manager import WidgetsManager
6 |
7 | import warnings
8 |
9 |
10 | def Table(data=None, width="auto", text_align="center"):
11 | if data is None:
12 | raise Exception("Please provide data!")
13 |
14 | if "DataFrame" not in str(type(data)):
15 | raise Exception("Wrong data provided! Expected data type is 'DataFrame'.")
16 |
17 | if "%" in width:
18 | raise Exception("Wrong width provided! You can't provide value using '%'.")
19 |
20 | if text_align not in ["center","left","right"]:
21 | raise Exception("Wrong align provided! You can choose one of following options: 'left', 'right', 'center'.")
22 |
23 | text_align= f"dt-{text_align}"
24 | show(data, classes=["display", "cell-border"], columnDefs=[{"className":text_align,"width":width,"targets":"_all"}])
25 |
26 |
--------------------------------------------------------------------------------
/mercury/widgets/user.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2023 MLJAR Sp. z o.o.
2 | #
3 | # This program is free software: you can redistribute it and/or modify
4 | # it under the terms of the GNU Affero General Public License as published by
5 | # the Free Software Foundation, either version 3 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU Affero General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU Affero General Public License
14 | # along with this program. If not, see .
15 |
16 | import os
17 | import json
18 |
19 |
20 | def user():
21 | data = os.environ.get("MERCURY_USER_INFO", "{}")
22 | return json.loads(data)
23 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = [
3 | "setuptools >= 50.0.0",
4 | "wheel",
5 | ]
6 | build-backend = "setuptools.build_meta"
--------------------------------------------------------------------------------
/scripts/dual_pack_mercury.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo 'Dual Pack Mercury'
4 |
5 | # clear dist directories
6 | rm -rf mercury/frontend-dist
7 | rm -rf mercury/frontend-single-site-dist
8 |
9 | cd frontend
10 | yarn install
11 | yarn local-build
12 |
13 | # change in the code
14 | cp src/Root.tsx src/Root_backup
15 | sed 's/ src/Root.tsx
16 | # create new package.json
17 | mv package.json package_backup
18 | head -4 package_backup > package.json
19 | echo " \"homepage\": \"http://mydomain.com/example/to/replace\"," >> package.json
20 | tail -n +5 package_backup >> package.json
21 |
22 | # run build for single-site
23 | yarn local-single-site-build
24 |
25 | # clean
26 | mv package_backup package.json
27 | mv src/Root_backup src/Root.tsx
28 |
29 | cd ..
30 | rm mercury/*.sqlite*
31 |
32 | python setup.py sdist
33 |
--------------------------------------------------------------------------------
/scripts/pack_mercury.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo 'Pack Mercury'
4 |
5 | rm -rf mercury/frontend-dist
6 | cd frontend
7 | yarn install
8 | yarn local-build
9 |
10 | cd ..
11 | rm mercury/*.sqlite*
12 |
13 | python setup.py sdist
14 |
--------------------------------------------------------------------------------
/setup-https.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | echo "Setup Mercury with HTTPS"
3 | if [[ $# -ne 1 ]]; then
4 | echo "Please specify your domain" >&2
5 | exit 2
6 | fi
7 | echo "Set domain to $1 in docker/init-letsencrypt.sh"
8 | sed -i "s/{{your_domain}}/$1/g" docker/init-letsencrypt.sh
9 | echo "Set domain to $1 in docker/nginx/pro/default.conf"
10 | sed -i "s/{{your_domain}}/$1/g" docker/nginx/pro/default.conf
11 | echo "[Done] Domain set"
12 | echo "---------------------------------------------------------"
13 | mv docker/init-letsencrypt.sh init-letsencrypt.sh
14 | echo "Build docker-compose:"
15 | sudo docker-compose -f docker-compose-https.yml build
16 | echo "[Done] Docker-compose build"
17 | echo "---------------------------------------------------------"
18 | echo "Initialize SSL certificates"
19 | sudo ./init-letsencrypt.sh
20 | echo "[Done] SSL certificates issued"
21 | echo "---------------------------------------------------------"
22 | echo "Start service"
23 | sudo docker-compose -f docker-compose-https.yml up --build -d
24 | echo "[Done] Mercury is running"
25 | echo "---------------------------------------------------------"
26 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import setup, find_packages
3 |
4 | with open("README.md", "r", encoding="utf-8", errors="ignore") as fh:
5 | long_description = fh.read()
6 |
7 | def list_files(directory):
8 | paths = []
9 | for (path, directories, filenames) in os.walk(directory):
10 | for filename in filenames:
11 | paths.append(os.path.join(path, filename))
12 | return paths
13 |
14 | setup(
15 | name="mercury",
16 | version="2.4.3",
17 | author="MLJAR Sp. z o.o.",
18 | maintainer="MLJAR Sp. z o.o.",
19 | maintainer_email="contact@mljar.com",
20 | description="Turn Jupyter Notebook to Web App and share with non-technical users",
21 | long_description=long_description,
22 | long_description_content_type="text/markdown",
23 | install_requires=open("mercury/requirements.txt").readlines(),
24 | url="https://github.com/mljar/mercury",
25 | packages=find_packages(),
26 | python_requires='>=3.8',
27 | classifiers=[
28 | "Operating System :: OS Independent",
29 | "Programming Language :: Python",
30 | "Programming Language :: Python :: 3.8",
31 | "Programming Language :: Python :: 3.9",
32 | "Programming Language :: Python :: 3.10",
33 | "Programming Language :: Python :: 3.11"
34 | ],
35 | entry_points={
36 | "console_scripts": ["mercury=mercury.mercury:main"],
37 | },
38 | package_data={"mercury": list_files("frontend-dist") + ["requirements.txt"]},
39 | include_package_data=True,
40 | )
41 |
--------------------------------------------------------------------------------