Repository: Crivella/ocr_translate (language: python)

Issue:
Error during install step "python manage.py migrate"
The following error occurs during the installation procedure. I followed the "from github" instructions. Up until that step, it was all fine. 

```
(ocr) PS C:\OCR\ocr\ocr_translate> python manage.py migrate
Traceback (most recent call last):
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\sqlite3\base.py", line 328, in execute
    return super().execute(query, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
sqlite3.OperationalError: no such table: ocr_translate_optiondict

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\OCR\ocr\ocr_translate\manage.py", line 22, in <module>
    main()
  File "C:\OCR\ocr\ocr_translate\manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\base.py", line 412, in run_from_argv
    self.execute(*args, **cmd_options)
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\base.py", line 458, in execute
    output = self.handle(*args, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\base.py", line 106, in wrapper
    res = handle_func(*args, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\commands\migrate.py", line 100, in handle
    self.check(databases=[database])
  File "C:\OCR\ocr\Lib\site-packages\django\core\management\base.py", line 485, in check
    all_issues = checks.run_checks(
                 ^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\core\checks\registry.py", line 88, in run_checks
    new_errors = check(app_configs=app_configs, databases=databases)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\core\checks\urls.py", line 42, in check_url_namespaces_unique
    all_namespaces = _load_all_namespaces(resolver)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\core\checks\urls.py", line 61, in _load_all_namespaces
    url_patterns = getattr(resolver, "url_patterns", [])
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\utils\functional.py", line 57, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
                                         ^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\urls\resolvers.py", line 715, in url_patterns
    patterns = getattr(self.urlconf_module, "urlpatterns", self.urlconf_module)
                       ^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\utils\functional.py", line 57, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
                                         ^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\urls\resolvers.py", line 708, in urlconf_module
    return import_module(self.urlconf_name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\jcaiq\AppData\Local\Programs\Python\Python311\Lib\importlib\__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1204, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1176, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1147, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "C:\OCR\ocr\ocr_translate\mysite\urls.py", line 39, in <module>
    path('', include('ocr_translate.urls')),
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\urls\conf.py", line 38, in include
    urlconf_module = import_module(urlconf_module)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\jcaiq\AppData\Local\Programs\Python\Python311\Lib\importlib\__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1204, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1176, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1147, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "C:\OCR\ocr\ocr_translate\ocr_translate\urls.py", line 22, in <module>
    from ocr_translate import views
  File "C:\OCR\ocr\ocr_translate\ocr_translate\views.py", line 37, in <module>
    from .ocr_tsl.box import get_box_model, load_box_model, unload_box_model
  File "C:\OCR\ocr\ocr_translate\ocr_translate\ocr_tsl\__init__.py", line 27, in <module>
    auto_create_languages()
  File "C:\OCR\ocr\ocr_translate\ocr_translate\ocr_tsl\initializers.py", line 69, in auto_create_languages
    opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\query.py", line 916, in get_or_create
    return self.get(**kwargs), False
           ^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\query.py", line 633, in get
    num = len(clone)
          ^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\query.py", line 380, in __len__
    self._fetch_all()
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\query.py", line 1881, in _fetch_all
    self._result_cache = list(self._iterable_class(self))
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\query.py", line 91, in __iter__
    results = compiler.execute_sql(
              ^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\models\sql\compiler.py", line 1562, in execute_sql
    cursor.execute(sql, params)
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\utils.py", line 67, in execute
    return self._execute_with_wrappers(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\utils.py", line 80, in _execute_with_wrappers
    return executor(sql, params, many, context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\utils.py", line 84, in _execute
    with self.db.wrap_database_errors:
  File "C:\OCR\ocr\Lib\site-packages\django\db\utils.py", line 91, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\OCR\ocr\Lib\site-packages\django\db\backends\sqlite3\base.py", line 328, in execute
    return super().execute(query, params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
django.db.utils.OperationalError: no such table: ocr_translate_optiondict

```

Here's all the commands I made. All went smoothly up until migrate:
```
PS C:\OCR> python -m venv ocr
PS C:\OCR> cd ocr
PS C:\OCR\ocr> scripts/activate
(ocr) PS C:\OCR\ocr> git clone https://github.com/Crivella/ocr_translate
(ocr) PS C:\OCR\ocr> cd ocr_translate
(ocr) PS C:\OCR\ocr\ocr_translate> pip install -r requirements-torch-cuda.txt
(ocr) PS C:\OCR\ocr\ocr_translate> pip install -r requirements.txt
(ocr) PS C:\OCR\ocr\ocr_translate> pip install -r plugins.txt
(ocr) PS C:\OCR\ocr\ocr_translate> python manage.py migrate

```
OS:
- Edition	Windows 11 Pro
- Version	24H2
- Installed on	‎16/‎06/‎2024
- OS build	26100.1000
- Experience	Windows Feature Experience Pack 1000.26100.6.0
- 
Hardware:
- Processor	AMD Ryzen 5 5600G
- Installed RAM	32,0 GB
- GPU NVIDIA GeForce RTX 3060

Python:
- Python 3.11.9

Creating models with null or empty iso_map is not possible
When creating a new model via the admin interface, if isomap is `null` or and empty dict `{}` Django will stop the creation with
`This field is required.` 
Access from other devices on local network
Is there a way to access the server from outside of localhost? 

I'm using the release version for windows and can't get the server to run with an address that isn't localhost to get the server to be visible on the local network. Is there something I'm missing?


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci-docs.yml b/.github/workflows/ci-docs.yml
index 2ac5a81..5a8c0ec 100644
--- a/.github/workflows/ci-docs.yml
+++ b/.github/workflows/ci-docs.yml
@@ -46,7 +46,7 @@ jobs:
       id: linkcheck
       run: |
         make -C docs html linkcheck 2>&1 | tee check.log
-        echo "broken=$(grep '(line\s*[0-9]*)\(\s\)broken\(\s\)' check.log)" >> $GITHUB_OUTPUT
+        echo "broken=$(grep -E 'line\s+[0-9]+)\s+broken\s+' check.log)" >> $GITHUB_OUTPUT
       env:
         SPHINXOPTS: -nW --keep-going
 
diff --git a/.gitignore b/.gitignore
index 85ae883..798e32d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@
 *.pot
 *.pyc
 __pycache__
-*.sqlite3
+*.sqlite3*
 media
 
 # Backup files #
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f7d39b..e1737ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,58 @@
 
 List of changes between versions
 
+## 0.6.0
+
+### IMPORTANT
+
+From version 0.6 onward `python` and `pip` need to be installed on the system.
+See more below in the [Changes](#changes) section.
+
+- Windows: https://www.python.org/downloads/windows/
+  - **NOTE**: make sure to check the box that says "Add Python to PATH" so that pip can be found by the server script without having to make any assumptions
+
+- Linux: Use your package manager (e.g. `sudo apt install python3 python3-pip`)
+
+### Changes
+
+- Removed the frozen executable from the release files in favor of an Automatic1111 stile batch script
+  - Even with the plugin manager, installing some dependencies that requiers actual compilation by invoking pip from within the frozen executable was giving non trivial to fix trouble.\
+    For this reason I decided to axe the PyInstaller frozen EXE all together and go with a batch script that will:
+    - Allow user to more easily set environment variables (a few of the most relevant ones are already set as empty in the script)
+    - Create or reuse a virtual environment in a folder `venv` in the same directory as the script
+    - Install the minimum required packages in it to run the server
+    - Run the server
+- Added a plugin manager to install/uninstall plugins on demand
+  - The installed plugins can be controlled via the new version of the firefox extension or directly using the
+    `manage_plugins/` endpoint.
+  - The plugins will by be installed under `$OCT_BASE_DIR/plugins` which by default will be under your user profile (e.g. `C:\Users\username\.ocr_translate` on windows). \
+    If you have trouble with space under `C:\` consider setting the `OCT_BASE_DIR` environment variable to a different location.
+  - The plugin data is stored in a JSON file inside the project [plugins_data.json](blob/v0.6.0/ocr_translate/plugins_data.json)
+  - Version/Scope/Extras of a package to be installed can be controlled via environment variables
+
+        OCT_PKG_<package_name(uppercase)>_[VERSION|SCOPE|EXTRAS]
+
+    (eg to change torch to version A.B.C you would set `OCT_PKG_TORCH_VERSION="A.B.C"`).
+    If the package name contains a `-` it should be replaced with `_min_` in the package name
+  - Removed env variable `AUTOCREATE_VALIDATED_MODELS` and relative server initialization.
+    Now models are created/activated or deactivated via the plugin manager, when the respective plugin is installed/uninstalled.
+- Streamlined docker image to also use the `run_server.py` script for initialization.
+- Added plugin for `ollama` (https://github.com/ollama/ollama) for translation using LLMs
+  - Note ollama needs to be run/installed separately and the plugin will just make calls to the server.
+  - Use the `OCT_OLLAMA_ENDPOINT` environment variable to specify the endpoint of the ollama server
+    ([see the plugin page for more details](https://github.com/Crivella/ocr_translate-ollama))
+- Added plugin for `PaddleOCR` (https://github.com/PaddlePaddle/PaddleOCR) (Box and OCR) (seems to work very well
+  with chinese).
+  - The default versions installed by the `plugin_manager` of `paddlepaddle` (`2.5.2` on linux and `2.6.1` on windows)
+    might not work for every system as there can be underlying failures in the C++ code that the plugin uses.
+    The version installed can be controlled using the environment variable `OCT_PKG_PADDLEPADDLE_VERSION`.
+- Added possibility to specify extra `DJANGO_ALLOWED_HOSTS` and a server bind address via environment variables. (Fixes #30)
+- Manual model is not implemented as an entrypoint anymore (will work also without recreating models).
+- OCR models can now use a `tokenizer` and a `processor` from different models.
+- Added caching of the languages and allowed box/ocr/tsl models for faster response times on the handshake endpoint.
+- New endpoint `run_tsl_xua` made to work with `XUnity.AutoTranslator` (https://github.com/bbepis/XUnity.AutoTranslator)
+- Improved API return codes
+
 ## 0.5.1
 
 - Implemented endpoint for manual translation
diff --git a/Dockerfile-cpu b/Dockerfile
similarity index 66%
rename from Dockerfile-cpu
rename to Dockerfile
index 82a2bff..e989328 100644
--- a/Dockerfile-cpu
+++ b/Dockerfile
@@ -5,17 +5,14 @@ RUN virtualenv /venv/
 
 RUN mkdir -p /src
 
-COPY requirements-torch-cpu.txt /src/
-COPY requirements.txt /src/
-COPY plugins.txt /src/
-# This might have to be removed when pushing the image?
-COPY .pip_cache-cpu /pip_cache
+COPY ocr_translate /src/ocr_translate
+COPY pyproject.toml /src/
+COPY README.md /src/
 
 RUN mkdir -p /pip_cache
-RUN /venv/bin/pip install -r /src/requirements-torch-cpu.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install -r /src/requirements.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install -r /src/plugins.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install gunicorn --cache-dir /pip_cache
+RUN --mount=type=cache,target=/pip_cache /venv/bin/pip install --cache-dir /pip_cache /src/
+# RUN --mount=type=cache,target=/pip_cache /venv/bin/pip install --cache-dir /pip_cache django-ocr_translate
+RUN --mount=type=cache,target=/pip_cache /venv/bin/pip install gunicorn --cache-dir /pip_cache
 
 FROM python:3.10.12-slim-bookworm
 
@@ -37,8 +34,8 @@ RUN mkdir -p /opt/app/static
 RUN mkdir -p /opt/app/media
 
 COPY start-server.sh /opt/app/
-COPY manage.py /opt/app/
-COPY ocr_translate /opt/app/ocr_translate/
+COPY run_server.py /opt/app/
+# COPY ocr_translate /opt/app/ocr_translate/
 COPY mysite /opt/app/mysite/
 COPY staticfiles /opt/app/static/
 COPY media /opt/app/media/
@@ -56,11 +53,8 @@ ENV \
     GID=1000 \
     LOAD_ON_START="true" \
     AUTOCREATE_LANGUAGES="true" \
-    AUTOCREATE_VALIDATED_MODELS="true" \
-    TRANSFORMERS_CACHE="/models" \
-    TRANSFORMERS_OFFLINE="0" \
     DEVICE="cpu" \
-    NUM_WEB_WORKERS="1" \
+    OCT_GUNICORN_NUM_WORKERS="1" \
     NUM_MAIN_WORKERS="4" \
     NUM_BOX_WORKERS="1" \
     NUM_OCR_WORKERS="1" \
@@ -70,14 +64,13 @@ ENV \
     DJANGO_SUPERUSER_USERNAME="" \
     DJANGO_SUPERUSER_PASSWORD="" \
     DATABASE_ENGINE="django.db.backends.sqlite3" \
-    DATABASE_NAME="/data/db.sqlite3" \
+    DATABASE_NAME="/db_data/db.sqlite3" \
     DATABASE_HOST="" \
     DATABASE_PORT="" \
     DATABASE_USER="" \
     DATABASE_PASSWORD=""
 
-VOLUME [ "/models" ]
-VOLUME [ "/data" ]
+VOLUME plugin_data, models, db_data
 
 WORKDIR /opt/app
 
diff --git a/Dockerfile-gpu b/Dockerfile-gpu
deleted file mode 100644
index 8b5e081..0000000
--- a/Dockerfile-gpu
+++ /dev/null
@@ -1,88 +0,0 @@
-FROM python:3.10.12-slim-bookworm as intermediate
-
-RUN pip install virtualenv
-RUN virtualenv /venv/
-
-RUN mkdir -p /src
-
-COPY requirements-torch-cuda.txt /src/
-COPY requirements.txt /src/
-COPY plugins.txt /src/
-# This might have to be removed when pushing the image?
-COPY .pip_cache-gpu /pip_cache
-
-RUN mkdir -p /pip_cache
-RUN /venv/bin/pip install -r /src/requirements-torch-cuda.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install -r /src/requirements.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install -r /src/plugins.txt --cache-dir /pip_cache
-RUN /venv/bin/pip install gunicorn --cache-dir /pip_cache
-
-FROM python:3.10.12-slim-bookworm
-
-RUN apt-get update && apt-get install \
-    nginx \
-    tesseract-ocr \
-    -y --no-install-recommends \
-    && rm -rf /var/lib/apt/lists/*
-
-RUN mkdir -p /opt/app
-RUN mkdir -p /models
-RUN mkdir -p /data
-
-COPY --from=intermediate /venv /venv
-
-
-RUN mkdir -p /opt/app/main
-RUN mkdir -p /opt/app/static
-RUN mkdir -p /opt/app/media
-
-COPY start-server.sh /opt/app/
-COPY manage.py /opt/app/
-COPY ocr_translate /opt/app/ocr_translate/
-COPY mysite /opt/app/mysite/
-COPY staticfiles /opt/app/static/
-COPY media /opt/app/media/
-
-RUN chown -R www-data:www-data /opt/app
-RUN chmod +x /opt/app/start-server.sh
-
-COPY nginx.default /etc/nginx/sites-available/default
-
-RUN ln -sf /dev/stdout /var/log/nginx/access.log \
-    && ln -sf /dev/stderr /var/log/nginx/error.log
-
-ENV \
-    UID=1000 \
-    GID=1000 \
-    LOAD_ON_START="true" \
-    AUTOCREATE_LANGUAGES="true" \
-    AUTOCREATE_VALIDATED_MODELS="true" \
-    TRANSFORMERS_CACHE="/models" \
-    TRANSFORMERS_OFFLINE="0" \
-    DEVICE="cuda" \
-    NUM_WEB_WORKERS="1" \
-    NUM_MAIN_WORKERS="4" \
-    NUM_BOX_WORKERS="1" \
-    NUM_OCR_WORKERS="1" \
-    NUM_TSL_WORKERS="1" \
-    DJANGO_DEBUG="false" \
-    DJANGO_LOG_LEVEL="INFO" \
-    DJANGO_SUPERUSER_USERNAME="" \
-    DJANGO_SUPERUSER_PASSWORD="" \
-    DATABASE_ENGINE="django.db.backends.sqlite3" \
-    DATABASE_NAME="/data/db.sqlite3" \
-    DATABASE_HOST="" \
-    DATABASE_PORT="" \
-    DATABASE_USER="" \
-    DATABASE_PASSWORD=""
-
-VOLUME [ "/models" ]
-VOLUME [ "/data" ]
-
-WORKDIR /opt/app
-
-EXPOSE 4000
-
-STOPSIGNAL SIGTERM
-
-CMD ["/opt/app/start-server.sh"]
diff --git a/README.md b/README.md
index 4480e24..2e7c79f 100644
--- a/README.md
+++ b/README.md
@@ -1,149 +1,69 @@
+<div align="center">
+  <p align="center">
+	<a href="https://pypi.org/project/django-ocr_translate/"><img src="https://img.shields.io/pypi/dm/django-ocr_translate?style=flat-square" alt="pypi"/></a>
+	<a href="https://pypi.org/project/django-ocr_translate/"><img src="https://img.shields.io/pypi/v/django-ocr_translate?style=flat-square" /></a>
+	<a href="https://pypi.org/project/django-ocr_translate/"><img src="https://img.shields.io/github/downloads/Crivella/ocr_translate/total.svg?style=flat-square" /></a>
+  <br />
+	<a href="https://crivella.github.io/ocr_translate/"><img src="https://img.shields.io/badge/GitHub%20Pages-222222?style=for-the-badge&logo=GitHub%20Pages&logoColor=white" alt="pypi"/></a>
+  </p>
+</div>
+
 # OCR_translate
 
 This is a Django app for creating back-end server aimed at performing OCR and translation of images received via a POST request.
 
 The OCR and translation is performed using freely available machine learning models and packages (see below for what is currently implemented).
 
-The server is designed to be used together with [this browser extension](https://github.com/Crivella/ocr_extension), acting as a front-end providing the images and controlling the model languages and models being used.
+The server is designed to be used together with [this browser extension](https://github.com/Crivella/ocr_extension), acting as a front-end providing the images and controlling the languages, models and plugins being used.
 
 For more information, please consult the **[Full Documentation](https://crivella.github.io/ocr_translate/)**
 
-## Possible problems
-
-- [Issue 25](#25) Using uBlock origin (or possibly other extension capable of blocking content) could stop the extension from sending requests to the server. This can be recognized if the popup for setting the language and models works fine but than the translations fails without producing any new log in the server windows. (WIP long term fix in the extension)
-
 ## Running the server
 
-If you plan to use a different settings (eg. database, or model location), you can either:
-
-- manually edit the [settings.py](mysite/settings.py)
-- Use the provided [Environment variables](#environment-variables)
-See below for a [list of supported databases](#supported-databases)
-
-You will also have to modify the `ALLOWED_HOSTS` in case you plan to access the server from somewhere other than *localhost*.
-
-All the different way to run the server may provide different set of default values (each of them is targeted for a different level of usage).
-
-### From Release file (Windows)
-
-(Tested on Windows 11)
-From the [github releases page](/../../releases/) you can download either:
-
-- The [CPU only version](/../../releases/latest/download/run_server-cpu.zip)
-- The GPU version split in [file 1](/../../releases/latest/download/run_server-gpu.zip.001) and [file 2](/../../releases/latest/download/run_server-gpu.zip.002) (The CUDA dependencies makes it take much more space), which can be restored using tools like [7zip](https://www.7-zip.org/https://www.7-zip.org/) and [NanaZip](https://github.com/M2Team/NanaZip).
-
-Usage:
-Unzip the file and from inside the folder, run the `run_server-XXX.exe` file (XXX=cpu/gpu)
-
-The server will run with sensible defaults. Most notably the models files and database will be downloaded/created under `%userprofile%/.ocr_translate`.
-Also the gpu version will attempt to run on GPU by default, and fall-back to CPU if the former is not available.
-
-For customization, you can set the [environment variable](#environment-variables) yourself:
-
-- Windows: either via powershell or by searching for *environment variable* in the settings menu.
-
-**SuperUser**: By default the server will be created with a admin user *admin*:*password* (release only).
-You can login to the admin interface at `http://127.0.0.1:4000/admin/` in order to manage the database.
-The main idea is to use this to manually add new models (with the appropriate entrypoint) if you want to test out,
-model that are not yet available. (If you find good models, please do share).
-
-**NOTE**: The server by default is open only to localhost. If you ever plan to change this, make sure to change the admin password.
-
-### From Github
-
-The Github repo provides not only the Django app files, but also the already configured project files used to start the server.
-
-- Clone or download the repository
+See the [documentation](https://crivella.github.io/ocr_translate/user/index.html#running-the-server) for more information.
 
-      git clone https://github.com/Crivella/ocr_translate.git
+**TLDR:** If you are on windows you will need to:
 
-- Install the project dependencies (choose the appropriate files depending if you wanna run on GPU or CPU only):
+- install [python](https://www.python.org/downloads/windows/) with the check on `Add python.exe to PATH`
+- download>unzip the [release file](/../../releases/latest/download/run_server.zip)
+- run the `run-user.bat` file
 
-      pip install -r requirements-torch-[cpu/cuda].txt
-      pip install -r requirements.txt
+### Why do I need to install python
 
-Create/Initialize your database by running
+Before version `v0.6.0` the server was distributed by including all possible plugins and their dependencies.\
+This made the distribution file (both the github release and docker image) quite large and the release process cumbersome.\
+Furthermore, not every user might be interested in every plugin and might end up downloading GB of files that they will never use.
 
-    python manage.py migrate
+For this reason a [plugin_manager](ocr_translate/plugin_manager.py) has been added to the project that will download/install the plugins and their dependencies on demand.\
+Installing python packages requires `pip` to be available (which is included with python on the windows installer).\
+Unfortunately I have not found a way to include pip reliably in the frozen install produced for the release file.\
+The alternative would've been to add a 2nd installer just to get `pip` before running the server, but why reinvent the wheel.
 
-inside your project folder.
+The check on `Add python.exe to PATH` is needed so that `pip` can be run without having to make any assumption on the installation path.
 
-**NOTE**: From version 0.3.X model installation and functionalities have been moved to plugins that need to be *pip installed* separately.
-See [this documentation page](https://crivella.github.io/ocr_translate/user/index.html) for a list of validated plugins.
+Also since I am now asking people to install python, I decided to go all the way and use an approach similar to what [automatic1111's webui.bat](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/master/webui.bat) does for stable diffusion.\
+This batch script will create/reuse a virtual environment in a folder `venv` in the same directory as the script and install the required packages in it.
 
-Run the server using for example one of the following options:
+## Contributing
 
-- Django development server. This is more oriented for developing than deploying, but is fine for a self-hosted single-user server accepting connections only on *localhost*
-  - From inside the project directory:
+- Suggestions/Ideas are always welcome and can be posted as [discussions](https://github.com/Crivella/ocr_translate/discussions).\
+  You can also just propose a new model to be tested/added to the ones available by default.
+- Bugs can be reported as [issues](https://github.com/Crivella/ocr_translate/issues)
+- Code contributions as [pull requests](https://github.com/Crivella/ocr_translate/pulls).
+  Check [the documentation](https://crivella.github.io/ocr_translate/contrib/index.html) for more information.
 
-        python manage.py runserver PORT
+## Plugins
 
-  - The suggested PORT would be 4000 as it is the one set by default in the extension
-- [Nginx](https://www.nginx.com/) + [Gunicorn](https://gunicorn.org/):
-  - Check the [Dockerfile](Dockerfile), as this is what the provided image makes use of.
+The server is designed to only offer the basic functionalities, while the models that can be used and how they are used are defined by plugins.
 
-At least for the first time, it is suggested to run the server with the [Environment variables](#environment-variables) `AUTOCREATE_LANGUAGES` and `AUTOCREATE_VALIDATED_MODELS` set to `"true"` to automatically load the validated languages and models provided by the project.
+See the documentation for a [list of available plugins](https://crivella.github.io/ocr_translate/plugins/index.html)
 
-Notes:
+### Notes
 
-- Gunicorn workers will each spawn a separate instance of the loaded models, each taking its own space in the memory. This can quickly fill up the memory especially if running on GPU. Ideally set this to 1.
-- Django development server will spawn new threads for handling incoming requests (if no currently existing thread is free), which share the same memory. Running more than one worker per loaded model concurrently might slow down the actual computation and in some case also block the execution.
+- When switching the server between CPU/CUDA mode for the first time, run the installation of the plugins again to make sure the scope-specific dependencies are installed.
 
-**SuperUser**: You can create a superuser for managing the database via the django-admin interface by running
-
-    python manage.py createsuperuser
-
-### From PyPI installation
-
-Run the command
-
-    pip install django-ocr_translate
-
-By default torch 2.x will come in its CUDA enabled version. While this works also for CPU, it will also install ~1 GB of cuda dependencies.
-If you wish to run on CPU only, download the file [requirements-torch-cpu.txt](requirements-torch-cpu.txt) first and run
-
-    pip install -r requirements-torch-cpu.txt
-
-before installing the python package.
-
-When installing the project from PyPI, only the app is available.
-This will need to be integrated in a Django project in order to be used.
-These are the minimal instruction for creating a project and start running the server:
-
-- Run `django-admin startproject mysite` to create a django project
-- Configure the server by replacing the automatically created files (strongly recommended):
-  - [settings.py](mysite/settings.py) with the one available on the repo.
-  - [urls.py](mysite/urls.py) with the one available on the repo.
-- or by manually editing the files:
-  - settings.py: Add the `ocr_translated` app to the `INSTALLED_APPS`
-  - urls.py: Include the `'ocr_translate.urls'` into your project urls.
-- From here follow the same instructions as when starting [from Github](#from-github)
-
-### From docker image
-
-This section assumes you have docker installed!!!
-
-CPU and CUDA specific images are available on [DockerHUB](https://hub.docker.com/r/crivella1/ocr_translate):
-
-Download the image from DockerHUB:
-
-- CPU: `docker pull crivella1/ocr_translate:latest-cpu`
-- GPU: `docker pull crivella1/ocr_translate:latest-gpu`
-
-or create it manually create your image:
-
-- Create a .pip-cache-[cpu/gpu] directory inside your project.
-- Optional: re-install the project dependencies pointing this as the cache folder for pip (will make the build process much faster, by reusing the cached dependencies)
-- Run `docker build -t IMAGE_TAG -f Dockerfile-[cpu/gpu] .`
-
-Run the command:
-
-    docker run --name CONTAINER_NAME -v PATH_TO_YOUR_MODEL_DIRECTORY:/models -v PATH_TO_DIR_WITH_SQLITE_FILE:/data --env-file=PATH_TO_AND_ENV_VARIABLE_FILE -p SERVER_PORT:4000 -d ocr_translate
+## Possible problems
 
-See the [Environment variables](#environment-variables) section for configuring your environment variable file. Additionally the docker image defines several other variables to automatically create an admin user for managing the database via the django-admin interface:
+- [Issue 25](/../../issues/25) Using uBlock origin (or possibly other extension capable of blocking content) could stop the extension from sending requests to the server. This can be recognized if the popup for setting the language and models works fine but than the translations fails without producing any new log in the server windows. (WIP long term fix in the extension)
 
-- `UID`: UID of the user owning the files in /models and /data
-- `GID`: GID of the user owning the files in /models and /data
-- `NUM_WEB_WORKERS`: Number of gunicorn workers for the server
-- `DJANGO_SUPERUSER_USERNAME`: The username of the admin user to be created.
-- `DJANGO_SUPERUSER_PASSWORD`: The password of the admin user to be created.
+- [Issue 27](/../../issues/27) Having non latin characters in the model's path can cause HuggingFace `transformers` to fail loading them
diff --git a/build.ps1 b/build.ps1
deleted file mode 100644
index c9c1983..0000000
--- a/build.ps1
+++ /dev/null
@@ -1,27 +0,0 @@
-$env:DJANGO_SETTINGS_MODULE="mysite.settings"
-
-pyinstaller `
-    --onedir `
-    --name run_server-gpu `
-    --icon icon.ico `
-    --add-data "ocr_translate/ocr_tsl/languages.json:ocr_translate/ocr_tsl" `
-    --add-data "ocr_translate/dictionaries/*:ocr_translate/dictionaries" `
-    --collect-all torch `
-    --collect-all torchvision `
-    --collect-all transformers `
-    --collect-all unidic_lite `
-    --collect-all sacremoses `
-    --collect-all sentencepiece `
-    --collect-all ocr_translate_hugging_face `
-    --collect-all ocr_translate_easyocr `
-    --collect-all ocr_translate_tesseract `
-    --recursive-copy-metadata torch `
-    --recursive-copy-metadata torchvision `
-    --recursive-copy-metadata transformers `
-    --recursive-copy-metadata unidic_lite `
-    --recursive-copy-metadata sacremoses `
-    --recursive-copy-metadata sentencepiece `
-    --recursive-copy-metadata ocr_translate_hugging_face `
-    --recursive-copy-metadata ocr_translate_easyocr `
-    --recursive-copy-metadata ocr_translate_tesseract `
-    run_server.py
diff --git a/build.sh b/build.sh
deleted file mode 100755
index 81819e1..0000000
--- a/build.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env bash
-
-export DJANGO_SETTINGS_MODULE=mysite.settings
-
-pyinstaller \
-    --onedir \
-    --name run_server-gpu \
-    --icon icon.ico \
-    --add-data "ocr_translate/ocr_tsl/languages.json:ocr_translate/ocr_tsl" \
-    --add-data "ocr_translate/dictionaries/*:ocr_translate/dictionaries" \
-    --collect-all torch \
-    --collect-all torchvision \
-    --collect-all transformers \
-    --collect-all unidic_lite \
-    --collect-all sacremoses \
-    --collect-all sentencepiece \
-    --collect-all ocr_translate-hugging_face \
-    --collect-all ocr_translate-easyocr \
-    --collect-all ocr_translate-tesseract \
-    --recursive-copy-metadata torch \
-    --recursive-copy-metadata torchvision \
-    --recursive-copy-metadata transformers \
-    --recursive-copy-metadata unidic_lite \
-    --recursive-copy-metadata sacremoses \
-    --recursive-copy-metadata sentencepiece \
-    --recursive-copy-metadat ocr_translate-hugging_face \
-    --recursive-copy-metadat ocr_translate-easyocr \
-    --recursive-copy-metadat ocr_translate-tesseract run_server.py
diff --git a/docs/source/contrib/plugins.rst b/docs/source/contrib/plugins.rst
index 64c8c11..222726c 100644
--- a/docs/source/contrib/plugins.rst
+++ b/docs/source/contrib/plugins.rst
@@ -1,194 +1,258 @@
-Writing plugins
-===============
-
-New models and proxy model classes can be added without modifying the core codebase by creating a python package and using the following entrypoints.
-
-When Creating a plugin, the following example show the minimal methods that need to be redefined.
-Beside this, the plugin can also redefine any of the methods to change eg how inputs are preprocessed, but be careful with this as it might break provenance (eg. `options` are used differently).
-
-A `cookiecutter <https://github.com/cookiecutter/cookiecutter>`_ recipe to create a new plugin is available `HERE <https://github.com/Crivella/ocr_translate-plugin_template>`_
-
-IMPORTANT: The model class are supposed to be proxy classes  and should ence contain
-
-.. code-block:: python
-
-    class Meta:
-        proxy = True
-
-- :code:`ocr_translate.box_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`OCRBoxModel`
-
-.. code-block:: python
-
-    easyocr_box_model_data = {
-        # Name of the model
-        'name': 'easyocr',
-        # List of ISO-639-1 codes supported by the model
-        'lang': ['en', 'ja', 'zh', 'ko'],
-        # How the model requires the codes to be passed (one of 'iso1', 'iso2b', 'iso2t', 'iso3')
-        # If the models codes only partially match or are totally different from one of the ISO standards, see iso1_map
-        'lang_code': 'iso1',
-        # Name of the entrypoint for the model (should match what is used in pyproject.toml)
-        'entrypoint': 'easyocr.box',
-        # Maps ISO-639-1 codes to the codes used by the model. Does not need to map every language, only those that are
-        # different from getattr(lang: m.Language, lang_code)
-        'iso1_map': {
-            'ce': 'che',
-            'zh': 'ch_sim',
-            'zht': 'ch_tra',
-            'tg': 'tjk',
-        }
-    }
-
-- :code:`ocr_translate.ocr_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`OCRModel`
-
-.. code-block:: python
-
-    khawhite_ocr_model_data = {
-        'name': 'kha-white/manga-ocr-base',
-        'lang': ['ja'],
-        'lang_code': 'iso1',
-        'entrypoint': 'hugginface.ved'
-    }
-
-- :code:`ocr_translate.tsl_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`TSLModel`
-
-.. code-block:: python
-
-    staka_fugumt_ja_en_tsl_model_data = {
-        'name': 'staka/fugumt-ja-en',
-        'lang_src': ['ja'],
-        'lang_dst': ['en'],
-        'lang_code': 'iso1',
-        'default_options': {
-            'break_newlines': True
-        },
-        'entrypoint': 'hugginface.seq2seq'
-    }
-
-- :code:`ocr_translate.box_models`: Point this entrypoint to a class that subclasses :code:`OCRBoxModel`. Should redefine atleast the following methods
-
-.. code-block:: python
-
-    class BoxDetectionResult(TypedDict):
-        """Type for the result of the box detection"""
-        single: list[tuple[int, int, int, int]]
-        merged: tuple[int, int, int, int]
-
-    class SomeNewClassName(m.OCRBoxModel):
-        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
-        class Meta:
-            proxy = True
-
-        def load(self):
-            """Load the model into memory."""
-            # Do something here to load the model or nothing if not needed (should still be defined)
-
-        def unload(self) -> None:
-            """Unload the model from memory."""
-            # Do something here to unload the model or nothing if not needed (should still be defined)
-
-
-        def _box_detection(
-                self,
-                image: PILImage, options: dict = None
-                ) -> list[BoxDetectionResult]:
-            """PLACEHOLDER (to be implemented via entrypoint): Perform box OCR on an image.
-            Returns list of bounding boxes as dicts:
-                - merged: The merged BBox as a tuple[int, int, int, int]
-                - single: List of BBoxed merged into the merged BBox as a tuple[int, int, int, int]
-
-            Args:
-                image (Image.Image): A Pillow image on which to perform OCR.
-                options (dict, optional): A dictionary of options.
-
-            Raises:
-                NotImplementedError: The type of model specified is not implemented.
-
-            Returns:
-                list[BoxDetectionResult]: List of dictionary with key/value pairs:
-                - merged: The merged BBox as a tuple[int, int, int, int]
-                - single: List of BBoxed merged into the merged BBox as a tuple[int, int, int, int]
-            """
-            # Redefine this method with the same signature as above
-            # Should return a list of `lrbt` boxes after processing the input PILImage
-
-- :code:`ocr_translate.ocr_models`: Point this entrypoint to a class that subclasses :code:`OCRModel`. Should redefine atleast the following methods
-
-.. code-block:: python
-
-    class SomeNewClassName(m.OCRModel):
-        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
-        class Meta:
-            proxy = True
-
-        def load(self):
-            """Load the model into memory."""
-            # Do something here to load the model or nothing if not needed (should still be defined)
-
-        def unload(self) -> None:
-            """Unload the model from memory."""
-            # Do something here to unload the model or nothing if not needed (should still be defined)
-
-
-        def _ocr(
-                self,
-                img: PILImage, lang: str = None, options: dict = None
-                ) -> str:
-            """Perform OCR on an image.
-
-            Args:
-                img (Image.Image):  A Pillow image on which to perform OCR.
-                lang (str, optional): The language to use for OCR. (Not every model will use this)
-                bbox (tuple[int, int, int, int], optional): The bounding box of the text on the image in lbrt format.
-                options (dict, optional): A dictionary of options to pass to the OCR model.
-
-            Raises:
-                TypeError: If img is not a Pillow image.
-
-            Returns:
-                str: The text extracted from the image.
-            """
-            # Redefine this method with the same signature as above
-            # Should return a sring with the result of the OCR performed on the input PILImage.
-            # Unless the methods `prepare_image` or `ocr` are also being overwritten, the input image will be the result of the CROP on the original image using the bounding boxes given by the box detection model.
-
-- :code:`ocr_translate.tsl_models`: Point this entrypoint to a class that subclasses :code:`TSLModel`. Should redefine atleast the following methods
-
-.. code-block:: python
-
-    class SomeNewClassName(m.TSLModel):
-        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
-        class Meta:
-            proxy = True
-
-        def load(self):
-            """Load the model into memory."""
-            # Do something here to load the model or nothing if not needed (should still be defined)
-
-        def unload(self) -> None:
-            """Unload the model from memory."""
-            # Do something here to unload the model or nothing if not needed (should still be defined)
-
-
-        def _translate(
-                self,
-                tokens: list, src_lang: str, dst_lang: str, options: dict = None) -> str | list[str]:
-            """Translate a text using a the loaded model.
-
-            Args:
-                tokens (list): list or list[list] of string tokens to be translated.
-                lang_src (str): Source language.
-                lang_dst (str): Destination language.
-                options (dict, optional): Options for the translation. Defaults to {}.
-
-            Raises:
-                TypeError: If text is not a string or a list of strings.
-
-            Returns:
-                Union[str,list[str]]: Translated text. If text is a list, returns a list of translated strings.
-            """
-            # Redefine this method with the same signature as above
-            # Should return a sring with the translated text.
-            # IMPORTANT: the main codebase treats this function as batchable:
-            # The input `tokens` can be a list of strings or a list of list of strings. The output should match the input being a string or list of strings.
-            # (This is used to leverage the capability of pytorch to batch inputs and outputs for faster performances, or it can also used to write a plugin for an online service by using a single request for multiple inputs using some separator that the service will leave unaltered.)
+Writing plugins
+===============
+
+New models and proxy model classes can be added without modifying the core codebase by creating a python package and using the following entrypoints.
+
+When Creating a plugin, the following example show the minimal methods that need to be redefined.
+Beside this, the plugin can also redefine any of the methods to change eg how inputs are preprocessed, but be careful with this as it might break provenance (eg. `options` are used differently).
+
+A `cookiecutter <https://github.com/cookiecutter/cookiecutter>`_ recipe to create a new plugin is available `HERE <https://github.com/Crivella/ocr_translate-plugin_template>`_
+
+IMPORTANT: The model class are supposed to be proxy classes  and should ence contain
+
+.. code-block:: python
+
+    class Meta:
+        proxy = True
+
+Model definitions
+-----------------
+
+The entrypoint for model definitions tell the server which models should be available to the user after the plugin is installed.
+
+They consist of an entrypoint of type `ocr_translate.XXX_data` where `XXX` is one of `box`, `ocr` and `tsl`.
+
+- :code:`ocr_translate.box_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`OCRBoxModel`
+
+.. code-block:: python
+
+    easyocr_box_model_data = {
+        # Name of the model
+        'name': 'easyocr',
+        # List of ISO-639-1 codes supported by the model
+        'lang': ['en', 'ja', 'zh', 'ko'],
+        # How the model requires the codes to be passed (one of 'iso1', 'iso2b', 'iso2t', 'iso3')
+        # If the models codes only partially match or are totally different from one of the ISO standards, see iso1_map
+        'lang_code': 'iso1',
+        # Name of the entrypoint for the model (should match what is used in pyproject.toml)
+        'entrypoint': 'easyocr.box',
+        # Maps ISO-639-1 codes to the codes used by the model. Does not need to map every language, only those that are
+        # different from getattr(lang: m.Language, lang_code)
+        'iso1_map': {
+            'ce': 'che',
+            'zh': 'ch_sim',
+            'zht': 'ch_tra',
+            'tg': 'tjk',
+        }
+    }
+
+- :code:`ocr_translate.ocr_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`OCRModel`
+
+.. code-block:: python
+
+    khawhite_ocr_model_data = {
+        'name': 'kha-white/manga-ocr-base',
+        'lang': ['ja'],
+        'lang_code': 'iso1',
+        'entrypoint': 'hugginface.ved'
+    }
+
+- :code:`ocr_translate.tsl_data`: Point this entrypoint to a dictionary with the info required to create a new :code:`TSLModel`
+
+.. code-block:: python
+
+    staka_fugumt_ja_en_tsl_model_data = {
+        'name': 'staka/fugumt-ja-en',
+        'lang_src': ['ja'],
+        'lang_dst': ['en'],
+        'lang_code': 'iso1',
+        'default_options': {
+            'break_newlines': True
+        },
+        'entrypoint': 'hugginface.seq2seq'
+    }
+
+Model usage
+-----------
+
+The entrypoint for model usage point the server to a proxy class of either `OCRBoxModel`, `OCRModel` or `TSLModel` that will be used to interact with the model.
+
+- :code:`ocr_translate.box_models`: Point this entrypoint to a class that subclasses :code:`OCRBoxModel`. Should redefine at least the following methods
+
+.. code-block:: python
+
+    class BoxDetectionResult(TypedDict):
+        """Type for the result of the box detection"""
+        single: list[tuple[int, int, int, int]]
+        merged: tuple[int, int, int, int]
+
+    class SomeNewClassName(m.OCRBoxModel):
+        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
+        class Meta:
+            proxy = True
+
+        def load(self):
+            """Load the model into memory."""
+            # Do something here to load the model or nothing if not needed (should still be defined)
+
+        def unload(self) -> None:
+            """Unload the model from memory."""
+            # Do something here to unload the model or nothing if not needed (should still be defined)
+
+
+        def _box_detection(
+                self,
+                image: PILImage, options: dict = None
+                ) -> list[BoxDetectionResult]:
+            """PLACEHOLDER (to be implemented via entrypoint): Perform box OCR on an image.
+            Returns list of bounding boxes as dicts:
+                - merged: The merged BBox as a tuple[int, int, int, int]
+                - single: List of BBoxed merged into the merged BBox as a tuple[int, int, int, int]
+
+            Args:
+                image (Image.Image): A Pillow image on which to perform OCR.
+                options (dict, optional): A dictionary of options.
+
+            Raises:
+                NotImplementedError: The type of model specified is not implemented.
+
+            Returns:
+                list[BoxDetectionResult]: List of dictionary with key/value pairs:
+                - merged: The merged BBox as a tuple[int, int, int, int]
+                - single: List of BBoxed merged into the merged BBox as a tuple[int, int, int, int]
+            """
+            # Redefine this method with the same signature as above
+            # Should return a list of `lrbt` boxes after processing the input PILImage
+
+- :code:`ocr_translate.ocr_models`: Point this entrypoint to a class that subclasses :code:`OCRModel`. Should redefine atleast the following methods
+
+.. code-block:: python
+
+    class SomeNewClassName(m.OCRModel):
+        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
+        class Meta:
+            proxy = True
+
+        def load(self):
+            """Load the model into memory."""
+            # Do something here to load the model or nothing if not needed (should still be defined)
+
+        def unload(self) -> None:
+            """Unload the model from memory."""
+            # Do something here to unload the model or nothing if not needed (should still be defined)
+
+
+        def _ocr(
+                self,
+                img: PILImage, lang: str = None, options: dict = None
+                ) -> str:
+            """Perform OCR on an image.
+
+            Args:
+                img (Image.Image):  A Pillow image on which to perform OCR.
+                lang (str, optional): The language to use for OCR. (Not every model will use this)
+                bbox (tuple[int, int, int, int], optional): The bounding box of the text on the image in lbrt format.
+                options (dict, optional): A dictionary of options to pass to the OCR model.
+
+            Raises:
+                TypeError: If img is not a Pillow image.
+
+            Returns:
+                str: The text extracted from the image.
+            """
+            # Redefine this method with the same signature as above
+            # Should return a sring with the result of the OCR performed on the input PILImage.
+            # Unless the methods `prepare_image` or `ocr` are also being overwritten, the input image will be the result of the CROP on the original image using the bounding boxes given by the box detection model.
+
+- :code:`ocr_translate.tsl_models`: Point this entrypoint to a class that subclasses :code:`TSLModel`. Should redefine atleast the following methods
+
+.. code-block:: python
+
+    class SomeNewClassName(m.TSLModel):
+        """OCRBoxtranslate plugin to allow usage of ... for box detection."""
+        class Meta:
+            proxy = True
+
+        def load(self):
+            """Load the model into memory."""
+            # Do something here to load the model or nothing if not needed (should still be defined)
+
+        def unload(self) -> None:
+            """Unload the model from memory."""
+            # Do something here to unload the model or nothing if not needed (should still be defined)
+
+
+        def _translate(
+                self,
+                tokens: list, src_lang: str, dst_lang: str, options: dict = None) -> str | list[str]:
+            """Translate a text using a the loaded model.
+
+            Args:
+                tokens (list): list or list[list] of string tokens to be translated.
+                lang_src (str): Source language.
+                lang_dst (str): Destination language.
+                options (dict, optional): Options for the translation. Defaults to {}.
+
+            Raises:
+                TypeError: If text is not a string or a list of strings.
+
+            Returns:
+                Union[str,list[str]]: Translated text. If text is a list, returns a list of translated strings.
+            """
+            # Redefine this method with the same signature as above
+            # Should return a sring with the translated text.
+            # IMPORTANT: the main codebase treats this function as batchable:
+            # The input `tokens` can be a list of strings or a list of list of strings. The output should match the input being a string or list of strings.
+            # (This is used to leverage the capability of pytorch to batch inputs and outputs for faster performances, or it can also used to write a plugin for an online service by using a single request for multiple inputs using some separator that the service will leave unaltered.)
+
+ALLOWED_OPTIONS
+---------------
+
+Since version `v0.5.1` the server can communicate a list of allowed options to the extension.
+The latter will use them to generate a form for the user to overwrite the default options.
+
+In order to use this feature, the plugin needs to define a class variable `ALLOWED_OPTIONS` that is a dictionary with the following structure:
+
+.. code-block:: python
+
+    class YourFancyModel(m.TSLModel):
+        """OCRtranslate plugin to allow usage of google_translate as translator."""
+
+        ALLOWED_OPTIONS = {
+            **m.TSLModel.ALLOWED_OPTIONS,
+            'OPTION_NAME1': {
+                'type': float,
+                'default': 2.0,
+                'description': 'This is a float option that will generate an input field of type number.',
+            },
+            'OPTION_NAME2': {
+                'type': bool,
+                'default': False,
+                'description': 'This is a boolean option that will generate an input field of type checkbox.',
+            },
+        }
+
+Note the line of code
+
+.. code-block:: python
+
+    **m.TSLModel.ALLOWED_OPTIONS,
+
+This is used to inherit the default options from the parent class.
+
+The allowed types are:
+
+- `int`
+- `float`
+- `str`
+- `bool`
+
+that will be converted respectively by the extension in the form into an input field of type:
+
+- `number`
+- `number`
+- `text`
+- `checkbox`
+
+The `descrciption` field is used to generate a tooltip that will be shown to the user when hovering
+a question mark icon next to the input field.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 77bad80..2d6d259 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,80 +1,80 @@
-.. ocr_translate documentation master file, created by
-   sphinx-quickstart on Thu Sep 21 10:37:43 2023.
-   You can adapt this file completely to your liking, but it should at least
-   contain the root `toctree` directive.
-
-Welcome to ocr_translate's documentation!
-=========================================
-
-This is a Django app for creating back-end server aimed at performing OCR and translation of images received via a POST request.
-
-The OCR and translation is performed using freely available machine learning models and packages (see below for what is currently implemented).
-
-The server is designed to be used together with this browser `extension`_, acting as a front-end providing the images and controlling the model languages and models being used.
-
-.. grid:: 2
-      :gutter: 3
-
-      .. grid-item-card:: Installation
-
-            Guide on installing ocr_translate with various methods.
-
-            +++
-
-            .. button-ref:: install/index
-               :expand:
-               :color: secondary
-               :click-parent:
-
-               Installation Guides
-
-      .. grid-item-card:: Running
-
-            Guide on running ocr_translate from one of the installations.
-
-            +++
-
-            .. button-ref:: running/index
-               :expand:
-               :color: secondary
-               :click-parent:
-
-               Running Guides
-
-      .. grid-item-card:: User Guides
-
-         User guide for ocr_translate.
-
-         +++
-
-         .. button-ref:: user/index
-            :expand:
-            :color: secondary
-            :click-parent:
-
-            User Guides
-
-      .. grid-item-card:: Contributor Guides
-
-         Want to add to the codebase?
-         The contributing guidelines will guide you through the
-         process of improving ocr_translate.
-
-         +++
-
-         .. button-ref:: contrib/index
-            :expand:
-            :color: secondary
-            :click-parent:
-
-            Contributor guides
-
-.. _extension: https://github.com/Crivella/ocr_extension
-
-.. toctree::
-   :hidden:
-
-   install/index
-   running/index
-   user/index
-   contrib/index
+.. ocr_translate documentation master file, created by
+   sphinx-quickstart on Thu Sep 21 10:37:43 2023.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Welcome to ocr_translate's documentation!
+=========================================
+
+This is a Django app for creating back-end server aimed at performing OCR and translation of images received via a POST request.
+
+The OCR and translation is performed using freely available machine learning models and packages (see below for what is currently implemented).
+
+The server is designed to be used together with this browser `extension`_, acting as a front-end providing the images and controlling the model languages and models being used.
+
+.. grid:: 2
+      :gutter: 3
+
+      .. grid-item-card:: Installation/Running
+
+            Guide on installing and running ocr_translate with various methods.
+
+            +++
+
+            .. button-ref:: install_run/index
+               :expand:
+               :color: secondary
+               :click-parent:
+
+               Installation/Running Guides
+
+      .. grid-item-card:: Plugins
+
+            Detail on the plugins available for ocr_translate.
+
+            +++
+
+            .. button-ref:: plugins/index
+               :expand:
+               :color: secondary
+               :click-parent:
+
+               Plugins
+
+      .. grid-item-card:: User Guides
+
+         User guide for ocr_translate.
+
+         +++
+
+         .. button-ref:: user/index
+            :expand:
+            :color: secondary
+            :click-parent:
+
+            User Guides
+
+      .. grid-item-card:: Contributor Guides
+
+         Want to add to the codebase?
+         The contributing guidelines will guide you through the
+         process of improving ocr_translate.
+
+         +++
+
+         .. button-ref:: contrib/index
+            :expand:
+            :color: secondary
+            :click-parent:
+
+            Contributor guides
+
+.. _extension: https://github.com/Crivella/ocr_extension
+
+.. toctree::
+   :hidden:
+
+   install_run/index
+   user/index
+   plugins/index
+   contrib/index
diff --git a/docs/source/install/index.rst b/docs/source/install/index.rst
deleted file mode 100644
index 700b74f..0000000
--- a/docs/source/install/index.rst
+++ /dev/null
@@ -1,57 +0,0 @@
-
-Installation
-====================
-
-For both the Githyb and PyPI installation it is strongly suggested to install this project using a `virtual environment <https://docs.python.org/3/library/venv.html>`_.
-
-.. toctree::
-   :maxdepth: 3
-
-From Release file (Windows only)
---------------------------------
-
-From the :github:`github releases page <releases/>` you can download either:
-
-- The :github:`CPU only version <releases/latest/download/run_server-cpu.zip>`
-- The GPU version split in :github:`file1 <releases/latest/download/run_server-gpu.zip.001>` and :github:`file2 <releases/latest/download/run_server-gpu.zip.002>` (The CUDA dependencies makes it take much more space), wich can be restored using tools like `7zip <https://www.7-zip.org/>`_ and `NanaZip <https://github.com/M2Team/NanaZip>`_.
-
-From Github
------------
-
-- Clone or download the repository
-  - :code:`git clone https://github.com/Crivella/ocr_translate.git`
-- Install the project dependencies (choose the appropriate files depending if you wanna run on GPU or CPU only):
-  - :code:`pip install -r requirements-torch-[cpu/cuda].txt`
-  - :code:`pip install -r requirementscs.txt`
-
-From Docker
------------
-
-CPU and CUDA specific images are available on :dockerhub:`Dockerhub <>`:
-
-- CPU: :code:`docker pull crivella1/ocr_translate:latest-cpu`
-- GPU: :code:`docker pull crivella1/ocr_translate:latest-gpu`
-
-Manually create your image:
-
-- Create a .pip-cache-[cpu/gpu] directory inside your project.
-- Optional: re-install the project dependencies pointing this as the cache folder for pip (will make the build process much faster, by reusing the cached dependencies)
-- Run :code:`docker build -t IMAGE_TAG -f Dockerfile-[cpu/gpu] .`
-
-From PyPI
----------
-
-Run the command
-
-- :code:`pip install django-ocr_translate`
-
-By default torch 2.x will come in its CUDA enabled version. While this works also for CPU, it will also install ~1 GB of cuda dependencies.
-If you wish to run on CPU only, download the file [requirements-torch-cpu.txt](requirements-torch-cpu.txt) first and run
-
-- :code:`pip install -r requirements-torch-cpu.txt`
-
-before installing the python package.
-
-Than install the validated plugins (or edit the settings.py file to remove them from the `INSTALLED_APPS`)
-
-- :code:`pip install -r plugins.txt`
diff --git a/docs/source/install_run/from_docker.rst b/docs/source/install_run/from_docker.rst
new file mode 100644
index 0000000..28b1eec
--- /dev/null
+++ b/docs/source/install_run/from_docker.rst
@@ -0,0 +1,30 @@
+From Docker image
+-----------------
+
+Images can be downloaded from :dockerhub:`Dockerhub <>`:
+
+- :code:`docker pull crivella1/ocr_translate:latest`
+
+Or manually created:
+
+- :code:`git clone https://github.com/Crivella/ocr_translate.git`
+- :code:`cd ocr_translate`
+- :code:`docker build -t IMAGE_TAG -f Dockerfile .`
+
+Run the command:
+
+- :code:`docker run --name CONTAINER_NAME -v OCT_MODELS:/models -v OCT_DB_DATA:/db_data -v OCT_PLUGINS_DATA:/plugins_data -p SERVER_PORT:4000 -d IMAGE_NAME`
+
+Replace the all caps parts with the appropriate values.
+For the volumes you can either specify a native docker volume using a name or a bind mount using a path (see `docker documentation <https://docs.docker.com/storage/volumes/>``).
+While it is possible to omit the `-v` this will result in a new volume with random name being created each time the container is run, making it hard to reuse previous data.
+
+See the :doc:`Environment variables <../user/envs>` section for configuring your environment variables via CLI or a file.
+
+Additionally the docker image defines several other variables to automatically create an admin user for managing the database via the django-admin interface:
+
+- :code:`UID`: UID of the user owning the files in /models and /data
+- :code:`GID`: GID of the user owning the files in /models and /data
+- :code:`NUM_WEB_WORKERS`: Number of gunicorn workers for the server
+- :code:`DJANGO_SUPERUSER_USERNAME`: The username of the admin user to be created.
+- :code:`DJANGO_SUPERUSER_PASSWORD`: The password of the admin user to be created.
diff --git a/docs/source/install_run/from_github.rst b/docs/source/install_run/from_github.rst
new file mode 100644
index 0000000..3fbc93e
--- /dev/null
+++ b/docs/source/install_run/from_github.rst
@@ -0,0 +1,55 @@
+From Github
+-----------
+
+This assumes you have access to a terminal and have `python` and `git` installed on your system.
+
+- Clone or download the repository
+
+  - :code:`git clone https://github.com/Crivella/ocr_translate.git`
+  - :code:`cd ocr_translate`
+
+- (Optional) create and use a `virtual environment <https://docs.python.org/3/library/venv.html>`_
+
+  - :code:`python -m venv venv`
+  - :code:`venv\\Scripts\\activate` (or the equivalent for your OS)
+
+- Install the project and its dependencies
+  - :code:`pip install .`
+
+The Github repo provides not only the Django app files, but also the already configured project files used to start the server.
+
+.. _github_run_server:
+
+Run the server
+______________
+
+You can either use the :code:`run_server.py` script that will bootstrap the server for you,
+
+- :code:`python run_server.py`
+
+or manually run the Django server.
+
+- Create/Initialize your database by running
+
+  - :code:`python manage.py migrate`
+
+  inside your project folder.
+
+- Run the server:
+
+  - With the Django development server. This is more oriented for developing than deploying, but is fine for a self-hosted single-user server accepting connections only on *localhost*
+
+    - :code:`python manage.py runserver PORT`
+    - The suggested PORT would be 4000 as it is the one set by default in the extension
+
+  - `Nginx <https://www.nginx.com/>`_ + `Gunicorn <https://gunicorn.org/>`_ (Linux only):
+
+    - Check :code:`Dockerfile` and :code:`run_server.py` files, as this is what the provided docker image makes use of.
+
+At least for the first time, it is suggested to run the server with the :doc:`Environment variable <../user/envs>` `AUTOCREATE_LANGUAGES` set to `"true"` to automatically load the validated languages and models provided by the project.
+
+Notes
+_____
+
+- Gunicorn workers will each spawn a separate instance of the loaded models, each taking its own space in the memory. This can quickly fill up the memory especially if running on GPU. Ideally set this to 1.
+- Django development server will spawn new threads for handling incoming requests (if no currently existing thread is free), which share the same memory. Running more than one worker per loaded model concurrently might slow down the actual computation and in some case also block the execution.
diff --git a/docs/source/running/from_pypi.rst b/docs/source/install_run/from_pypi.rst
similarity index 61%
rename from docs/source/running/from_pypi.rst
rename to docs/source/install_run/from_pypi.rst
index c2d2244..a11580c 100644
--- a/docs/source/running/from_pypi.rst
+++ b/docs/source/install_run/from_pypi.rst
@@ -1,17 +1,25 @@
-From PyPI installation
-----------------------
-
-See the section on how to :doc:`install from PyPI </install/index>` first.
-
-When installing the project from PyPI, only the app is available.
-This will need to be integrated in a Django project in order to be used.
-These are the minimal instruction for creating a project and start running the server:
-
-- Run :code:`django-admin startproject mysite` to create a django project
-- Configure the server by replacing the automatically created files (strongly recommended):
-  - [settings.py](mysite/settings.py) with the one available on the repo.
-  - [urls.py](mysite/urls.py) with the one available on the repo.
-- or by manually editing the files:
-  - settings.py: Add the :code:`ocr_translated` app to the :code:`INSTALLED_APPS`
-  - urls.py: Include the :code:`'ocr_translate.urls'` into your project urls.
-- From here follow the same instructions as when starting :doc:`from Github </running/from_github>`
+From PyPI installation
+----------------------
+
+When installing the project from PyPI, only the app is available.
+This will need to be integrated in a Django project in order to be used.
+These are the minimal instruction for creating a project and start running the server
+
+- (Optional) create and use a `virtual environment <https://docs.python.org/3/library/venv.html>`_
+
+  - :code:`python -m venv venv`
+  - :code:`venv\\Scripts\\activate` (or the equivalent for your OS)
+
+- :code:`pip install django-ocr_translate`
+- :code:`django-admin startproject mysite`
+- Configure the server by replacing the automatically created files (strongly recommended):
+
+  - [settings.py](mysite/settings.py) with the one available on the repo.
+  - [urls.py](mysite/urls.py) with the one available on the repo.
+
+- or by manually editing the files:
+
+  - settings.py: Add the :code:`ocr_translated` app to the :code:`INSTALLED_APPS`
+  - urls.py: Include the :code:`'ocr_translate.urls'` into your project urls.
+
+- From here follow the same instructions as :ref:`with Github <github_run_server>`
diff --git a/docs/source/install_run/from_release.rst b/docs/source/install_run/from_release.rst
new file mode 100644
index 0000000..81e1cdb
--- /dev/null
+++ b/docs/source/install_run/from_release.rst
@@ -0,0 +1,48 @@
+From Release File (Windows)
+---------------------------
+
+Tested for Windows11
+
+- Make sure to have `python <https://www.python.org/downloads/windows/>`_ (3.11 suggested) installed on your system and available in the PATH (There is a checkbox that you need to tick as soon as you run the installer to automatically do this).
+
+.. image:: windows_python_install.png
+    :alt: Python installer
+    :align: center
+
+- From :github:`github <releases/>` download the :github:`release file <releases/latest/download/run_server.zip>`
+- Extract the content of the zip file in a folder of your choice
+- Run the :code:`run_server.exe` file
+- Unzip the release file downloaded in the previous step.
+- From inside the folder, run the :code:`run-user.bat` file that will
+
+  - Create or reuse a virtual environment in the folder :code:`venv` in the same directory as the script
+  - Install the required packages to run the server
+  - Start the server
+
+The server will run with sensible defaults.
+Most notably the models files, plugin files (needed python packages) and database will be downloaded/created under :code:`%userprofile%/.ocr_translate`.
+If not DEVICE is specified, the server will attempt to find the :code:`nvidia-smi` executable and if available, will run on GPU.
+
+For customization, you can set the :doc:`Environment variables <../user/envs>` yourself.
+
+Since the introduction of the :code:`run-user.bat` script, this has been made easier as you just need to edit that file
+with any text editor and set the environment variables at the top of the file
+
+.. code-block:: bat
+
+    @echo off
+
+    set LOAD_ON_START=
+
+    set DEVICE=cpu
+    set OCT_BASE_DIR=
+    set DATABASE_NAME=
+
+    set DJANGO_DEBUG=false
+    set DJANGO_LOG_LEVEL=INFO
+
+    call run.bat
+
+You can see some of them already set to their default values or nothing, meaning they are left undefined.
+
+The `call run.bat` calls the secondary script with the boilerplate code to start the server.
diff --git a/docs/source/install_run/index.rst b/docs/source/install_run/index.rst
new file mode 100644
index 0000000..ca55a21
--- /dev/null
+++ b/docs/source/install_run/index.rst
@@ -0,0 +1,42 @@
+Install and Run
+===============
+
+
+.. toctree::
+    :maxdepth: 2
+
+    from_release
+    from_github
+    from_pypi
+    from_docker
+
+General info
+------------
+
+NOTE!!: Since version `v0.6`, `python` (or at least) `pip` needs to be installed on the system. (See https://www.python.org/downloads/)
+
+Most of the server behavior the behavior of the server (eg: model/plugin/db install locations and allowed hosts) can be customized using :doc:`Environment variables <../user/envs>`
+
+SuperUser
+---------
+
+Following the described procedures, the server will be created with a admin user *admin*:*password*.
+
+The username and password can be changed by setting the environment variables `DJANGO_SUPERUSER_USERNAME` and `DJANGO_SUPERUSER_PASSWORD` respectively. (See :doc:`Environment variables <../user/envs>`)
+
+You can login to the admin interface at `http://SERVER_ADDRESS:SERVER_PORT/admin/` (eg: `http://127.0.0.1:4000/admin/`) in order to manage the database.
+The main idea is to use this to manually add new models (with the appropriate entrypoint) if you want to test out,
+model that are not yet available. (If you find good models, please do share).
+
+GPU
+---
+
+By default the `run_server.py` will attempt to use a specific device following:
+
+- If the environment variable `DEVICE` is set, it will use the device specified by the value.
+- If `nvidia-smi` is available the server will try running in GPU mode.
+- If none of the above is available, the server will run in CPU mode.
+
+Independent of the configured specified/found `DEVICE`, if `torch` is already installed it will be used to determine if it can run with CUDA.
+
+As of `v0.6` no device specific dependencies are included by default, but some plugins will install/use different dependencies based on the device used.
diff --git a/docs/source/install_run/windows_python_install.png b/docs/source/install_run/windows_python_install.png
new file mode 100644
index 0000000..c9748ae
Binary files /dev/null and b/docs/source/install_run/windows_python_install.png differ
diff --git a/docs/source/plugins/index.rst b/docs/source/plugins/index.rst
new file mode 100644
index 0000000..641165e
--- /dev/null
+++ b/docs/source/plugins/index.rst
@@ -0,0 +1,149 @@
+Plugins
+=======
+
+Plugins are the bread and butter of `ocr_translate`.
+
+The base project implements functionalities to handle the web server, database, translation pipelines and queues.
+
+How and which models are used is implemented entirely in plugins.
+
+Plugins can be installed by using the plugin collapsable menu on the extension, or by directly using the `manage_plugins/` endpoint (:doc:`see here <../contrib/api>`).
+
+ocr_translate-hugging_face
+--------------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-hugging_face>`__
+
+Plugin for using Hugging Face models for OCR and translation.
+
+Implements `VED models <https://huggingface.co/docs/transformers/en/model_doc/vision-encoder-decoder>`_ for OCR and `Seq2Seq <https://huggingface.co/learn/nlp-course/en/chapter1/7>`_ models for translation.
+
+Plugin or associated packages environment variables
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`TRANSFORMERS_CACHE`
+      - :code:`$OCT_BASE_DIR/models/huggingface`
+      - Directory where `Hugging Face <https://huggingface.co/>`_ models are being
+
+        stored (either downloaded manually or downloaded
+
+        by `transformers`)
+    * - :code:`TRANSFORMERS_OFFLINE`
+      - 0
+      - By default `transformers` will try to download
+
+        missing models. Set this to 1 to only work in
+
+        offline mode
+
+ocr_translate-easyocr
+---------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-easyocr>`__
+
+Plugin for using EasyOCR models for BBox detection.
+
+Plugin or associated packages environment variables
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`EASYOCR_PREFIX`
+      - :code:`$OCT_BASE_DIR/models/easyocr`
+      - Directory where EasyOCR downloads its models
+
+ocr_translate-tesseract
+-----------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-tesseract>`__
+
+Plugin for using Tesseract models for OCR.
+This requires tesseract to be installed on your machine.
+
+Plugin or associated packages environment variables
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`TESSERACT_PREFIX`
+      - :code:`$OCT_BASE_DIR/models/tesseract`
+      - Directory where tesseract downloads its models
+
+    * - :code:`TESSERACT_ALLOW_DOWNLOAD`
+      - true
+      - Control whether the app should download missing
+
+        models (true) or work in offline mode only (false)
+
+ocr_translate-paddle
+--------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-paddle>`__
+
+Plugin for using PaddleOCR models for BBox detection and OCR.
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`PADDLEOCR_PREFIX`
+      - :code:`$OCT_BASE_DIR/models/paddleocr`
+      - Directory where PaddleOCR downloads its models
+
+ocr_translate-ollama
+--------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-ollama>`__
+
+Plugin for using LLMs through ollama for translation.
+
+The `Ollama <https://ollama.com/>`_ server needs to be setup independently.
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`OCT_OLLAMA_ENDPOINT`
+      - :code:`http://127.0.0.1:11434/api`
+      - Endpoint where the ollama server is running
+
+
+ocr_translate-google
+--------------------
+
+`HOMEPAGE <https://github.com/Crivella/ocr_translate-google>`__
+
+Plugin for using Google Translate for translation.
+
+This plugins requires an internet connection and will send the text to be translated to google.
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - EMPTY
+      - EMPTY
+      - EMPTY
diff --git a/docs/source/running/from_docker.rst b/docs/source/running/from_docker.rst
deleted file mode 100644
index 5359e29..0000000
--- a/docs/source/running/from_docker.rst
+++ /dev/null
@@ -1,18 +0,0 @@
-From Docker image
------------------
-
-See the section on how to :doc:`install from DockerHUB </install/index>` first.
-
-This section assumes you have docker installed and the image of the project.
-
-Run the command:
-
-- :code:`docker run --name CONTAINER_NAME -v PATH_TO_YOUR_MODEL_DIRECTORY:/models -v PATH_TO_DIR_WITH_SQLITE_FILE:/data --env-file=PATH_TO_AND_ENV_VARIABLE_FILE -p SERVER_PORT:4000 -d ocr_translate`
-
-See the [Environment variables](#environment-variables) section for configuring your environment variable file. Additionaly the docker image defines several other variables to automatically create an admin user for managing the database via the django-admin interface:
-
-- :code:`UID`: UID of the user owning the files in /models and /data
-- :code:`GID`: GID of the user owning the files in /models and /data
-- :code:`NUM_WEB_WORKERS`: Number of gunicorn workers for the server
-- :code:`DJANGO_SUPERUSER_USERNAME`: The username of the admin user to be created.
-- :code:`DJANGO_SUPERUSER_PASSWORD`: The password of the admin user to be created.
diff --git a/docs/source/running/from_github.rst b/docs/source/running/from_github.rst
deleted file mode 100644
index 5f35b54..0000000
--- a/docs/source/running/from_github.rst
+++ /dev/null
@@ -1,27 +0,0 @@
-From Github installation
-------------------------
-
-See the section on how to :doc:`install from Github </install/index>` first.
-
-The Github repo provides not only the Django app files, but also the already configured project files used to start the server.
-
-Create/Initialize your database by running
-
-- :code:`python manage.py migrate`
-
-inside your project folder.
-
-Run the server using for example one of the following options:
-
-- Django development server. This is more oriented for developing than deploying, but is fine for a self-hosted single-user server accepting connections only on *localhost*
-  - From inside the project directory: :code:`python manage.py runserver PORT`
-  - The suggested PORT would be 4000 as it is the one set by default in the extension
-- `Nginx <https://www.nginx.com/>`_ + `Gunicorn <https://gunicorn.org/>`_:
-  - Check the :code:`Dockerfile`, as this is what the provided image makes use of.
-
-At least for the first time, it is suggested to run the server with the :doc:`Environment variables <../user/envs>` `AUTOCREATE_LANGUAGES` and `AUTOCREATE_VALIDATED_MODELS` set to `"true"` to automatically load the validated languages and models provided by the project.
-
-Notes:
-
-- Gunicorn workers will each spawn a separate instance of the loaded models, each taking its own space in the memory. This can quickly fill up the memory especially if running on GPU. Ideally set this to 1.
-- Django development server will spawn new threads for handling incoming requests (if no currently existing thread is free), which share the same memory. Running more than one worker per loaded model concurrently might slow down the actual computation and in some case also block the execution.
diff --git a/docs/source/running/from_release.rst b/docs/source/running/from_release.rst
deleted file mode 100644
index 4f9a2d6..0000000
--- a/docs/source/running/from_release.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-From Release (Windows)
-----------------------
-
-Tested for Windows11
-
-- Unzip the release file downloaded in the previous step.
-- From inside the folder, run the :code:`run_server-XXX.exe` file (XXX=cpu/gpu)
-
-The server will run with sensible defaults. Most notably the models files and database will be downloaded/created under `%userprofile%/.ocr_translate`.
-Also the gpu version will attempt to run on GPU by default, and fall-back to CPU if the former is not available.
-
-For customization, you can set the :doc:`Environment variables <../user/envs>` yourself:
-
-- Powershell:
-
-.. code-block:: powershell
-
-    $env:ENV_VAR_NAME="XXX"
-
-- by searching for :code:`environment variable` in the settings menu.
diff --git a/docs/source/running/index.rst b/docs/source/running/index.rst
deleted file mode 100644
index 2b953c9..0000000
--- a/docs/source/running/index.rst
+++ /dev/null
@@ -1,29 +0,0 @@
-
-Running
-====================
-
-This is a guide on running the ocr_translate server using different methods.
-
-.. toctree::
-    :maxdepth: 2
-
-    from_release
-    from_github
-    from_pypi
-    from_docker
-
-General info
-------------
-
-If you plan to use a different settings (eg. database, or model location), you can either:
-
-- Manually edit the :code:`settings.py` file
-- Use the provided :doc:`Environment variables <../user/envs>`
-
-See below for a :doc:`list of supported databases <../user/index>`
-
-You will also have to modify the :code:`ALLOWED_HOSTS` in case you plan to access the server from somewhere other than `localhost`.
-
-All the different way to run the server may provide different set of default values (each of them is targeted for a different level of usage).
-
-.. _settings.py: mysite/settings.py
diff --git a/docs/source/user/envs.rst b/docs/source/user/envs.rst
index bfd312e..0f83291 100644
--- a/docs/source/user/envs.rst
+++ b/docs/source/user/envs.rst
@@ -1,122 +1,190 @@
-Environment Variables
-=====================
-
-The server will check a number of environment variables to configure itself.
-
-Setting Environment variables
------------------------------
-
-Environment variables can be set in many ways depending on the the OS and/or the tool used to launch the server.
-This is a list of common possible ways:
-
-- Windows Powershell: :code:`$env:VARIABLE_NAME = "value"` (code must be run in the same shell)
-- Windows Command Prompt: :code:`set VARIABLE_NAME=value` (code must be run in the same shell)
-- Windows Settings: :code:`Control Panel > System > Advanced System Settings > Environment Variables`
-- Linux BASH: :code:`export VARIABLE_NAME=value` (code must be run in the same shell)
-- VSCode: :code:`launch.json > env > VARIABLE_NAME`
-
-.. code-block::
-
-    {
-        "version": "0.2.0",
-        "configurations": [
-            {
-                ...
-                "env": {
-                    "VARIABLE_NAME_1": "VALUE_1",
-                    ...
-                },
-                ...
-            }
-        ]
-    }
-
-
-App variable List
------------------
-
-Variables used by the application.
-
-.. list-table:: Title
-    :widths: 25 25 50
-    :header-rows: 1
-
-    * - Variable
-      - Values
-      - Usage
-    * - :code:`LOAD_ON_START`
-      - false[/true]
-      - Will automatically load the most used source/destination languages and most used models for that language combination at server start
-    * - :code:`AUTOCREATE_LANGUAGES`
-      - false[/true]
-      - Will automatically create the Language entries in the database as defined in [languages.json](ocr_translate/OCR_TSL/languages.json)
-    * - :code:`AUTOCREATE_VALIDATED_MODELS`
-      - false[/true]
-      - Will automatically create the model entries defined in code and plugins `entrypoints`.
-    * - :code:`DEVICE`
-      - cpu[/cuda]
-      - Which device to use with torch
-    * - :code:`EASYOCR_MODULE_PATH`
-      - :code:`$HOME/.EasyOCR`
-      - Directory where EasyOCR store its downloaded models
-    * - :code:`TRANSFORMERS_CACHE`
-      - :code:`$HOME/.cache/huggingface/hub/`
-      - Directory where [Hugging Face](https://huggingface.co/) models are being stored (either downloaded manually or downloaded by `transformers`)
-    * - :code:`TRANSFORMERS_OFFLINE`
-      - 1[/0]
-      - By default `transformers` will try to download missing models. Set this to 0 to only work in offline mode
-    * - :code:`TESSERACT_PREFIX`
-      - :code:`$TRANSFORMERS_CACHE/tesseract`
-      - Directory where tesseract will store and look for models
-    * - :code:`TESSERACT_ALLOW_DOWNLOAD`
-      - false[/true]
-      - Control whether the app should download missing models (true) or work in offline mode only (false)
-    * - :code:`NUM_MAIN_WORKERS`
-      - 4
-      - Number of `WorkerMessageQueue` workers handling incoming OCR_TSL post requests
-    * - :code:`NUM_BOX_WORKERS`
-      - 1
-      - Number of `WorkerMessageQueue` workers handling box_ocr pipelines (Should be set as 1 until the pipeline is build to handle multiple concurrent request efficiently without slowdowns)
-    * - :code:`NUM_OCR_WORKERS`
-      - 1
-      - Number of `WorkerMessageQueue` workers handling ocr pipelines (Should be set as 1 until the pipeline is build to handle multiple concurrent request efficiently without slowdowns)
-    * - :code:`NUM_TSL_WORKERS`
-      - 1
-      - Number of `WorkerMessageQueue` workers handling translation pipelines (Should be set as 1 until the pipeline is build to handle multiple concurrent request efficiently without slowdowns)
-
-Server variable List
---------------------
-
-Variables used specifically by the DJANGO server.
-
-.. list-table:: Title
-    :widths: 25 25 50
-    :header-rows: 1
-
-    * - Variable
-      - Values
-      - Usage
-    * - :code:`DJANGO_DEBUG`
-      - false[/true]
-      - Whether to run the server in debug (true) or production (false) mode
-    * - :code:`DJANGO_LOG_LEVEL`
-      - INFO
-      - python `logging` level for
-    * - :code:`DATABASE_NAME`
-      - *db.sqlite3*
-      - For `sqlite3` this is the path to the database file. For other backend it should be the name of the database
-    * - :code:`DATABASE_ENGINE`
-      - `django.db.backends.sqlite3`
-      - Change this to either a Django or 3rd party provided backend to use another Database type
-    * - :code:`DATABASE_HOST`
-      - optional
-      - Required if using another db back-end
-    * - :code:`DATABASE_PORT`
-      - optional
-      - Required if using another db back-end
-    * - :code:`DATABASE_USER`
-      - optional
-      - Probably required if using another db back-end
-    * - :code:`DATABASE_PASSWORD`
-      - optional
-      - Probably required if using another db back-end
+Environment Variables
+=====================
+
+The server will check a number of environment variables to configure itself.
+
+Setting Environment variables
+-----------------------------
+
+Environment variables can be set in many ways depending on the the OS and/or the tool used to launch the server.
+This is a list of common possible ways:
+
+- Windows Powershell: :code:`$env:VARIABLE_NAME = "value"` (code must be run in the same shell)
+- Windows Command Prompt: :code:`set VARIABLE_NAME=value` (code must be run in the same shell)
+  !! Do not use quotes as they will be included in the value
+- Windows Settings: :code:`Control Panel > System > Advanced System Settings > Environment Variables`
+- Linux BASH: :code:`export VARIABLE_NAME=value` (code must be run in the same shell)
+- VSCode: :code:`launch.json > env > VARIABLE_NAME`
+
+.. code-block::
+
+    {
+        "version": "0.2.0",
+        "configurations": [
+            {
+                ...
+                "env": {
+                    "VARIABLE_NAME_1": "VALUE_1",
+                    ...
+                },
+                ...
+            }
+        ]
+    }
+
+- Docker: :code:`docker run -e VARIABLE_NAME=value ...`
+- Docker: :code:`docker run --env-file .env ...` where `.env` is a file with the format
+
+.. code-block::
+
+    VARIABLE_NAME_1=VALUE_1
+    ...
+    VARIABLE_NAME_N=VALUE_N
+
+App variable List
+-----------------
+
+Variables used by the application.
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`OCT_BASE_DIR`
+      - false
+      - Path to the base directory of the project.
+
+        If no other paths are configured using environment
+
+        variables, the server database, plugin files
+
+        and downloaded models will be stored here.
+    * - :code:`LOAD_ON_START`
+      - false
+      - Will automatically load the most used
+
+        source/destination languages and most used models
+
+        for that language combination at server start
+    * - :code:`AUTOCREATE_LANGUAGES`
+      - false
+      - Will force the server to automatically
+
+        create/update the Language entries in the database.
+    * - :code:`DEVICE`
+      - cpu
+      - Which device to use for plugins that support it.
+
+        Currently allowed: cpu, cuda
+    * - :code:`NUM_MAIN_WORKERS`
+      - 4
+      - Number of `WorkerMessageQueue` workers handling
+
+        incoming OCR_TSL post requests
+    * - :code:`NUM_BOX_WORKERS`
+      - 1
+      - Number of `WorkerMessageQueue` workers handling
+
+        box_ocr pipelines (Should be set as 1 until the
+
+        pipeline is build to handle multiple concurrent
+
+        request efficiently without slowdowns)
+    * - :code:`NUM_OCR_WORKERS`
+      - 1
+      - Number of `WorkerMessageQueue` workers handling
+
+        ocr pipelines (Should be set as 1 until the
+
+        pipeline is build to handle multiple concurrent
+
+        request efficiently without slowdowns)
+    * - :code:`NUM_TSL_WORKERS`
+      - 1
+      - Number of `WorkerMessageQueue` workers handling
+
+        translation pipelines (Should be set as 1 until
+
+        the pipeline is build to handle multiple
+
+        concurrent request efficiently without slowdowns)
+
+run_server.py variable List
+---------------------------
+
+This variables are used if running the server using the provided `run_server.py` script.
+This includes the windows release file and docker image that are based on the same script.
+
+.. list-table:: Title
+    :widths: 16 16 68
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`DJANGO_SUPERUSER_USERNAME`
+      - admin
+      - Username for the superuser to be created
+    * - :code:`DJANGO_SUPERUSER_PASSWORD`
+      - password
+      - Password for the superuser to be created
+    * - :code:`OCT_DJANGO_BIND_ADDRESS`
+      - 127.0.0.1
+      - Address to bind the server to
+    * - :code:`OCT_DJANGO_PORT`
+      - 4000
+      - Port the server will listen to
+    * - :code:`OCT_GUNICORN_USER`
+      - current user
+      - User to run the server as if using gunicorn.
+    * - :code:`OCT_GUNICORN_TIMEOUT`
+      - 1200
+      - Timeout for gunicorn workers
+    * - :code:`OCT_GUNICORN_NUM_WORKERS`
+      - 1
+      - Number of gunicorn workers
+
+Plugin specific variables
+-------------------------
+
+See :doc:`plugins doc <../plugins/index>`
+
+Server variable List
+--------------------
+
+Variables used specifically by the DJANGO server.
+
+.. list-table:: Title
+    :widths: 25 25 50
+    :header-rows: 1
+
+    * - Variable
+      - Default
+      - Usage
+    * - :code:`DJANGO_DEBUG`
+      - false
+      - Whether to run the server in debug (true) or production (false) mode
+    * - :code:`DJANGO_LOG_LEVEL`
+      - INFO
+      - python `logging` level for
+    * - :code:`DATABASE_NAME`
+      - *db.sqlite3*
+      - For `sqlite3` this is the path to the database file. For other backend it should be the name of the database
+    * - :code:`DATABASE_ENGINE`
+      - `django.db.backends.sqlite3`
+      - Change this to either a Django or 3rd party provided backend to use another Database type
+    * - :code:`DATABASE_HOST`
+      - optional
+      - Required if using another db back-end
+    * - :code:`DATABASE_PORT`
+      - optional
+      - Required if using another db back-end
+    * - :code:`DATABASE_USER`
+      - optional
+      - Probably required if using another db back-end
+    * - :code:`DATABASE_PASSWORD`
+      - optional
+      - Probably required if using another db back-end
diff --git a/docs/source/user/index.rst b/docs/source/user/index.rst
index b374e55..21a4f3d 100644
--- a/docs/source/user/index.rst
+++ b/docs/source/user/index.rst
@@ -1,24 +1,16 @@
-
-User's guide
-============
-
-.. toctree::
-   :maxdepth: 2
-
-   envs
-
-Known validated plugins
------------------------
-
-- `ocr_translate-hugging_face <https://github.com/Crivella/ocr_translate-hugging_face>`_: Enables usage of HuggingFace VED models for OCR and seq2seq models for translations.
-- `ocr_translate-easyocr <https://github.com/Crivella/ocr_translate-easyocr>`_: Enables usage of easyocr for Box detection.
-- `ocr_translate-tesseract <https://github.com/Crivella/ocr_translate-tesseract>`_: Enables usage of tesseract for OCR (tesseract need to be installed on the pc).
-- `ocr_translate-google <https://github.com/Crivella/ocr_translate-google>`_: Enables usage of GoogleTranslate for translations.
-
-Supported/tested databases
---------------------------
-
-- `SQLite <https://www.sqlite.org/>`_: This is mostly fine for a self-hosted server accessed by a single  or few users (and it's probably gonna be faster than any other database not running on the same network as the server because of latency).
-- `MySQL <https://www.mysql.com/>`_
-- `MariaDB <https://mariadb.org/>`_
-- `PostgreSQL <https://www.postgresql.org/>`_
+
+User's guide
+============
+
+.. toctree::
+   :maxdepth: 2
+
+   envs
+
+Supported/tested databases
+--------------------------
+
+- `SQLite <https://www.sqlite.org/>`_: This is mostly fine for a self-hosted server accessed by a single  or few users (and it's probably gonna be faster than any other database not running on the same network as the server because of latency).
+- `MySQL <https://www.mysql.com/>`_
+- `MariaDB <https://mariadb.org/>`_
+- `PostgreSQL <https://www.postgresql.org/>`_
diff --git a/icon.ico b/icon.ico
deleted file mode 100644
index 4fd1baa..0000000
Binary files a/icon.ico and /dev/null differ
diff --git a/mysite/settings.py b/mysite/settings.py
index 6294680..9bf45bd 100644
--- a/mysite/settings.py
+++ b/mysite/settings.py
@@ -31,6 +31,10 @@
 import os
 from pathlib import Path
 
+from ocr_translate.plugin_manager import PluginManager
+
+PMNG = PluginManager()
+
 # Build paths inside the project like this: BASE_DIR / 'subdir'.
 BASE_DIR = Path(__file__).resolve().parent.parent
 
@@ -59,7 +63,7 @@
     },
     'handlers': {
         'console': {
-            'level': os.environ.get('DJANGO_LOG_LEVEL', 'INFO'),
+            'level': os.environ.get('DJANGO_LOG_LEVEL', 'INFO').upper(),
             'filters': ['require_debug_true'],
             'class': 'logging.StreamHandler',
             'formatter': 'medium',
@@ -68,7 +72,7 @@
     'loggers': {
         'django': {
             'handlers': ['console'],
-            'propagate': True,
+            'propagate': False,
         },
         'django.request': {
             'handlers': ['console'],
@@ -79,16 +83,19 @@
             'handlers': ['console'],
             'level': 'DEBUG',
             'filters': ['require_debug_true'],
+            'propagate': False,
         },
         'ocr.worker': {
             'handlers': ['console'],
             'level': 'DEBUG',
             'filters': ['require_debug_true'],
+            'propagate': False,
         },
         'plugin': {
             'handlers': ['console'],
             'level': 'DEBUG',
             'filters': ['require_debug_true'],
+            'propagate': False,
         },
     },
 }
@@ -104,10 +111,10 @@
 DEBUG = os.environ.get('DJANGO_DEBUG', '').lower() == 'true'
 
 ALLOWED_HOSTS = ['localhost', '127.0.0.1']
+ALLOWED_HOSTS += os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(';')
 
 
 # Application definition
-
 INSTALLED_APPS = [
     'django.contrib.admin',
     'django.contrib.auth',
@@ -116,11 +123,7 @@
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'ocr_translate',
-    # 'ocr_translate_tesseract',
-    # 'ocr_translate_easyocr',
-    # 'ocr_translate_google',
-    # 'ocr_translate_hugging_face',
-]
+] + PMNG.plugins
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
@@ -173,6 +176,11 @@
     # https://stackoverflow.com/questions/2108824/mysql-incorrect-string-value-error-when-save-unicode-string-in-django
     OPTIONS['charset'] = 'utf8mb4'
 
+if DATABASE_ENGINE == 'django.db.backends.sqlite3':
+    ptr = {}
+    DATABASE['OPTIONS'] = ptr
+    ptr['init_command'] = 'PRAGMA journal_mode=wal;'
+
 DATABASES = {
     'default': DATABASE
 }
diff --git a/nginx.default b/nginx.default
index f959cf2..1adac6c 100644
--- a/nginx.default
+++ b/nginx.default
@@ -7,6 +7,7 @@ server {
         proxy_pass http://127.0.0.1:4010;
         proxy_set_header Host $host;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_connect_timeout 1200s;
     }
     location /static {
         root /opt/app;
diff --git a/ocr_translate/__init__.py b/ocr_translate/__init__.py
index 9e31fea..c353817 100644
--- a/ocr_translate/__init__.py
+++ b/ocr_translate/__init__.py
@@ -18,5 +18,5 @@
 ###################################################################################
 """OCR and translation of images."""
 
-__version__ = '0.5.1'
+__version__ = '0.6.0'
 __version__array__ = [int(_) for _ in __version__.split('.')]
diff --git a/ocr_translate/admin.py b/ocr_translate/admin.py
index 72561fd..342a4c0 100644
--- a/ocr_translate/admin.py
+++ b/ocr_translate/admin.py
@@ -25,6 +25,21 @@
 from . import models as m
 
 
+class AllowEmptyJSONField(forms.JSONField):
+    """JSONField that allows empty values"""
+    empty_values = [None, '', [], ()]
+
+class OptionDictForm(forms.ModelForm):
+    """Form for OptionDict model"""
+    options = AllowEmptyJSONField(required=False)
+
+@admin.register(m.OptionDict)
+class OptionDictAdmin(admin.ModelAdmin):
+    """Admin interface for OptionDict model"""
+    list_display = ('options',)
+    form = OptionDictForm
+
+@admin.register(m.Language)
 class LanguageAdmin(admin.ModelAdmin):
     """Admin interface for Language model"""
     list_display = ('name', 'iso1', 'iso2b', 'iso2t', 'iso3')
@@ -34,38 +49,39 @@ class OCRBoxModelForm(forms.ModelForm):
     entrypoint = forms.ChoiceField(
         choices=[(ep.name, ep.name) for ep in entry_points(group=m.OCRBoxModel.entrypoint_namespace)]
         )
+    iso1_map = AllowEmptyJSONField(required=False)
 
 class OCRModelForm(forms.ModelForm):
     """Form for OCRModel model"""
     entrypoint = forms.ChoiceField(
         choices=[(ep.name, ep.name) for ep in entry_points(group=m.OCRModel.entrypoint_namespace)]
         )
+    iso1_map = AllowEmptyJSONField(required=False)
 
 class TSLModelForm(forms.ModelForm):
     """Form for TSLModel model"""
     entrypoint = forms.ChoiceField(
         choices=[(ep.name, ep.name) for ep in entry_points(group=m.TSLModel.entrypoint_namespace)]
         )
+    iso1_map = AllowEmptyJSONField(required=False)
+
+@admin.register(m.OCRBoxModel)
 class OCRBoxModelAdmin(admin.ModelAdmin):
     """Admin interface for OCRBoxModel model"""
     list_display = ('name',)
     filter_horizontal = ('languages',)
     form = OCRBoxModelForm
 
+@admin.register(m.OCRModel)
 class OCRModelAdmin(admin.ModelAdmin):
     """Admin interface for OCRModel model"""
     list_display = ('name',)
     filter_horizontal = ('languages',)
     form = OCRModelForm
 
+@admin.register(m.TSLModel)
 class TSLModelAdmin(admin.ModelAdmin):
     """Admin interface for TSLModel model"""
     list_display = ('name',)
     filter_horizontal = ('src_languages', 'dst_languages')
     form = TSLModelForm
-
-
-admin.site.register(m.Language, LanguageAdmin)
-admin.site.register(m.OCRBoxModel, OCRBoxModelAdmin)
-admin.site.register(m.OCRModel, OCRModelAdmin)
-admin.site.register(m.TSLModel, TSLModelAdmin)
diff --git a/ocr_translate/entrypoint_manager.py b/ocr_translate/entrypoint_manager.py
new file mode 100644
index 0000000..e040a5b
--- /dev/null
+++ b/ocr_translate/entrypoint_manager.py
@@ -0,0 +1,98 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Manages changes in entrypoints after installation/removal of plugins."""
+
+import contextlib
+import logging
+from importlib.metadata import EntryPoint, entry_points
+
+from . import models as m
+from .ocr_tsl import initializers as ini
+from .ocr_tsl.box import get_box_model, unload_box_model
+from .ocr_tsl.ocr import get_ocr_model, unload_ocr_model
+from .ocr_tsl.signals import refresh_model_cache_signal
+from .ocr_tsl.tsl import get_tsl_model, unload_tsl_model
+
+logger = logging.getLogger('ocr.general')
+
+def get_group_entrypoints(group: str) -> set[EntryPoint]:
+    """Get all entrypoints for a group"""
+    return set(ep for ep in entry_points(group=group))
+
+GROUPS = {
+    'ocr_translate.box_data': (m.OCRBoxModel, ini.add_box_model, get_box_model, unload_box_model),
+    'ocr_translate.ocr_data': (m.OCRModel, ini.add_ocr_model, get_ocr_model, unload_ocr_model),
+    'ocr_translate.tsl_data': (m.TSLModel, ini.add_tsl_model, get_tsl_model, unload_tsl_model),
+}
+
+@contextlib.contextmanager
+def ep_manager():
+    """Context manager for entrypoint management.
+    Provides a context that monitors changes to server server specific entrypoints.
+    Make sures models from added entrypoints are created/activated and models from removed
+    entrypoints are deactivated.
+    """
+    before: dict[str, set] = {}
+    for grp in GROUPS:
+        before[grp] = get_group_entrypoints(grp)
+
+    yield
+
+    after: dict[str, set] = {}
+    for grp in GROUPS:
+        after[grp] = get_group_entrypoints(grp)
+
+    flag = False
+    for grp, data in GROUPS.items():
+        cls, create_func, get_func, unload_func = data
+        loaded_model = get_func()
+        added = after[grp] - before[grp]
+        # Using after to ensure that when using same plugin folder with new db, the models are created
+        for ept in after[grp]:
+            if ept in added:
+                logger.info(f'New entrypoint {ept.name} found')
+            data = ept.load()
+            model_id = data['name']
+            try:
+                model = cls.objects.get(name=model_id)
+            except cls.DoesNotExist:
+                model = create_func(data.copy())
+            model.active = True
+            model.save()
+
+        removed = before[grp] - after[grp]
+        for ept in removed:
+            data = ept.load()
+            model_id = data['name']
+            try:
+                model = cls.objects.get(name=model_id)
+                if loaded_model is not None:
+                    if loaded_model.name == model.name:
+                        unload_func()
+                model.active = False
+                model.save()
+                logger.info(f'Entrypoint {ept.name} removed')
+            except cls.DoesNotExist:
+                logger.warning(f'Could not find model {model_id} to deactivate')
+                continue
+
+        flag = flag or bool(added or removed)
+
+    if flag:
+        refresh_model_cache_signal.send(sender=None)
diff --git a/ocr_translate/messaging.py b/ocr_translate/messaging.py
index 287a77c..19b53e4 100644
--- a/ocr_translate/messaging.py
+++ b/ocr_translate/messaging.py
@@ -59,9 +59,12 @@ def resolve(self):
         try:
             self._response = self.handler(*self.msg.get('args', ()), **self.msg.get('kwargs', {}))
         except Exception as exc:
+            logger.error(f'Error resolving message {self.msg}', exc_info=True)
             self._response = exc
-            raise
-        logger.debug(f'MSG Resolved {self.msg} -> {self._response}')
+            # Avoid killing the worker thread
+            # raise
+        else:
+            logger.debug(f'MSG Resolved {self.msg} -> {self._response}')
 
         # Make sure to dereference the message to avoid keeping raw images in memory
         # since i am gonna keep the message in the queue after it is resolved (for msg caching)
diff --git a/ocr_translate/migrations/0017_alter_ocrboxmodel_iso1_map_alter_ocrmodel_iso1_map_and_more.py b/ocr_translate/migrations/0017_alter_ocrboxmodel_iso1_map_alter_ocrmodel_iso1_map_and_more.py
new file mode 100644
index 0000000..a07a756
--- /dev/null
+++ b/ocr_translate/migrations/0017_alter_ocrboxmodel_iso1_map_alter_ocrmodel_iso1_map_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.3 on 2023-12-17 20:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ocr_translate', '0010_rename_from_ocr_bbox_from_ocr_merged_squashed_0016_alter_ocrrun_result_merged'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ocrboxmodel',
+            name='iso1_map',
+            field=models.JSONField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='ocrmodel',
+            name='iso1_map',
+            field=models.JSONField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='tslmodel',
+            name='iso1_map',
+            field=models.JSONField(blank=True, null=True),
+        ),
+    ]
diff --git a/ocr_translate/migrations/0018_ocrmodel_processor_name_ocrmodel_tokenizer_name.py b/ocr_translate/migrations/0018_ocrmodel_processor_name_ocrmodel_tokenizer_name.py
new file mode 100644
index 0000000..22bd1ea
--- /dev/null
+++ b/ocr_translate/migrations/0018_ocrmodel_processor_name_ocrmodel_tokenizer_name.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.3 on 2024-02-01 00:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ocr_translate', '0017_alter_ocrboxmodel_iso1_map_alter_ocrmodel_iso1_map_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ocrmodel',
+            name='processor_name',
+            field=models.CharField(max_length=128, null=True),
+        ),
+        migrations.AddField(
+            model_name='ocrmodel',
+            name='tokenizer_name',
+            field=models.CharField(max_length=128, null=True),
+        ),
+    ]
diff --git a/ocr_translate/migrations/0019_ocrboxmodel_active_ocrmodel_active_tslmodel_active.py b/ocr_translate/migrations/0019_ocrboxmodel_active_ocrmodel_active_tslmodel_active.py
new file mode 100644
index 0000000..2bbe9fd
--- /dev/null
+++ b/ocr_translate/migrations/0019_ocrboxmodel_active_ocrmodel_active_tslmodel_active.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.1 on 2024-08-10 00:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ocr_translate', '0018_ocrmodel_processor_name_ocrmodel_tokenizer_name'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ocrboxmodel',
+            name='active',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='ocrmodel',
+            name='active',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='tslmodel',
+            name='active',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/ocr_translate/models.py b/ocr_translate/models.py
index 6774445..d3b0b7b 100644
--- a/ocr_translate/models.py
+++ b/ocr_translate/models.py
@@ -61,6 +61,10 @@ def __eq__(self, other):
             return self.iso1 == other
         return False
 
+    # https://stackoverflow.com/questions/61212514/django-model-objects-became-not-hashable-after-upgrading-to-django-2-2
+    def __hash__(self):
+        return hash(self.iso1)
+
 class BaseModel(models.Model):
     """Mixin class for loading entrypoint models"""
     class Meta:
@@ -78,7 +82,9 @@ class Meta:
     entrypoint = models.CharField(max_length=128, null=True)
 
     language_format = models.CharField(max_length=32, null=True)
-    iso1_map = models.JSONField(null=True)
+    iso1_map = models.JSONField(null=True, blank=True)
+
+    active = models.BooleanField(default=False)
 
     default_options = models.ForeignKey(
         OptionDict, on_delete=models.SET_NULL, related_name='used_by_%(class)s', null=True
@@ -140,6 +146,8 @@ class OCRModel(BaseModel):
 
     languages = models.ManyToManyField(Language, related_name='ocr_models')
     ocr_mode = models.CharField(max_length=32, choices=OCR_MODE_CHOICES, default=MERGED)
+    tokenizer_name = models.CharField(max_length=128, null=True)
+    processor_name = models.CharField(max_length=128, null=True)
 
     def prepare_image(
             self,
@@ -301,7 +309,7 @@ def ocr(
                 raise ValueError('Image is required for OCR')
             logger.info('Running OCR')
 
-            id_ = (bbox_obj.id, self.id, lang.id)
+            id_ = (bbox_obj.id, self.id, lang.id, options_obj.id)
             mlang = self.get_lang_code(lang)
             opt_dct = options_obj.options
             text = queues.ocr_queue.put(
@@ -416,7 +424,7 @@ def box_detection( # pylint: disable=too-many-locals
                 raise ValueError('Image is required for BBox OCR')
             logger.info('Running BBox OCR')
             opt_dct = options_obj.options
-            id_ = (img_obj.id, self.id, lang.id)
+            id_ = (img_obj.id, self.id, lang.id, options_obj.id)
             bboxes = queues.box_queue.put(
                 id_=id_,
                 handler=self._box_detection,
@@ -643,7 +651,7 @@ def find_manual(self,  text_obj: 'Text', src: 'Language', dst: 'Language') -> 'T
         Returns:
             m.TranslationRun: The TranslationRun object from the database.
         """
-        manual_model = TSLModel.objects.filter(name='manual').first()
+        manual_model, _ = TSLModel.objects.get_or_create(name='manual')
         params = {
             'model': manual_model,
             'lang_src': src,
@@ -703,8 +711,8 @@ def translate(
                 raise ValueError('Value not found for lazy TSL run')
             logger.info('Running TSL')
             # Generate a unique id for a message
-            id_ = (text_obj.id, self.id, options_obj.id, src.id, dst.id)
-            batch_id = (self.id, options_obj.id, src.id, dst.id)
+            id_ = (text_obj.id, self.id, options_obj.id, src.id, dst.id, options_obj.id)
+            batch_id = (self.id, options_obj.id, src.id, dst.id, options_obj.id)
             lang_dct = getattr(src.default_options, 'options', {})
             model_dct =  getattr(self.default_options, 'options', {})
             opt_dct = {**lang_dct, **model_dct, **options_obj.options}
diff --git a/ocr_translate/ocr_tsl/__init__.py b/ocr_translate/ocr_tsl/__init__.py
index 70d1142..7f7e6c3 100644
--- a/ocr_translate/ocr_tsl/__init__.py
+++ b/ocr_translate/ocr_tsl/__init__.py
@@ -19,15 +19,28 @@
 """Initialize the server based on environment variables."""
 
 import os
+from typing import Callable
 
-from .initializers import (auto_create_languages, auto_create_models,
-                           init_most_used)
+from django.db.utils import OperationalError
 
-if os.environ.get('AUTOCREATE_LANGUAGES', 'false').lower() == 'true':
-    auto_create_languages()
+from .initializers import auto_create_languages, init_most_used
 
-if os.environ.get('AUTOCREATE_VALIDATED_MODELS', 'false').lower() == 'true':
-    auto_create_models()
+FAIL = False
 
-if os.environ.get('LOAD_ON_START', 'false').lower() == 'true':
-    init_most_used()
+def run_on_env(env_name: str, func: Callable):
+    """Run a function if the environment variable is set."""
+    global FAIL
+    if os.environ.get(env_name, 'false').lower() == 'true':
+        try:
+            func()
+            print(f'INFO: Ran `{func.__name__}` based on environment variable `{env_name}`')
+        except OperationalError as exc:
+            FAIL = True
+            print(f'WARNING: Ignoring environment variable `{env_name}` as the database is not ready/migrated.')
+            print(f'WARNING: {exc}')
+
+run_on_env('AUTOCREATE_LANGUAGES', auto_create_languages)
+run_on_env('LOAD_ON_START', init_most_used)
+
+if FAIL:
+    print('WARNING: Create/migrate the database by running `python manage.py migrate`')
diff --git a/ocr_translate/ocr_tsl/cached_lists.py b/ocr_translate/ocr_tsl/cached_lists.py
new file mode 100644
index 0000000..2175da4
--- /dev/null
+++ b/ocr_translate/ocr_tsl/cached_lists.py
@@ -0,0 +1,108 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Caches for list of models to be sent to the frontend."""
+from django.db.models import Count
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from .. import models as m
+from .lang import get_lang_dst, get_lang_src
+from .signals import refresh_model_cache_signal
+
+# Caches for the languages ordered by number of translations
+ALL_LANG_SRC: list[m.Language] = None
+ALL_LANG_DST: list[m.Language] = None
+
+ALLOWED_BOX_MODELS: list[m.OCRBoxModel] = None
+ALLOWED_OCR_MODELS: list[m.OCRModel] = None
+ALLOWED_TSL_MODELS: list[m.TSLModel] = None
+
+def refresh_model_cache():
+    """Refresh the models cached entry."""
+    global ALLOWED_BOX_MODELS
+    global ALLOWED_OCR_MODELS
+    global ALLOWED_TSL_MODELS
+
+    lang_src = get_lang_src()
+    lang_dst = get_lang_dst()
+
+    ALLOWED_BOX_MODELS = []
+    ALLOWED_OCR_MODELS = []
+    ALLOWED_TSL_MODELS = []
+    if not lang_src is None:
+        ALLOWED_BOX_MODELS = m.OCRBoxModel.objects.annotate(
+            count=Count('box_runs')
+            ).filter(languages=lang_src, active=True).order_by('-count').all()
+        ALLOWED_OCR_MODELS = m.OCRModel.objects.annotate(
+            count=Count('ocr_runs')
+            ).filter(languages=lang_src, active=True).order_by('-count').all()
+        if not lang_dst is None:
+            ALLOWED_TSL_MODELS = m.TSLModel.objects.annotate(
+                count=Count('tsl_runs')
+                ).filter(src_languages=lang_src, dst_languages=lang_dst, active=True).order_by('-count').all()
+
+def refresh_lang_cache():
+    """Refresh the languages cached entry."""
+    global ALL_LANG_SRC
+    global ALL_LANG_DST
+    ALL_LANG_SRC = m.Language.objects.annotate(count=Count('trans_src')).order_by('-count').all()
+    ALL_LANG_DST = m.Language.objects.annotate(count=Count('trans_dst')).order_by('-count').all()
+
+def get_all_lang_src():
+    """Return all the source languages."""
+    if ALL_LANG_SRC is None:
+        refresh_lang_cache()
+    return ALL_LANG_SRC
+
+def get_all_lang_dst():
+    """Return all the destination languages."""
+    if ALL_LANG_DST is None:
+        refresh_lang_cache()
+    return ALL_LANG_DST
+
+def get_allowed_box_models():
+    """Return the allowed box models."""
+    if ALLOWED_BOX_MODELS is None:
+        refresh_model_cache()
+    return ALLOWED_BOX_MODELS
+
+def get_allowed_ocr_models():
+    """Return the allowed OCR models."""
+    if ALLOWED_OCR_MODELS is None:
+        refresh_model_cache()
+    return ALLOWED_OCR_MODELS
+
+def get_allowed_tsl_models():
+    """Return the allowed TSL models."""
+    if ALLOWED_TSL_MODELS is None:
+        refresh_model_cache()
+    return ALLOWED_TSL_MODELS
+
+@receiver(post_save, sender=m.Language)
+def refres_lang_callback(sender, instance, **kwargs): # pylint: disable=unused-argument
+    """Callback to refresh the cached language list when a language is added/modified."""
+    refresh_lang_cache()
+
+@receiver(refresh_model_cache_signal)
+@receiver(post_save, sender=m.OCRBoxModel)
+@receiver(post_save, sender=m.OCRModel)
+@receiver(post_save, sender=m.TSLModel)
+def refresh_models_callback(sender, **kwargs): # pylint: disable=unused-argument
+    """Callback to refresh the cached model list when a language is added/modified."""
+    refresh_model_cache()
diff --git a/ocr_translate/ocr_tsl/initializers.py b/ocr_translate/ocr_tsl/initializers.py
index 907a11a..8991f66 100644
--- a/ocr_translate/ocr_tsl/initializers.py
+++ b/ocr_translate/ocr_tsl/initializers.py
@@ -25,6 +25,7 @@
 from django.db.models import Count
 
 from .. import models as m
+from ..plugin_manager import PluginManager
 from .box import load_box_model
 from .lang import load_lang_dst, load_lang_src
 from .ocr import load_ocr_model
@@ -56,7 +57,9 @@ def init_most_used():
 
 def auto_create_languages():
     """Create Language objects from json file."""
-    langs = json.load(resources.files('ocr_translate.ocr_tsl').joinpath('languages.json').open(encoding='utf-8'))
+    lang_file = resources.files('ocr_translate.ocr_tsl').joinpath('languages.json')
+    with lang_file.open(encoding='utf-8') as f:
+        langs = json.load(f)
 
     for lang in langs:
         logger.debug(f'Creating language: {lang}')
@@ -73,94 +76,95 @@ def auto_create_languages():
         #     setattr(l, k, v)
         l.save()
 
+    m.OptionDict.objects.get_or_create(options={})
+
 def load_ept_data(namespace):
     """Load all entrypoints from a namespace into a list"""
-    res = []
-
-    for ept in entry_points(group=namespace):
-        # Copy required for pop on dct
-        res.append(ept.load().copy())
-
-    return res
+    return[_.load() for _ in entry_points(group=namespace)]
 
 # Pop and set after so that running this after a migration should modify the existing model (with less
 # attributes) instead of creating a new one
-def auto_create_box():
-    """Create OCRBoxModel objects from entrypoints."""
-    for box in load_ept_data('ocr_translate.box_data'):
-        logger.debug(f'Creating box model: {box}')
-        lang = box.pop('lang')
-        lcode = box.pop('lang_code')
-        entrypoint = box.pop('entrypoint')
-        iso1_map = box.pop('iso1_map', {})
-        def_opt = box.pop('default_options', {})
-        opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
-        model, _ = m.OCRBoxModel.objects.get_or_create(**box)
-        model.default_options = opt_obj
-        model.entrypoint = entrypoint
-        model.language_format = lcode
-        model.iso1_map = iso1_map
-        model.languages.clear()
-        for l in lang:
-            model.languages.add(m.Language.objects.get(iso1=l))
-        model.save()
-
-def auto_create_ocr():
-    """Create OCRModel objects from entrypoints."""
-    for ocr in load_ept_data('ocr_translate.ocr_data'):
-        logger.debug(f'Creating ocr model: {ocr}')
-        lang = ocr.pop('lang')
-        lcode = ocr.pop('lang_code')
-        ocr_mode = ocr.pop('ocr_mode', m.OCRModel.MERGED)
-        entrypoint = ocr.pop('entrypoint')
-        iso1_map = ocr.pop('iso1_map', {})
-        def_opt = ocr.pop('default_options', {})
-        opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
-        model, _ = m.OCRModel.objects.get_or_create(**ocr)
-        model.default_options = opt_obj
-        model.language_format = lcode
-        model.ocr_mode = ocr_mode
-        model.iso1_map = iso1_map
-        model.entrypoint = entrypoint
-        model.languages.clear()
-        for l in lang:
-            model.languages.add(m.Language.objects.get(iso1=l))
-        model.save()
-
-def auto_create_tsl():
-    """Create TSLModel objects from entrypoints."""
-    for tsl in load_ept_data('ocr_translate.tsl_data'):
-        logger.debug(f'Creating tsl model: {tsl}')
-        src = tsl.pop('lang_src', [])
-        dst = tsl.pop('lang_dst', [])
-        lcode = tsl.pop('lang_code', None)
-        entrypoint = tsl.pop('entrypoint', None)
-        iso1_map = tsl.pop('iso1_map', {})
-        def_opt = tsl.pop('default_options', {})
-        opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
-        model, _ = m.TSLModel.objects.get_or_create(**tsl)
-        model.default_options = opt_obj
-        model.language_format = lcode
-        model.iso1_map = iso1_map
-        model.entrypoint = entrypoint
-        model.src_languages.clear()
-        for l in src:
-            logger.debug(f'Adding src language: {l}')
-            kwargs = {lcode: l}
-            model.src_languages.add(*m.Language.objects.filter(**kwargs))
-
-        model.dst_languages.clear()
-        for l in dst:
-            logger.debug(f'Adding dst language: {l}')
-            kwargs = {lcode: l}
-            model.dst_languages.add(*m.Language.objects.filter(**kwargs))
-        model.save()
-
-def auto_create_models():
-    """Create OCR and TSL models from json file. Also create default OptionDict"""
-    logger.info('Creating default models')
-    auto_create_box()
-    auto_create_ocr()
-    auto_create_tsl()
-
-    m.OptionDict.objects.get_or_create(options={})
+def add_box_model(ep_dict: dict) -> m.OCRBoxModel:
+    """Create OCRBoxModel object from dict."""
+    ep_dict = ep_dict.copy()
+    logger.debug(f'Creating box model: {ep_dict}')
+    lang = ep_dict.pop('lang')
+    lcode = ep_dict.pop('lang_code')
+    entrypoint = ep_dict.pop('entrypoint')
+    iso1_map = ep_dict.pop('iso1_map', {})
+    def_opt = ep_dict.pop('default_options', {})
+    opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
+    model, _ = m.OCRBoxModel.objects.get_or_create(**ep_dict)
+    model.default_options = opt_obj
+    model.entrypoint = entrypoint
+    model.language_format = lcode
+    model.iso1_map = iso1_map
+    model.languages.clear()
+    for l in lang:
+        model.languages.add(m.Language.objects.get(iso1=l))
+    model.save()
+    return model
+
+def add_ocr_model(ep_dict: dict) -> m.OCRModel:
+    """Create OCRModel object from dict."""
+    ep_dict = ep_dict.copy()
+    logger.debug(f'Creating ocr model: {ep_dict}')
+    lang = ep_dict.pop('lang')
+    lcode = ep_dict.pop('lang_code')
+    ocr_mode = ep_dict.pop('ocr_mode', m.OCRModel.MERGED)
+    entrypoint = ep_dict.pop('entrypoint')
+    iso1_map = ep_dict.pop('iso1_map', {})
+    def_opt = ep_dict.pop('default_options', {})
+    opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
+    model, _ = m.OCRModel.objects.get_or_create(**ep_dict)
+    model.default_options = opt_obj
+    model.language_format = lcode
+    model.ocr_mode = ocr_mode
+    model.iso1_map = iso1_map
+    model.entrypoint = entrypoint
+    model.languages.clear()
+    for l in lang:
+        model.languages.add(m.Language.objects.get(iso1=l))
+    model.save()
+    return model
+
+def add_tsl_model(ep_dict: dict) -> m.TSLModel:
+    """Create TSLModel object from dict."""
+    ep_dict = ep_dict.copy()
+    logger.debug(f'Creating tsl model: {ep_dict}')
+    src = ep_dict.pop('lang_src', [])
+    dst = ep_dict.pop('lang_dst', [])
+    lcode = ep_dict.pop('lang_code', None)
+    entrypoint = ep_dict.pop('entrypoint', None)
+    iso1_map = ep_dict.pop('iso1_map', {})
+    def_opt = ep_dict.pop('default_options', {})
+    opt_obj, _ = m.OptionDict.objects.get_or_create(options=def_opt)
+    model, _ = m.TSLModel.objects.get_or_create(**ep_dict)
+    model.default_options = opt_obj
+    model.language_format = lcode
+    model.iso1_map = iso1_map
+    model.entrypoint = entrypoint
+    model.src_languages.clear()
+    for l in src:
+        logger.debug(f'Adding src language: {l}')
+        kwargs = {lcode: l}
+        model.src_languages.add(*m.Language.objects.filter(**kwargs))
+
+    model.dst_languages.clear()
+    for l in dst:
+        logger.debug(f'Adding dst language: {l}')
+        kwargs = {lcode: l}
+        model.dst_languages.add(*m.Language.objects.filter(**kwargs))
+    model.save()
+    return model
+
+def ensure_plugins():
+    """Ensure that all plugins are installed on initialization.
+    This is used to make sure that running the server with a new DEVICE will have the correct dependencies installed."""
+    logger.info('Ensuring that all plugins are loaded')
+    pmng = PluginManager()
+    known = set(_['name'] for _ in pmng.plugins_data)
+    installed = set(pmng.plugins)
+    for plugin in known:
+        if plugin in installed:
+            pmng.install_plugin(plugin)
diff --git a/ocr_translate/ocr_tsl/lang.py b/ocr_translate/ocr_tsl/lang.py
index 14866ad..be53d04 100644
--- a/ocr_translate/ocr_tsl/lang.py
+++ b/ocr_translate/ocr_tsl/lang.py
@@ -18,9 +18,10 @@
 ###################################################################################
 """Manages the globaly selected source and destination language."""
 from .. import models as m
+from .signals import refresh_model_cache_signal
 
-LANG_SRC = None
-LANG_DST = None
+LANG_SRC: m.Language = None
+LANG_DST: m.Language = None
 
 def get_lang_src():
     """Return the source language."""
@@ -34,8 +35,10 @@ def load_lang_src(iso1):
     """Load the source language."""
     global LANG_SRC
     LANG_SRC = m.Language.objects.get(iso1=iso1)
+    refresh_model_cache_signal.send(sender=None)
 
 def load_lang_dst(iso1):
     """Load the destination language."""
     global LANG_DST
     LANG_DST = m.Language.objects.get(iso1=iso1)
+    refresh_model_cache_signal.send(sender=None)
diff --git a/ocr_translate/ocr_tsl/signals.py b/ocr_translate/ocr_tsl/signals.py
new file mode 100644
index 0000000..3922a7d
--- /dev/null
+++ b/ocr_translate/ocr_tsl/signals.py
@@ -0,0 +1,22 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Django signals for the ocr_translate app."""
+import django.dispatch
+
+refresh_model_cache_signal = django.dispatch.Signal()
diff --git a/ocr_translate/ocr_tsl/tsl.py b/ocr_translate/ocr_tsl/tsl.py
index 93532b7..2b67882 100644
--- a/ocr_translate/ocr_tsl/tsl.py
+++ b/ocr_translate/ocr_tsl/tsl.py
@@ -55,7 +55,3 @@ def load_tsl_model(model_id):
 def get_tsl_model() -> m.TSLModel:
     """Get the current TSL model."""
     return TSL_MODEL_OBJ
-
-manual_data = {
-    'name': 'manual',
-}
diff --git a/ocr_translate/plugin_manager.py b/ocr_translate/plugin_manager.py
new file mode 100644
index 0000000..b494412
--- /dev/null
+++ b/ocr_translate/plugin_manager.py
@@ -0,0 +1,405 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Utils to manage ocr_translate plugins."""
+
+# pylint: disable=redefined-outer-name,unspecified-encoding,too-many-branches
+
+import functools
+import json
+import logging
+import os
+import platform
+import shutil
+import site
+import subprocess
+import sys
+from importlib import resources
+from pathlib import Path
+from threading import Lock
+
+from django.apps import apps
+from django.conf import settings
+
+GENERIC_SCOPE = 'generic'
+DEFAULT_OCT_BASE_DIR: Path = Path.home() / '.ocr_translate'
+
+logger = logging.getLogger('ocr.general')
+
+def find_site_packages(start_dir: Path) -> Path:
+    """Find the site-packages directory."""
+    for pth in start_dir.rglob('site-packages'):
+        return pth
+    return None
+
+def reload_django_apps():
+    """Reload the plugins."""
+    apps.app_configs = {}
+    apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+    apps.clear_cache()
+    apps.populate(settings.INSTALLED_APPS)
+
+def get_safe_name(name: str) -> str:
+    """Get a safe name for the plugin."""
+    return name.replace('-', '_min_')
+
+def install_overrides_decorator(func):
+    """Decorator to install a package."""
+    arg_names = ['version', 'extras', 'scope', 'system']
+
+    @functools.wraps(func)
+    def wrapped(*args, **kwargs):
+        args = list(args)
+        cls = args.pop(0)
+        if args:
+            if 'name' in kwargs:
+                raise TypeError('Got multiple values for argument name')
+            name = args.pop(0)
+        elif 'name' in kwargs:
+            name = kwargs.pop('name')
+        else:
+            raise ValueError('Name required')
+
+        new_args = {'name': name}
+        for val,key in zip(args, arg_names):
+            new_args[key] = val
+        for key, val in kwargs.items():
+            if key in new_args:
+                raise  TypeError(f'Got multiple values for argument {key}')
+            new_args[key] = val
+
+        safe_name = get_safe_name(name).upper()
+        for key in arg_names:
+            over_name = f'OCT_PKG_{safe_name}_{key.upper()}'
+            if over_name in os.environ:
+                new_args[key] = os.environ[over_name]
+
+        return func(cls, **new_args)
+
+    return wrapped
+
+def _pip_install(package: str, prefix: str, extras: list[str] = None):
+    """Install a package with pip."""
+    if extras is None:
+        extras = []
+    logger.info(f'Installing {package}')
+    cmd = [
+        'pip', 'install', package,
+        '--ignore-installed',
+        '--no-deps',
+        '--no-build-isolation',
+        f'--prefix={prefix}',
+        ] + extras
+    try:
+        res = subprocess.run(cmd, check=True, capture_output=True)
+    except subprocess.CalledProcessError as exc:
+        logger.error(f'Error installing {package}')
+        logger.error(exc.stdout.decode())
+        logger.error(exc.stderr.decode())
+        raise exc
+    logger.debug(res.stdout.decode())
+
+class PluginManager:  # pylint: disable=too-many-instance-attributes
+    """Manage the plugins."""
+    _SINGLETON = None
+
+    device: str = None
+    scopes: list[str] = None
+
+    base_dir: Path = None
+    plugin_dir: Path = None
+    PLUGIN_SP: dict[str, Path] = {}
+
+    plugin_list_file: Path = None
+    installed_file: Path = None
+
+    def __new__(cls, *args, **kwargs):
+        if not cls._SINGLETON:
+            cls._SINGLETON = super().__new__(cls, *args, **kwargs)
+        return cls._SINGLETON
+
+    def __init__(self):
+        """Initialize the plugin manager."""
+        self.device = os.environ.get('DEVICE', 'cpu')
+        self.scopes = [GENERIC_SCOPE, self.device]
+        self.base_dir = Path(os.environ.get('OCT_BASE_DIR', DEFAULT_OCT_BASE_DIR))
+        self.plugin_dir = self.base_dir / 'plugins'
+
+        self.plugin_list_file = self.base_dir / 'plugins.json'
+        self.installed_file = self.plugin_dir / 'installed.json'
+
+        self.disabled = os.environ.get('OCT_DISABLE_PLUGINS', False)
+
+        self.lock_plugin = Lock()
+        self.lock_pkg = Lock()
+
+        self._plugins = None
+        self._plugin_data = None
+        self._installed = None
+        self.system = platform.system().lower()
+
+        self.ensure_pip()
+        self.initialize_scopes()
+
+        # Ensure no previous empty or wrong site-packages dirs are present
+        while (tmp_dir := find_site_packages(self.plugin_dir)) is not None:
+            shutil.rmtree(tmp_dir)
+
+    def ensure_pip(self):
+        """Ensure pip is installed."""
+        if self.disabled:
+            return
+        try:
+            subprocess.run(['pip', '-V'], capture_output=True, check=True)
+        except FileNotFoundError as exc:
+            msg = ' ---- '.join([
+                '!!!!!'
+                'pip not found, please install a version of python https://www.python.org/downloads/'
+                'Make sure to check the `Add python.exe to PATH` checkbox during installation.'
+            ])
+            logger.error(msg)
+            raise FileNotFoundError(msg) from exc
+
+    @property
+    def plugins_data(self) -> list[dict]:
+        """Get/cache the plugin data."""
+        if self.disabled:
+            return []
+        if self._plugin_data is None:
+            data_file = resources.files('ocr_translate') / 'plugins_data.json'
+            if not data_file.exists():
+                self._plugin_data = []
+            else:
+                with open(data_file) as f:
+                    self._plugin_data = json.load(f)
+        return self._plugin_data
+
+    def get_plugin_data(self, name: str) -> dict:
+        """Get the data for a specific plugin."""
+        for plugin in self.plugins_data:
+            if plugin['name'] == name:
+                return plugin
+        return {}
+
+    def initialize_scopes(self):
+        """Configure the scopes."""
+        if self.disabled:
+            return
+        for scope in self.scopes:
+            pth = self.plugin_dir / scope
+            pth.mkdir(exist_ok=True, parents=True)
+            self.PLUGIN_SP[scope] = pth
+            sys.path.insert(0, pth.as_posix())
+        sys.path_importer_cache.clear()
+
+        site.USER_BASE = self.plugin_dir.as_posix()
+        site.USER_SITE = self.PLUGIN_SP[GENERIC_SCOPE].as_posix()
+
+    @property
+    def plugins(self) -> list[str]:
+        """Get/cache the list of installed plugins."""
+        if self.disabled:
+            return []
+        if self._plugins is None:
+            self._plugins = []
+            if self.plugin_list_file.exists():
+                with open(self.plugin_list_file) as f:
+                    self._plugins = json.load(f)
+        return self._plugins
+
+    @property
+    def installed_pkgs(self) -> dict:
+        """Get/cache the installed packages."""
+        if self.disabled:
+            return {}
+        if self._installed is None:
+            self._installed = {}
+            if self.installed_file.exists():
+                with open(self.installed_file) as f:
+                    self._installed = json.load(f)
+
+        return self._installed
+
+    def save_plugin_list(self):
+        """Save the list of installed plugins."""
+        if self.disabled:
+            return
+        with open(self.plugin_list_file, 'w') as f:
+            json.dump(self.plugins, f, indent=2)
+
+    def save_installed(self):
+        """Save the installed packages."""
+        if self.disabled:
+            return
+        with open(self.installed_file, 'w') as f:
+            json.dump(self.installed_pkgs, f, indent=2)
+
+    @install_overrides_decorator
+    def install_package(
+            self,
+            name: str,
+            version: str,
+            extras: list| str = None,
+            scope: str = GENERIC_SCOPE,
+            system: str = '',
+            ):
+        """Install a package."""
+        with self.lock_pkg:
+            if scope not in self.scopes:
+                # logging.debug(f'Skipping {name}=={version} for scope {scope}')
+                logger.debug(f'Skipping {name}=={version} for scope {scope}')
+                return
+            if system and system.lower() != self.system:
+                logger.debug(f'Skipping {name}=={version} for system {system}')
+                return
+
+            if extras is None:
+                extras = []
+            elif isinstance(extras, str):
+                extras = list(filter(None, extras.split(' ')))
+            scoped_name = f'{scope}::{name}'
+
+            if scoped_name in self.installed_pkgs:
+                if self.installed_pkgs[scoped_name]['version'] == version:
+                    logger.debug(f'{scoped_name}=={version} already installed')
+                    return
+                logger.debug(f'Uninstalling {scoped_name}=={self.installed_pkgs[scoped_name]["version"]}')
+                self._uninstall_package(scoped_name)
+
+            ptr = {}
+            self.installed_pkgs[scoped_name] = ptr
+            ptr['version'] = version
+            ptr['files'] = []
+            ptr['dirs'] = []
+
+            _pip_install(f'{name}=={version}', self.plugin_dir, extras)
+
+            tmp_dir = find_site_packages(self.plugin_dir)
+            if tmp_dir is None:
+                raise FileNotFoundError(f'Could not find site-packages in {self.plugin_dir}')
+            logger.debug(f'Package installed in {tmp_dir}')
+            for src in tmp_dir.iterdir():
+                if src.name.endswith('__pycache__'):
+                    continue
+                # shutil.move(d, PLUGIN_SP[scope])
+                dst = self.PLUGIN_SP[scope] / src.name
+                if src.is_file():
+                    ptr['files'].append(src.name)
+                    if dst.exists():
+                        dst.unlink()
+                    shutil.move(src, dst)
+                elif src.is_dir():
+                    ptr['dirs'].append(src.name)
+                    if dst.exists():
+                        shutil.copytree(src, dst, dirs_exist_ok=True)
+                        shutil.rmtree(src)
+                    else:
+                        shutil.move(src, dst)
+
+            # Make sure only one site-packages dir exists after installing a package
+            shutil.rmtree(tmp_dir)
+
+            self.save_installed()
+
+    def _install_plugin(self, name: str):
+        """Run the `install` part of the plugin."""
+        if not (data := self.get_plugin_data(name)):
+            raise ValueError(f'Plugin {name} not found')
+        pkg = data['package']
+        version = data['version']
+        deps = data.get('dependencies', [])
+
+        logger.info(f'Installing plugin {pkg}=={version} with dependencies')
+        for dep in deps[::-1]:
+            self.install_package(**dep)
+
+        self.install_package(pkg, version)
+
+    def install_plugin(self, name: str):
+        """Ensure the plugin is installed."""
+        with self.lock_plugin:
+            self._install_plugin(name)
+            if name not in self.plugins:
+                self.plugins.append(name)
+                self.save_plugin_list()
+            if name not in settings.INSTALLED_APPS:
+                settings.INSTALLED_APPS.append(name)
+            reload_django_apps()
+
+    def _uninstall_package(self, name: str):
+        """Uninstall a package."""
+        if name not in self.installed_pkgs:
+            return
+        logger.info(f'Uninstalling package {name}')
+        scope, _ = name.split('::')
+        if scope not in self.scopes:
+            logger.debug(f'Skipping {name} for scope {scope}')
+            return
+        ptr = self.installed_pkgs.pop(name)
+        scope_dir = self.PLUGIN_SP[scope]
+        for pth in ptr['files']:
+            (scope_dir / pth).unlink()
+        for pth in ptr['dirs']:
+            shutil.rmtree(scope_dir / pth)
+
+        self.save_installed()
+
+
+    def uninstall_package(self, name: str):
+        """Uninstall a package thread safe."""
+        with self.lock_pkg:
+            self._uninstall_package(name)
+
+    def uninstall_plugin(self, name: str):
+        """Uninstall the plugin."""
+        with self.lock_plugin:
+            if name not in self.plugins:
+                return
+            data = self.get_plugin_data(name)
+            pkg = data['package']
+            scope = data.get('scope', GENERIC_SCOPE)
+            scoped_name = f'{scope}::{pkg}'
+            logger.info(f'Uninstalling plugin {scoped_name} and non-shared dependencies')
+            # logger.debug(f'INSTALLED: {INSTALLED}')
+            if scoped_name not in self.installed_pkgs:
+                raise ValueError(f'Plugin {name} not installed')
+
+            other_deps = set()
+            plugin_deps = set()
+            torm_deps = set()
+            for plugin in self.plugins_data:
+                if plugin['name'] not in self.plugins:
+                    continue
+                deps = set()
+                for dep in plugin.get('dependencies', []):
+                    deps.add(f"{dep.get('scope', GENERIC_SCOPE)}::{dep['name']}")
+                if plugin['name'] == name:
+                    plugin_deps |= deps
+                else:
+                    other_deps |= deps
+            torm_deps = plugin_deps - other_deps
+
+            for dep in torm_deps:
+                self.uninstall_package(dep)
+            self.uninstall_package(scoped_name)
+
+            self.plugins.remove(name)
+            self.save_plugin_list()
+
+            settings.INSTALLED_APPS.remove(name)
+            reload_django_apps()
diff --git a/ocr_translate/plugins_data.json b/ocr_translate/plugins_data.json
new file mode 100644
index 0000000..30c9fb2
--- /dev/null
+++ b/ocr_translate/plugins_data.json
@@ -0,0 +1,1165 @@
+[
+  {
+    "name": "ocr_translate_hugging_face",
+    "package": "ocr_translate-hugging_face",
+    "description": "Plugins to enable usage of HuggingFace Models in ocr_translate",
+    "version": "0.3.0",
+    "homepage": "https://github.com/Crivella/ocr_translate-hugging_face",
+    "dependencies": [
+      {
+        "name": "fugashi",
+        "version": "1.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "protobuf",
+        "version": "3.20.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sacremoses",
+        "version": "0.0.53",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "click",
+        "version": "8.1.7",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "joblib",
+        "version": "1.3.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "six",
+        "version": "1.16.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sentencepiece",
+        "version": "0.1.99",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "torchvision",
+        "version": "0.17.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cpu",
+        "scope": "cpu"
+      },
+      {
+        "name": "torchvision",
+        "version": "0.17.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cu118",
+        "scope": "cuda"
+      },
+      {
+        "name": "torch",
+        "version": "2.2.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cpu",
+        "scope": "cpu"
+      },
+      {
+        "name": "torch",
+        "version": "2.2.1+cu118",
+        "extras": "--index-url https://download.pytorch.org/whl/cu118",
+        "scope": "cuda"
+      },
+      {
+        "name": "nvidia-cuda-cupti-cu11",
+        "version": "11.8.87",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cuda-nvrtc-cu11",
+        "version": "11.8.89",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cuda-runtime-cu11",
+        "version": "11.8.89",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cudnn-cu11",
+        "version": "8.7.0.84",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cufft-cu11",
+        "version": "10.9.0.58",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-curand-cu11",
+        "version": "10.3.0.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cusolver-cu11",
+        "version": "11.4.1.48",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cublas-cu11",
+        "version": "11.11.3.6",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cusparse-cu11",
+        "version": "11.7.5.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-nccl-cu11",
+        "version": "=2.19.3",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-nvtx-cu11",
+        "version": "11.8.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "triton",
+        "version": "2.2.0",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "Jinja2",
+        "version": "3.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "MarkupSafe",
+        "version": "2.1.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "networkx",
+        "version": "3.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sympy",
+        "version": "1.12",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "mpmath",
+        "version": "1.3.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "transformers",
+        "version": "4.30.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "huggingface-hub",
+        "version": "0.21.4",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "filelock",
+        "version": "3.13.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "fsspec",
+        "version": "2024.3.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "typing_extensions",
+        "version": "4.8.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "packaging",
+        "version": "24.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "PyYAML",
+        "version": "6.0.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "regex",
+        "version": "2023.12.25",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "requests",
+        "version": "2.31.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "certifi",
+        "version": "2024.2.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "charset-normalizer",
+        "version": "3.3.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "idna",
+        "version": "3.6",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "urllib3",
+        "version": "2.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "safetensors",
+        "version": "0.3.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tokenizers",
+        "version": "0.13.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tqdm",
+        "version": "4.66.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "unidic-lite",
+        "version": "1.0.8",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  },
+  {
+    "name": "ocr_translate_easyocr",
+    "package": "ocr_translate-easyocr",
+    "description": "Plugins to enable usage of EasyOCR in ocr_translate",
+    "version": "0.4.1",
+    "homepage": "https://github.com/Crivella/ocr_translate-easyocr",
+    "dependencies": [
+      {
+        "name": "easyocr",
+        "version": "1.7.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "ninja",
+        "version": "1.11.1.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pyclipper",
+        "version": "1.3.0.post5",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "python-bidi",
+        "version": "0.4.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "six",
+        "version": "1.16.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "PyYAML",
+        "version": "6.0.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "scikit-image",
+        "version": "0.22.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "imageio",
+        "version": "2.34.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "lazy_loader",
+        "version": "0.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "packaging",
+        "version": "24.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tifffile",
+        "version": "2024.2.12",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "scipy",
+        "version": "1.12.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "shapely",
+        "version": "2.0.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "torchvision",
+        "version": "0.17.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cpu",
+        "scope": "cpu"
+      },
+      {
+        "name": "torchvision",
+        "version": "0.17.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cu118",
+        "scope": "cuda"
+      },
+      {
+        "name": "torch",
+        "version": "2.2.1",
+        "extras": "--index-url https://download.pytorch.org/whl/cpu",
+        "scope": "cpu"
+      },
+      {
+        "name": "torch",
+        "version": "2.2.1+cu118",
+        "extras": "--index-url https://download.pytorch.org/whl/cu118",
+        "scope": "cuda"
+      },
+      {
+        "name": "nvidia-cuda-cupti-cu11",
+        "version": "11.8.87",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cuda-nvrtc-cu11",
+        "version": "11.8.89",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cuda-runtime-cu11",
+        "version": "11.8.89",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cudnn-cu11",
+        "version": "8.7.0.84",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cufft-cu11",
+        "version": "10.9.0.58",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-curand-cu11",
+        "version": "10.3.0.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cusolver-cu11",
+        "version": "11.4.1.48",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cublas-cu11",
+        "version": "11.11.3.6",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-cusparse-cu11",
+        "version": "11.7.5.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-nccl-cu11",
+        "version": "=2.19.3",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "nvidia-nvtx-cu11",
+        "version": "11.8.86",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "triton",
+        "version": "2.2.0",
+        "extras": "",
+        "scope": "cuda",
+        "system": "linux"
+      },
+      {
+        "name": "filelock",
+        "version": "3.13.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "fsspec",
+        "version": "2024.3.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Jinja2",
+        "version": "3.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "MarkupSafe",
+        "version": "2.1.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "networkx",
+        "version": "3.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sympy",
+        "version": "1.12",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "mpmath",
+        "version": "1.3.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "typing_extensions",
+        "version": "4.8.0",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  },
+  {
+    "name": "ocr_translate_tesseract",
+    "package": "ocr_translate-tesseract",
+    "description": "Plugins to enable usage of Tesseract in ocr_translate",
+    "warning": "This plugin requires Tesseract to be installed on your system (or already included in Docker release).",
+    "version": "0.3.0",
+    "homepage": "https://github.com/Crivella/ocr_translate-tesseract",
+    "dependencies": [
+      {
+        "name": "pytesseract",
+        "version": "0.3.10",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "packaging",
+        "version": "24.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "requests",
+        "version": "2.31.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "certifi",
+        "version": "2024.2.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "charset-normalizer",
+        "version": "3.3.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "idna",
+        "version": "3.6",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "urllib3",
+        "version": "2.2.1",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  },
+  {
+    "name": "ocr_translate_paddle",
+    "package": "ocr_translate-paddle",
+    "description": "This is a plugin for the ocr_translate. Plugins to enable usage of PaddleOCR in ocr_translate",
+    "version": "0.2.2",
+    "homepage": "https://github.com/Crivella/ocr_translate-paddle",
+    "dependencies": [
+      {
+        "name": "paddleocr",
+        "version": "2.8.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "paddlepaddle",
+        "version": "2.5.2",
+        "extras": "",
+        "scope": "generic",
+        "system": "linux"
+      },
+      {
+        "name": "paddlepaddle",
+        "version": "2.6.1",
+        "extras": "",
+        "scope": "generic",
+        "system": "windows"
+      },
+      {
+        "name": "astor",
+        "version": "0.8.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "decorator",
+        "version": "5.1.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "httpx",
+        "version": "0.13.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "chardet",
+        "version": "3.0.4",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hstspreload",
+        "version": "2024.8.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "httpcore",
+        "version": "0.9.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "h11",
+        "version": "0.9.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "h2",
+        "version": "3.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hpack",
+        "version": "3.0.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hyperframe",
+        "version": "5.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "rfc3986",
+        "version": "1.5.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sniffio",
+        "version": "1.3.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "opt-einsum",
+        "version": "3.3.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "attrdict",
+        "version": "2.0.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "beautifulsoup4",
+        "version": "4.12.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "soupsieve",
+        "version": "2.5",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Cython",
+        "version": "3.0.9",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "imgaug",
+        "version": "0.4.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "lmdb",
+        "version": "1.4.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "openpyxl",
+        "version": "3.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "et-xmlfile",
+        "version": "1.1.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pdf2docx",
+        "version": "0.5.8",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "PyMuPDF",
+        "version": "1.24.9",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "premailer",
+        "version": "3.10.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "cachetools",
+        "version": "5.3.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "cssselect",
+        "version": "1.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "cssutils",
+        "version": "2.9.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pyclipper",
+        "version": "1.3.0.post5",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "python-docx",
+        "version": "1.1.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "lxml",
+        "version": "5.1.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "typing_extensions",
+        "version": "4.8.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "PyYAML",
+        "version": "6.0.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "rapidfuzz",
+        "version": "3.7.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "scikit-image",
+        "version": "0.22.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "imageio",
+        "version": "2.34.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "lazy_loader",
+        "version": "0.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "networkx",
+        "version": "3.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "scipy",
+        "version": "1.12.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tifffile",
+        "version": "2024.2.12",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "shapely",
+        "version": "2.0.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tqdm",
+        "version": "4.66.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "visualdl",
+        "version": "2.5.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "bce-python-sdk",
+        "version": "0.9.5",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "future",
+        "version": "1.0.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pycryptodome",
+        "version": "3.20.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "flask-babel",
+        "version": "4.0.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Babel",
+        "version": "2.14.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Flask",
+        "version": "3.0.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "blinker",
+        "version": "1.7.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "click",
+        "version": "8.1.7",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "itsdangerous",
+        "version": "2.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Werkzeug",
+        "version": "3.0.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "Jinja2",
+        "version": "3.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "MarkupSafe",
+        "version": "2.1.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "matplotlib",
+        "version": "3.8.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "contourpy",
+        "version": "1.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "cycler",
+        "version": "0.12.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "fonttools",
+        "version": "4.50.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "kiwisolver",
+        "version": "1.4.5",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pyparsing",
+        "version": "3.1.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "packaging",
+        "version": "24.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pandas",
+        "version": "2.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "python-dateutil",
+        "version": "2.9.0.post0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "pytz",
+        "version": "2024.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "tzdata",
+        "version": "2024.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "protobuf",
+        "version": "3.20.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "psutil",
+        "version": "5.9.8",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "rarfile",
+        "version": "4.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "requests",
+        "version": "2.31.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "certifi",
+        "version": "2024.2.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "charset-normalizer",
+        "version": "3.3.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "idna",
+        "version": "3.6",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "urllib3",
+        "version": "2.2.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "six",
+        "version": "1.16.0",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  },
+  {
+    "name": "ocr_translate_ollama",
+    "package": "ocr_translate_ollama",
+    "description": "This is a plugin for ocr_translate that implements translations through ollama using Large Language Models (LLM)s.",
+    "warning": "This plugin requires a running ollama server that is not include with the project. See the homepage for more details",
+    "version": "0.1.4",
+    "homepage": "https://github.com/Crivella/ocr_translate-ollama",
+    "dependencies": [
+      {
+        "name": "requests",
+        "version": "2.31.0",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  },
+  {
+    "name": "ocr_translate_google",
+    "package": "ocr_translate-google",
+    "description": "Plugins to enable usage of GoogleTranslate in ocr_translate using `googletrans`",
+    "warning": "This plugin requires an active internet connection to work, and text to be translated will be sent to Google's servers for translation.",
+    "version": "0.2.1",
+    "homepage": "https://github.com/Crivella/ocr_translate-google",
+    "dependencies": [
+      {
+        "name": "googletrans",
+        "version": "4.0.0rc1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "httpx",
+        "version": "0.13.3",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "certifi",
+        "version": "2024.2.2",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "chardet",
+        "version": "3.0.4",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hstspreload",
+        "version": "2024.8.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "httpcore",
+        "version": "0.9.1",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "h11",
+        "version": "0.9.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "h2",
+        "version": "3.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hpack",
+        "version": "3.0.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "hyperframe",
+        "version": "5.2.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "idna",
+        "version": "3.6",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "rfc3986",
+        "version": "1.5.0",
+        "extras": "",
+        "scope": "generic"
+      },
+      {
+        "name": "sniffio",
+        "version": "1.3.1",
+        "extras": "",
+        "scope": "generic"
+      }
+    ]
+  }
+]
\ No newline at end of file
diff --git a/ocr_translate/request_decorators.py b/ocr_translate/request_decorators.py
new file mode 100644
index 0000000..1d108ad
--- /dev/null
+++ b/ocr_translate/request_decorators.py
@@ -0,0 +1,119 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Decorators to preparse requests and responses."""
+
+import json
+from functools import wraps
+
+from django.http import HttpRequest, JsonResponse
+
+from .ocr_tsl.box import get_box_model
+from .ocr_tsl.lang import get_lang_dst, get_lang_src
+from .ocr_tsl.ocr import get_ocr_model
+from .ocr_tsl.tsl import get_tsl_model
+
+
+def get_backend_models(strict: bool = True):
+    """Decorator to check and add loaded models."""
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            box_model = get_box_model() or ''
+            ocr_model = get_ocr_model() or ''
+            tsl_model = get_tsl_model() or ''
+            if strict and (not box_model or not ocr_model or not tsl_model):
+                return JsonResponse({'error': 'Models not loaded'}, status=513)
+            return func(*args, **kwargs, box_model=box_model, ocr_model=ocr_model, tsl_model=tsl_model)
+        return wrapper
+    return decorator
+
+def get_backend_langs(strict: bool = True):
+    """Decorator to check and add loaded languages."""
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            lang_src = get_lang_src() or ''
+            lang_dst = get_lang_dst() or ''
+            if strict and (not lang_src or not lang_dst):
+                return JsonResponse({'error': 'Languages not loaded'}, status=512)
+            return func(*args, **kwargs, lang_src=lang_src, lang_dst=lang_dst)
+        return wrapper
+    return decorator
+
+def method_or_405(methods):
+    """Decorator to allow only specific methods."""
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            request: HttpRequest = args[0]
+            if request.method not in methods:
+                return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
+            return func(*args, **kwargs)
+        return wrapper
+    return decorator
+
+def post_data_deserializer(expected_keys: list[str], strict: bool = True, required: bool = True):
+    """Decorator to deserialize POST data.
+
+    Args:
+        expected_keys (list[str]): List of keys to expect in the POST data.
+        strict (bool, optional): If any key not present in `expected_keys` is found, return 400. Defaults to True.
+        required (bool, optional): If `True` not found expected keys are set to None, else return 400. Defaults to True.
+    """
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            request: HttpRequest = args[0]
+            if request.content_type != 'application/json':
+                return JsonResponse({'error': 'Content-Type must be application/json'}, status=400)
+            _data = json.loads(request.body)
+            data = {}
+            for key in expected_keys:
+                if key not in _data and required:
+                    return JsonResponse({'error': f'{key} not found in POST data'}, status=400)
+                data[key] = _data.pop(key, None)
+            if strict and _data:
+                return JsonResponse({'error': f'Unexpected keys: {", ".join(_data.keys())}'}, status=400)
+            return func(*args, **kwargs, **data)
+        return wrapper
+    return decorator
+
+def get_data_deserializer(expected_keys: list[str], strict: bool = True, required: bool = True):
+    """Decorator to deserialize GET data.
+
+    Args:
+        expected_keys (list[str]): List of keys to expect in the GET data.
+        strict (bool, optional): If any key not present in `expected_keys` is found, return 400. Defaults to True.
+        required (bool, optional): If `True` not found expected keys are set to None, else return 400. Defaults to True.
+    """
+    def decorator(func):
+        @wraps(func)
+        def wrapper(*args, **kwargs):
+            request: HttpRequest = args[0]
+            _data = request.GET.dict()
+            data = {}
+            for key in expected_keys:
+                if key not in _data and required:
+                    return JsonResponse({'error': f'{key} not found in GET data'}, status=400)
+                data[key] = _data.pop(key, None)
+            if strict and _data:
+                return JsonResponse({'error': f'Unexpected keys: {", ".join(_data.keys())}'}, status=400)
+            return func(*args, **kwargs, **data)
+        return wrapper
+    return decorator
diff --git a/ocr_translate/urls.py b/ocr_translate/urls.py
index a56c1ab..b36a5cd 100644
--- a/ocr_translate/urls.py
+++ b/ocr_translate/urls.py
@@ -1,34 +1,37 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Django routing for the ocr_translate app."""
-from django.urls import path
-
-from ocr_translate import views
-
-app_name = 'ocr_translate'
-urlpatterns = [
-    path('', views.handshake, name='handshake'),
-    path('set_models/', views.set_models, name='set_models'),
-    path('set_lang/', views.set_lang, name='set_lang'),
-    path('get_trans/', views.get_translations, name='get_trans'),
-    path('run_tsl/', views.run_tsl, name='run_tsl'),
-    path('run_ocrtsl/', views.run_ocrtsl, name='run_ocrtsl'),
-    path('set_manual_translation/', views.set_manual_translation, name='set_manual_translation'),
-    path('get_active_options/', views.get_active_options, name='get_active_options'),
-]
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Django routing for the ocr_translate app."""
+from django.urls import path
+
+from ocr_translate import views
+
+app_name = 'ocr_translate'
+urlpatterns = [
+    path('', views.handshake, name='handshake'),
+    path('set_models/', views.set_models, name='set_models'),
+    path('set_lang/', views.set_lang, name='set_lang'),
+    path('get_trans/', views.get_translations, name='get_trans'),
+    path('run_tsl/', views.run_tsl, name='run_tsl'),
+    path('run_tsl_xua', views.run_tsl_get_xunityautotrans, name='run_tsl_get_xunityautotrans'),
+    path('run_ocrtsl/', views.run_ocrtsl, name='run_ocrtsl'),
+    path('set_manual_translation/', views.set_manual_translation, name='set_manual_translation'),
+    path('get_active_options/', views.get_active_options, name='get_active_options'),
+    path('get_plugin_data/', views.get_plugin_data, name='get_plugin_data'),
+    path('manage_plugins/', views.manage_plugins, name='manage_plugins'),
+]
diff --git a/ocr_translate/views.py b/ocr_translate/views.py
index 63562e1..90a6e01 100644
--- a/ocr_translate/views.py
+++ b/ocr_translate/views.py
@@ -1,485 +1,469 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Django views for the ocr_translate app."""
-# pylint: disable=too-many-return-statements
-import base64
-import hashlib
-import io
-import json
-import logging
-from typing import Union
-
-import numpy as np
-from django.db.models import Count
-from django.http import HttpRequest, JsonResponse
-from django.middleware import csrf
-from django.views.decorators.csrf import csrf_exempt
-from PIL import Image
-
-from . import __version__array__
-from . import models as m
-from .ocr_tsl.box import get_box_model, load_box_model, unload_box_model
-from .ocr_tsl.full import ocr_tsl_pipeline_lazy, ocr_tsl_pipeline_work
-from .ocr_tsl.lang import (get_lang_dst, get_lang_src, load_lang_dst,
-                           load_lang_src)
-from .ocr_tsl.ocr import get_ocr_model, load_ocr_model, unload_ocr_model
-from .ocr_tsl.tsl import get_tsl_model, load_tsl_model, unload_tsl_model
-from .queues import main_queue as q
-from .tries import load_trie_src
-
-logger = logging.getLogger('ocr.general')
-
-
-def post_data_converter(request: HttpRequest) -> dict:
-    """Convert POST data to a dict."""
-    if request.content_type == 'application/json':
-        return json.loads(request.body)
-
-    raise ValueError('invalid content type')
-
-
-def handshake(request: HttpRequest) -> JsonResponse:
-    """Handshake with the client."""
-    if not request.method == 'GET':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    csrf.get_token(request)
-
-    logger.debug(f'Handshake: {str(get_ocr_model())}, {str(get_tsl_model())}')
-    lang_src = get_lang_src()
-    lang_dst = get_lang_dst()
-
-    languages = m.Language.objects.annotate(count=Count('trans_src')+Count('trans_dst')).order_by('-count')
-
-    box_models = []
-    ocr_models = []
-    tsl_models = []
-    if not lang_src is None:
-        box_models = m.OCRBoxModel.objects.annotate(count=Count('box_runs')).order_by('-count')
-        ocr_models = m.OCRModel.objects.annotate(count=Count('ocr_runs')).order_by('-count')
-        box_models = box_models.filter(languages=lang_src)
-        ocr_models = ocr_models.filter(languages=lang_src)
-        if not lang_dst is None:
-            tsl_models = m.TSLModel.objects.annotate(count=Count('tsl_runs')).order_by('-count')
-            tsl_models = tsl_models.filter(
-                src_languages=lang_src,
-                dst_languages=lang_dst,
-                )
-
-    box_model = get_box_model() or ''
-    ocr_model = get_ocr_model() or ''
-    tsl_model = get_tsl_model() or ''
-
-    lang_src = getattr(lang_src, 'iso1', None) or ''
-    lang_dst = getattr(lang_dst, 'iso1', None) or ''
-
-    return JsonResponse({
-        'version': __version__array__,
-        'Languages': [_.iso1 for _ in languages],
-        'Languages_hr': [_.name for _ in languages],
-        'BOXModels': [str(_) for _ in box_models],
-        'OCRModels': [str(_) for _ in ocr_models],
-        'TSLModels': [str(_) for _ in tsl_models],
-
-        'box_selected': str(box_model),
-        'ocr_selected': str(ocr_model),
-        'tsl_selected': str(tsl_model),
-        'lang_src': lang_src,
-        'lang_dst': lang_dst,
-        })
-
-@csrf_exempt
-def set_models(request: HttpRequest) -> JsonResponse:
-    """Handle a POST request to load models.
-    Expected data:
-    {
-        'box_model_id': 'id',
-        'ocr_model_id': 'id',
-        'tsl_model_id': 'id',
-    }
-    """
-    if request.method == 'POST':
-        try:
-            data = post_data_converter(request)
-        except ValueError:
-            return JsonResponse({'error': 'invalid content type'}, status=400)
-
-        logger.info(f'LOAD MODELS: {data}')
-
-        box_model_id = data.pop('box_model_id', None)
-        ocr_model_id = data.pop('ocr_model_id', None)
-        tsl_model_id = data.pop('tsl_model_id', None)
-
-        if len(data) > 0:
-            return JsonResponse({'error': f'invalid data: {data}'}, status=400)
-
-        try:
-            if not box_model_id is None and not box_model_id == '':
-                load_box_model(box_model_id)
-            if not ocr_model_id is None and not ocr_model_id == '':
-                load_ocr_model(ocr_model_id)
-            if not tsl_model_id is None and not tsl_model_id == '':
-                load_tsl_model(tsl_model_id)
-        except Exception as exc:
-            logger.error(f'Failed to load models: {exc}')
-            return JsonResponse({'error': str(exc)}, status=400)
-
-        return JsonResponse({})
-    return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-@csrf_exempt
-def set_lang(request: HttpRequest) -> JsonResponse:
-    """Handle a POST request to set languages.
-    Expected data:
-    {
-        'lang_src': 'id',
-        'lang_dst': 'id',
-    }
-    """
-    if request.method != 'POST':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    try:
-        data = post_data_converter(request)
-    except ValueError:
-        return JsonResponse({'error': 'invalid content type'}, status=400)
-    logger.info(f'SET LANG: {data}')
-
-    lang_src = data.pop('lang_src', None)
-    lang_dst = data.pop('lang_dst', None)
-    if lang_src is None:
-        return JsonResponse({'error': 'no lang_src'}, status=400)
-    if lang_dst is None:
-        return JsonResponse({'error': 'no lang_dst'}, status=400)
-    if len(data) > 0:
-        return JsonResponse({'error': f'invalid data: {data}'}, status=400)
-
-    old_src = get_lang_src()
-    old_dst = get_lang_dst()
-    try:
-        load_lang_src(lang_src)
-        load_lang_dst(lang_dst)
-        load_trie_src(lang_src)
-        # load_trie_dst(lang_dst)
-    except Exception as exc:
-        return JsonResponse({'error': str(exc)}, status=400)
-    new_src = get_lang_src()
-    new_dst = get_lang_dst()
-
-    check1 = old_src != new_src
-    check2 = old_dst != new_dst
-    if check1 or check2:
-        tsl_model = get_tsl_model()
-        if (
-            not tsl_model is None and
-            (
-                new_src not in tsl_model.src_languages.all() or
-                new_dst not in tsl_model.dst_languages.all()
-            )):
-            unload_tsl_model()
-    if check1:
-        box_model = get_box_model()
-        ocr_model = get_ocr_model()
-        if not box_model is None and (new_src not in box_model.languages.all()):
-            unload_box_model()
-        if not ocr_model is None and (new_src not in ocr_model.languages.all()):
-            unload_ocr_model()
-    # if check2:
-    #     pass
-
-    return JsonResponse({})
-
-@csrf_exempt
-def run_tsl(request: HttpRequest) -> JsonResponse:
-    """Handle a POST request to run translation.
-    Expected data:
-    {
-        'text': 'text',
-    }
-    """
-    if request.method != 'POST':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    try:
-        data = post_data_converter(request)
-    except ValueError:
-        return JsonResponse({'error': 'invalid content type'}, status=400)
-
-    text = data.pop('text', None)
-    if text is None:
-        return JsonResponse({'error': 'no text'}, status=400)
-    if len(data) > 0:
-        return JsonResponse({'error': f'invalid data: {data}'}, status=400)
-
-    tsl_model = get_tsl_model()
-
-
-    src_obj, _ = m.Text.objects.get_or_create(text=text)
-    dst_obj = tsl_model.translate(src_obj, get_lang_src(), get_lang_dst())
-    dst_obj = next(dst_obj)
-
-    return JsonResponse({
-        'text': dst_obj.text,
-        })
-
-@csrf_exempt
-def run_ocrtsl(request: HttpRequest) -> JsonResponse:
-    """Handle a POST request to run OCR and translation.
-    Expected data:
-    {
-        'contents': 'base64',
-        'md5': 'md5',
-        'force': 'bool',
-        'options': 'dict',
-    }
-    """
-    if request.method != 'POST':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    try:
-        data = post_data_converter(request)
-    except ValueError:
-        return JsonResponse({'error': 'invalid content type'}, status=400)
-
-    b64 = data.pop('contents', None)
-    md5 = data.pop('md5', None)
-    frc = data.pop('force', False)
-    opt = data.pop('options', {})
-
-    opt = {
-        **opt.get(get_box_model().name if get_box_model() else None, {}),
-        **opt.get(get_ocr_model().name if get_ocr_model() else None, {}),
-        **opt.get(get_tsl_model().name if get_tsl_model() else None, {}),
-    }
-
-    if md5 is None:
-        return JsonResponse({'error': 'no md5'}, status=400)
-    if len(data) > 0:
-        return JsonResponse({'error': f'invalid data: {data}'}, status=400)
-
-    if b64 is None:
-        logger.info('No contents, trying to lazyload')
-        if frc:
-            return JsonResponse({'error': 'Cannot force ocr without contents'}, status=400)
-        try:
-            res = ocr_tsl_pipeline_lazy(md5, options=opt)
-        except ValueError:
-            logger.info('Failed to lazyload ocr')
-            return JsonResponse({'error': 'Failed to lazyload ocr'}, status=406)
-    else:
-        binary = base64.b64decode(b64)
-        # Doing md5 on the base64 to have consistency with the JS generate one
-        # Can't find a way to run md5 on the binary in JS (the blob does not work)
-        if md5 != hashlib.md5(b64.encode('utf-8')).hexdigest():
-            return JsonResponse({'error': 'md5 mismatch'}, status=400)
-        logger.debug(f'md5 {md5} <- {len(binary)} bytes')
-
-        img = Image.open(io.BytesIO(binary))
-        # Needed to make sure the image is loaded synchronously before going forward
-        # Enforce thread safety. Maybe there is a way to do it without numpy?
-        np.array(img)
-
-        # Check if same request is already in queue. If yes attach listener to it
-        lang_src = get_lang_src()
-        lang_dst = get_lang_dst()
-        box_model = get_box_model()
-        ocr_model = get_ocr_model()
-        tsl_model = get_tsl_model()
-
-        try:
-            id_ = (md5, lang_src.id, lang_dst.id, box_model.id, ocr_model.id, tsl_model.id)
-        except AttributeError:
-            return JsonResponse({'error': 'No models selected'}, status=400)
-
-        msg = q.put(
-            id_ = id_,
-            msg = {
-                'args': (img, md5),
-                'kwargs': {'force': frc, 'options': opt},
-            },
-            handler = ocr_tsl_pipeline_work,
-        )
-
-        res = msg.response()
-
-
-    return JsonResponse({
-        'result': res,
-        })
-
-
-@csrf_exempt
-def get_translations(request: HttpRequest) -> JsonResponse:
-    """Handle a GET request to get translations.
-    Expected parameters:
-    {
-        'text': 'text',
-    }
-    """
-    if request.method != 'GET':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    params = request.GET.dict()
-    text = params.pop('text', None)
-
-    if len(params) > 0:
-        return JsonResponse({'error': f'invalid data: {params}'}, status=400)
-    if text is None:
-        return JsonResponse({'error': 'no text'}, status=400)
-
-    text_obj = m.Text.objects.filter(text=text).first()
-    if text_obj is None:
-        return JsonResponse({'error': 'text not found'}, status=404)
-
-    translations = text_obj.to_trans.filter(
-        lang_src=get_lang_src(),
-        lang_dst=get_lang_dst(),
-        )
-    return JsonResponse({
-        'translations': [{
-            'model': str(_.model),
-            'text': _.result.text,
-            } for _ in translations],
-        })
-
-@csrf_exempt
-def set_manual_translation(request: HttpRequest) -> JsonResponse:
-    """Handle a POST request to apply a manual translation.
-    Expected data:
-    {
-        'text': 'text',
-        'translation': 'translation',
-    }
-    """
-    if request.method != 'POST':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    try:
-        data = post_data_converter(request)
-    except ValueError:
-        return JsonResponse({'error': 'invalid content type'}, status=400)
-
-    text = data.pop('text', None)
-    translation = data.pop('translation', None)
-    if text is None:
-        return JsonResponse({'error': 'no text'}, status=400)
-    if translation is None:
-        return JsonResponse({'error': 'no translation'}, status=400)
-    if len(data) > 0:
-        return JsonResponse({'error': f'invalid data: {data}'}, status=400)
-
-    text_obj = m.Text.objects.filter(text=text).first()
-    if text_obj is None:
-        return JsonResponse({'error': 'text not found'}, status=404)
-
-    manual_model = m.TSLModel.objects.filter(name='manual').first()
-    params = {
-        'model': manual_model,
-        'text': text_obj,
-        'lang_src': get_lang_src(),
-        'lang_dst': get_lang_dst(),
-        'options': m.OptionDict.objects.get(options={}),
-    }
-
-    tsl_run_obj = m.TranslationRun.objects.filter(**params).first()
-    if tsl_run_obj is None:
-        res_obj, _ = m.Text.objects.get_or_create(text=translation)
-        params['result'] = res_obj
-        tsl_run_obj = m.TranslationRun.objects.create(**params)
-    else:
-        tsl_run_obj.result.text = translation
-        tsl_run_obj.result.save()
-
-    return JsonResponse({})
-
-def get_default_options_from_cascade(
-        objects: list[Union[str, 'm.OptionDict']], option: str, default: Union[int, float, str, bool] = None
-        ) -> Union[int, float, str, bool]:
-    """Get the default value of an option from a cascade of objects.
-
-    Args:
-        objects (list[Union[str, OptionDict]]): List of option objects or string identifying a model type.
-        option (str): Name of the option to get.
-        default (Union[int, float, str, bool], optional): Default value to return for the option. Defaults to None.
-
-    Raises:
-        ValueError: Invalid string in objects list.
-        TypeError: Invalid type in objects list.
-
-    Returns:
-        Union[int, float, str, bool]: The default value of the option.
-    """    """"""
-    res = default
-    for obj in objects:
-        if obj is None:
-            continue
-        if isinstance(obj, m.OptionDict):
-            res = obj.options.get(option, res)
-        elif isinstance(obj, str):
-            if obj == 'lang_src':
-                model = get_lang_src()
-            elif obj == 'lang_dst':
-                model = get_lang_dst()
-            elif obj == 'box_model':
-                model = get_box_model()
-            elif obj == 'ocr_model':
-                model = get_ocr_model()
-            elif obj == 'tsl_model':
-                model = get_tsl_model()
-            else:
-                raise ValueError(f'Unknown option cascade object: {obj}')
-            res = model.default_options.options.get(option, res)
-        else:
-            raise TypeError(f'Cannot get default options from {type(obj)}')
-    return res
-
-def get_active_options(request: HttpRequest) -> JsonResponse:
-    """Handle a GET request to get active options."""
-    if request.method != 'GET':
-        return JsonResponse({'error': f'{request.method} not allowed'}, status=405)
-
-    params = request.GET.dict()
-    if len(params) > 0:
-        return JsonResponse({'error': f'invalid data: {params}'}, status=400)
-
-    res = {}
-    for typ,model in [
-        ('box_model', get_box_model()),
-        ('ocr_model', get_ocr_model()),
-        ('tsl_model', get_tsl_model()),
-        ]:
-        ptr = res.setdefault(typ, {})
-        if model is None:
-            continue
-            # return JsonResponse({'error': 'No models selected'}, status=400)
-
-        for opt,val in model.ALLOWED_OPTIONS.items():
-            val = val.copy()
-            ptr[opt] = val
-            default = val['default']
-            if isinstance(default, tuple):
-                if default[0] == 'cascade':
-                    default = get_default_options_from_cascade(default[1], opt, default[2])
-                    default = val['type'](default)
-                    val['default'] = default
-                else:
-                    raise ValueError(f'Unknown default action type: {default[0]}')
-            val['type'] = val['type'].__name__
-
-    return JsonResponse({'options': res})
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Django views for the ocr_translate app."""
+# pylint: disable=too-many-return-statements
+import base64
+import hashlib
+import io
+import logging
+from typing import Union
+
+import numpy as np
+from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.middleware import csrf
+from django.views.decorators.csrf import csrf_exempt
+from PIL import Image
+
+from . import __version__array__
+from . import models as m
+from .entrypoint_manager import ep_manager
+from .ocr_tsl.box import get_box_model, load_box_model, unload_box_model
+from .ocr_tsl.cached_lists import (get_all_lang_dst, get_all_lang_src,
+                                   get_allowed_box_models,
+                                   get_allowed_ocr_models,
+                                   get_allowed_tsl_models)
+from .ocr_tsl.full import ocr_tsl_pipeline_lazy, ocr_tsl_pipeline_work
+from .ocr_tsl.lang import (get_lang_dst, get_lang_src, load_lang_dst,
+                           load_lang_src)
+from .ocr_tsl.ocr import get_ocr_model, load_ocr_model, unload_ocr_model
+from .ocr_tsl.tsl import get_tsl_model, load_tsl_model, unload_tsl_model
+from .plugin_manager import PluginManager
+from .queues import main_queue as q
+from .request_decorators import (get_backend_langs, get_backend_models,
+                                 get_data_deserializer, method_or_405,
+                                 post_data_deserializer)
+from .tries import load_trie_src
+
+logger = logging.getLogger('ocr.general')
+
+PMNG = PluginManager()
+
+@method_or_405(['GET'])
+def handshake(request: HttpRequest) -> JsonResponse:
+    """Handshake with the client."""
+    csrf.get_token(request)
+
+    logger.debug(f'Handshake: {str(get_ocr_model())}, {str(get_tsl_model())}')
+    lang_src = get_lang_src()
+    lang_dst = get_lang_dst()
+
+    languages = get_all_lang_src()
+    languages_src = get_all_lang_src()
+    languages_dst = get_all_lang_dst()
+
+    box_models = get_allowed_box_models()
+    ocr_models = get_allowed_ocr_models()
+    tsl_models = get_allowed_tsl_models()
+
+    box_model = get_box_model() or ''
+    ocr_model = get_ocr_model() or ''
+    tsl_model = get_tsl_model() or ''
+
+    lang_src = getattr(lang_src, 'iso1', None) or ''
+    lang_dst = getattr(lang_dst, 'iso1', None) or ''
+
+    res = JsonResponse({
+        'version': __version__array__,
+        'Languages': [_.iso1 for _ in languages_src],
+        'Languages_src': [_.iso1 for _ in languages_src],
+        'Languages_dst': [_.iso1 for _ in languages_dst],
+        'Languages_hr': [_.name for _ in languages],
+        'BOXModels': [str(_) for _ in box_models],
+        'OCRModels': [str(_) for _ in ocr_models],
+        'TSLModels': [str(_) for _ in tsl_models],
+
+        'box_selected': str(box_model),
+        'ocr_selected': str(ocr_model),
+        'tsl_selected': str(tsl_model),
+        'lang_src': lang_src,
+        'lang_dst': lang_dst,
+        })
+
+    # res['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
+    # res['Access-Control-Allow-Credentials'] = 'true'
+
+    return res
+
+@csrf_exempt
+@method_or_405(['POST'])
+@post_data_deserializer(['box_model_id', 'ocr_model_id', 'tsl_model_id'], required=False)
+def set_models(request: HttpRequest, box_model_id, ocr_model_id, tsl_model_id) -> JsonResponse:
+    """Handle a POST request to load models.
+    Expected data:
+    {
+        'box_model_id': 'id',
+        'ocr_model_id': 'id',
+        'tsl_model_id': 'id',
+    }
+    """
+    logger.info(f'LOAD MODELS: {box_model_id}, {ocr_model_id}, {tsl_model_id}')
+
+    try:
+        if not box_model_id is None and not box_model_id == '':
+            load_box_model(box_model_id)
+        if not ocr_model_id is None and not ocr_model_id == '':
+            load_ocr_model(ocr_model_id)
+        if not tsl_model_id is None and not tsl_model_id == '':
+            load_tsl_model(tsl_model_id)
+    except Exception as exc:
+        logger.error(f'Failed to load models: {exc}')
+        return JsonResponse({'error': str(exc)}, status=400)
+
+    return JsonResponse({})
+
+@csrf_exempt
+@method_or_405(['POST'])
+@post_data_deserializer(['lang_src', 'lang_dst'], required=True)
+def set_lang(request: HttpRequest, lang_src, lang_dst) -> JsonResponse:
+    """Handle a POST request to set languages.
+    Expected data:
+    {
+        'lang_src': 'id',
+        'lang_dst': 'id',
+    }
+    """
+    logger.info(f'SET LANG: {lang_src}, {lang_dst}')
+
+    old_src = get_lang_src()
+    old_dst = get_lang_dst()
+    try:
+        load_lang_src(lang_src)
+        load_lang_dst(lang_dst)
+        load_trie_src(lang_src)
+        # load_trie_dst(lang_dst)
+    except Exception as exc:
+        return JsonResponse({'error': str(exc)}, status=400)
+    new_src = get_lang_src()
+    new_dst = get_lang_dst()
+
+    check1 = old_src != new_src
+    check2 = old_dst != new_dst
+    if check1 or check2:
+        tsl_model = get_tsl_model()
+        if (
+            not tsl_model is None and
+            (
+                new_src not in tsl_model.src_languages.all() or
+                new_dst not in tsl_model.dst_languages.all()
+            )):
+            unload_tsl_model()
+    if check1:
+        box_model = get_box_model()
+        ocr_model = get_ocr_model()
+        if not box_model is None and (new_src not in box_model.languages.all()):
+            unload_box_model()
+        if not ocr_model is None and (new_src not in ocr_model.languages.all()):
+            unload_ocr_model()
+    # if check2:
+    #     pass
+
+    return JsonResponse({})
+
+@csrf_exempt
+@method_or_405(['POST'])
+@post_data_deserializer(['text'], required=True)
+@get_backend_langs(strict=True)
+@get_backend_models(strict=True)
+def run_tsl(request: HttpRequest, text, tsl_model: m.TSLModel, **kwargs) -> JsonResponse:
+    """Handle a POST request to run translation.
+    Expected data:
+    {
+        'text': 'text',
+    }
+    """
+    src_obj, _ = m.Text.objects.get_or_create(text=text)
+    dst_obj = tsl_model.translate(src_obj, get_lang_src(), get_lang_dst())
+    dst_obj = next(dst_obj)
+
+    return JsonResponse({
+        'text': dst_obj.text,
+        })
+
+@method_or_405(['GET'])
+@get_backend_langs(strict=True)
+@get_backend_models(strict=True)
+@get_data_deserializer(['text'], required=True)
+def run_tsl_get_xunityautotrans(
+    request: HttpRequest, tsl_model: m.TSLModel, text: str,
+    lang_src: m.Language, lang_dst: m.Language, **kwargs
+    ) -> JsonResponse:
+    """Handle a GET request to run translation.
+    Expected parameters:
+    {
+        'text': 'text',
+    }
+    """
+    src_obj, _ = m.Text.objects.get_or_create(text=text)
+    dst_obj = tsl_model.translate(src_obj, lang_src, lang_dst)
+    dst_obj = next(dst_obj)
+
+    return HttpResponse(dst_obj.text)
+
+@csrf_exempt
+@method_or_405(['POST'])
+@get_backend_langs(strict=True)
+@get_backend_models(strict=True)
+@post_data_deserializer(['contents', 'md5', 'force', 'options'], required=False)
+def run_ocrtsl(
+    request: HttpRequest,
+    lang_src: m.Language, lang_dst: m.Language,
+    box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel,
+    contents: str, md5: str, force: bool, options: dict,
+    ) -> JsonResponse:
+    """Handle a POST request to run OCR and translation.
+    Expected data:
+    {
+        'contents': 'base64',
+        'md5': 'md5',
+        'force': 'bool',
+        'options': 'dict',
+    }
+    """
+    b64 = contents
+    frc = force
+    opt = options or {}
+
+    opt = {
+        **opt.get(box_model.name if box_model else None, {}),
+        **opt.get(ocr_model.name if ocr_model else None, {}),
+        **opt.get(tsl_model.name if tsl_model else None, {}),
+    }
+
+    if b64 is None:
+        logger.info('No contents, trying to lazyload')
+        if frc:
+            return JsonResponse({'error': 'Cannot force ocr without contents'}, status=400)
+        try:
+            res = ocr_tsl_pipeline_lazy(md5, options=opt)
+        except ValueError:
+            logger.info('Failed to lazyload ocr')
+            return JsonResponse({'error': 'Failed to lazyload ocr'}, status=406)
+    else:
+        binary = base64.b64decode(b64)
+        # Doing md5 on the base64 to have consistency with the JS generate one
+        # Can't find a way to run md5 on the binary in JS (the blob does not work)
+        if md5 != hashlib.md5(b64.encode('utf-8')).hexdigest():
+            return JsonResponse({'error': 'md5 mismatch'}, status=400)
+        logger.debug(f'md5 {md5} <- {len(binary)} bytes')
+
+        img = Image.open(io.BytesIO(binary))
+        # Needed to make sure the image is loaded synchronously before going forward
+        # Enforce thread safety. Maybe there is a way to do it without numpy?
+        np.array(img)
+
+        # Check if same request is already in queue. If yes attach listener to it
+        options, _ = m.OptionDict.objects.get_or_create(options=opt)
+
+        id_ = (md5, lang_src.id, lang_dst.id, box_model.id, ocr_model.id, tsl_model.id, options.id)
+
+        msg = q.put(
+            id_ = id_,
+            msg = {
+                'args': (img, md5),
+                'kwargs': {'force': frc, 'options': opt},
+            },
+            handler = ocr_tsl_pipeline_work,
+        )
+
+        res = msg.response()
+
+        if isinstance(res, Exception):
+            logger.error(f'Failed to run ocr: {res}')
+            return JsonResponse({'error': str(res)}, status=400)
+
+
+    return JsonResponse({
+        'result': res,
+        })
+
+
+@csrf_exempt
+@method_or_405(['GET'])
+@get_backend_langs(strict=True)
+@get_data_deserializer(['text'], required=True)
+def get_translations(
+    request: HttpRequest,
+    lang_src: m.Language, lang_dst: m.Language,
+    text: str,
+    ) -> JsonResponse:
+    """Handle a GET request to get translations.
+    Expected parameters:
+    {
+        'text': 'text',
+    }
+    """
+    text_obj = m.Text.objects.filter(text=text).first()
+    if text_obj is None:
+        return JsonResponse({'error': 'text not found'}, status=404)
+
+    translations = text_obj.to_trans.filter(
+        lang_src=lang_src,
+        lang_dst=lang_dst,
+        )
+    return JsonResponse({
+        'translations': [{
+            'model': str(_.model),
+            'text': _.result.text,
+            } for _ in translations],
+        })
+
+@csrf_exempt
+@method_or_405(['POST'])
+@get_backend_langs(strict=True)
+@post_data_deserializer(['text', 'translation'], required=True)
+def set_manual_translation(
+    request: HttpRequest,
+    lang_src: m.Language, lang_dst: m.Language,
+    text: str, translation: str,
+    ) -> JsonResponse:
+    """Handle a POST request to apply a manual translation.
+    Expected data:
+    {
+        'text': 'text',
+        'translation': 'translation',
+    }
+    """
+    text_obj = m.Text.objects.filter(text=text).first()
+    if text_obj is None:
+        return JsonResponse({'error': 'text not found'}, status=404)
+
+    manual_model = m.TSLModel.objects.filter(name='manual').first()
+    params = {
+        'model': manual_model,
+        'text': text_obj,
+        'lang_src': lang_src,
+        'lang_dst': lang_dst,
+        'options': m.OptionDict.objects.get(options={}),
+    }
+
+    tsl_run_obj = m.TranslationRun.objects.filter(**params).first()
+    if tsl_run_obj is None:
+        res_obj, _ = m.Text.objects.get_or_create(text=translation)
+        params['result'] = res_obj
+        tsl_run_obj = m.TranslationRun.objects.create(**params)
+    else:
+        tsl_run_obj.result.text = translation
+        tsl_run_obj.result.save()
+
+    return JsonResponse({})
+
+def get_default_options_from_cascade(
+        objects: list[Union[str, 'm.OptionDict']], option: str, default: Union[int, float, str, bool] = None
+        ) -> Union[int, float, str, bool]:
+    """Get the default value of an option from a cascade of objects.
+
+    Args:
+        objects (list[Union[str, OptionDict]]): List of option objects or string identifying a model type.
+        option (str): Name of the option to get.
+        default (Union[int, float, str, bool], optional): Default value to return for the option. Defaults to None.
+
+    Raises:
+        ValueError: Invalid string in objects list.
+        TypeError: Invalid type in objects list.
+
+    Returns:
+        Union[int, float, str, bool]: The default value of the option.
+    """
+    res = default
+    for obj in objects:
+        if obj is None:
+            continue
+        if isinstance(obj, m.OptionDict):
+            res = obj.options.get(option, res)
+        elif isinstance(obj, str):
+            if obj == 'lang_src':
+                model = get_lang_src()
+            elif obj == 'lang_dst':
+                model = get_lang_dst()
+            elif obj == 'box_model':
+                model = get_box_model()
+            elif obj == 'ocr_model':
+                model = get_ocr_model()
+            elif obj == 'tsl_model':
+                model = get_tsl_model()
+            else:
+                raise ValueError(f'Unknown option cascade object: {obj}')
+            res = model.default_options.options.get(option, res)
+        else:
+            raise TypeError(f'Cannot get default options from {type(obj)}')
+    return res
+
+@method_or_405(['GET'])
+@get_backend_models(strict=False)
+@get_data_deserializer([], required=False)
+def get_active_options(
+    request: HttpRequest,
+    box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel,
+    ) -> JsonResponse:
+    """Handle a GET request to get active options."""
+    res = {}
+    for typ,model in [
+        ('box_model', box_model),
+        ('ocr_model', ocr_model),
+        ('tsl_model', tsl_model),
+        ]:
+        ptr = res.setdefault(typ, {})
+        if model == '':
+            continue
+
+        for opt,val in model.ALLOWED_OPTIONS.items():
+            val = val.copy()
+            ptr[opt] = val
+            default = val['default']
+            if isinstance(default, tuple):
+                if default[0] == 'cascade':
+                    default = get_default_options_from_cascade(default[1], opt, default[2])
+                    default = val['type'](default)
+                    val['default'] = default
+                else:
+                    raise ValueError(f'Unknown default action type: {default[0]}')
+            val['type'] = val['type'].__name__
+
+    return JsonResponse({'options': res})
+
+@method_or_405(['GET'])
+def get_plugin_data(request: HttpRequest) -> JsonResponse:
+    """Handle a GET request to get plugins."""
+    resp = {}
+    tpl = {
+        'homepage': None,
+        'warning': None,
+        'description': None,
+    }
+    for plugin in PMNG.plugins_data:
+        ptr = {**tpl, **plugin}
+        name = ptr.pop('name')
+        ptr.pop('dependencies', None)
+        ptr['installed'] = name in PMNG.plugins
+        resp[name] = ptr
+    return JsonResponse(resp)
+
+@csrf_exempt
+@method_or_405(['POST'])
+@post_data_deserializer(['plugins'], required=True)
+def manage_plugins(request: HttpRequest, plugins: dict[str, bool]) -> JsonResponse:
+    """Handle a POST request to install a plugin."""
+    logger.debug(f'Manage plugins: {plugins}')
+    with ep_manager():
+        for plugin, present in plugins.items():
+            if present:
+                PMNG.install_plugin(plugin)
+            else:
+                PMNG.uninstall_plugin(plugin)
+    return JsonResponse({})
diff --git a/openapi.yml b/openapi.yml
index b346005..e9e0fb5 100644
--- a/openapi.yml
+++ b/openapi.yml
@@ -1,315 +1,415 @@
-openapi: 3.1.0
-info:
-  title: ocr_translate server API
-  description: Description of the API for communicating with an [ocr_translate server](https://github.com/Crivella/ocr_translate/) via HTTP requests.
-  version: 0.2.1
-servers:
-  - url: http://127.0.0.1:4000
-    description: This will be the address to which you deploy the server (the specified one is the default/expected one)
-paths:
-  /:
-    get:
-      summary: Server handshake.
-      description: Return a JSON response containing information about the available languages/models and the currently in use src/dst language and box/ocr/tsl models.
-      responses:
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: A JSON dictionary with handshake info.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  Languages:
-                    type: array
-                    items:
-                      type: string
-                  BOXModels:
-                    type: array
-                    items:
-                      type: string
-                  OCRModels:
-                    type: array
-                    items:
-                      type: string
-                  TSLModels:
-                    type: array
-                    items:
-                      type: string
-                  box_selected:
-                    type: string
-                  ocr_selected:
-                    type: string
-                  tsl_selected:
-                    type: string
-                  lang_src:
-                    type: string
-                  lang_dst:
-                    type: string
-  /get_trans/:
-    get:
-      summary: Get translation.
-      description: Return a JSON response containing all the available translation of the specified text.
-      parameters:
-        - in: query
-          name: text
-          schema:
-            type: string
-          required: true
-          description: The text for which to search for translations.
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '404':  # status code
-          description: Text not found.
-        '200':    # status code
-          description: A JSON dictionary with the translation.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  translation:
-                    type: array
-                    items:
-                      type: object
-                      properties:
-                        text:
-                          type: string
-                        model:
-                          type: string
-  /set_lang/:
-    post:
-      summary: Set source and destination languages.
-      description: Set the source and destination languages for the translation.
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                lang_src:
-                  type: string
-                lang_dst:
-                  type: string
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: Success.
-  /set_models/:
-    post:
-      summary: Set box, ocr and tsl models.
-      description: Set the box, ocr and tsl models for the translation.
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                box_model_id:
-                  type: string
-                ocr_model_id:
-                  type: string
-                tsl_model_id:
-                  type: string
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: Success.
-  /run_tsl/:
-    post:
-      summary: Run translation on text.
-      description: Run translation on text using active model.
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                text:
-                  type: string
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: A JSON dictionary with the translated text.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  text:
-                    type: string
-  /run_ocrtsl/:
-    post:
-      summary: Run OCR and translation on image.
-      description: Run OCR and translation on image () using active models.
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                contents:
-                  type: string
-                  description: Base64 encoded image.
-                md5:
-                  type: string
-                  description: MD5 hash of the image.
-                force:
-                  type: boolean
-                  description: Force OCR+translation even if the image is already in the cache/database.
-                  default: false
-                options:
-                  type: object
-                  description: Options dictionary for the OCR and translation.
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '406': # status code
-          description: Can't lazyload with only md5.
-        '200':    # status code
-          description: A JSON dictionary with the translated text.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  result:
-                    type: array
-                    items:
-                      type: object
-                      properties:
-                        ocr:
-                          type: string
-                        tsl:
-                          type: string
-                        box:
-                          type: array
-                          items:
-                            type: integer
-                            description: "l, b, r, t"
-                          minItems: 4
-                          maxItems: 4
-  /set_manual_translation:
-    post:
-      summary: Set manual translation.
-      description: Set manual translation for a text object.
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                text:
-                  type: string
-                translation:
-                  type: string
-      responses:
-        '400':  # status code
-          description: Bad request.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '404':  # status code
-          description: Text not found.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  error:
-                    type: string
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: Success.
-  /get_active_options:
-    get:
-      summary: Get active options.
-      description: Get the active options for the OCR and translation.
-      responses:
-        '405':   # status code
-          description: Method not allowed.
-        '200':    # status code
-          description: A JSON dictionary with the active options.
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  options:
-                    type: object
-                    properties:
-                      box_model: &options_model_dict
-                        type: object
-                        properties:
-                          <option_name>:
-                            type: object
-                            properties:
-                              type:
-                                type: string
-                              default:
-                                type: string
-                              description:
-                                type: string
-                              value: ""
-                      ocr_model: *options_model_dict
-                      tsl_model: *options_model_dict
+openapi: 3.1.0
+info:
+  title: ocr_translate server API
+  description: Description of the API for communicating with an [ocr_translate server](https://github.com/Crivella/ocr_translate/) via HTTP requests.
+  version: 0.2.1
+servers:
+  - url: http://127.0.0.1:4000
+    description: This will be the address to which you deploy the server (the specified one is the default/expected one)
+paths:
+  /:
+    get:
+      summary: Server handshake.
+      description: Return a JSON response containing information about the available languages/models and the currently in use src/dst language and box/ocr/tsl models.
+      responses:
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: A JSON dictionary with handshake info.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  Languages:
+                    type: array
+                    items:
+                      type: string
+                  BOXModels:
+                    type: array
+                    items:
+                      type: string
+                  OCRModels:
+                    type: array
+                    items:
+                      type: string
+                  TSLModels:
+                    type: array
+                    items:
+                      type: string
+                  box_selected:
+                    type: string
+                  ocr_selected:
+                    type: string
+                  tsl_selected:
+                    type: string
+                  lang_src:
+                    type: string
+                  lang_dst:
+                    type: string
+  /get_trans/:
+    get:
+      summary: Get translation.
+      description: Return a JSON response containing all the available translation of the specified text.
+      parameters:
+        - in: query
+          name: text
+          schema:
+            type: string
+          required: true
+          description: The text for which to search for translations.
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '404':  # status code
+          description: Text not found.
+        '512': # status code
+          description: Attempting translation with no languages selected.
+        '200':    # status code
+          description: A JSON dictionary with the translation.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  translation:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        text:
+                          type: string
+                        model:
+                          type: string
+  /set_lang/:
+    post:
+      summary: Set source and destination languages.
+      description: Set the source and destination languages for the translation.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                lang_src:
+                  type: string
+                lang_dst:
+                  type: string
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: Success.
+  /set_models/:
+    post:
+      summary: Set box, ocr and tsl models.
+      description: Set the box, ocr and tsl models for the translation.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                box_model_id:
+                  type: string
+                ocr_model_id:
+                  type: string
+                tsl_model_id:
+                  type: string
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: Success.
+  /run_tsl/:
+    post:
+      summary: Run translation on text.
+      description: Run translation on text using active model.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                text:
+                  type: string
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '512': # status code
+          description: Attempting translation with no languages selected.
+        '513': # status code
+          description: Attempting translation with no models selected.
+        '200':    # status code
+          description: A JSON dictionary with the translated text.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  text:
+                    type: string
+  /run_ocrtsl/:
+    post:
+      summary: Run OCR and translation on image.
+      description: Run OCR and translation on image () using active models.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                contents:
+                  type: string
+                  description: Base64 encoded image.
+                md5:
+                  type: string
+                  description: MD5 hash of the image.
+                force:
+                  type: boolean
+                  description: Force OCR+translation even if the image is already in the cache/database.
+                  default: false
+                options:
+                  type: object
+                  description: Options dictionary for the OCR and translation.
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '406': # status code
+          description: Can't lazyload with only md5.
+        '512': # status code
+          description: Attempting translation with no languages selected.
+        '513': # status code
+          description: Attempting translation with no models selected.
+        '200':    # status code
+          description: A JSON dictionary with the translated text.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  result:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        ocr:
+                          type: string
+                        tsl:
+                          type: string
+                        box:
+                          type: array
+                          items:
+                            type: integer
+                            description: "l, b, r, t"
+                          minItems: 4
+                          maxItems: 4
+  /run_tsl_get_xunityautotrans:
+    get:
+      summary: Run translation from a GET endpoint.
+      description: Runt translation of text with API compatible with https://github.com/bbepis/XUnity.AutoTranslator.
+      parameters:
+        - in: query
+          name: text
+          schema:
+            type: string
+          required: true
+          description: The text to translate.
+      responses:
+        '404':  # status code
+          description: Text not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: Success.
+  /set_manual_translation:
+    post:
+      summary: Set manual translation.
+      description: Set manual translation for a text object.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                text:
+                  type: string
+                translation:
+                  type: string
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '404':  # status code
+          description: Text not found.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '512': # status code
+          description: Attempting translation with no languages selected.
+        '200':    # status code
+          description: Success.
+  /get_active_options:
+    get:
+      summary: Get active options.
+      description: Get the active options for the OCR and translation.
+      responses:
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: A JSON dictionary with the active options.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  options:
+                    type: object
+                    properties:
+                      box_model: &options_model_dict
+                        type: object
+                        properties:
+                          <option_name>:
+                            type: object
+                            properties:
+                              type:
+                                type: string
+                              default:
+                                type: string
+                              description:
+                                type: string
+                              value: ""
+                      ocr_model: *options_model_dict
+                      tsl_model: *options_model_dict
+  /get_plugin_data:
+    get:
+      summary: Get the data of the available plugins
+      description: Get a dict of available plugins and their data, including if they are installed or not.
+      responses:
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: A JSON dictionary with the plugin data.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  plugins:
+                    type: object
+                    properties:
+                      <plugin_name>:
+                        type: object
+                        required:
+                          - name
+                          - installed
+                          - version
+                        properties:
+                          name:
+                            type: string
+                          version:
+                            type: string
+                          description:
+                            type: string
+                          homepage:
+                            type: string
+                          warning:
+                            type: string
+                          installed:
+                            type: boolean
+  /manage_plugins:
+    post:
+      summary: Manage plugins.
+      description: Manage the plugins, by installing/uninstalling them.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                <plugin_name>:
+                  type: boolean
+      responses:
+        '400':  # status code
+          description: Bad request.
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+        '405':   # status code
+          description: Method not allowed.
+        '200':    # status code
+          description: Success.
diff --git a/plugins.txt b/plugins.txt
deleted file mode 100644
index 792e01d..0000000
--- a/plugins.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-ocr_translate_hugging_face
-ocr_translate_easyocr
-ocr_translate_tesseract
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 9597efb..59997ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,124 +1,122 @@
-[build-system]
-# build the package with [flit](https://flit.readthedocs.io)
-requires = ["flit_core >=3.4,<4"]
-build-backend = "flit_core.buildapi"
-
-[project]
-# See https://www.python.org/dev/peps/pep-0621/
-name = "django-ocr_translate"
-dynamic = ["version"] # read from ocr_translate/__init__.py
-description = "Django app for OCR and translation"
-authors = [
-    { name = "Davide Grassano" },
-]
-readme = "README.md"
-license = { file = "LICENSE.txt" }
-classifiers = [
-    "Development Status :: 2 - Pre-Alpha",
-    "Framework :: Django",
-    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-    "Operating System :: Microsoft :: Windows",
-    "Operating System :: POSIX :: Linux",
-    "Programming Language :: Python",
-    "Programming Language :: Python :: 3.10",
-    "Programming Language :: Python :: 3.11",
-    "Natural Language :: English",
-    "Topic :: Multimedia :: Graphics",
-    "Topic :: Database :: Database Engines/Servers",
-    "Topic :: Text Processing :: Linguistic",
-    "Intended Audience :: End Users/Desktop",
-]
-keywords = ["django", "ocr", "translation"]
-requires-python = ">=3.10"
-dependencies = [
-    "numpy~=1.24.1",
-    "Django~=4.2.3",
-    "Pillow~=10.0.1",
-]
-
-[project.urls]
-Source = "https://github.com/crivella/ocr_translate"
-
-[project.optional-dependencies]
-mysql = [
-    "pymysql==1.1.0",
-]
-postgres = [
-    "psycopg[binary]==3.1.9",
-]
-docs = [
-    "sphinx~=7.2.6",
-    "sphinx_design~=0.5.0",
-    "sphinx-rtd-theme~=1.3.0",
-    "sphinx-rtd-dark-mode~=1.3.0",
-    "sphinxcontrib-openapi"
-]
-tests = [
-    "pytest",
-    "pytest-cov",
-    "pytest-django~=4.5.2",
-    "pytest-regressions~=2.4",
-]
-pre-commit = [
-    "pre-commit~=3.3.3",
-    "pylint~=2.17.4",
-    "pylint-pytest~=1.1.2",
-    "pylint-django~=2.5.3",
-]
-release = [
-    "flit",
-    "pyinstaller"
-]
-
-[project.entry-points."ocr_translate.tsl_data"]
-"tsl.manual" = "ocr_translate.ocr_tsl.tsl:manual_data"
-
-[tool.flit.module]
-name = "ocr_translate"
-
-[tool.flit.sdist]
-exclude = [
-    ".gitignore", ".github", ".pre-commit-config.yaml",
-    "icon.ico", "run_server.py", "build.sh",
-    "Dockerfile-cpu", "Dockerfile-gpu", "nginx.default", "start-server.sh",
-    "mysite/", "mysite/*", "manage.py", "tests/", "tests/*",
-    "docs/", "docs/*"
-    ]
-
-[tool.pytest.ini_options]
-testpaths = ["tests"]
-DJANGO_SETTINGS_MODULE = "mysite.settings"
-log_cli = 1
-
-[tool.pylint.main]
-load-plugins = [
-    "pylint_django",
-    "pylint_pytest"
-]
-django-settings-module = "mysite.settings"
-
-[tool.pylint.messages_control]
-disable = [
-    "logging-fstring-interpolation",
-    "global-statement",
-    "broad-exception-caught",
-    "too-few-public-methods",
-]
-
-
-[tool.pylint.format]
-max-line-length = 120
-good-names = [
-    "_",
-    "l", "r", "b", "t",
-    "l1", "r1", "b1", "t1",
-    "l2", "r2", "b2", "t2",
-    "i", "j",
-    "k", "v",
-    "f",
-]
-
-[tool.pylint.design]
-max-args = 10
-max-locals = 20
-max-attributes = 12
+[build-system]
+# build the package with [flit](https://flit.readthedocs.io)
+requires = ["flit_core >=3.4,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+# See https://www.python.org/dev/peps/pep-0621/
+name = "django-ocr_translate"
+dynamic = ["version"] # read from ocr_translate/__init__.py
+description = "Django app for OCR and translation"
+authors = [
+    { name = "Davide Grassano" },
+]
+readme = "README.md"
+license = { file = "LICENSE.txt" }
+classifiers = [
+    "Development Status :: 2 - Pre-Alpha",
+    "Framework :: Django",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: Microsoft :: Windows",
+    "Operating System :: POSIX :: Linux",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Natural Language :: English",
+    "Topic :: Multimedia :: Graphics",
+    "Topic :: Database :: Database Engines/Servers",
+    "Topic :: Text Processing :: Linguistic",
+    "Intended Audience :: End Users/Desktop",
+]
+keywords = ["django", "ocr", "translation"]
+requires-python = ">=3.10"
+dependencies = [
+    "numpy~=1.24.1",
+    "Django~=5.1",
+    "Pillow~=10.4.0",
+    "opencv-python-headless~=4.8.0.76"
+]
+
+[project.urls]
+Source = "https://github.com/crivella/ocr_translate"
+
+[project.optional-dependencies]
+mysql = [
+    "pymysql==1.1.0",
+]
+postgres = [
+    "psycopg[binary]==3.1.9",
+]
+docs = [
+    "docutils==0.20.1",
+    "sphinx~=7.2.6",
+    "sphinx_design~=0.5.0",
+    "sphinx-rtd-theme~=2.0.0",
+    "sphinx-rtd-dark-mode~=1.3.0",
+    "sphinxcontrib-openapi==0.8.0",
+]
+tests = [
+    "pytest",
+    "pytest-cov",
+    "pytest-django~=4.5.2",
+    "pytest-regressions~=2.4",
+]
+pre-commit = [
+    "pre-commit~=3.3.3",
+    "pylint~=2.17.4",
+    "pylint-pytest~=1.1.2",
+    "pylint-django~=2.5.3",
+]
+release = [
+    "flit"
+]
+
+[tool.flit.module]
+name = "ocr_translate"
+
+[tool.flit.sdist]
+exclude = [
+    ".gitignore", ".github", ".pre-commit-config.yaml",
+    "icon.ico", "run_server.py", "run.bat",
+    "Dockerfile", "nginx.default", "start-server.sh",
+    "mysite/", "mysite/*", "manage.py", "tests/", "tests/*",
+    "docs/", "docs/*"
+    ]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+DJANGO_SETTINGS_MODULE = "mysite.settings"
+log_cli = 1
+
+[tool.pylint.main]
+load-plugins = [
+    "pylint_django",
+    "pylint_pytest"
+]
+django-settings-module = "mysite.settings"
+
+[tool.pylint.messages_control]
+disable = [
+    "logging-fstring-interpolation",
+    "global-statement",
+    "broad-exception-caught",
+    "too-few-public-methods",
+]
+
+
+[tool.pylint.format]
+max-line-length = 120
+good-names = [
+    "_",
+    "l", "r", "b", "t",
+    "l1", "r1", "b1", "t1",
+    "l2", "r2", "b2", "t2",
+    "i", "j",
+    "k", "v",
+    "f",
+]
+
+[tool.pylint.design]
+max-args = 10
+max-locals = 20
+max-attributes = 12
diff --git a/requirements-torch-cpu.txt b/requirements-torch-cpu.txt
deleted file mode 100644
index 846020a..0000000
--- a/requirements-torch-cpu.txt
+++ /dev/null
@@ -1,3 +0,0 @@
--i https://download.pytorch.org/whl/cpu
-torch~=2.0.1
-torchvision~=0.15.2
\ No newline at end of file
diff --git a/requirements-torch-cuda.txt b/requirements-torch-cuda.txt
deleted file mode 100644
index b7bc919..0000000
--- a/requirements-torch-cuda.txt
+++ /dev/null
@@ -1,3 +0,0 @@
--i https://download.pytorch.org/whl/cu118
-torch~=2.0.1
-torchvision~=0.15.2
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index f38085e..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-numpy~=1.24.1
-Django~=4.2.3
-Pillow~=10.0.1
diff --git a/run-user.bat b/run-user.bat
new file mode 100644
index 0000000..b24fa34
--- /dev/null
+++ b/run-user.bat
@@ -0,0 +1,12 @@
+@echo off
+
+set LOAD_ON_START=
+
+set DEVICE=cpu
+set OCT_BASE_DIR=
+set DATABASE_NAME=
+
+set DJANGO_DEBUG=true
+set DJANGO_LOG_LEVEL=INFO
+
+call run.bat
diff --git a/run-user.sh b/run-user.sh
new file mode 100755
index 0000000..b6ebe1a
--- /dev/null
+++ b/run-user.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# export LOAD_ON_START=
+
+export DEVICE=cpu
+# export OCT_BASE_DIR=
+# export DATABASE_NAME=
+
+export DJANGO_DEBUG=true
+export DJANGO_LOG_LEVEL=INFO
+
+./run.sh
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000..5b1e27d
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,74 @@
+@echo off
+
+if not defined PYTHON (set PYTHON=python)
+if not defined VENV_DIR (set "VENV_DIR=%~dp0%venv")
+
+mkdir tmp 2>NUL
+
+%PYTHON% -c "" >tmp/stdout.txt 2>tmp/stderr.txt
+if %ERRORLEVEL% == 0 goto :check_pip
+echo Couldn't launch python
+goto :show_stdout_stderr
+
+:check_pip
+%PYTHON% -mpip --help >tmp/stdout.txt 2>tmp/stderr.txt
+if %ERRORLEVEL% == 0 goto :start_venv
+if "%PIP_INSTALLER_LOCATION%" == "" goto :show_stdout_stderr
+%PYTHON% "%PIP_INSTALLER_LOCATION%" >tmp/stdout.txt 2>tmp/stderr.txt
+if %ERRORLEVEL% == 0 goto :start_venv
+echo Couldn't install pip
+goto :show_stdout_stderr
+
+:start_venv
+if ["%VENV_DIR%"] == ["-"] goto :skip_venv
+if ["%SKIP_VENV%"] == ["1"] goto :skip_venv
+
+dir "%VENV_DIR%\Scripts\Python.exe" >tmp/stdout.txt 2>tmp/stderr.txt
+if %ERRORLEVEL% == 0 goto :activate_venv
+
+for /f "delims=" %%i in ('CALL %PYTHON% -c "import sys; print(sys.executable)"') do set PYTHON_FULLNAME="%%i"
+echo Creating venv in directory %VENV_DIR% using python %PYTHON_FULLNAME%
+%PYTHON_FULLNAME% -m venv "%VENV_DIR%" >tmp/stdout.txt 2>tmp/stderr.txt
+if %ERRORLEVEL% == 0 goto :upgrade_pip
+echo Unable to create venv in directory "%VENV_DIR%"
+goto :show_stdout_stderr
+
+:upgrade_pip
+"%VENV_DIR%\Scripts\Python.exe" -m pip install --upgrade pip
+if %ERRORLEVEL% == 0 goto :activate_venv
+echo Warning: Failed to upgrade PIP version
+
+:activate_venv
+set PYTHON="%VENV_DIR%\Scripts\Python.exe"
+call "%VENV_DIR%\Scripts\activate.bat"
+echo venv %PYTHON%
+
+:skip_venv
+:launch
+%PYTHON% run_server.py %*
+pause
+exit /b
+
+:show_stdout_stderr
+
+echo.
+echo exit code: %errorlevel%
+
+for /f %%i in ("tmp\stdout.txt") do set size=%%~zi
+if %size% equ 0 goto :show_stderr
+echo.
+echo stdout:
+type tmp\stdout.txt
+
+:show_stderr
+for /f %%i in ("tmp\stderr.txt") do set size=%%~zi
+if %size% equ 0 goto :show_stderr
+echo.
+echo stderr:
+type tmp\stderr.txt
+
+:endofscript
+
+echo.
+echo Launch unsuccessful. Exiting.
+pause
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..0031664
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+
+if [ -z "$PYTHON" ]; then
+    PYTHON=python
+fi
+
+if [ -z "$VENV_DIR" ]; then
+    VENV_DIR="$(dirname "$0")/venv"
+fi
+
+mkdir tmp 2>/dev/null
+
+check_pip() {
+    $PYTHON -mpip --help >tmp/stdout.txt 2>tmp/stderr.txt
+    if [ $? -eq 0 ]; then
+        start_venv
+    elif [ -z "$PIP_INSTALLER_LOCATION" ]; then
+        show_stdout_stderr
+    else
+        $PYTHON "$PIP_INSTALLER_LOCATION" >tmp/stdout.txt 2>tmp/stderr.txt
+        if [ $? -eq 0 ]; then
+            start_venv
+        else
+            echo "Couldn't install pip"
+            show_stdout_stderr
+        fi
+    fi
+}
+
+start_venv() {
+    if [ "$VENV_DIR" = "-" ] || [ "$SKIP_VENV" = "1" ]; then
+        return
+    fi
+
+    if [ -x "$VENV_DIR/bin/python" ]; then
+        activate_venv
+    else
+        PYTHON_FULLNAME=$(python -c "import sys; print(sys.executable)")
+        echo "Creating venv in directory $VENV_DIR using python $PYTHON_FULLNAME"
+        $PYTHON_FULLNAME -m venv "$VENV_DIR" >tmp/stdout.txt 2>tmp/stderr.txt
+        if [ $? -eq 0 ]; then
+            upgrade_pip
+        else
+            echo "Unable to create venv in directory $VENV_DIR"
+            show_stdout_stderr
+        fi
+    fi
+}
+
+upgrade_pip() {
+    "$VENV_DIR/bin/python" -m pip install --upgrade pip
+    if [ $? -eq 0 ]; then
+        activate_venv
+    else
+        echo "Warning: Failed to upgrade PIP version"
+    fi
+}
+
+activate_venv() {
+    PYTHON="$VENV_DIR/bin/python"
+    source "$VENV_DIR/bin/activate"
+    echo "venv $PYTHON"
+}
+
+launch() {
+    $PYTHON run_server.py "$@"
+    read -p "Press any key to continue..."
+    exit
+}
+
+show_stdout_stderr() {
+    echo
+    echo "exit code: $?"
+
+    if [ -s "tmp/stdout.txt" ]; then
+        echo
+        echo "stdout:"
+        cat tmp/stdout.txt
+    fi
+
+    if [ -s "tmp/stderr.txt" ]; then
+        echo
+        echo "stderr:"
+        cat tmp/stderr.txt
+    fi
+}
+
+$PYTHON -c "" >tmp/stdout.txt 2>tmp/stderr.txt
+if [ $? -eq 0 ]; then
+    check_pip
+else
+    echo "Couldn't launch python"
+    show_stdout_stderr
+fi
+
+# Main script execution
+launch
diff --git a/run_server.py b/run_server.py
index ae17a17..043a6ab 100644
--- a/run_server.py
+++ b/run_server.py
@@ -1,132 +1,183 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Run the django migrations and start the server."""
-# pylint: disable=import-outside-toplevel,too-many-statements,invalid-name
-
-import importlib
-import os
-from pathlib import Path
-
-import django
-from django.core.management import call_command
-
-from ocr_translate import __version__
-
-
-def banner():
-    """Print the banner."""
-    print('--------------------------------------------')
-    print('-                                          -')
-    print(f'-     OCR_TRANSLATE version v{__version__:<13s} -')
-    print('-                                          -')
-    print('--------------------------------------------')
-
-def env_default():
-    """Set default environment variables."""
-    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
-    os.environ.setdefault('DJANGO_DEBUG', 'True')
-    os.environ.setdefault('DJANGO_LOG_LEVEL', 'INFO')
-
-def dir_check():
-    """Check if required directories exist and create them if needed."""
-    home = Path.home()  / '.ocr_translate'
-    if not 'TRANSFORMERS_CACHE' in os.environ:
-        print(f'TRANSFORMERS_CACHE not set:  Using "{home}" as transformers cache')
-        os.environ['TRANSFORMERS_CACHE'] = str(home)
-        home.mkdir(exist_ok=True, parents=True)
-    else:
-        Path(os.environ['TRANSFORMERS_CACHE']).mkdir(exist_ok=True, parents=True)
-    if not 'DATABASE_NAME' in os.environ:
-        print(f'DATABASE_NAME nto set:  Using "{home / "db.sqlite3"}" as database')
-        os.environ['DATABASE_NAME'] = str(home / 'db.sqlite3')
-        home.mkdir(exist_ok=True, parents=True)
-    elif (db_name := os.environ['DATABASE_NAME']).endswith('.sqlite3'):
-        Path(db_name).parent.mkdir(exist_ok=True, parents=True)
-
-def cuda_check():
-    """Check if cuda is available and set the environment variable DEVICE."""
-    try:
-        importlib.import_module('torch')
-    except ModuleNotFoundError:
-        pass
-    else:
-        import torch
-        if not torch.cuda.is_available():
-            print('CUDA is not available, falling back to using CPU')
-            os.environ['DEVICE'] = 'cpu'
-        elif os.environ.get('DEVICE', 'cuda') != 'cuda':
-            print('CUDA is available, but manually disabled using the "DEVICE" environment variable, using CPU')
-        else:
-            os.environ.setdefault('DEVICE', 'cuda')
-            print('CUDA is available, using GPU')
-
-def init():
-    """Run server initializations"""
-    from ocr_translate.ocr_tsl.initializers import (auto_create_languages,
-                                                    auto_create_models,
-                                                    init_most_used)
-    ac_lang = os.environ.get('AUTO_CREATE_LANGUAGES', 'true')
-    if ac_lang == 'true':
-        print('Autocreate language entries in database...')
-        auto_create_languages()
-    ac_models = os.environ.get('AUTOCREATE_VALIDATED_MODELS', 'true')
-    if ac_models == 'true':
-        print('Autocreate validated models in database...')
-        auto_create_models()
-    if os.getenv('LOAD_ON_START', 'false') == 'true':
-        print('Load most used models in memory...')
-
-        init_most_used()
-def superuser():
-    """Create a superuser if it does not exist and check for default password."""
-    default_su_password = 'password'
-    su_name = os.environ.get('DJANGO_SUPERUSER_USERNAME', 'admin')
-    su_pass = os.environ.get('DJANGO_SUPERUSER_PASSWORD', default_su_password)
-    if su_name and su_pass:
-        from django.contrib.auth import get_user_model
-        User = get_user_model()
-        if not User.objects.filter(username=su_name).exists():
-            print(f'Creating superuser "{su_name}" with password "{su_pass}"')
-            User.objects.create_superuser(su_name, '', su_pass)
-        else:
-            print(f'Superuser "{su_name}" already exists')
-            if User.objects.get(username=su_name).check_password(default_su_password):
-                print(f'   password still set to the default "{default_su_password}"')
-
-def main():
-    """Run the django migrations and start the server."""
-    banner()
-    env_default()
-    dir_check()
-    cuda_check()
-
-    print('Running django setup...')
-    django.setup()
-
-    print('Create database if needed and apply database migrations (if any)...')
-    call_command('migrate')
-
-    superuser()
-    init()
-
-    print('Starting server...')
-    call_command('runserver', '--noreload', '4000')
-
-if __name__ == '__main__':
-    main()
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Run the django migrations and start the server."""
+# pylint: disable=import-outside-toplevel,too-many-statements,invalid-name
+
+import importlib
+import os
+import subprocess
+from pathlib import Path
+
+try:
+    import django
+except ImportError:
+    print('Django not found: installing django-ocr_translate...')
+    subprocess.run(['pip', 'install', 'django-ocr_translate==0.6.0'], check=True)
+    # subprocess.run(['pip', 'install', '../..'], check=True)
+    import django
+
+from django.core.management import call_command
+
+import ocr_translate.plugin_manager as pm
+from ocr_translate import __version__
+
+
+def banner():
+    """Print the banner."""
+    print('--------------------------------------------')
+    print('-                                          -')
+    print(f'-     OCR_TRANSLATE version v{__version__:<13s} -')
+    print('-                                          -')
+    print('--------------------------------------------')
+
+def env_default():
+    """Set default environment variables."""
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
+    os.environ.setdefault('DJANGO_DEBUG', 'True')
+    os.environ.setdefault('DJANGO_LOG_LEVEL', 'INFO')
+
+def dir_check():
+    """Check if required directories exist and create them if needed."""
+    if not 'OCT_BASE_DIR' in os.environ:
+        base = Path.home()  / '.ocr_translate'
+        print(f'OCT_BASE_DIR not set:  Using "{base}" as base dir for ocr_translate')
+        os.environ['OCT_BASE_DIR'] = base.as_posix()
+    else:
+        base = Path(os.environ['OCT_BASE_DIR'])
+    base.mkdir(exist_ok=True, parents=True)
+
+    if not 'DATABASE_NAME' in os.environ:
+        print(f'DATABASE_NAME not set:  Using "{base / "db.sqlite3"}" as database')
+        os.environ['DATABASE_NAME'] = (base / 'db.sqlite3').as_posix()
+        base.mkdir(exist_ok=True, parents=True)
+    elif (db_name := os.environ['DATABASE_NAME']).endswith('.sqlite3'):
+        Path(db_name).parent.mkdir(exist_ok=True, parents=True)
+
+def cuda_check():
+    """Check if cuda is available and set the environment variable DEVICE."""
+    print('Checking for CUDA availability...')
+    if 'DEVICE' in os.environ:
+        device = os.environ['DEVICE']
+        print(f'Device set via environment variable to: `{device}`')
+        if device == 'cpu':
+            return
+    else:
+        try:
+            subprocess.run(['nvidia-smi'], check=True, capture_output=True)
+        except FileNotFoundError:
+            pass
+        else:
+            print('`nvidia-smi` is available, setting DEVICE to `cuda`')
+            os.environ['DEVICE'] = 'cuda'
+
+    try:
+        # The version of torch found depends on the current scope.
+        # This check is still useful to se if a CUDA capale torch is installed but a GPU is not available.
+        pm.PluginManager()  # Make sure the plugin manager is initialized to have installed plugins libs in path
+        importlib.import_module('torch')
+    except ModuleNotFoundError:
+        print('Torch not found: cannot check for CUDA availability explicitly.')
+        print('In case of errors try setting the DEVICE environment variable to "cpu"')
+    else:
+        print('Torch found, using it for CUDA availability check...')
+        import torch
+        if not torch.cuda.is_available():
+            print('CUDA is not available, falling back to using CPU')
+            os.environ['DEVICE'] = 'cpu'
+        else:
+            os.environ.setdefault('DEVICE', 'cuda')
+            print('CUDA is available, using GPU')
+        importlib.reload(pm)  # Reload the plugin manager to have the correct DEVICE set
+
+def init():
+    """Run server initializations"""
+    from ocr_translate import models as m
+    from ocr_translate.ocr_tsl.initializers import (auto_create_languages,
+                                                    ensure_plugins)
+    ac_lang = os.environ.get('AUTO_CREATE_LANGUAGES', 'false').lower() in ['true', '1']
+    ac_lang |= m.Language.objects.count() == 0
+    if ac_lang:
+        print('Autocreate language entries in database...')
+        auto_create_languages()
+        os.environ['AUTO_CREATE_LANGUAGES'] = 'false'
+    ensure_plugins()
+
+def superuser():
+    """Create a superuser if it does not exist and check for default password."""
+    default_su_password = 'password'
+    su_name = os.environ.get('DJANGO_SUPERUSER_USERNAME', 'admin')
+    su_pass = os.environ.get('DJANGO_SUPERUSER_PASSWORD', default_su_password)
+    if su_name and su_pass:
+        from django.contrib.auth import get_user_model
+        User = get_user_model()
+        if not User.objects.filter(username=su_name).exists():
+            print(f'Creating superuser "{su_name}" with password "{su_pass}"')
+            User.objects.create_superuser(su_name, '', su_pass)
+        else:
+            print(f'Superuser "{su_name}" already exists')
+            if User.objects.get(username=su_name).check_password(default_su_password):
+                print(f'   password still set to the default "{default_su_password}"')
+
+def main():
+    """Run the django migrations and start the server."""
+    banner()
+    env_default()
+    dir_check()
+    print('--------------------------------------------')
+    cuda_check()
+
+    print('--------------------------------------------')
+    print('Running django setup...')
+    django.setup()
+
+    print('Create database (if needed) and apply database migrations (if any)...')
+    call_command('migrate')
+    print('--------------------------------------------')
+
+    superuser()
+    print('--------------------------------------------')
+    print('Running server initializations...')
+    init()
+
+    bind_address = os.environ.get('OCT_DJANGO_BIND_ADDRESS', '127.0.0.1')
+    port = os.environ.get('OCT_DJANGO_PORT', '4000')
+
+    print('--------------------------------------------')
+    print('Starting server...')
+    try:
+        importlib.import_module('gunicorn')
+    except ImportError:
+        print('...using django development server')
+        call_command('runserver', '--noreload', f'{bind_address}:{port}')
+    else:
+        print('...using gunicorn')
+        import getpass
+        user = os.environ.get('OCT_GUNICORN_USER', getpass.getuser())
+        timeout = os.environ.get('OCT_GUNICORN_TIMEOUT', '1200')
+        num_workers = os.environ.get('OCT_GUNICORN_NUM_WORKERS', '1')
+        subprocess.run([
+            'gunicorn', 'mysite.wsgi',
+            '--user', user,
+            '--bind', f'{bind_address}:{port}',
+            '--timeout', timeout,
+            '--workers', num_workers,
+        ], check=True)
+
+if __name__ == '__main__':
+    main()
diff --git a/start-server.sh b/start-server.sh
index 697f5da..6af8e54 100644
--- a/start-server.sh
+++ b/start-server.sh
@@ -2,45 +2,34 @@
 # start-server.sh
 
 echo "Create group and user with specified UID/GID"
-groupadd -g ${GID} runner
+USER=runner
+if [ ! -z ${OCT_GUNICORN_USER} ]; then
+    USER=${OCT_GUNICORN_USER}
+fi
+groupadd -g ${GID} ${USER}
 useradd -u ${UID} -g ${GID} -s /bin/bash runner
 
-source /venv/bin/activate
+mkdir -p /plugin_data
+mkdir -p /models
+mkdir -p /db_data
 
-function migrate {
-    echo "Running database migrations."
-    LOAD_ON_START=false AUTOCREATE_LANGUAGES=false AUTOCREATE_VALIDATED_MODELS=false python manage.py migrate
-    if [ $? -ne 0 ]; then
-        echo "Error: Database migration failed."
-        exit 1
-    fi
-}
+chown -R ${USER}:${USER} /plugin_data
+chown -R ${USER}:${USER} /models
+chown -R ${USER}:${USER} /db_data
 
-export EASYOCR_MODULE_PATH="${TRANSFORMERS_CACHE}/.easyocr"
-export HF_TRANSORMERS_CACHE="${TRANSFORMERS_CACHE}"
+echo "Starting nginx."
+nginx
 
-echo "Make sure /models folder is writable is readable and writable"
-chown -R runner:runner /models
+su ${USER}
 
-if [ ${DATABASE_ENGINE} == 'django.db.backends.sqlite3' ]; then
-    if [[ ! -e ${DATABASE_NAME} ]]; then
-        migrate
-    fi
-fi
-echo "Make sure database is readable and writable"
-chown -R runner:runner /data
+export OCT_DJANGO_PORT=4010
+export OCT_BASE_DIR="/plugin_data"
+export TRANSFORMERS_CACHE="/models/huggingface"
+export TRANSFORMERS_OFFLINE="0"
+export EASYOCR_MODULE_PATH="/models/easyocr"
+export TESSERACT_PREFIX="/models/tesseract"
+export TESSERACT_ALLOW_DOWNLOAD="true"
+export PADDLEOCR_PREFIX="/models/paddleocr"
 
-# If starting from an uninitialized database, run migrations
-if [[ ! `python manage.py inspectdb` ]]; then
-    migrate
-fi
-# Create superuser if DJANGO_SUPERUSER_USERNAME and DJANGO_SUPERUSER_PASSWORD are set
-if [ -n "${DJANGO_SUPERUSER_USERNAME}" ] && [ -n "${DJANGO_SUPERUSER_PASSWORD}" ] ; then
-    echo "Creating superuser ${DJANGO_SUPERUSER_USERNAME}"
-    (LOAD_ON_START=false AUTOCREATE_LANGUAGES=false AUTOCREATE_VALIDATED_MODELS=false python manage.py createsuperuser --no-input --email a@b.c)
-fi
-
-echo "Starting Gunicorn with #${NUM_WEB_WORKERS} workers."
-su runner -c "source /venv/bin/activate && gunicorn mysite.wsgi --user runner --bind 0.0.0.0:4010 --workers ${NUM_WEB_WORKERS}" &
-echo "Starting nginx."
-nginx -g "daemon off;"
+source /venv/bin/activate
+python run_server.py


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c6b39c7..17b4f46 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,294 +1,318 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Fixtures for the tests."""
-
-import numpy as np
-import pytest
-from PIL import Image
-
-import ocr_translate
-from ocr_translate import models as m
-from ocr_translate import ocr_tsl, views
-from ocr_translate.ocr_tsl import box, lang, ocr, tsl
-
-strings = [
-    'This is a test string.',
-    'This is a test string.\nWith a newline.',
-    'This is a test string.\nWith a newline.\nAnd another.',
-    'This is a test string.? With a special break character.',
-    'This is a test string.? With a special break character.\nAnd a newline.',
-    'String with a dash-newline brok-\nen word.'
-]
-ids = [
-    'simple',
-    'newline',
-    'newlines',
-    'breakchar',
-    'breakchar_newline',
-    'dash_newline'
-]
-
-@pytest.fixture(params=strings, ids=ids)
-def string(request):
-    """String to perform TSL on."""
-    return request.param
-
-@pytest.fixture()
-def batch_string(string):
-    """Batched string to perform TSL on."""
-    return [string, string, string]
-
-
-@pytest.fixture()
-def language_dict():
-    """Dict defining a language"""
-    return {
-        'name': 'Japanese',
-        'iso1': 'ja',
-        'iso2b': 'jpn',
-        'iso2t': 'jpn',
-        'iso3': 'jpn',
-    }
-
-@pytest.fixture()
-def box_model_dict():
-    """Dict defining an OCRBoxModel"""
-    return {
-        'name': 'test_box_model/id',
-        'language_format': 'iso1',
-        'entrypoint': 'test_entrypoint.box',
-        'iso1_map': {
-            'ja': 'jap',
-        }
-    }
-
-@pytest.fixture()
-def ocr_model_dict():
-    """Dict defining an OCRModel"""
-    return {
-        'name': 'test_ocr_model/id',
-        'language_format': 'iso1',
-        'entrypoint': 'test_entrypoint.ocr'
-    }
-
-@pytest.fixture()
-def ocr_model_dict_single():
-    """Dict defining an OCRModel working in single mode only"""
-    return {
-        'name': 'test_ocr_model/id',
-        'language_format': 'iso1',
-        'entrypoint': 'test_entrypoint.ocr',
-        'ocr_mode': 'single'
-    }
-
-@pytest.fixture()
-def tsl_model_dict():
-    """Dict defining a TSLModel"""
-    return {
-        'name': 'test_tsl_model/id',
-        'language_format': 'iso1',
-        'entrypoint': 'test_entrypoint.tsl'
-    }
-
-@pytest.fixture()
-def option_dict():
-    """OptionDict database object."""
-    return m.OptionDict.objects.create(options={})
-
-@pytest.fixture()
-def language(language_dict):
-    """Language database object."""
-    return m.Language.objects.create(**language_dict)
-
-@pytest.fixture()
-def language2(language_dict):
-    """Another Language database object."""
-    dct = {k: v+'2' for k, v in language_dict.items()}
-    return m.Language.objects.create(**dct)
-
-@pytest.fixture(scope='session')
-def image_pillow():
-    """Random Pillow image."""
-    npimg = np.random.randint(0,255,(25,25,3), dtype=np.uint8)
-    return Image.fromarray(npimg)
-
-@pytest.fixture()
-def image():
-    """Image database object."""
-    return m.Image.objects.create(md5='test_md5')
-
-@pytest.fixture()
-def text():
-    """Text database object."""
-    return m.Text.objects.create(text='test_text')
-
-@pytest.fixture()
-def box_model(language, box_model_dict):
-    """OCRBoxModel database object."""
-    res = m.OCRBoxModel.objects.create(**box_model_dict)
-    res.languages.add(language)
-
-    return res
-
-@pytest.fixture()
-def ocr_model(language, ocr_model_dict):
-    """OCRModel database object."""
-    res = m.OCRModel.objects.create(**ocr_model_dict)
-    res.languages.add(language)
-
-    return res
-
-@pytest.fixture()
-def ocr_model_single(language, ocr_model_dict_single):
-    """OCRModel database object."""
-    res = m.OCRModel.objects.create(**ocr_model_dict_single)
-    res.languages.add(language)
-
-    return res
-
-@pytest.fixture()
-def tsl_model(language, tsl_model_dict):
-    """TSLModel database object."""
-    res = m.TSLModel.objects.create(**tsl_model_dict)
-    res.src_languages.add(language)
-    res.dst_languages.add(language)
-
-    return res
-
-@pytest.fixture()
-def manual_model():
-    """ManualModel database object."""
-    return m.TSLModel.objects.create(name='manual')
-
-@pytest.fixture()
-def box_run(language, image, box_model, option_dict):
-    """OCRBoxRun database object."""
-    return m.OCRBoxRun.objects.create(lang_src=language, image=image, model=box_model, options=option_dict)
-
-
-@pytest.fixture()
-def bbox(image, box_run):
-    """BBox database object."""
-    return m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4, from_ocr_merged=box_run)
-
-@pytest.fixture()
-def bbox_single(image, box_run):
-    """BBox database object."""
-    return m.BBox.objects.create(image=image, l=1, b=2, r=1, t=2, from_ocr_single=box_run)
-
-@pytest.fixture()
-def ocr_run(language, bbox, ocr_model, option_dict, text):
-    """OCRRun database object."""
-    return m.OCRRun.objects.create(
-        lang_src=language, bbox=bbox, model=ocr_model, options=option_dict, result_merged=text
-        )
-
-@pytest.fixture()
-def tsl_run(language, text, tsl_model, option_dict):
-    """OCRTSRun database object."""
-    return m.TranslationRun.objects.create(
-        lang_src=language, lang_dst=language, text=text, model=tsl_model, options=option_dict, result=text
-        )
-
-@pytest.fixture()
-def mock_loaded(monkeypatch, language, box_model, ocr_model, tsl_model):
-    """Mock models being loaded"""
-    monkeypatch.setattr(box, 'BOX_MODEL_ID', box_model.name)
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(ocr, 'OBJ_MODEL_ID', ocr_model.name)
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_ID', tsl_model.name)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(lang, 'LANG_DST', language)
-
-@pytest.fixture()
-def mock_loaders(monkeypatch):
-    """Mock the load functions. Act on global variables, but avoid downloading and actually loading models."""
-    def mock_load_lang_src(name):
-        monkeypatch.setattr(lang, 'LANG_SRC', m.Language.objects.get(iso1=name))
-    def mock_load_lang_dst(name):
-        monkeypatch.setattr(lang, 'LANG_DST', m.Language.objects.get(iso1=name))
-    def mock_load_box_model(name):
-        monkeypatch.setattr(box, 'BOX_MODEL_ID', name)
-        monkeypatch.setattr(box, 'BOX_MODEL_OBJ', m.OCRBoxModel.objects.get(name=name))
-    def mock_load_ocr_model(name):
-        monkeypatch.setattr(ocr, 'OBJ_MODEL_ID', name)
-        monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', m.OCRModel.objects.get(name=name))
-    def mock_load_tsl_model(name):
-        monkeypatch.setattr(tsl, 'TSL_MODEL_ID', name)
-        monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', m.TSLModel.objects.get(name=name))
-
-    dct = {
-        'load_lang_src': mock_load_lang_src,
-        'load_lang_dst': mock_load_lang_dst,
-        'load_box_model': mock_load_box_model,
-        'load_ocr_model': mock_load_ocr_model,
-        'load_tsl_model': mock_load_tsl_model,
-    }
-
-    for mod in [ocr_translate, ocr_tsl, ocr_tsl.initializers, box, lang, ocr, tsl, views]:
-        for fname, mock in dct.items():
-            try:
-                monkeypatch.setattr(mod, fname, mock)
-            except AttributeError:
-                pass
-
-@pytest.fixture(scope='function')
-def mock_called(request):
-    """Generic mock function to check if it was called."""
-    def mock_call(*args, **kwargs): # pylint: disable=inconsistent-return-statements
-        mock_call.called = True
-        mock_call.args = args
-        mock_call.kwargs = kwargs
-
-        if hasattr(request, 'param'):
-            return request.param
-
-    if hasattr(request, 'param'):
-        mock_call.expected = request.param
-
-    return mock_call
-
-@pytest.fixture()
-def mock_load_ept():
-    """Mock entrypoint loading for testing with dummy models."""
-    def function(namespace):
-        res = {
-            'lang': ['ja'],
-            'lang_code': 'iso1',
-        }
-        if namespace == 'ocr_translate.box_data':
-            res['name'] = 'test_box'
-            res['entrypoint'] = 'test.box'
-        elif namespace == 'ocr_translate.ocr_data':
-            res['name'] = 'test_ocr'
-            res['entrypoint'] = 'test.ocr'
-        elif namespace == 'ocr_translate.tsl_data':
-            l = res.pop('lang')
-            res['name'] = 'test_tsl'
-            res['entrypoint'] = 'test.tsl'
-            res['lang_src'] = l
-            res['lang_dst'] = l
-        else:
-            raise ValueError(f'Unknown namespace: {namespace}')
-
-        return [res]
-
-    return function
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Fixtures for the tests."""
+
+import numpy as np
+import pytest
+from PIL import Image
+
+import ocr_translate
+from ocr_translate import entrypoint_manager as epm
+from ocr_translate import models as m
+from ocr_translate import ocr_tsl, queues, views
+from ocr_translate.ocr_tsl import box, lang, ocr, tsl
+
+strings = [
+    'This is a test string.',
+    'This is a test string.\nWith a newline.',
+    'This is a test string.\nWith a newline.\nAnd another.',
+    'This is a test string.? With a special break character.',
+    'This is a test string.? With a special break character.\nAnd a newline.',
+    'String with a dash-newline brok-\nen word.'
+]
+ids = [
+    'simple',
+    'newline',
+    'newlines',
+    'breakchar',
+    'breakchar_newline',
+    'dash_newline'
+]
+
+@pytest.fixture(params=strings, ids=ids)
+def string(request):
+    """String to perform TSL on."""
+    return request.param
+
+@pytest.fixture()
+def batch_string(string):
+    """Batched string to perform TSL on."""
+    return [string, string, string]
+
+
+@pytest.fixture()
+def language_dict():
+    """Dict defining a language"""
+    return {
+        'name': 'Japanese',
+        'iso1': 'ja',
+        'iso2b': 'jpn',
+        'iso2t': 'jpn',
+        'iso3': 'jpn',
+    }
+
+@pytest.fixture()
+def box_model_dict():
+    """Dict defining an OCRBoxModel"""
+    return {
+        'name': 'test_box_model/id',
+        'language_format': 'iso1',
+        'entrypoint': 'test_entrypoint.box',
+        'iso1_map': {
+            'ja': 'jap',
+        },
+        'active': True
+    }
+
+@pytest.fixture()
+def ocr_model_dict():
+    """Dict defining an OCRModel"""
+    return {
+        'name': 'test_ocr_model/id',
+        'language_format': 'iso1',
+        'entrypoint': 'test_entrypoint.ocr',
+        'active': True
+    }
+
+@pytest.fixture()
+def ocr_model_dict_single():
+    """Dict defining an OCRModel working in single mode only"""
+    return {
+        'name': 'test_ocr_model/id',
+        'language_format': 'iso1',
+        'entrypoint': 'test_entrypoint.ocr',
+        'ocr_mode': 'single',
+        'active': True
+    }
+
+@pytest.fixture()
+def tsl_model_dict():
+    """Dict defining a TSLModel"""
+    return {
+        'name': 'test_tsl_model/id',
+        'language_format': 'iso1',
+        'entrypoint': 'test_entrypoint.tsl',
+        'active': True
+    }
+
+@pytest.fixture()
+def option_dict():
+    """OptionDict database object."""
+    return m.OptionDict.objects.create(options={})
+
+@pytest.fixture()
+def language(language_dict):
+    """Language database object."""
+    return m.Language.objects.create(**language_dict)
+
+@pytest.fixture()
+def language2(language_dict):
+    """Another Language database object."""
+    dct = {k: v+'2' for k, v in language_dict.items()}
+    return m.Language.objects.create(**dct)
+
+@pytest.fixture(scope='session')
+def image_pillow():
+    """Random Pillow image."""
+    npimg = np.random.randint(0,255,(25,25,3), dtype=np.uint8)
+    return Image.fromarray(npimg)
+
+@pytest.fixture()
+def image():
+    """Image database object."""
+    return m.Image.objects.create(md5='test_md5')
+
+@pytest.fixture()
+def text():
+    """Text database object."""
+    return m.Text.objects.create(text='test_text')
+
+@pytest.fixture()
+def box_model(language, box_model_dict):
+    """OCRBoxModel database object."""
+    res = m.OCRBoxModel.objects.create(**box_model_dict)
+    res.languages.add(language)
+
+    return res
+
+@pytest.fixture()
+def ocr_model(language, ocr_model_dict):
+    """OCRModel database object."""
+    res = m.OCRModel.objects.create(**ocr_model_dict)
+    res.languages.add(language)
+
+    return res
+
+@pytest.fixture()
+def ocr_model_single(language, ocr_model_dict_single):
+    """OCRModel database object."""
+    res = m.OCRModel.objects.create(**ocr_model_dict_single)
+    res.languages.add(language)
+
+    return res
+
+@pytest.fixture()
+def tsl_model(language, tsl_model_dict):
+    """TSLModel database object."""
+    res = m.TSLModel.objects.create(**tsl_model_dict)
+    res.src_languages.add(language)
+    res.dst_languages.add(language)
+
+    return res
+
+@pytest.fixture()
+def manual_model():
+    """ManualModel database object."""
+    return m.TSLModel.objects.create(name='manual')
+
+@pytest.fixture()
+def box_run(language, image, box_model, option_dict):
+    """OCRBoxRun database object."""
+    return m.OCRBoxRun.objects.create(lang_src=language, image=image, model=box_model, options=option_dict)
+
+
+@pytest.fixture()
+def bbox(image, box_run):
+    """BBox database object."""
+    return m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4, from_ocr_merged=box_run)
+
+@pytest.fixture()
+def bbox_single(image, box_run):
+    """BBox database object."""
+    return m.BBox.objects.create(image=image, l=1, b=2, r=1, t=2, from_ocr_single=box_run)
+
+@pytest.fixture()
+def ocr_run(language, bbox, ocr_model, option_dict, text):
+    """OCRRun database object."""
+    return m.OCRRun.objects.create(
+        lang_src=language, bbox=bbox, model=ocr_model, options=option_dict, result_merged=text
+        )
+
+@pytest.fixture()
+def tsl_run(language, text, tsl_model, option_dict):
+    """OCRTSRun database object."""
+    return m.TranslationRun.objects.create(
+        lang_src=language, lang_dst=language, text=text, model=tsl_model, options=option_dict, result=text
+        )
+
+@pytest.fixture()
+def mock_loaded_lang_only(monkeypatch, language):
+    """Mock languages being loaded"""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+
+@pytest.fixture()
+def mock_loaded(monkeypatch, language, box_model, ocr_model, tsl_model):
+    """Mock models being loaded"""
+    monkeypatch.setattr(box, 'BOX_MODEL_ID', box_model.name)
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(ocr, 'OBJ_MODEL_ID', ocr_model.name)
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_ID', tsl_model.name)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+
+@pytest.fixture()
+def mock_loaders(monkeypatch):
+    """Mock the load functions. Act on global variables, but avoid downloading and actually loading models."""
+    def mock_load_lang_src(name):
+        monkeypatch.setattr(lang, 'LANG_SRC', m.Language.objects.get(iso1=name))
+    def mock_load_lang_dst(name):
+        monkeypatch.setattr(lang, 'LANG_DST', m.Language.objects.get(iso1=name))
+    def mock_load_box_model(name):
+        monkeypatch.setattr(box, 'BOX_MODEL_ID', name)
+        monkeypatch.setattr(box, 'BOX_MODEL_OBJ', m.OCRBoxModel.objects.get(name=name))
+    def mock_load_ocr_model(name):
+        monkeypatch.setattr(ocr, 'OBJ_MODEL_ID', name)
+        monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', m.OCRModel.objects.get(name=name))
+    def mock_load_tsl_model(name):
+        monkeypatch.setattr(tsl, 'TSL_MODEL_ID', name)
+        monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', m.TSLModel.objects.get(name=name))
+
+    dct = {
+        'load_lang_src': mock_load_lang_src,
+        'load_lang_dst': mock_load_lang_dst,
+        'load_box_model': mock_load_box_model,
+        'load_ocr_model': mock_load_ocr_model,
+        'load_tsl_model': mock_load_tsl_model,
+    }
+
+    for mod in [ocr_translate, ocr_tsl, ocr_tsl.initializers, box, lang, ocr, tsl, views]:
+        for fname, mock in dct.items():
+            try:
+                monkeypatch.setattr(mod, fname, mock)
+            except AttributeError:
+                pass
+
+@pytest.fixture(scope='function')
+def mock_called(request):
+    """Generic mock function to check if it was called."""
+    def mock_call(*args, **kwargs): # pylint: disable=inconsistent-return-statements
+        mock_call.called = True
+        mock_call.args = args
+        mock_call.kwargs = kwargs
+
+        if hasattr(request, 'param'):
+            return request.param
+
+    if hasattr(request, 'param'):
+        mock_call.expected = request.param
+
+    return mock_call
+
+@pytest.fixture()
+def mock_load_ept():
+    """Mock entrypoint loading for testing with dummy models."""
+    def function(namespace):
+        res = {
+            'lang': ['ja'],
+            'lang_code': 'iso1',
+        }
+        if namespace == 'ocr_translate.box_data':
+            res['name'] = 'test_box'
+            res['entrypoint'] = 'test.box'
+        elif namespace == 'ocr_translate.ocr_data':
+            res['name'] = 'test_ocr'
+            res['entrypoint'] = 'test.ocr'
+        elif namespace == 'ocr_translate.tsl_data':
+            l = res.pop('lang')
+            res['name'] = 'test_tsl'
+            res['entrypoint'] = 'test.tsl'
+            res['lang_src'] = l
+            res['lang_dst'] = l
+        else:
+            raise ValueError(f'Unknown namespace: {namespace}')
+
+        return [res]
+
+    return function
+
+@pytest.fixture()
+def queues_no_reuse(monkeypatch):
+    """Set queues to not reuse connections."""
+    monkeypatch.setattr(queues.main_queue.msg_queue, 'reuse_msg', False)
+    monkeypatch.setattr(queues.box_queue.msg_queue, 'reuse_msg', False)
+    monkeypatch.setattr(queues.ocr_queue.msg_queue, 'reuse_msg', False)
+    monkeypatch.setattr(queues.tsl_queue.msg_queue, 'reuse_msg', False)
+
+@pytest.fixture()
+def epm_no_ept(monkeypatch):
+    """Set entrypoints to be empty."""
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda *args, **kwargs: set())
diff --git a/tests/messaging/test_message.py b/tests/messaging/test_message.py
index e4f7f69..8e5bce5 100644
--- a/tests/messaging/test_message.py
+++ b/tests/messaging/test_message.py
@@ -89,8 +89,7 @@ def test_message_ne_different(message):
 def test_message_resolve_exception(message):
     """Test exception handling in the message resolve method."""
     assert not message.is_resolved
-    with pytest.raises(Exception):
-        message.resolve()
+    message.resolve()
     res = message.response()
     assert isinstance(res, Exception)
     assert res.args == ('Test exception',)
diff --git a/tests/ocr_tsl/test_cached_lists.py b/tests/ocr_tsl/test_cached_lists.py
new file mode 100644
index 0000000..95f9718
--- /dev/null
+++ b/tests/ocr_tsl/test_cached_lists.py
@@ -0,0 +1,222 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Tests for the cached lists."""
+#pylint: disable=protected-access
+
+import pytest
+
+from ocr_translate.ocr_tsl import cached_lists as cl
+from ocr_translate.ocr_tsl import lang, signals
+
+pytestmark = pytest.mark.django_db
+
+def test_refresh_lang_cache(language):
+    """Test that the lang cache is refreshed."""
+    cl.refresh_lang_cache()
+    assert set(cl.ALL_LANG_SRC) == {language}
+    assert set(cl.ALL_LANG_DST) == {language}
+
+def test_refresh_lang_cache2(language, language2):
+    """Test that the lang cache is refreshed."""
+    cl.refresh_lang_cache()
+    assert set(cl.ALL_LANG_SRC) == {language, language2}
+    assert set(cl.ALL_LANG_DST) == {language, language2}
+
+def test_get_lang_src_none(monkeypatch, mock_called):
+    """Test that the lang cache is refreshed when ALL_LANG_SRC is None."""
+    monkeypatch.setattr(cl, 'ALL_LANG_SRC', None)
+    monkeypatch.setattr(cl, 'refresh_lang_cache', mock_called)
+
+    assert cl.ALL_LANG_SRC is None
+    cl.get_all_lang_src()
+    assert hasattr(mock_called, 'called')
+
+def test_get_lang_src_set(monkeypatch, mock_called):
+    """Test that the lang cache is not refreshed when ALL_LANG_SRC is set."""
+    monkeypatch.setattr(cl, 'ALL_LANG_SRC', 'test')
+    monkeypatch.setattr(cl, 'refresh_lang_cache', mock_called)
+
+    assert cl.ALL_LANG_SRC == 'test'
+    cl.get_all_lang_src()
+    assert not hasattr(mock_called, 'called')
+
+def test_get_lang_dst_none(monkeypatch, mock_called):
+    """Test that the lang cache is refreshed when ALL_LANG_DST is None."""
+    monkeypatch.setattr(cl, 'ALL_LANG_DST', None)
+    monkeypatch.setattr(cl, 'refresh_lang_cache', mock_called)
+
+    assert cl.ALL_LANG_DST is None
+    cl.get_all_lang_dst()
+    assert hasattr(mock_called, 'called')
+
+def test_get_lang_dst_set(monkeypatch, mock_called):
+    """Test that the lang cache is not refreshed when ALL_LANG_DST set."""
+    monkeypatch.setattr(cl, 'ALL_LANG_DST', 'test')
+    monkeypatch.setattr(cl, 'refresh_lang_cache', mock_called)
+
+    assert cl.ALL_LANG_DST == 'test'
+    cl.get_all_lang_dst()
+    assert not hasattr(mock_called, 'called')
+
+def test_refresh_model_cache(monkeypatch, language):
+    """Test that the model cache is refreshed."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == set()
+    assert set(cl.ALLOWED_OCR_MODELS) == set()
+    assert set(cl.ALLOWED_TSL_MODELS) == set()
+
+def test_refresh_model_cache_all(monkeypatch, language, box_model, ocr_model, tsl_model):
+    """Test that the model cache is refreshed."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == {box_model}
+    assert set(cl.ALLOWED_OCR_MODELS) == {ocr_model}
+    assert set(cl.ALLOWED_TSL_MODELS) == {tsl_model}
+
+def test_refresh_model_cache_all_inactive_box(monkeypatch, language, box_model, ocr_model, tsl_model):
+    """Test that the model cache is refreshed."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    box_model.active = False
+    box_model.save()
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == set()
+    assert set(cl.ALLOWED_OCR_MODELS) == {ocr_model}
+    assert set(cl.ALLOWED_TSL_MODELS) == {tsl_model}
+
+def test_refresh_model_cache_all_inactive_ocr(monkeypatch, language, box_model, ocr_model, tsl_model):
+    """Test that the model cache is refreshed."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    ocr_model.active = False
+    ocr_model.save()
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == {box_model}
+    assert set(cl.ALLOWED_OCR_MODELS) == set()
+    assert set(cl.ALLOWED_TSL_MODELS) == {tsl_model}
+
+def test_refresh_model_cache_all_inactive_tsl(monkeypatch, language, box_model, ocr_model, tsl_model):
+    """Test that the model cache is refreshed."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    tsl_model.active = False
+    tsl_model.save()
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == {box_model}
+    assert set(cl.ALLOWED_OCR_MODELS) == {ocr_model}
+    assert set(cl.ALLOWED_TSL_MODELS) == set()
+
+def test_refresh_model_cache_all_none(language, box_model, ocr_model, tsl_model):
+    """Test that the model cache is refreshed."""
+    cl.refresh_model_cache()
+    assert set(cl.ALLOWED_BOX_MODELS) == set()
+    assert set(cl.ALLOWED_OCR_MODELS) == set()
+    assert set(cl.ALLOWED_TSL_MODELS) == set()
+
+def test_get_allowed_box_models_none(monkeypatch, mock_called):
+    """Test that the lang cache is refreshed when ALLOWED_BOX_MODELS is None."""
+    monkeypatch.setattr(cl, 'ALLOWED_BOX_MODELS', None)
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_BOX_MODELS is None
+    cl.get_allowed_box_models()
+    assert hasattr(mock_called, 'called')
+
+def test_get_allowed_box_models_set(monkeypatch, mock_called):
+    """Test that the lang cache is not refreshed when ALLOWED_BOX_MODELS set."""
+    monkeypatch.setattr(cl, 'ALLOWED_BOX_MODELS', 'test')
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_BOX_MODELS == 'test'
+    cl.get_allowed_box_models()
+    assert not hasattr(mock_called, 'called')
+
+def test_get_allowed_ocr_models_none(monkeypatch, mock_called):
+    """Test that the lang cache is refreshed when ALLOWED_OCR_MODELS is None."""
+    monkeypatch.setattr(cl, 'ALLOWED_OCR_MODELS', None)
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_OCR_MODELS is None
+    cl.get_allowed_ocr_models()
+    assert hasattr(mock_called, 'called')
+
+def test_get_allowed_ocr_models_set(monkeypatch, mock_called):
+    """Test that the lang cache is not refreshed when ALLOWED_OCR_MODELS set."""
+    monkeypatch.setattr(cl, 'ALLOWED_OCR_MODELS', 'test')
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_OCR_MODELS == 'test'
+    cl.get_allowed_ocr_models()
+    assert not hasattr(mock_called, 'called')
+
+def test_get_allowed_tsl_models_none(monkeypatch, mock_called):
+    """Test that the lang cache is refreshed when ALLOWED_TSL_MODELS is None."""
+    monkeypatch.setattr(cl, 'ALLOWED_TSL_MODELS', None)
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_TSL_MODELS is None
+    cl.get_allowed_tsl_models()
+    assert hasattr(mock_called, 'called')
+
+def test_get_allowed_tsl_models_set(monkeypatch, mock_called):
+    """Test that the lang cache is not refreshed when ALLOWED_TSL_MODELS set."""
+    monkeypatch.setattr(cl, 'ALLOWED_TSL_MODELS', 'test')
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+
+    assert cl.ALLOWED_TSL_MODELS == 'test'
+    cl.get_allowed_tsl_models()
+    assert not hasattr(mock_called, 'called')
+
+def test_post_save_lang_signal(monkeypatch, mock_called, language):
+    """Test that the lang cache is refreshed on post_save signal."""
+    monkeypatch.setattr(cl, 'refresh_lang_cache', mock_called)
+    assert not hasattr(mock_called, 'called')
+    language.save()
+    assert hasattr(mock_called, 'called')
+
+def test_post_save_box_signal(monkeypatch, mock_called, box_model):
+    """Test that the model cache is refreshed on post_save signal from OCRBoxModel."""
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+    assert not hasattr(mock_called, 'called')
+    box_model.save()
+    assert hasattr(mock_called, 'called')
+
+def test_post_save_ocr_signal(monkeypatch, mock_called, ocr_model):
+    """Test that the model cache is refreshed on post_save signal from OCRModel."""
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+    assert not hasattr(mock_called, 'called')
+    ocr_model.save()
+    assert hasattr(mock_called, 'called')
+
+def test_post_save_tsl_signal(monkeypatch, mock_called, tsl_model):
+    """Test that the model cache is refreshed on post_save signal from TSLModel."""
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+    assert not hasattr(mock_called, 'called')
+    tsl_model.save()
+    assert hasattr(mock_called, 'called')
+
+def test_refresh_model_cache_signal(monkeypatch, mock_called):
+    """Test that the model cache is refreshed on refresh_model_cache_signal."""
+    monkeypatch.setattr(cl, 'refresh_model_cache', mock_called)
+    assert not hasattr(mock_called, 'called')
+    signals.refresh_model_cache_signal.send(sender=None)
+    assert hasattr(mock_called, 'called')
diff --git a/tests/ocr_tsl/test_oct_init.py b/tests/ocr_tsl/test_oct_init.py
new file mode 100644
index 0000000..fafa5e5
--- /dev/null
+++ b/tests/ocr_tsl/test_oct_init.py
@@ -0,0 +1,81 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Tests ENVIROMENT VARIABLES initialization."""
+
+import importlib
+
+import pytest
+from django.db.utils import OperationalError
+
+from ocr_translate import ocr_tsl as o
+from ocr_translate.ocr_tsl import initializers as ini
+
+
+@pytest.fixture(autouse=True)
+def init_no_fail(monkeypatch):
+    """Initialize without fail."""
+    monkeypatch.setattr(o, 'FAIL', False)
+
+def test_oct_init_fail(monkeypatch, capsys):
+    """Test call on env variable."""
+    def raise_func():
+        raise OperationalError('Test')
+    monkeypatch.setenv('LOAD_ON_START', 'true')
+    monkeypatch.setattr(ini, 'init_most_used', raise_func)
+    importlib.reload(o)
+
+    assert o.FAIL
+    captured = capsys.readouterr()
+    stdout = list(filter(None, captured.out.split('\n')))
+    assert len(stdout) == 3
+    assert all(x.startswith('WARNING:') for x in stdout)
+
+def test_oct_init_call_true(monkeypatch, mock_called):
+    """Test call on env variable."""
+    monkeypatch.setattr(o, 'FAIL', False)
+    monkeypatch.setenv('TEST', 'True')
+
+    assert not hasattr(mock_called, 'called')
+    o.run_on_env('TEST', mock_called)
+    assert hasattr(mock_called, 'called')
+    assert not o.FAIL
+
+def test_oct_init_call_false(monkeypatch, mock_called):
+    """Test call on env variable."""
+    monkeypatch.setenv('TEST', 'False')
+
+    assert not hasattr(mock_called, 'called')
+    o.run_on_env('TEST', mock_called)
+    assert not hasattr(mock_called, 'called')
+    assert not o.FAIL
+
+def test_oct_init_call_notdef(mock_called):
+    """Test call on env variable."""
+    assert not hasattr(mock_called, 'called')
+    o.run_on_env('TEST', mock_called)
+    assert not hasattr(mock_called, 'called')
+    assert not o.FAIL
+
+def test_oct_init_call_raise(monkeypatch):
+    """Test call on env variable."""
+    monkeypatch.setenv('TEST', 'True')
+    def func():
+        raise OperationalError('Test')
+    o.run_on_env('TEST', func)
+    assert o.FAIL
diff --git a/tests/test_entrypoint_manager.py b/tests/test_entrypoint_manager.py
new file mode 100644
index 0000000..0d51f42
--- /dev/null
+++ b/tests/test_entrypoint_manager.py
@@ -0,0 +1,194 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test plugin manager."""
+
+# pylint: disable=unused-argument,redefined-outer-name
+
+import importlib
+from collections import defaultdict
+from importlib.metadata import EntryPoint
+
+import pytest
+
+from ocr_translate import entrypoint_manager as epm
+from ocr_translate import models as m
+from ocr_translate.ocr_tsl import box, cached_lists
+from ocr_translate.ocr_tsl import initializers as ini
+
+
+@pytest.fixture
+def mock_cls(request):
+    """Mock class"""
+    class MockCls:
+        """Mock class"""
+        get_counter = defaultdict(int)
+        set_counter = defaultdict(int)
+        call_counter = defaultdict(int)
+
+        class DoesNotExist(Exception):
+            """Mock DoesNotExist"""
+
+        def __init__(self):
+            self.prev68187471 = None
+
+        def __getattr__(self, name):
+            """Mock getattr"""
+            if name == 'prev68187471':
+                return object.__getattribute__(self, name)
+            MockCls.get_counter[name] += 1
+            self.prev68187471 = name
+            return self  # pylint: disable=inconsistent-return-statements
+
+        def __setattr__(self, name, value):
+            """Mock setattr"""
+            if name == 'prev68187471':
+                object.__setattr__(self, name, value)
+            MockCls.set_counter[name] += 1
+
+        def __call__(self, *args, **kwargs):
+            """Mock call"""
+            if self.prev68187471 == 'get':
+                if hasattr(request, 'param') and request.param == 'raise':
+                    raise MockCls.DoesNotExist()
+            MockCls.call_counter[self.prev68187471] += 1
+            return self
+
+    return MockCls
+
+@pytest.fixture
+def mock_models(monkeypatch, mock_cls):
+    """Mock models"""
+    box = mock_cls()
+    ocr = mock_cls()
+    tsl = mock_cls()
+    monkeypatch.setattr(m, 'OCRBoxModel', box)
+    monkeypatch.setattr(m, 'OCRModel', ocr)
+    monkeypatch.setattr(m, 'TSLModel', tsl)
+    importlib.reload(epm)
+    return box, ocr, tsl
+
+@pytest.fixture
+def mock_ini(monkeypatch, mock_cls):
+    """Mock models"""
+    box = mock_cls()
+    box.prev68187471 = 'box'
+    ocr = mock_cls()
+    ocr.prev68187471 = 'ocr'
+    tsl = mock_cls()
+    tsl.prev68187471 = 'tsl'
+    monkeypatch.setattr(ini, 'add_box_model', box)
+    monkeypatch.setattr(ini, 'add_ocr_model', ocr)
+    monkeypatch.setattr(ini, 'add_tsl_model', tsl)
+    importlib.reload(epm)
+
+@pytest.fixture
+def entrypoint1():
+    """Mock entrypoint"""
+    class MockEntryPoint(EntryPoint):
+        """Mock entrypoint"""
+        def load(self):
+            """Mock load"""
+            return {'name': 'name1', 'module': 'module1', 'attr': 'attr1'}
+        @property
+        def name(self):
+            """Mock name"""
+            return 'name1'
+    return MockEntryPoint('name1', 'module1', 'attr1')
+
+
+def test_get_group_entrypoints():
+    """Test get_group_entrypoints"""
+    res = epm.get_group_entrypoints('ocr_translate.box_data')
+    assert isinstance(res, set)
+    assert all(isinstance(ep, EntryPoint) for ep in res)
+
+def test_manager_no_change_empty(monkeypatch, mock_models, mock_cls):
+    """Test manager with no changes and no entrypoints"""
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+    with epm.ep_manager():
+        pass
+
+    assert mock_cls.call_counter == {}
+def test_manager_no_change_models(monkeypatch, mock_models, entrypoint1, mock_cls):
+    """Test manager with no changes and entrypoints"""
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+    with epm.ep_manager():
+        pass
+
+    assert mock_cls.call_counter['get'] == 3
+    assert mock_cls.call_counter['save'] == 3
+    assert mock_cls.set_counter['active'] == 3
+
+def test_manager_change_add(monkeypatch, mock_models, mock_cls, entrypoint1, mock_called):
+    """Test manager with entrypoint added"""
+    monkeypatch.setattr(cached_lists, 'refresh_model_cache', mock_called)
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+    with epm.ep_manager():
+        monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+
+    assert mock_called.called
+    assert mock_cls.call_counter['save'] == 3
+    assert mock_cls.set_counter['active'] == 3
+
+@pytest.mark.parametrize('mock_cls', ['raise'], indirect=True)
+def test_manager_change_add_notexists(monkeypatch, mock_models, mock_ini, mock_cls, entrypoint1):
+    """Test manager with entrypoint added"""
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+
+    with epm.ep_manager():
+        monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+
+    assert mock_cls.call_counter['box'] == 1
+    assert mock_cls.call_counter['ocr'] == 1
+    assert mock_cls.call_counter['tsl'] == 1
+
+def test_manager_change_remove(monkeypatch, mock_models, mock_cls, entrypoint1, mock_called):
+    """Test manager with entrypoint removed"""
+    monkeypatch.setattr(cached_lists, 'refresh_model_cache', mock_called)
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+    with epm.ep_manager():
+        monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+
+    assert mock_called.called
+    assert mock_cls.call_counter['save'] == 3
+    assert mock_cls.set_counter['active'] == 3
+
+def test_manager_change_remove_loaded(monkeypatch, mock_models, entrypoint1, mock_called):
+    """Test manager with entrypoint removed"""
+    monkeypatch.setattr(cached_lists, 'refresh_model_cache', lambda: None)
+    monkeypatch.setattr(box, 'unload_box_model', mock_called)
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', mock_models[0])
+    importlib.reload(epm)
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+    with epm.ep_manager():
+        monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+
+    assert mock_called.called
+
+@pytest.mark.parametrize('mock_cls', ['raise'], indirect=True)
+def test_manager_change_remove_notexists(monkeypatch, mock_models, mock_cls, entrypoint1, mock_called):
+    """Test manager with entrypoint removed"""
+    monkeypatch.setattr(cached_lists, 'refresh_model_cache', mock_called)
+    monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: {entrypoint1})
+    with epm.ep_manager():
+        monkeypatch.setattr(epm, 'get_group_entrypoints', lambda x: set())
+
+    assert mock_called.called
+    assert mock_cls.call_counter['save'] == 0
+    assert mock_cls.set_counter['active'] == 0
diff --git a/tests/test_init.py b/tests/test_init.py
index a54d1ca..dd70e15 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -1,305 +1,291 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test environment initialization."""
-
-import importlib
-
-import pytest
-
-from ocr_translate import models as m
-from ocr_translate import ocr_tsl, tries
-from ocr_translate.ocr_tsl import box, lang, ocr, tsl
-
-pytestmark = pytest.mark.django_db
-
-def test_init_most_used_clean(mock_loaders):
-    """Test init_most_used with empty database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ is None
-    assert ocr.OCR_MODEL_OBJ is None
-    assert tsl.TSL_MODEL_OBJ is None
-    assert lang.LANG_SRC is None
-    assert lang.LANG_DST is None
-
-def test_init_most_used_content_nousage(mock_loaders, language, box_model, ocr_model, tsl_model):
-    """Test init_most_used with content in the database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ is None
-    assert ocr.OCR_MODEL_OBJ is None
-    assert tsl.TSL_MODEL_OBJ is None
-    assert lang.LANG_SRC is None
-    assert lang.LANG_DST is None
-
-def test_init_most_used_content_partial_usage_box(
-        mock_loaders, language, box_model, ocr_model, tsl_model, box_run,
-        ):
-    """Test init_most_used with content in the database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ == box_model
-    assert ocr.OCR_MODEL_OBJ is None
-    assert tsl.TSL_MODEL_OBJ is None
-    assert lang.LANG_SRC is None
-    assert lang.LANG_DST is None
-
-def test_init_most_used_content_partial_usage_ocr(
-        mock_loaders, language, box_model, ocr_model, tsl_model, ocr_run,
-        ):
-    """Test init_most_used with content in the database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ == box_model
-    assert ocr.OCR_MODEL_OBJ == ocr_model
-    assert tsl.TSL_MODEL_OBJ is None
-    assert lang.LANG_SRC is None
-    assert lang.LANG_DST is None
-
-def test_init_most_used_content_partial_usage_tsl(
-        mock_loaders, language, box_model, ocr_model, tsl_model, tsl_run
-        ):
-    """Test init_most_used with content in the database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ is None
-    assert ocr.OCR_MODEL_OBJ is None
-    assert tsl.TSL_MODEL_OBJ == tsl_model
-    assert lang.LANG_SRC == language
-    assert lang.LANG_DST == language
-
-def test_init_most_used_content_full_usage(
-        mock_loaders, language, box_model, ocr_model, tsl_model,
-        box_run, ocr_run, tsl_run
-        ):
-    """Test init_most_used with content in the database."""
-    ocr_tsl.init_most_used()
-    assert box.BOX_MODEL_OBJ == box_model
-    assert ocr.OCR_MODEL_OBJ == ocr_model
-    assert tsl.TSL_MODEL_OBJ == tsl_model
-    assert lang.LANG_SRC == language
-    assert lang.LANG_DST == language
-
-
-def test_init_most_used_more_content(mock_loaders, language_dict, image, option_dict, text):
-    """Test init_most_used with more content in the database. Check that sorting is working."""
-    # pylint: disable=unused-variable,too-many-locals
-    dct1 = {k:v+'1' for k,v in language_dict.items()}
-    dct2 = {k:v+'2' for k,v in language_dict.items()}
-    dct3 = {k:v+'3' for k,v in language_dict.items()}
-    lang1 = m.Language.objects.create(**dct1)
-    lang2 = m.Language.objects.create(**dct2)
-    lang3 = m.Language.objects.create(**dct3)
-
-    ocr_box_model1 = m.OCRBoxModel.objects.create(name='test_model1/id')
-    ocr_box_model2 = m.OCRBoxModel.objects.create(name='test_model2/id')
-
-    ocr_model1 = m.OCRModel.objects.create(name='test_model1/id')
-    ocr_model2 = m.OCRModel.objects.create(name='test_model2/id')
-
-    tsl_model1 = m.TSLModel.objects.create(name='test_model1/id')
-    tsl_model2 = m.TSLModel.objects.create(name='test_model2/id')
-
-    box_run1 = m.OCRBoxRun.objects.create(
-        model=ocr_box_model1, lang_src=lang1, image=image, options=option_dict
-        )
-    box_run2_1 = m.OCRBoxRun.objects.create(
-        model=ocr_box_model2, lang_src=lang2, image=image, options=option_dict
-        )
-    box_run2_2 = m.OCRBoxRun.objects.create(
-        model=ocr_box_model2, lang_src=lang2, image=image, options=option_dict
-        )
-
-    bbox = m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4, from_ocr_merged=box_run1)
-
-    ocr_run1 = m.OCRRun.objects.create(
-        lang_src=lang1, bbox=bbox, model=ocr_model1, options=option_dict, result_merged=text
-        )
-    ocr_run2_1 = m.OCRRun.objects.create(
-        lang_src=lang1, bbox=bbox, model=ocr_model2, options=option_dict, result_merged=text
-        )
-    ocr_run2_2 = m.OCRRun.objects.create(
-        lang_src=lang1, bbox=bbox, model=ocr_model2, options=option_dict, result_merged=text
-        )
-
-    tsl_run1_1 = m.TranslationRun.objects.create(
-        lang_src=lang2, lang_dst=lang3, text=text, model=tsl_model1, options=option_dict, result=text
-        )
-    tsl_run1_2 = m.TranslationRun.objects.create(
-        lang_src=lang2, lang_dst=lang3, text=text, model=tsl_model1, options=option_dict, result=text
-        )
-    tsl_run2 = m.TranslationRun.objects.create(
-        lang_src=lang1, lang_dst=lang1, text=text, model=tsl_model2, options=option_dict, result=text
-        )
-
-    ocr_tsl.init_most_used()
-
-    assert lang.LANG_SRC == lang2
-    assert lang.LANG_DST == lang3
-
-    assert box.BOX_MODEL_OBJ == ocr_box_model2
-    assert ocr.OCR_MODEL_OBJ == ocr_model2
-    assert tsl.TSL_MODEL_OBJ == tsl_model1
-
-def test_auto_create_languages():
-    """Test auto_create_languages."""
-    ocr_tsl.auto_create_languages()
-
-    assert m.Language.objects.count() > 50
-
-    # Test settings of **kwargs
-    jap = m.Language.objects.get(iso1='ja')
-    assert jap.default_options.options['break_chars'] is not None
-    assert jap.default_options.options['break_chars'] is not None
-
-def test_load_ept_data(monkeypatch):
-    """Test load_ept_data function."""
-    def mock_entrypoints(*, group):
-        assert group == '123'
-        class Ept:
-            """Mock entrypoint"""
-            def load(self):
-                """Mock entrypoint loader"""
-                return {'i am': 'an entrypoint'}
-
-        return Ept(), Ept()
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'entry_points', mock_entrypoints)
-
-    ept1, ept2 = ocr_tsl.initializers.load_ept_data('123') # pylint: disable=unbalanced-tuple-unpacking
-    assert ept1['i am'] == 'an entrypoint'
-    assert ept2['i am'] == 'an entrypoint'
-    ept1.pop('i am')
-
-    assert 'i am' not in ept1
-    assert 'i am' in ept2
-
-
-def test_auto_create_models_nolang(monkeypatch, mock_load_ept):
-    """Test auto_create_models without creating languages before"""
-    monkeypatch.setattr(ocr_tsl.initializers, 'load_ept_data', mock_load_ept)
-    with pytest.raises(m.Language.DoesNotExist):
-        ocr_tsl.auto_create_models()
-
-def test_auto_create_models_test_data(monkeypatch, mock_load_ept):
-    """Test creating dummy models"""
-    # Needed after no more entrypoint are defined here
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'load_ept_data', mock_load_ept)
-    ocr_tsl.auto_create_languages()
-    ocr_tsl.auto_create_models()
-
-    assert m.OCRBoxModel.objects.count() > 0
-    assert m.OCRModel.objects.count() > 0
-    assert m.TSLModel.objects.count() > 0
-
-def test_env_init_most_used(monkeypatch):
-    """Test that init_most_used is called when LOAD_ON_START is 'true'."""
-    def mock_init_most_used():
-        """Mock init_most_used."""
-        mock_init_most_used.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'init_most_used', mock_init_most_used)
-    monkeypatch.setenv('LOAD_ON_START', 'true')
-
-    importlib.reload(ocr_tsl)
-    assert mock_init_most_used.called
-
-def test_env_init_most_used_false(monkeypatch):
-    """Test that init_most_used is not called when LOAD_ON_START is not 'true'."""
-    def mock_init_most_used():
-        """Mock init_most_used."""
-        mock_init_most_used.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'init_most_used', mock_init_most_used)
-    monkeypatch.setenv('LOAD_ON_START', 'false')
-
-    importlib.reload(ocr_tsl)
-    assert not hasattr(mock_init_most_used, 'called')
-
-def test_env_auto_create_languges(monkeypatch):
-    """Test that auto_create_languages is called when AUTOCREATE_LANGUAGES is 'true'."""
-    def mock_auto_create_languages():
-        """Mock auto_create_languages."""
-        mock_auto_create_languages.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'auto_create_languages', mock_auto_create_languages)
-    monkeypatch.setenv('AUTOCREATE_LANGUAGES', 'true')
-
-    importlib.reload(ocr_tsl)
-    assert mock_auto_create_languages.called
-
-def test_env_auto_create_languges_false(monkeypatch):
-    """Test that auto_create_languages is not called when AUTOCREATE_LANGUAGES is not 'true'."""
-    def mock_auto_create_languages():
-        """Mock auto_create_languages."""
-        mock_auto_create_languages.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'auto_create_languages', mock_auto_create_languages)
-    monkeypatch.setenv('AUTOCREATE_LANGUAGES', 'false')
-
-    importlib.reload(ocr_tsl)
-    assert not hasattr(mock_auto_create_languages, 'called')
-
-def test_env_auto_create_models(monkeypatch):
-    """Test that auto_create_models is called when AUTOCREATE_VALIDATED_MODELS is 'true'."""
-    def mock_auto_create_models():
-        """Mock auto_create_models."""
-        mock_auto_create_models.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'auto_create_models', mock_auto_create_models)
-    monkeypatch.setenv('AUTOCREATE_VALIDATED_MODELS', 'true')
-
-    importlib.reload(ocr_tsl)
-    assert mock_auto_create_models.called
-
-def test_env_auto_create_models_false(monkeypatch):
-    """Test that auto_create_models is not called when AUTOCREATE_VALIDATED_MODELS is not 'true'."""
-    def mock_auto_create_models():
-        """Mock auto_create_models."""
-        mock_auto_create_models.called = True
-
-    monkeypatch.setattr(ocr_tsl.initializers, 'auto_create_models', mock_auto_create_models)
-    monkeypatch.setenv('AUTOCREATE_VALIDATED_MODELS', 'false')
-
-    importlib.reload(ocr_tsl)
-    assert not hasattr(mock_auto_create_models, 'called')
-
-def test_no_load_trie():
-    """Test that the trie is not loaded when LOAD_TRIE is 'false'."""
-    assert tries.TRIE_SRC is None
-    assert tries.get_trie_src() is None
-
-def test_load_trie_unkwnown():
-    """Test that the trie is not loaded when LOAD_TRIE is not 'true' or 'false'."""
-    res = tries.load_trie('unknown')
-    assert res is None
-
-def test_load_trie(monkeypatch):
-    """Test that the trie is loaded properly."""
-    monkeypatch.setattr(tries, 'TRIE_SRC', None)
-    tries.load_trie_src('test')
-    assert tries.TRIE_SRC is not None
-
-    trie = tries.get_trie_src()
-    assert trie is not None
-
-    assert trie.search('ab') is True
-    assert trie.search('cd') is True
-    assert trie.search('abc') is False
-
-    assert trie.get_freq('a') == 0.1
-    assert trie.get_freq('b') == 0.2
-    assert trie.get_freq('c') == 0.3
-    assert trie.get_freq('ab') == -1e-4
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test environment initialization."""
+
+# pylint: disable=redefined-outer-name
+
+import importlib
+
+import pytest
+
+from ocr_translate import models as m
+from ocr_translate import ocr_tsl, tries
+from ocr_translate.ocr_tsl import box
+from ocr_translate.ocr_tsl import initializers as ini
+from ocr_translate.ocr_tsl import lang, ocr, tsl
+
+pytestmark = pytest.mark.django_db
+
+def test_init_most_used_clean(mock_loaders):
+    """Test init_most_used with empty database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ is None
+    assert ocr.OCR_MODEL_OBJ is None
+    assert tsl.TSL_MODEL_OBJ is None
+    assert lang.LANG_SRC is None
+    assert lang.LANG_DST is None
+
+def test_init_most_used_content_nousage(mock_loaders, language, box_model, ocr_model, tsl_model):
+    """Test init_most_used with content in the database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ is None
+    assert ocr.OCR_MODEL_OBJ is None
+    assert tsl.TSL_MODEL_OBJ is None
+    assert lang.LANG_SRC is None
+    assert lang.LANG_DST is None
+
+def test_init_most_used_content_partial_usage_box(
+        mock_loaders, language, box_model, ocr_model, tsl_model, box_run,
+        ):
+    """Test init_most_used with content in the database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ == box_model
+    assert ocr.OCR_MODEL_OBJ is None
+    assert tsl.TSL_MODEL_OBJ is None
+    assert lang.LANG_SRC is None
+    assert lang.LANG_DST is None
+
+def test_init_most_used_content_partial_usage_ocr(
+        mock_loaders, language, box_model, ocr_model, tsl_model, ocr_run,
+        ):
+    """Test init_most_used with content in the database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ == box_model
+    assert ocr.OCR_MODEL_OBJ == ocr_model
+    assert tsl.TSL_MODEL_OBJ is None
+    assert lang.LANG_SRC is None
+    assert lang.LANG_DST is None
+
+def test_init_most_used_content_partial_usage_tsl(
+        mock_loaders, language, box_model, ocr_model, tsl_model, tsl_run
+        ):
+    """Test init_most_used with content in the database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ is None
+    assert ocr.OCR_MODEL_OBJ is None
+    assert tsl.TSL_MODEL_OBJ == tsl_model
+    assert lang.LANG_SRC == language
+    assert lang.LANG_DST == language
+
+def test_init_most_used_content_full_usage(
+        mock_loaders, language, box_model, ocr_model, tsl_model,
+        box_run, ocr_run, tsl_run
+        ):
+    """Test init_most_used with content in the database."""
+    ocr_tsl.init_most_used()
+    assert box.BOX_MODEL_OBJ == box_model
+    assert ocr.OCR_MODEL_OBJ == ocr_model
+    assert tsl.TSL_MODEL_OBJ == tsl_model
+    assert lang.LANG_SRC == language
+    assert lang.LANG_DST == language
+
+
+def test_init_most_used_more_content(mock_loaders, language_dict, image, option_dict, text):
+    """Test init_most_used with more content in the database. Check that sorting is working."""
+    # pylint: disable=unused-variable,too-many-locals
+    dct1 = {k:v+'1' for k,v in language_dict.items()}
+    dct2 = {k:v+'2' for k,v in language_dict.items()}
+    dct3 = {k:v+'3' for k,v in language_dict.items()}
+    lang1 = m.Language.objects.create(**dct1)
+    lang2 = m.Language.objects.create(**dct2)
+    lang3 = m.Language.objects.create(**dct3)
+
+    ocr_box_model1 = m.OCRBoxModel.objects.create(name='test_model1/id')
+    ocr_box_model2 = m.OCRBoxModel.objects.create(name='test_model2/id')
+
+    ocr_model1 = m.OCRModel.objects.create(name='test_model1/id')
+    ocr_model2 = m.OCRModel.objects.create(name='test_model2/id')
+
+    tsl_model1 = m.TSLModel.objects.create(name='test_model1/id')
+    tsl_model2 = m.TSLModel.objects.create(name='test_model2/id')
+
+    box_run1 = m.OCRBoxRun.objects.create(
+        model=ocr_box_model1, lang_src=lang1, image=image, options=option_dict
+        )
+    box_run2_1 = m.OCRBoxRun.objects.create(
+        model=ocr_box_model2, lang_src=lang2, image=image, options=option_dict
+        )
+    box_run2_2 = m.OCRBoxRun.objects.create(
+        model=ocr_box_model2, lang_src=lang2, image=image, options=option_dict
+        )
+
+    bbox = m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4, from_ocr_merged=box_run1)
+
+    ocr_run1 = m.OCRRun.objects.create(
+        lang_src=lang1, bbox=bbox, model=ocr_model1, options=option_dict, result_merged=text
+        )
+    ocr_run2_1 = m.OCRRun.objects.create(
+        lang_src=lang1, bbox=bbox, model=ocr_model2, options=option_dict, result_merged=text
+        )
+    ocr_run2_2 = m.OCRRun.objects.create(
+        lang_src=lang1, bbox=bbox, model=ocr_model2, options=option_dict, result_merged=text
+        )
+
+    tsl_run1_1 = m.TranslationRun.objects.create(
+        lang_src=lang2, lang_dst=lang3, text=text, model=tsl_model1, options=option_dict, result=text
+        )
+    tsl_run1_2 = m.TranslationRun.objects.create(
+        lang_src=lang2, lang_dst=lang3, text=text, model=tsl_model1, options=option_dict, result=text
+        )
+    tsl_run2 = m.TranslationRun.objects.create(
+        lang_src=lang1, lang_dst=lang1, text=text, model=tsl_model2, options=option_dict, result=text
+        )
+
+    ocr_tsl.init_most_used()
+
+    assert lang.LANG_SRC == lang2
+    assert lang.LANG_DST == lang3
+
+    assert box.BOX_MODEL_OBJ == ocr_box_model2
+    assert ocr.OCR_MODEL_OBJ == ocr_model2
+    assert tsl.TSL_MODEL_OBJ == tsl_model1
+
+def test_auto_create_languages():
+    """Test auto_create_languages."""
+    ocr_tsl.auto_create_languages()
+
+    assert m.Language.objects.count() > 50
+
+    # Test settings of **kwargs
+    jap = m.Language.objects.get(iso1='ja')
+    assert jap.default_options.options['break_chars'] is not None
+    assert jap.default_options.options['break_chars'] is not None
+
+def test_load_ept_data(monkeypatch):
+    """Test load_ept_data function."""
+    def mock_entrypoints(*, group):
+        assert group == '123'
+        class Ept:
+            """Mock entrypoint"""
+            def load(self):
+                """Mock entrypoint loader"""
+                return {'i am': 'an entrypoint'}
+
+        return Ept(), Ept()
+
+    monkeypatch.setattr(ini, 'entry_points', mock_entrypoints)
+
+    ept1, ept2 = ini.load_ept_data('123') # pylint: disable=unbalanced-tuple-unpacking
+    assert ept1['i am'] == 'an entrypoint'
+    assert ept2['i am'] == 'an entrypoint'
+    ept1.pop('i am')
+
+    assert 'i am' not in ept1
+    assert 'i am' in ept2
+
+def test_create_models_nolang(monkeypatch, mock_load_ept):
+    """Test auto_create_models without creating languages before"""
+    monkeypatch.setattr(ini, 'load_ept_data', mock_load_ept)
+    with pytest.raises(m.Language.DoesNotExist):
+        for ocr in ini.load_ept_data('ocr_translate.ocr_data'):
+            ini.add_ocr_model(ocr)
+
+def test_auto_create_models_test_data(monkeypatch, mock_load_ept):
+    """Test creating dummy models"""
+    # Needed after no more entrypoint are defined here
+
+    monkeypatch.setattr(ini, 'load_ept_data', mock_load_ept)
+    ocr_tsl.auto_create_languages()
+
+    for box in ini.load_ept_data('ocr_translate.box_data'):
+        ini.add_box_model(box)
+    for ocr in ini.load_ept_data('ocr_translate.ocr_data'):
+        ini.add_ocr_model(ocr)
+    for tsl in ini.load_ept_data('ocr_translate.tsl_data'):
+        ini.add_tsl_model(tsl)
+
+    assert m.OCRBoxModel.objects.count() > 0
+    assert m.OCRModel.objects.count() > 0
+    assert m.TSLModel.objects.count() > 0
+
+def test_env_init_most_used(monkeypatch):
+    """Test that init_most_used is called when LOAD_ON_START is 'true'."""
+    def mock_init_most_used():
+        """Mock init_most_used."""
+        mock_init_most_used.called = True
+
+    monkeypatch.setattr(ini, 'init_most_used', mock_init_most_used)
+    monkeypatch.setenv('LOAD_ON_START', 'true')
+
+    importlib.reload(ocr_tsl)
+    assert mock_init_most_used.called
+
+def test_env_init_most_used_false(monkeypatch):
+    """Test that init_most_used is not called when LOAD_ON_START is not 'true'."""
+    def mock_init_most_used():
+        """Mock init_most_used."""
+        mock_init_most_used.called = True
+
+    monkeypatch.setattr(ini, 'init_most_used', mock_init_most_used)
+    monkeypatch.setenv('LOAD_ON_START', 'false')
+
+    importlib.reload(ocr_tsl)
+    assert not hasattr(mock_init_most_used, 'called')
+
+def test_env_auto_create_languges(monkeypatch):
+    """Test that auto_create_languages is called when AUTOCREATE_LANGUAGES is 'true'."""
+    def mock_auto_create_languages():
+        """Mock auto_create_languages."""
+        mock_auto_create_languages.called = True
+
+    monkeypatch.setattr(ini, 'auto_create_languages', mock_auto_create_languages)
+    monkeypatch.setenv('AUTOCREATE_LANGUAGES', 'true')
+
+    importlib.reload(ocr_tsl)
+    assert mock_auto_create_languages.called
+
+def test_env_auto_create_languges_false(monkeypatch):
+    """Test that auto_create_languages is not called when AUTOCREATE_LANGUAGES is not 'true'."""
+    def mock_auto_create_languages():
+        """Mock auto_create_languages."""
+        mock_auto_create_languages.called = True
+
+    monkeypatch.setattr(ini, 'auto_create_languages', mock_auto_create_languages)
+    monkeypatch.setenv('AUTOCREATE_LANGUAGES', 'false')
+
+    importlib.reload(ocr_tsl)
+    assert not hasattr(mock_auto_create_languages, 'called')
+
+def test_no_load_trie():
+    """Test that the trie is not loaded when LOAD_TRIE is 'false'."""
+    assert tries.TRIE_SRC is None
+    assert tries.get_trie_src() is None
+
+def test_load_trie_unkwnown():
+    """Test that the trie is not loaded when LOAD_TRIE is not 'true' or 'false'."""
+    res = tries.load_trie('unknown')
+    assert res is None
+
+def test_load_trie(monkeypatch):
+    """Test that the trie is loaded properly."""
+    monkeypatch.setattr(tries, 'TRIE_SRC', None)
+    tries.load_trie_src('test')
+    assert tries.TRIE_SRC is not None
+
+    trie = tries.get_trie_src()
+    assert trie is not None
+
+    assert trie.search('ab') is True
+    assert trie.search('cd') is True
+    assert trie.search('abc') is False
+
+    assert trie.get_freq('a') == 0.1
+    assert trie.get_freq('b') == 0.2
+    assert trie.get_freq('c') == 0.3
+    assert trie.get_freq('ab') == -1e-4
diff --git a/tests/test_models.py b/tests/test_models.py
index 8f984a9..53b7940 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,578 +1,583 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Tests for the database models."""
-#pylint: disable=protected-access
-
-from dataclasses import dataclass
-
-import django
-import numpy as np
-import pytest
-from PIL.Image import Image as PILImage
-
-from ocr_translate import models as m
-from ocr_translate import tries
-from ocr_translate.messaging import Message
-from ocr_translate.ocr_tsl import box, full, lang, ocr, tsl
-from ocr_translate.trie import Trie
-
-pytestmark = pytest.mark.django_db
-
-def test_add_language(language_dict: dict, language: m.Language):
-    """Test adding a language."""
-    query = m.Language.objects.filter(**language_dict)
-    assert query.exists()
-    assert query.first() == language
-
-def test_language_str(language: m.Language):
-    """Test language string representation."""
-    assert isinstance(str(language), str)
-    assert str(language).startswith(language.name)
-    assert language.iso1 in str(language)
-
-def test_add_language_existing(language_dict: dict, language: m.Language):
-    """Test adding a language."""
-    with pytest.raises(django.db.utils.IntegrityError):
-        m.Language.objects.create(**language_dict)
-
-def test_language_eq_obj(language: m.Language):
-    """Test language equality."""
-    assert language == language # pylint: disable=comparison-with-itself
-
-def test_language_eq_iso1(language: m.Language):
-    """Test language equality."""
-    assert language == language.iso1
-
-def test_add_ocr_box_model(box_model_dict: dict, box_model: m.OCRBoxModel):
-    """Test adding a new OCRBoxModel"""
-    query = m.OCRBoxModel.objects.filter(**box_model_dict)
-    assert query.exists()
-    assert str(query.first()) == box_model_dict['name']
-
-def test_add_ocr_model(ocr_model_dict: dict, ocr_model: m.OCRModel):
-    """Test adding a new OCRModel"""
-    query = m.OCRModel.objects.filter(**ocr_model_dict)
-    assert query.exists()
-    assert str(query.first()) == ocr_model_dict['name']
-
-def test_add_tsl_model(tsl_model_dict: dict, tsl_model: m.TSLModel):
-    """Test adding a new TSLModel"""
-    query = m.TSLModel.objects.filter(**tsl_model_dict)
-    assert query.exists()
-    assert str(query.first()) == tsl_model_dict['name']
-
-def test_add_option_dict(option_dict: m.OptionDict):
-    """Test adding a new OptionDict"""
-    query = m.OptionDict.objects.filter(options={})
-    assert query.exists()
-    assert str(query.first()) == str({})
-
-def test_box_run(
-        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict
-        ):
-    """Test adding a new BoxRun"""
-
-    lbrt = (1,2,3,4)
-    def mock_pipeline(*args, **kwargs):
-        return [{'single': [lbrt], 'merged': lbrt}]
-
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    box_model._box_detection = mock_pipeline
-
-    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
-
-    assert isinstance(single, list)
-    assert isinstance(merged, list)
-    assert isinstance(single[0], m.BBox)
-    assert isinstance(merged[0], m.BBox)
-    assert merged[0].lbrt == lbrt
-
-def test_box_run_reuse(
-        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict
-        ):
-    """Test adding a new BoxRun"""
-    lbrt = (1,2,3,4)
-    def mock_pipeline(*args, **kwargs):
-        return [{'single': [lbrt], 'merged': lbrt}]
-
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
-
-    assert m.OCRBoxRun.objects.count() == 0
-    box_model.box_detection(image, language, image=1, options=option_dict)
-    assert m.OCRBoxRun.objects.count() == 1
-    box_model.box_detection(image, language, image=1, options=option_dict)
-    assert m.OCRBoxRun.objects.count() == 1
-
-def test_box_run_04migration_donothing(
-        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict,
-        ):
-    """Test that baxrun on v0.4.x that reuses a run from < v0.4.0 is always re-run"""
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-
-    assert m.OCRBoxRun.objects.count() == 0
-    bbox_run = m.OCRBoxRun.objects.create(
-        image=image,
-        model=box_model,
-        lang_src=language,
-        options=option_dict,
-    )
-    assert m.OCRBoxRun.objects.count() == 1
-
-    lbrt = (1,2,3,4)
-    def mock_pipeline(*args, **kwargs):
-        return [{'single': [lbrt], 'merged': lbrt}]
-
-    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
-
-    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
-    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
-    assert m.OCRBoxRun.objects.count() == 1
-    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
-    assert len(single) == 0
-    assert len(merged) == 0
-
-def test_box_run_04migration_replace(
-        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict,
-        ):
-    """Test that baxrun on v0.4.x that reuses a run from < v0.4.0 is always re-run"""
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-
-    bbox = m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4)
-
-    assert m.OCRBoxRun.objects.count() == 0
-    bbox_run = m.OCRBoxRun.objects.create(
-        image=image,
-        model=box_model,
-        lang_src=language,
-        options=option_dict,
-    )
-    bbox_run.result_merged.add(bbox)
-    assert m.OCRBoxRun.objects.count() == 1
-
-    lbrt = (1,2,3,4)
-    def mock_pipeline(*args, **kwargs):
-        return [{'single': [lbrt], 'merged': lbrt}]
-
-    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
-
-    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
-    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
-    assert m.OCRBoxRun.objects.count() == 1
-    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first() is None
-    assert len(single) == 1
-    assert len(merged) == 1
-
-def test_ocr_run_nooption(
-        monkeypatch, image_pillow: PILImage,
-        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_run blocking"""
-    text = 'test_text'
-    def mock_ocr(*args, **kwargs):
-        return text
-
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    ocr_model._ocr = mock_ocr
-
-    gen = ocr_model.ocr(bbox, language, image=image_pillow)
-
-    res = next(gen)
-
-    assert isinstance(res, m.Text)
-    assert res.text == text
-    assert res.from_ocr_merged.first().options.options == {}
-
-def test_ocr_run_noimage(
-        monkeypatch,
-        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_run blocking"""
-    text = 'test_text'
-    def mock_ocr(*args, **kwargs):
-        return text
-
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    ocr_model._ocr = mock_ocr
-
-    gen = ocr_model.ocr(bbox, language, options=option_dict)
-
-    with pytest.raises(ValueError, match=r'^Image is required for OCR$'):
-        next(gen)
-
-def test_ocr_run(
-        monkeypatch, mock_called, image_pillow: PILImage,
-        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_run blocking + same pipeline (has to run lazily by refetching previous result)"""
-    text = 'test_text'
-    def mock_ocr(*args, **kwargs):
-        return text
-
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    ocr_model._ocr = mock_ocr
-
-    gen = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict)
-
-    res = next(gen)
-
-    assert isinstance(res, m.Text)
-    assert res.text == text
-
-    ocr_model._ocr = mock_called # Should not be called as it should be lazy
-    gen_lazy = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict)
-
-    assert not hasattr(mock_called, 'called')
-    assert next(gen_lazy) == res
-
-def test_ocr_run_nonblock(
-        monkeypatch, mock_called, image_pillow: PILImage,
-        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_run non-blocking + same pipeline (has to run lazily by refetching previous result)"""
-    text = 'test_text'
-    def mock_ocr(*args, **kwargs):
-        return text
-
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    ocr_model._ocr = mock_ocr
-
-    gen = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict, block=False)
-
-    msg = next(gen)
-    # msg.resolve()
-    res = next(gen)
-
-    assert isinstance(msg, Message)
-    assert isinstance(res, m.Text)
-    assert res.text == text
-
-    ocr_model._ocr = mock_called # Should not be called as it should be lazy
-    gen_lazy = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict, block=False)
-
-    assert not hasattr(mock_called, 'called')
-    assert next(gen_lazy) is None
-    assert next(gen_lazy) == res
-
-@pytest.mark.parametrize('lang_src', ['ja', 'en'])
-def test_ocr_merge_single_result(lang_src): # pylint: disable=too-many-locals
-    # pylint: disable=invalid-name
-    """Test merge_single_result behavior"""
-    @dataclass
-    class BBox:
-        """Mock BBox class not related to database"""
-        l: int
-        b: int
-        r: int
-        t: int
-        to_merged: 'BBox' = None
-
-        def __post_init__(self, *args, **kwargs):
-            self.id = np.random.rand(1)[0]
-
-        @property
-        def lbrt(self): # pylint: disable=missing-function-docstring
-            return self.l, self.b, self.r, self.t
-
-        def __hash__(self):
-            return hash(self.id)
-
-    merged_lst = [
-        BBox(0, 100, 30, 130),
-        BBox(100, 0, 130, 30),
-        BBox(50, 50, 80, 80),
-    ]
-
-    w = 10
-    h = 10
-    s = 2
-    rrx = 2
-    rry = 2
-    boxes = []
-    np.random.seed(0)
-    for merged in merged_lst:
-        xoff = merged.l
-        yoff = merged.b
-        for i in range(9):
-            errx =  np.random.rand(2) * rrx
-            erry =  np.random.rand(2) * rry
-            l = i % 3 * (w+s) + xoff + errx[0]
-            b = i // 3 * (w+s) + yoff + errx[1]
-            r = l + w + erry[0]
-            t = b + h + erry[1]
-            boxes.append((str(i+1), BBox(l, b, r, t, merged)))
-
-    texts = [_[0] for _ in boxes]
-    bboxes_single = [_[1] for _ in boxes]
-
-    vertical = lang_src in  m.OCRModel._VERTICAL_LANGS
-    expected = '369258147' if vertical else '1 2 3 4 5 6 7 8 9'
-
-    res = m.OCRModel.merge_single_result(lang_src, texts, bboxes_single, merged_lst)
-
-    for result in res:
-        assert result == expected
-
-
-
-def test_tsl_pre_tokenize(data_regression, string: str):
-    """Test tsl module."""
-    options = [
-        {},
-        {'break_newlines': True},
-        {'break_newlines': 'True'},
-        {'break_newlines': False},
-        {'break_chars': '?.!'},
-        {'ignore_chars': '?.!'},
-        {'break_newlines': False, 'break_chars': '?.!'},
-        {'break_newlines': False, 'ignore_chars': '?.!'},
-        {'restore_missing_spaces': True},
-        {'restore_missing_spaces': 'True'},
-        {'restore_dash_newlines': True},
-        {'restore_dash_newlines': 'True'},
-    ]
-
-    res = []
-    for option in options:
-        dct = {
-            'string': string,
-            'options': option,
-            'tokens': m.TSLModel.pre_tokenize(string, **option)
-        }
-        res.append(dct)
-
-    data_regression.check({'res': res})
-
-@pytest.mark.parametrize('extra_start', ['$', '$%n', 'n$', 'n$$'])
-def test_tsl_pre_tokenize_allowed_start(extra_start):
-    """Test pre_tokenize with allowed_start_end."""
-    pret = m.TSLModel.pre_tokenize
-    res = pret(extra_start + ' ' + 'apple', allowed_start_end='a-zA-Z0-9\\-\\.\\,\\;\\?\\! ')
-    assert res[0].strip() == 'apple'
-
-@pytest.mark.parametrize('extra_end', ['$', '$%n', 'n$', 'n$$'])
-def test_tsl_pre_tokenize_allowed_end(extra_end):
-    """Test pre_tokenize with allowed_start_end."""
-    pret = m.TSLModel.pre_tokenize
-    res = pret('apple' + ' ' + extra_end, allowed_start_end='a-zA-Z0-9\\-\\.\\,\\;\\?\\! ')
-    assert res[0].strip() == 'apple'
-
-def test_tsl_pre_tokenize_restorespaces(monkeypatch):
-    """Test pre_tokenize with restore spaces."""
-    trie = Trie()
-    trie.insert('app')
-    trie.insert('apple')
-    trie.insert('pie')
-    monkeypatch.setattr(tries, 'TRIE_SRC', trie)
-    res = m.TSLModel.pre_tokenize('applepie', restore_missing_spaces=True)
-    assert res == ['apple pie']
-
-def test_tsl_run(
-        monkeypatch, mock_called,
-        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict
-        ):
-    """Test performing an tsl_run blocking"""
-    def mock_tsl_pipeline(*args, **kwargs):
-        return text.text
-
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-    tsl_model._translate = mock_tsl_pipeline
-
-    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict)
-
-    res = next(gen)
-
-    assert isinstance(res, m.Text)
-    assert res.text == text.text
-
-    tsl_model._translate = mock_called # Should not be called as it should be lazy
-    gen_lazy = tsl_model.translate(text, src=language, dst=language, options=option_dict)
-
-    assert not hasattr(mock_called, 'called')
-    assert next(gen_lazy) == res
-
-def test_tsl_run_manual(
-        monkeypatch, mock_called,
-        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict,
-        manual_model: m.TSLModel
-        ):
-    """Test performing an tsl_run blocking"""
-    manual = m.Text.objects.create(text='manual')
-    m.TranslationRun.objects.create(
-        text=text,
-        result=manual,
-        model=manual_model,
-        options=option_dict,
-        lang_src=language,
-        lang_dst=language,
-    )
-    def mock_tsl_pipeline(*args, **kwargs):
-        return text.text
-
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-    tsl_model._translate = mock_tsl_pipeline
-
-    tsl_model._translate = mock_called # Should not be called as it should be lazy
-    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict)
-
-    res = next(gen)
-
-    assert isinstance(res, m.Text)
-    assert res.text == manual.text
-    assert not hasattr(mock_called, 'called')
-
-def test_tsl_run_nonblock(
-        monkeypatch, mock_called,
-        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict
-        ):
-    """Test performing an tsl_run non-blocking"""
-    def mock_tsl_pipeline(*args, **kwargs):
-        return text.text
-
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-    tsl_model._translate = mock_tsl_pipeline
-
-    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict, block=False)
-
-    msg = next(gen)
-    # msg.resolve()
-    res = next(gen)
-
-    assert isinstance(msg, Message)
-    assert isinstance(res, m.Text)
-    assert res.text == text.text
-
-    tsl_model._translate = mock_called # Should not be called as it should be lazy
-    gen_lazy = tsl_model.translate(text, src=language, dst=language, options=option_dict, block=False)
-
-    assert not hasattr(mock_called, 'called')
-    assert next(gen_lazy) is None
-    assert next(gen_lazy) == res
-
-def test_tsl_run_lazy(text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict):
-    """Test tsl pipeline with worker"""
-    # Force and lazy should not be used together
-    with pytest.raises(ValueError):
-        gen = tsl_model.translate(text, language, language, force=True, lazy=True)
-        next(gen)
-
-    # Nothing in the DB, so should rise ValueError (no previous found and lazy=True)
-    with pytest.raises(ValueError):
-        gen = tsl_model.translate(text, language, language, lazy=True)
-        next(gen)
-
-@pytest.mark.parametrize('mock_called', [['test_text_ocred']], indirect=True)
-def test_ocr_tsl_work_single_ocr_plus_lazy( # pylint: disable=too-many-arguments
-        monkeypatch, image_pillow: PILImage, mock_called,
-        image: m.Image, text: m.Text, bbox: m.BBox, bbox_single: m.BBox, language: m.Language,
-        box_model: m.OCRBoxModel, ocr_model_single: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_tsl_run non-lazy"""
-    def mock_box_run(*args, **kwargs):
-        return [bbox_single], [bbox]
-    def mock_ocr_run(*args, block=True, **kwargs):
-        if not block:
-            yield
-        res, _ = m.Text.objects.get_or_create(text = text.text + '_ocred')
-        yield res
-    def mock_tsl_run(obj, *args, block=True, **kwargs):
-        if not block:
-            yield
-        res, _ = m.Text.objects.get_or_create(text = obj.text + '_translated')
-        yield res
-
-    box_model.box_detection = mock_box_run
-    ocr_model_single.ocr = mock_ocr_run
-    tsl_model.translate = mock_tsl_run
-
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(ocr_model_single, 'merge_single_result', mock_called)
-
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model_single)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-
-    res = full.ocr_tsl_pipeline_work(image_pillow, image.md5)
-
-    # Check that `merge_single_result` is being called with the expected arguments
-    assert mock_called.args[0] == language.iso1
-    assert mock_called.args[1] == [text.text + '_ocred']
-    assert mock_called.args[2] == [bbox_single]
-    assert mock_called.args[3] == [bbox]
-
-    res_lazy = full.ocr_tsl_pipeline_lazy(image.md5)
-
-    assert res == res_lazy
-
-def test_ocr_tsl_work_plus_lazy(
-        monkeypatch, image_pillow: PILImage,
-        image: m.Image, text: m.Text, bbox: m.BBox, language: m.Language,
-        box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_tsl_run non-lazy"""
-    def mock_box_run(*args, **kwargs):
-        return [bbox], [bbox]
-    def mock_ocr_run(*args, block=True, **kwargs):
-        if not block:
-            yield
-        res, _ = m.Text.objects.get_or_create(text = text.text + '_ocred')
-        yield res
-    def mock_tsl_run(obj, *args, block=True, **kwargs):
-        if not block:
-            yield
-        res, _ = m.Text.objects.get_or_create(text = obj.text + '_translated')
-        yield res
-
-    box_model.box_detection = mock_box_run
-    ocr_model.ocr = mock_ocr_run
-    tsl_model.translate = mock_tsl_run
-
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-
-    res = full.ocr_tsl_pipeline_work(image_pillow, image.md5)
-
-    assert isinstance(res, list)
-    assert len(res) == 1
-    assert isinstance(res[0], dict)
-    assert res[0]['ocr'] == text.text + '_ocred'
-    assert res[0]['tsl'] == text.text + '_ocred' + '_translated'
-    assert res[0]['box'] == bbox.lbrt
-
-    res_lazy = full.ocr_tsl_pipeline_lazy(image.md5)
-
-    assert res == res_lazy
-
-def test_ocr_tsl_lazy():
-    """Test performing an ocr_tsl_run lazy (no image)"""
-    with pytest.raises(ValueError, match=r'^Image with md5 .* does not exist$'):
-        full.ocr_tsl_pipeline_lazy('')
-
-def test_ocr_tsl_lazy_image(
-        monkeypatch, image: m.Image,
-        box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
-        ):
-    """Test performing an ocr_tsl_run lazy (with image but missing ocr-tsl steps)"""
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-
-    with pytest.raises(ValueError):
-        full.ocr_tsl_pipeline_lazy(image.md5)
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Tests for the database models."""
+#pylint: disable=protected-access
+
+from dataclasses import dataclass
+
+import django
+import numpy as np
+import pytest
+from PIL.Image import Image as PILImage
+
+from ocr_translate import models as m
+from ocr_translate import tries
+from ocr_translate.messaging import Message
+from ocr_translate.ocr_tsl import box, full, lang, ocr, tsl
+from ocr_translate.trie import Trie
+
+pytestmark = pytest.mark.django_db
+
+def test_add_language(language_dict: dict, language: m.Language):
+    """Test adding a language."""
+    query = m.Language.objects.filter(**language_dict)
+    assert query.exists()
+    assert query.first() == language
+
+def test_language_str(language: m.Language):
+    """Test language string representation."""
+    assert isinstance(str(language), str)
+    assert str(language).startswith(language.name)
+    assert language.iso1 in str(language)
+
+def test_language_hash(language: m.Language):
+    """Test language hash."""
+    assert isinstance(hash(language), int)
+    assert hash(language) == hash(language.iso1)
+
+def test_add_language_existing(language_dict: dict, language: m.Language):
+    """Test adding a language."""
+    with pytest.raises(django.db.utils.IntegrityError):
+        m.Language.objects.create(**language_dict)
+
+def test_language_eq_obj(language: m.Language):
+    """Test language equality."""
+    assert language == language # pylint: disable=comparison-with-itself
+
+def test_language_eq_iso1(language: m.Language):
+    """Test language equality."""
+    assert language == language.iso1
+
+def test_add_ocr_box_model(box_model_dict: dict, box_model: m.OCRBoxModel):
+    """Test adding a new OCRBoxModel"""
+    query = m.OCRBoxModel.objects.filter(**box_model_dict)
+    assert query.exists()
+    assert str(query.first()) == box_model_dict['name']
+
+def test_add_ocr_model(ocr_model_dict: dict, ocr_model: m.OCRModel):
+    """Test adding a new OCRModel"""
+    query = m.OCRModel.objects.filter(**ocr_model_dict)
+    assert query.exists()
+    assert str(query.first()) == ocr_model_dict['name']
+
+def test_add_tsl_model(tsl_model_dict: dict, tsl_model: m.TSLModel):
+    """Test adding a new TSLModel"""
+    query = m.TSLModel.objects.filter(**tsl_model_dict)
+    assert query.exists()
+    assert str(query.first()) == tsl_model_dict['name']
+
+def test_add_option_dict(option_dict: m.OptionDict):
+    """Test adding a new OptionDict"""
+    query = m.OptionDict.objects.filter(options={})
+    assert query.exists()
+    assert str(query.first()) == str({})
+
+def test_box_run(
+        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict
+        ):
+    """Test adding a new BoxRun"""
+
+    lbrt = (1,2,3,4)
+    def mock_pipeline(*args, **kwargs):
+        return [{'single': [lbrt], 'merged': lbrt}]
+
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    box_model._box_detection = mock_pipeline
+
+    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
+
+    assert isinstance(single, list)
+    assert isinstance(merged, list)
+    assert isinstance(single[0], m.BBox)
+    assert isinstance(merged[0], m.BBox)
+    assert merged[0].lbrt == lbrt
+
+def test_box_run_reuse(
+        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict
+        ):
+    """Test adding a new BoxRun"""
+    lbrt = (1,2,3,4)
+    def mock_pipeline(*args, **kwargs):
+        return [{'single': [lbrt], 'merged': lbrt}]
+
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
+
+    assert m.OCRBoxRun.objects.count() == 0
+    box_model.box_detection(image, language, image=1, options=option_dict)
+    assert m.OCRBoxRun.objects.count() == 1
+    box_model.box_detection(image, language, image=1, options=option_dict)
+    assert m.OCRBoxRun.objects.count() == 1
+
+def test_box_run_04migration_donothing(
+        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict,
+        ):
+    """Test that baxrun on v0.4.x that reuses a run from < v0.4.0 is always re-run"""
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+
+    assert m.OCRBoxRun.objects.count() == 0
+    bbox_run = m.OCRBoxRun.objects.create(
+        image=image,
+        model=box_model,
+        lang_src=language,
+        options=option_dict,
+    )
+    assert m.OCRBoxRun.objects.count() == 1
+
+    lbrt = (1,2,3,4)
+    def mock_pipeline(*args, **kwargs):
+        return [{'single': [lbrt], 'merged': lbrt}]
+
+    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
+
+    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
+    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
+    assert m.OCRBoxRun.objects.count() == 1
+    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
+    assert len(single) == 0
+    assert len(merged) == 0
+
+def test_box_run_04migration_replace(
+        monkeypatch, image: m.Image, language: m.Language, box_model: m.OCRBoxModel, option_dict: m.OptionDict,
+        ):
+    """Test that baxrun on v0.4.x that reuses a run from < v0.4.0 is always re-run"""
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+
+    bbox = m.BBox.objects.create(image=image, l=1, b=2, r=3, t=4)
+
+    assert m.OCRBoxRun.objects.count() == 0
+    bbox_run = m.OCRBoxRun.objects.create(
+        image=image,
+        model=box_model,
+        lang_src=language,
+        options=option_dict,
+    )
+    bbox_run.result_merged.add(bbox)
+    assert m.OCRBoxRun.objects.count() == 1
+
+    lbrt = (1,2,3,4)
+    def mock_pipeline(*args, **kwargs):
+        return [{'single': [lbrt], 'merged': lbrt}]
+
+    monkeypatch.setattr(box_model, '_box_detection', mock_pipeline)
+
+    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first().id == bbox_run.id
+    single, merged = box_model.box_detection(image, language, image=1, options=option_dict)
+    assert m.OCRBoxRun.objects.count() == 1
+    assert m.OCRBoxRun.objects.filter(id=bbox_run.id).first() is None
+    assert len(single) == 1
+    assert len(merged) == 1
+
+def test_ocr_run_nooption(
+        monkeypatch, image_pillow: PILImage,
+        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_run blocking"""
+    text = 'test_text'
+    def mock_ocr(*args, **kwargs):
+        return text
+
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    ocr_model._ocr = mock_ocr
+
+    gen = ocr_model.ocr(bbox, language, image=image_pillow)
+
+    res = next(gen)
+
+    assert isinstance(res, m.Text)
+    assert res.text == text
+    assert res.from_ocr_merged.first().options.options == {}
+
+def test_ocr_run_noimage(
+        monkeypatch,
+        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_run blocking"""
+    text = 'test_text'
+    def mock_ocr(*args, **kwargs):
+        return text
+
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    ocr_model._ocr = mock_ocr
+
+    gen = ocr_model.ocr(bbox, language, options=option_dict)
+
+    with pytest.raises(ValueError, match=r'^Image is required for OCR$'):
+        next(gen)
+
+def test_ocr_run(
+        monkeypatch, mock_called, image_pillow: PILImage,
+        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_run blocking + same pipeline (has to run lazily by refetching previous result)"""
+    text = 'test_text'
+    def mock_ocr(*args, **kwargs):
+        return text
+
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    ocr_model._ocr = mock_ocr
+
+    gen = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict)
+
+    res = next(gen)
+
+    assert isinstance(res, m.Text)
+    assert res.text == text
+
+    ocr_model._ocr = mock_called # Should not be called as it should be lazy
+    gen_lazy = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict)
+
+    assert not hasattr(mock_called, 'called')
+    assert next(gen_lazy) == res
+
+def test_ocr_run_nonblock(
+        monkeypatch, mock_called, image_pillow: PILImage,
+        bbox: m.BBox, language: m.Language, ocr_model: m.OCRModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_run non-blocking + same pipeline (has to run lazily by refetching previous result)"""
+    text = 'test_text'
+    def mock_ocr(*args, **kwargs):
+        return text
+
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    ocr_model._ocr = mock_ocr
+
+    gen = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict, block=False)
+
+    msg = next(gen)
+    # msg.resolve()
+    res = next(gen)
+
+    assert isinstance(msg, Message)
+    assert isinstance(res, m.Text)
+    assert res.text == text
+
+    ocr_model._ocr = mock_called # Should not be called as it should be lazy
+    gen_lazy = ocr_model.ocr(bbox, language, image=image_pillow, options=option_dict, block=False)
+
+    assert not hasattr(mock_called, 'called')
+    assert next(gen_lazy) is None
+    assert next(gen_lazy) == res
+
+@pytest.mark.parametrize('lang_src', ['ja', 'en'])
+def test_ocr_merge_single_result(lang_src): # pylint: disable=too-many-locals
+    # pylint: disable=invalid-name
+    """Test merge_single_result behavior"""
+    @dataclass
+    class BBox:
+        """Mock BBox class not related to database"""
+        l: int
+        b: int
+        r: int
+        t: int
+        to_merged: 'BBox' = None
+
+        def __post_init__(self, *args, **kwargs):
+            self.id = np.random.rand(1)[0]
+
+        @property
+        def lbrt(self): # pylint: disable=missing-function-docstring
+            return self.l, self.b, self.r, self.t
+
+        def __hash__(self):
+            return hash(self.id)
+
+    merged_lst = [
+        BBox(0, 100, 30, 130),
+        BBox(100, 0, 130, 30),
+        BBox(50, 50, 80, 80),
+    ]
+
+    w = 10
+    h = 10
+    s = 2
+    rrx = 2
+    rry = 2
+    boxes = []
+    np.random.seed(0)
+    for merged in merged_lst:
+        xoff = merged.l
+        yoff = merged.b
+        for i in range(9):
+            errx =  np.random.rand(2) * rrx
+            erry =  np.random.rand(2) * rry
+            l = i % 3 * (w+s) + xoff + errx[0]
+            b = i // 3 * (w+s) + yoff + errx[1]
+            r = l + w + erry[0]
+            t = b + h + erry[1]
+            boxes.append((str(i+1), BBox(l, b, r, t, merged)))
+
+    texts = [_[0] for _ in boxes]
+    bboxes_single = [_[1] for _ in boxes]
+
+    vertical = lang_src in  m.OCRModel._VERTICAL_LANGS
+    expected = '369258147' if vertical else '1 2 3 4 5 6 7 8 9'
+
+    res = m.OCRModel.merge_single_result(lang_src, texts, bboxes_single, merged_lst)
+
+    for result in res:
+        assert result == expected
+
+
+
+def test_tsl_pre_tokenize(data_regression, string: str):
+    """Test tsl module."""
+    options = [
+        {},
+        {'break_newlines': True},
+        {'break_newlines': 'True'},
+        {'break_newlines': False},
+        {'break_chars': '?.!'},
+        {'ignore_chars': '?.!'},
+        {'break_newlines': False, 'break_chars': '?.!'},
+        {'break_newlines': False, 'ignore_chars': '?.!'},
+        {'restore_missing_spaces': True},
+        {'restore_missing_spaces': 'True'},
+        {'restore_dash_newlines': True},
+        {'restore_dash_newlines': 'True'},
+    ]
+
+    res = []
+    for option in options:
+        dct = {
+            'string': string,
+            'options': option,
+            'tokens': m.TSLModel.pre_tokenize(string, **option)
+        }
+        res.append(dct)
+
+    data_regression.check({'res': res})
+
+@pytest.mark.parametrize('extra_start', ['$', '$%n', 'n$', 'n$$'])
+def test_tsl_pre_tokenize_allowed_start(extra_start):
+    """Test pre_tokenize with allowed_start_end."""
+    pret = m.TSLModel.pre_tokenize
+    res = pret(extra_start + ' ' + 'apple', allowed_start_end='a-zA-Z0-9\\-\\.\\,\\;\\?\\! ')
+    assert res[0].strip() == 'apple'
+
+@pytest.mark.parametrize('extra_end', ['$', '$%n', 'n$', 'n$$'])
+def test_tsl_pre_tokenize_allowed_end(extra_end):
+    """Test pre_tokenize with allowed_start_end."""
+    pret = m.TSLModel.pre_tokenize
+    res = pret('apple' + ' ' + extra_end, allowed_start_end='a-zA-Z0-9\\-\\.\\,\\;\\?\\! ')
+    assert res[0].strip() == 'apple'
+
+def test_tsl_pre_tokenize_restorespaces(monkeypatch):
+    """Test pre_tokenize with restore spaces."""
+    trie = Trie()
+    trie.insert('app')
+    trie.insert('apple')
+    trie.insert('pie')
+    monkeypatch.setattr(tries, 'TRIE_SRC', trie)
+    res = m.TSLModel.pre_tokenize('applepie', restore_missing_spaces=True)
+    assert res == ['apple pie']
+
+def test_tsl_run(
+        monkeypatch, mock_called,
+        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict
+        ):
+    """Test performing an tsl_run blocking"""
+    def mock_tsl_pipeline(*args, **kwargs):
+        return text.text
+
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    tsl_model._translate = mock_tsl_pipeline
+
+    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict)
+
+    res = next(gen)
+
+    assert isinstance(res, m.Text)
+    assert res.text == text.text
+
+    tsl_model._translate = mock_called # Should not be called as it should be lazy
+    gen_lazy = tsl_model.translate(text, src=language, dst=language, options=option_dict)
+
+    assert not hasattr(mock_called, 'called')
+    assert next(gen_lazy) == res
+
+def test_tsl_run_manual(
+        monkeypatch, mock_called,
+        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict,
+        manual_model: m.TSLModel
+        ):
+    """Test performing an tsl_run blocking"""
+    manual = m.Text.objects.create(text='manual')
+    m.TranslationRun.objects.create(
+        text=text,
+        result=manual,
+        model=manual_model,
+        options=option_dict,
+        lang_src=language,
+        lang_dst=language,
+    )
+    def mock_tsl_pipeline(*args, **kwargs):
+        return text.text
+
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    tsl_model._translate = mock_tsl_pipeline
+
+    tsl_model._translate = mock_called # Should not be called as it should be lazy
+    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict)
+
+    res = next(gen)
+
+    assert isinstance(res, m.Text)
+    assert res.text == manual.text
+    assert not hasattr(mock_called, 'called')
+
+def test_tsl_run_nonblock(
+        monkeypatch, mock_called,
+        text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict
+        ):
+    """Test performing an tsl_run non-blocking"""
+    def mock_tsl_pipeline(*args, **kwargs):
+        return text.text
+
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    tsl_model._translate = mock_tsl_pipeline
+
+    gen = tsl_model.translate(text, src=language, dst=language, options=option_dict, block=False)
+
+    msg = next(gen)
+    # msg.resolve()
+    res = next(gen)
+
+    assert isinstance(msg, Message)
+    assert isinstance(res, m.Text)
+    assert res.text == text.text
+
+    tsl_model._translate = mock_called # Should not be called as it should be lazy
+    gen_lazy = tsl_model.translate(text, src=language, dst=language, options=option_dict, block=False)
+
+    assert not hasattr(mock_called, 'called')
+    assert next(gen_lazy) is None
+    assert next(gen_lazy) == res
+
+def test_tsl_run_lazy(text: m.Text, language: m.Language, tsl_model: m.TSLModel, option_dict: m.OptionDict):
+    """Test tsl pipeline with worker"""
+    # Force and lazy should not be used together
+    with pytest.raises(ValueError):
+        gen = tsl_model.translate(text, language, language, force=True, lazy=True)
+        next(gen)
+
+    # Nothing in the DB, so should rise ValueError (no previous found and lazy=True)
+    with pytest.raises(ValueError):
+        gen = tsl_model.translate(text, language, language, lazy=True)
+        next(gen)
+
+@pytest.mark.parametrize('mock_called', [['test_text_ocred']], indirect=True)
+def test_ocr_tsl_work_single_ocr_plus_lazy( # pylint: disable=too-many-arguments
+        monkeypatch, image_pillow: PILImage, mock_called,
+        image: m.Image, text: m.Text, bbox: m.BBox, bbox_single: m.BBox, language: m.Language,
+        box_model: m.OCRBoxModel, ocr_model_single: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_tsl_run non-lazy"""
+    def mock_box_run(*args, **kwargs):
+        return [bbox_single], [bbox]
+    def mock_ocr_run(*args, block=True, **kwargs):
+        if not block:
+            yield
+        res, _ = m.Text.objects.get_or_create(text = text.text + '_ocred')
+        yield res
+    def mock_tsl_run(obj, *args, block=True, **kwargs):
+        if not block:
+            yield
+        res, _ = m.Text.objects.get_or_create(text = obj.text + '_translated')
+        yield res
+
+    box_model.box_detection = mock_box_run
+    ocr_model_single.ocr = mock_ocr_run
+    tsl_model.translate = mock_tsl_run
+
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(ocr_model_single, 'merge_single_result', mock_called)
+
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model_single)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+
+    res = full.ocr_tsl_pipeline_work(image_pillow, image.md5)
+
+    # Check that `merge_single_result` is being called with the expected arguments
+    assert mock_called.args[0] == language.iso1
+    assert mock_called.args[1] == [text.text + '_ocred']
+    assert mock_called.args[2] == [bbox_single]
+    assert mock_called.args[3] == [bbox]
+
+    res_lazy = full.ocr_tsl_pipeline_lazy(image.md5)
+
+    assert res == res_lazy
+
+def test_ocr_tsl_work_plus_lazy(
+        monkeypatch, image_pillow: PILImage,
+        image: m.Image, text: m.Text, bbox: m.BBox, language: m.Language,
+        box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_tsl_run non-lazy"""
+    def mock_box_run(*args, **kwargs):
+        return [bbox], [bbox]
+    def mock_ocr_run(*args, block=True, **kwargs):
+        if not block:
+            yield
+        res, _ = m.Text.objects.get_or_create(text = text.text + '_ocred')
+        yield res
+    def mock_tsl_run(obj, *args, block=True, **kwargs):
+        if not block:
+            yield
+        res, _ = m.Text.objects.get_or_create(text = obj.text + '_translated')
+        yield res
+
+    box_model.box_detection = mock_box_run
+    ocr_model.ocr = mock_ocr_run
+    tsl_model.translate = mock_tsl_run
+
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+
+    res = full.ocr_tsl_pipeline_work(image_pillow, image.md5)
+
+    assert isinstance(res, list)
+    assert len(res) == 1
+    assert isinstance(res[0], dict)
+    assert res[0]['ocr'] == text.text + '_ocred'
+    assert res[0]['tsl'] == text.text + '_ocred' + '_translated'
+    assert res[0]['box'] == bbox.lbrt
+
+    res_lazy = full.ocr_tsl_pipeline_lazy(image.md5)
+
+    assert res == res_lazy
+
+def test_ocr_tsl_lazy():
+    """Test performing an ocr_tsl_run lazy (no image)"""
+    with pytest.raises(ValueError, match=r'^Image with md5 .* does not exist$'):
+        full.ocr_tsl_pipeline_lazy('')
+
+def test_ocr_tsl_lazy_image(
+        monkeypatch, image: m.Image,
+        box_model: m.OCRBoxModel, ocr_model: m.OCRModel, tsl_model: m.TSLModel, option_dict: m.OptionDict
+        ):
+    """Test performing an ocr_tsl_run lazy (with image but missing ocr-tsl steps)"""
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+
+    with pytest.raises(ValueError):
+        full.ocr_tsl_pipeline_lazy(image.md5)
diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py
new file mode 100644
index 0000000..2883745
--- /dev/null
+++ b/tests/test_plugin_manager.py
@@ -0,0 +1,1048 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test plugin manager."""
+
+# pylint: disable=missing-class-docstring,protected-access,missing-function-docstring,import-outside-toplevel
+# pylint: disable=too-many-lines
+
+import json
+import threading
+import time
+from pathlib import Path
+
+import pytest
+
+from ocr_translate import plugin_manager as pm
+from ocr_translate.ocr_tsl.initializers import ensure_plugins
+
+
+@pytest.fixture()
+def mock_log_called():
+    """Mock funciton that logs called arguments over multiple cals."""
+    class MockLogFunc:
+        called_args = []
+        called_kwargs = []
+        def __call__(self, *args, **kwargs):
+            MockLogFunc.called_args.append(args)
+            MockLogFunc.called_kwargs.append(kwargs)
+    return MockLogFunc
+
+@pytest.fixture(autouse=True, scope='function')
+def tmp_base_dir(tmp_path, monkeypatch):
+    """Set the base directory to a temporary directory."""
+    monkeypatch.setenv('OCT_BASE_DIR', str(tmp_path))
+    return tmp_path
+
+@pytest.fixture()
+def disabled(monkeypatch):
+    """Disable plugins."""
+    monkeypatch.setenv('OCT_DISABLE_PLUGINS', '1')
+
+@pytest.fixture()
+def django_debug(monkeypatch):
+    """Set the Django debug environment variable."""
+    monkeypatch.setenv('DJANGO_DEBUG', 'true')
+
+@pytest.fixture()
+def device(monkeypatch):
+    """Set the device environment variable."""
+    value = '123abc'
+    monkeypatch.setenv('DEVICE', value)
+    return value
+
+@pytest.fixture()
+def mock_plugin_data(tmp_base_dir, monkeypatch, device):
+    """Create a mock plugin data file."""
+    mock_file = tmp_base_dir / 'plugins_data.json'
+    monkeypatch.setattr(pm.resources, 'files', lambda x: tmp_base_dir)
+    mock_data = [
+        {
+            'name': 'plugin1',
+            'package': 'pkg-p1',
+            'version': '1.0',
+            'dependencies': [
+                {'name': 'pkg1', 'version': '1.0'},
+                {'name': 'pkg2', 'version': '2.0'},
+                {'name': 'pkg4', 'version': '1.1', 'scope': pm.GENERIC_SCOPE},
+                {'name': 'pkg4', 'version': '1.2', 'scope': device},
+                {'name': 'pkg5', 'version': '1.1', 'system': 'windows'},
+                {'name': 'pkg5', 'version': '1.2', 'system': 'linux'},
+            ]
+        },
+        {
+            'name': 'plugin2',
+            'package': 'pkg-p2',
+            'version': '1.1',
+            'dependencies': [
+                {'name': 'pkg1', 'version': '1.0'},
+                {'name': 'pkg3', 'version': '2.0'},
+            ]
+        },
+    ]
+    with mock_file.open('w') as f:
+        json.dump(mock_data, f)
+    return mock_data
+
+@pytest.fixture()
+def mock_plugin_file(tmp_base_dir):
+    """Create a mock plugin data file."""
+    mock_file = tmp_base_dir / 'plugins.json'
+    mock_data = ['plugin1', 'plugin2']
+    with mock_file.open('w') as f:
+        json.dump(mock_data, f)
+    return mock_data
+
+@pytest.fixture()
+def mock_installed_file(tmp_base_dir, device):
+    """Create a mock plugin data file."""
+    plugin_dir = tmp_base_dir / 'plugins'
+    mock_file = plugin_dir / 'installed.json'
+
+    sp_generic = plugin_dir / pm.GENERIC_SCOPE
+    sp_device = plugin_dir / device
+    sp_generic.mkdir(parents=True, exist_ok=True)
+    sp_device.mkdir(parents=True, exist_ok=True)
+
+    files1 = ['test_file-1_1', 'test_file-1_2']
+    dirs1 = ['test_dir-1_1', 'test_dir-1_2']
+    files2 = ['test_file-2_1', 'test_file-2_2']
+    dirs2 = ['test_dir-2_1', 'test_dir-2_2']
+
+    for pth in files1:
+        (sp_generic / pth).touch()
+    for pth in dirs1:
+        (sp_generic / pth).mkdir()
+    for pth in files2:
+        (sp_device / pth).touch()
+    for pth in dirs2:
+        (sp_device / pth).mkdir()
+
+    mock_data = {
+        f'{pm.GENERIC_SCOPE}::pkg1': {'files': [], 'dirs': []},
+        f'{pm.GENERIC_SCOPE}::pkg2': {'files': [], 'dirs': []},
+        f'{pm.GENERIC_SCOPE}::pkg5': {'files': [], 'dirs': []},
+        f'{pm.GENERIC_SCOPE}::pkg4': {
+            'version': '1.1',
+            'files': files1,
+            'dirs': dirs1,
+        },
+        f'{device}::pkg4': {
+            'version': '1.2',
+            'files': files2,
+            'dirs': dirs2,
+        },
+    }
+
+    with mock_file.open('w') as f:
+        json.dump(mock_data, f)
+    return mock_data
+
+@pytest.fixture(autouse=True)
+def mock_django_configs(monkeypatch):
+    monkeypatch.setattr(pm.settings, 'INSTALLED_APPS', [])
+    monkeypatch.setattr(pm.apps, 'populate', lambda x: None)
+
+def test_get_safe_name():
+    """Test that safe name returns a valid python variable name."""
+    assert pm.get_safe_name('-') == '_min_'
+    assert pm.get_safe_name('--') == '_min__min_'
+
+@pytest.mark.parametrize('mock_called', ['safe_name'], indirect=True)
+def test_overrides_decorator_noname(monkeypatch, mock_called):
+    """Test that overrides decorator raises if no name is given."""
+    monkeypatch.setattr(pm, 'get_safe_name', mock_called)
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args, kwargs
+
+    with pytest.raises(ValueError):
+        test_func('cls')
+
+@pytest.mark.parametrize('mock_called', ['safe_name'], indirect=True)
+def test_overrides_decorator_name_args(monkeypatch, mock_called):
+    """Test that overrides decorator 2nd argument is a string passed to safe name."""
+    monkeypatch.setattr(pm, 'get_safe_name', mock_called)
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args
+
+    assert test_func('cls', 'name') == ('cls',)
+    assert mock_called.args == ('name',)
+
+def test_overrides_decorator_name_double():
+    """Test that overrides decorator 2nd argument cant also be present in kwargs."""
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args
+
+    with pytest.raises(TypeError):
+        test_func('cls', 'name', name='123')
+
+def test_overrides_decorator_other_double():
+    """Test that overrides decorator 3nd and forth argument cant also be present in kwargs."""
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args
+
+    args = ['cls', 'name', ]
+    arg_names = ['version', 'extras', 'scope', 'system']
+    for name in arg_names:
+        args.append(name)
+        with pytest.raises(TypeError):
+            test_func(*args, **{name: '123'})
+
+@pytest.mark.parametrize('mock_called', ['safe_name'], indirect=True)
+def test_overrides_decorator_name_kwargs(monkeypatch, mock_called):
+    """Test that overrides decorator 2nd argument is a string passed to safe name."""
+    monkeypatch.setattr(pm, 'get_safe_name', mock_called)
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args, kwargs
+
+    args, kwargs = test_func('cls', name='name')
+
+    assert args == ('cls',)
+    assert kwargs == {'name': 'name'}
+
+def test_overrides_decorator_only_kwargs():
+    """Test that overrides decorator only passes kwargs downstream beside class."""
+    @pm.install_overrides_decorator
+    def test_func(*args, **kwargs):
+        return args
+
+    assert test_func('cls', 'name', 3, somekw=1) == ('cls',)
+
+def test_pip_install_raisee(monkeypatch, mock_called):
+    """Test pip install with raise call."""
+    def raise_func(*args, **kwargs):
+        exc = pm.subprocess.CalledProcessError(1, 'test')
+        exc.stdout = b'test stdout'
+        exc.stderr = b'test stderr'
+        raise exc
+    monkeypatch.setattr(pm.subprocess, 'run', raise_func)
+    monkeypatch.setattr(pm.logger, 'error', mock_called)
+
+    with pytest.raises(pm.subprocess.CalledProcessError):
+        pm._pip_install('test', '.')
+    assert mock_called.called
+    assert mock_called.args[0] == 'test stderr'
+
+def test_pip_install_extras_none(monkeypatch):
+    """Test pip install with no extras."""
+    class MockCalled:
+        called = False
+        args = []
+        def __call__(self, *args, **kwargs):
+            MockCalled.called = True
+            MockCalled.args = args
+            return self
+        @property
+        def stdout(self):
+            return b'test'
+    monkeypatch.setattr(pm.subprocess, 'run', MockCalled())
+    pm._pip_install('test', '.')
+    assert MockCalled.called
+    assert MockCalled.args[0][-1] == '--prefix=.'
+
+def test_pip_install_extras_empty(monkeypatch):
+    """Test pip install with no extras."""
+    class MockCalled:
+        called = False
+        args = []
+        def __call__(self, *args, **kwargs):
+            MockCalled.called = True
+            MockCalled.args = args
+            return self
+        @property
+        def stdout(self):
+            return b'test'
+    monkeypatch.setattr(pm.subprocess, 'run', MockCalled())
+    pm._pip_install('test', '.', [])
+    assert MockCalled.called
+    assert MockCalled.args[0][-1] == '--prefix=.'
+
+def test_pip_install_extras(monkeypatch):
+    """Test pip install with no extras."""
+    class MockCalled:
+        called = False
+        args = []
+        def __call__(self, *args, **kwargs):
+            MockCalled.called = True
+            MockCalled.args = args
+            return self
+        @property
+        def stdout(self):
+            return b'test'
+    monkeypatch.setattr(pm.subprocess, 'run', MockCalled())
+    pm._pip_install('test', '.', ['123'])
+    assert MockCalled.called
+    assert MockCalled.args[0][-1] == '123'
+
+def test_manager_singleton():
+    """Test that the manager is a singleton."""
+    pmng1 = pm.PluginManager()
+    pmng2 = pm.PluginManager()
+    assert pmng1 is pmng2
+
+def test_ensurepip_disabled(monkeypatch, disabled, mock_called):
+    """Test plugin manager ensure_pip function with disabled form env."""
+    monkeypatch.setattr(pm.subprocess, 'run', mock_called)
+    pm.PluginManager()
+    assert not hasattr(mock_called, 'called')
+
+def test_ensurepip_success(monkeypatch, mock_called):
+    """Test plugin manager ensure_pip function mocked success."""
+    monkeypatch.setattr(pm.subprocess, 'run', mock_called)
+    pm.PluginManager()
+    assert mock_called.called
+
+def test_ensurepip_fail(monkeypatch, mock_called):
+    """Test plugin manager ensure_pip function mocked failure. Test that running subprocess.run on a non-existent
+    file raises FileNotFoundError."""
+    import functools
+    import subprocess
+    app = functools.partial(subprocess.run, ['thisfileshouldnotexist'])
+    def mock_run(*args, **kwargs):
+        app(*args[1:], **kwargs)
+    monkeypatch.setattr(pm.subprocess, 'run', mock_run)
+    with pytest.raises(FileNotFoundError):
+        pm.PluginManager()
+
+def test_init_env_device(device):
+    """Test plugin manager init with environment variables."""
+    pmng = pm.PluginManager()
+    assert pmng.device == device
+
+def test_init_env_device_default(monkeypatch):
+    """Test plugin manager init with environment variables."""
+    pmng = pm.PluginManager()
+    assert pmng.device == 'cpu'
+
+def test_init_plugin_dir_creation(tmp_base_dir):
+    """Test plugin manager creates OCT_BASE_DIR/plugins directory."""
+    pm.PluginManager()
+    assert tmp_base_dir.joinpath('plugins').exists()
+
+def test_init_scopes_disabled(monkeypatch, disabled, tmp_base_dir):
+    """Test plugin manager init scopes."""
+    monkeypatch.setattr(pm.site, 'USER_BASE', None)
+    monkeypatch.setattr(pm.site, 'USER_SITE', None)
+    pmng = pm.PluginManager()
+    assert pm.GENERIC_SCOPE in pmng.scopes
+    assert not (tmp_base_dir / 'plugins' / pm.GENERIC_SCOPE).exists()
+
+def test_init_scopes(monkeypatch, device, tmp_base_dir):
+    """Test plugin manager init scopes."""
+    monkeypatch.setattr(pm.site, 'USER_BASE', None)
+    monkeypatch.setattr(pm.site, 'USER_SITE', None)
+    pmng = pm.PluginManager()
+    assert pm.GENERIC_SCOPE in pmng.scopes
+    assert device in pmng.scopes
+    assert (tmp_base_dir / 'plugins' / pm.GENERIC_SCOPE).exists()
+    assert (tmp_base_dir / 'plugins' / device).exists()
+
+    import sys
+    assert sys.path[0] == pmng.PLUGIN_SP[device].as_posix()
+    assert sys.path[1] == pmng.PLUGIN_SP[pm.GENERIC_SCOPE].as_posix()
+
+    import site
+    assert site.USER_BASE == pmng.plugin_dir.as_posix()
+    assert site.USER_SITE == pmng.PLUGIN_SP[pm.GENERIC_SCOPE].as_posix()
+
+def test_init_site_packages_removal(tmp_base_dir):
+    """Test plugin manager init site packages removal."""
+    sp1 = tmp_base_dir / 'plugins' / 'site-packages'
+    sp2 = tmp_base_dir / 'plugins' / 'test1' / 'site-packages'
+    sp3 = tmp_base_dir / 'plugins' / 'test1' / 'test2' / 'site-packages'
+    sp1.mkdir(parents=True, exist_ok=True)
+    sp2.mkdir(parents=True, exist_ok=True)
+    sp3.mkdir(parents=True, exist_ok=True)
+    pm.PluginManager()
+    assert not sp1.exists()
+    assert not sp2.exists()
+    assert not sp3.exists()
+
+def test_plugin_data():
+    """Test plugin manager loading plugin data  with real file."""
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.plugins_data, list)
+
+def test_plugin_data_disabled(disabled):
+    """Test plugin manager loading plugin data  with real file."""
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.plugins_data, list)
+    assert pmng._plugin_data is None
+
+def test_plugin_data_nofile(monkeypatch, tmp_base_dir):
+    """Test plugin manager loading plugin data with non-existent file."""
+    monkeypatch.setattr(pm.resources, 'files', lambda x: tmp_base_dir)
+
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.plugins_data, list)
+    assert pmng.plugins_data == []
+
+def test_plugin_data_mock(monkeypatch, mock_plugin_data, mock_called):
+    """Test plugin manager loading plugin data with mock file."""
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.plugins_data, list)
+    assert pmng._plugin_data is not None
+    assert pmng.plugins_data == mock_plugin_data
+
+    # Test caching of file
+    monkeypatch.setattr(pm.json, 'load', mock_called)
+    pmng.plugins_data  # pylint: disable=pointless-statement
+    assert not hasattr(mock_called, 'called')
+
+def test_get_plugin_data_absent(mock_plugin_data):
+    """Test plugin manager get data from known plugin."""
+    pmng = pm.PluginManager()
+    data = pmng.get_plugin_data(mock_plugin_data[0]['name'] + 'x')
+    assert data == {}
+
+def test_get_plugin_data_present(mock_plugin_data):
+    """Test plugin manager get data from known plugin."""
+    pmng = pm.PluginManager()
+    data = pmng.get_plugin_data(mock_plugin_data[0]['name'])
+    assert data['name'] == mock_plugin_data[0]['name']
+    assert data['version'] == mock_plugin_data[0]['version']
+
+
+def test_plugin_file_mock(monkeypatch, mock_plugin_file, mock_called):
+    """Test plugin manager loading installed plugin list."""
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.plugins, list)
+    assert pmng.plugins == mock_plugin_file
+
+    # Test caching of file
+    monkeypatch.setattr(pm.json, 'load', mock_called)
+    pmng.plugins  # pylint: disable=pointless-statement
+    assert not hasattr(mock_called, 'called')
+
+def test_plugin_file_mock_disabled(monkeypatch, disabled, mock_plugin_file, mock_called):
+    """Test plugin manager loading installed plugin list."""
+    monkeypatch.setattr(pm.json, 'load', mock_called)
+    pmng = pm.PluginManager()
+    assert pmng.plugins == []
+    assert not hasattr(mock_called, 'called')
+
+def test_installed_pkgs_envdisabled(disabled):
+    """Test plugin manager loading installed plugin list disabled by env var."""
+    pmng = pm.PluginManager()
+    assert pmng.installed_pkgs == {}
+    assert pmng._installed is None
+
+def test_installed_file_mock(monkeypatch, mock_installed_file, mock_called):
+    """Test plugin manager loading installed pkgs list."""
+    pmng = pm.PluginManager()
+    assert isinstance(pmng.installed_pkgs, dict)
+    assert pmng._installed is not None
+    assert pmng.installed_pkgs == mock_installed_file
+
+    # Test caching of file
+    monkeypatch.setattr(pm.json, 'load', mock_called)
+    pmng.installed_pkgs  # pylint: disable=pointless-statement
+    assert not hasattr(mock_called, 'called')
+
+def test_save_plugin_list_disabled(monkeypatch, disabled, mock_called):
+    """Test plugin manager saving installed plugin list with disabled plugins."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm.json, 'dump', mock_called)
+    pmng.save_plugin_list()
+    assert not hasattr(mock_called, 'called')
+
+def test_save_plugin_list(monkeypatch, mock_plugin_file, mock_called):
+    """Test plugin manager saving installed plugin list."""
+    pmng = pm.PluginManager()
+    pmng._plugins = mock_plugin_file
+    monkeypatch.setattr(pm.json, 'dump', mock_called)
+    pmng.save_plugin_list()
+    assert mock_called.called
+    assert mock_called.args[0] == mock_plugin_file
+    assert Path(mock_called.args[1].name) == pmng.plugin_list_file
+
+def test_save_installed_disabled(monkeypatch, disabled, mock_called):
+    """Test plugin manager saving installed pkgs list with disabled plugins."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm.json, 'dump', mock_called)
+    pmng.save_installed()
+    assert not hasattr(mock_called, 'called')
+
+def test_save_installed(monkeypatch, mock_installed_file, mock_called):
+    """Test plugin manager saving installed pkgs list."""
+    pmng = pm.PluginManager()
+    pmng._installed = mock_installed_file
+    monkeypatch.setattr(pm.json, 'dump', mock_called)
+    pmng.save_installed()
+    assert mock_called.called
+    assert mock_called.args[0] == mock_installed_file
+    assert Path(mock_called.args[1].name) == pmng.installed_file
+
+def test_install_plugin_unknown(mock_plugin_data):
+    """Test plugin manager installing a plugin unknown."""
+    pmng = pm.PluginManager()
+    name = mock_plugin_data[0]['name'] + 'x'
+    with pytest.raises(ValueError):
+        pmng.install_plugin(name)
+
+def test_install_plugin_known(monkeypatch, mock_plugin_data, mock_log_called):
+    """Test plugin manager installing a plugin known."""
+    data = mock_plugin_data[0]
+    name = data['name']
+    pkg = data['package']
+    ver = data['version']
+    dps = data['dependencies']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, 'install_package', mock_log_called())
+
+    assert name not in pmng.plugins
+    assert name not in pm.settings.INSTALLED_APPS
+    pmng.install_plugin(name)
+    assert name in pmng.plugins
+    assert name in pm.settings.INSTALLED_APPS
+
+    def get_name_version():
+        args =  mock_log_called.called_args.pop()
+        kwargs = mock_log_called.called_kwargs.pop()
+        pkg_name = kwargs.get('name', None)
+        pkg_version = kwargs.get('version', None)
+        if len(args) > 0:
+            pkg_name = args[0]
+        if len(args) > 1:
+            pkg_version = args[1]
+
+        return pkg_name, pkg_version
+
+    assert get_name_version() == (pkg, ver)
+
+    for dep in dps:
+        assert get_name_version() == (dep['name'], dep['version'])
+
+def test_install_plugin_new_and_existing(monkeypatch, mock_plugin_data, mock_called):
+    """Test plugin manager installing a plugin new first and existing after."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, '_install_plugin', lambda x: None)
+    name = mock_plugin_data[0]['name']
+    assert not pmng.plugin_list_file.exists()
+    assert name not in pm.settings.INSTALLED_APPS
+    pmng.install_plugin(name)
+    assert name in pmng.plugins
+    assert name in pm.settings.INSTALLED_APPS
+    assert pmng.plugin_list_file.exists()
+
+    monkeypatch.setattr(pmng, 'save_plugin_list', mock_called)
+    pmng.install_plugin(name)
+    assert not hasattr(mock_called, 'called')
+
+def test_install_plugin_thread_lock(monkeypatch, mock_plugin_data):
+    """Test plugin manager installing a plugin with thread lock."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, '_install_plugin', lambda *a: time.sleep(0.25))
+    name = mock_plugin_data[0]['name']
+
+    ran = [1,0,0,0,0]
+    def install(i):
+        nonlocal ran
+        pmng.install_plugin(name)
+        ran[i+1] = 1
+        assert ran[i] == 1
+
+    threads = []
+    for i in range(4):
+        t = threading.Thread(target=install, args=(i,))
+        t.start()
+        time.sleep(0.005)
+        threads.append(t)
+
+    for t in threads:
+        t.join()
+
+def test_uninstall_plugin_notinstalled_plist(monkeypatch, mock_plugin_data, mock_called):
+    """Test plugin manager removing plugins not present in plugin list."""
+    name = mock_plugin_data[0]['name']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, 'uninstall_package', mock_called)
+    pmng.uninstall_plugin(name)
+
+    assert not hasattr(mock_called, 'called')
+
+def test_uninstall_plugin_notinstalled_pkgs(monkeypatch, mock_plugin_data):
+    """Test plugin manager removing plugins not present in installed files."""
+    name = mock_plugin_data[0]['name']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, '_plugins', [name])
+    with pytest.raises(ValueError):
+        pmng.uninstall_plugin(name)
+
+def test_uninstall_plugin_remove_single(monkeypatch, mock_plugin_data, mock_log_called, device):
+    """Test plugin manager removing a single plugins."""
+    name = mock_plugin_data[0]['name']
+    pkg = mock_plugin_data[0]['package']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm.settings, 'INSTALLED_APPS', [name])
+    monkeypatch.setattr(pmng, '_plugins', [name])
+    monkeypatch.setattr(pmng, '_installed', {f'{pm.GENERIC_SCOPE}::{pkg}' : {}})
+    monkeypatch.setattr(pmng, 'uninstall_package', mock_log_called())
+
+    pmng.uninstall_plugin(name)
+
+    assert mock_log_called.called_args.pop() == (f'{pm.GENERIC_SCOPE}::{pkg}',)
+    assert (f'{pm.GENERIC_SCOPE}::pkg1',) in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg2',) in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg5',) in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg4',) in mock_log_called.called_args
+    assert (f'{device}::pkg4',) in mock_log_called.called_args
+
+    assert pmng.plugins == []
+    assert pm.settings.INSTALLED_APPS == []
+
+def test_uninstall_plugin_remove_shared(monkeypatch, mock_plugin_data, mock_log_called, device):
+    """Test plugin manager removing a plugin with shared dependencies."""
+    name1 = mock_plugin_data[0]['name']
+    name2 = mock_plugin_data[1]['name']
+    pkg = mock_plugin_data[0]['package']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm.settings, 'INSTALLED_APPS', [name1, name2])
+    monkeypatch.setattr(pmng, '_plugins', [name1, name2])
+    monkeypatch.setattr(pmng, '_installed', {f'{pm.GENERIC_SCOPE}::{pkg}' : {}})
+    monkeypatch.setattr(pmng, 'uninstall_package', mock_log_called())
+
+    pmng.uninstall_plugin(name1)
+
+    assert mock_log_called.called_args.pop() == (f'{pm.GENERIC_SCOPE}::{pkg}',)
+    assert (f'{pm.GENERIC_SCOPE}::pkg1',) not in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg2',) in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg5',) in mock_log_called.called_args
+    assert (f'{pm.GENERIC_SCOPE}::pkg4',) in mock_log_called.called_args
+    assert (f'{device}::pkg4',) in mock_log_called.called_args
+
+    assert pmng.plugins == [name2]
+    assert pm.settings.INSTALLED_APPS == [name2]
+
+def test_uninstall_plugin_thread_lock(monkeypatch, mock_plugin_data, mock_called):
+    """Test plugin manager removing a plugin with thread lock."""
+    name = mock_plugin_data[0]['name']
+    pkg = mock_plugin_data[0]['package']
+
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, '_plugins', [name])
+    monkeypatch.setattr(pm.settings, 'INSTALLED_APPS', [name])
+    monkeypatch.setattr(pmng, '_plugins', [name])
+    monkeypatch.setattr(pmng, '_installed', {f'{pm.GENERIC_SCOPE}::{pkg}' : {}})
+    monkeypatch.setattr(pmng, 'uninstall_package', lambda x: time.sleep(0.25))
+
+    ran = [1,0,0,0,0]
+    def uninstall(i):
+        nonlocal ran
+        pmng.uninstall_plugin(name)
+        ran[i+1] = 1
+        assert ran[i] == 1
+
+    threads = []
+    for i in range(4):
+        t = threading.Thread(target=uninstall, args=(i,))
+        t.start()
+        if i == 1:
+            monkeypatch.setattr(pmng, 'get_plugin_data', mock_called)
+        time.sleep(0.005)
+        threads.append(t)
+
+    for t in threads:
+        t.join()
+
+    assert name not in pmng.plugins
+    assert not hasattr(mock_called, 'called')
+
+def test_install_package_unkown_scope(monkeypatch, mock_called):
+    """Test plugin manager installing a package with unknown scope."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = 'unknown'
+    pmng.install_package(name, version, scope=scope)
+    assert not hasattr(mock_called, 'called')
+
+def test_find_site_packages_none():
+    """Test plugin manager finding site packages not existing."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    # sp_dir = pmng.plugin_dir / 'test123' / 'site-packages'
+    assert pm.find_site_packages(pmng.plugin_dir) is None
+
+def test_find_site_packages_exists():
+    """Test plugin manager finding site packages existing."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    sp_dir = pmng.plugin_dir / 'test123' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    assert pm.find_site_packages(pmng.plugin_dir) == sp_dir
+
+def test_install_package_unkown_system(monkeypatch, mock_called):
+    """Test plugin manager installing a package with unknown system."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    system = 'unknown'
+    pmng.install_package(name, version, system=system)
+    assert not hasattr(mock_called, 'called')
+
+def test_install_package_nospdir(monkeypatch, mock_called):
+    """Test plugin manager installing a package without any file created."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    with pytest.raises(FileNotFoundError):
+        pmng.install_package(name, version, scope=scope)
+
+def test_install_package_install_file_dirs(monkeypatch, mock_called):
+    """Test plugin manager installing a package with file created."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    scoped_name = f'{scope}::{name}'
+    filename = 'test_file'
+    dirname = 'test_dir'
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    (sp_dir / filename).touch()
+    (sp_dir / dirname).mkdir()
+    (sp_dir / '__pycache__').mkdir()  # Test that __pycache__ is ignored
+
+    pmng.install_package(name, version, scope=scope)
+    assert hasattr(mock_called, 'called')
+    assert scoped_name in pmng.installed_pkgs
+
+    assert (pmng.PLUGIN_SP[scope] / filename).exists()
+    assert (pmng.PLUGIN_SP[scope] / dirname).exists()
+    assert pmng.installed_pkgs[scoped_name]['files'] == [filename]
+    assert pmng.installed_pkgs[scoped_name]['dirs'] == [dirname]
+
+
+def test_install_package_install_file_overwrite(monkeypatch, mock_called):
+    """Test plugin manager installing a package with overwriting existing file."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    scoped_name = f'{scope}::{name}'
+    filename = 'test_file'
+    dst_file = pmng.PLUGIN_SP[scope] / filename
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    with (sp_dir / 'test_file').open('w') as f:
+        f.write('test_new')
+    with dst_file.open('w') as f:
+        f.write('test_old')
+
+    pmng.install_package(name, version, scope=scope)
+    assert hasattr(mock_called, 'called')
+    assert scoped_name in pmng.installed_pkgs
+
+    assert dst_file.exists()
+    assert dst_file.read_text() == 'test_new'
+    assert pmng.installed_pkgs[scoped_name]['files'] == [filename]
+
+
+def test_install_package_install_dir_merge(monkeypatch, mock_called):
+    """Test plugin manager installing a package with dir merging."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    scoped_name = f'{scope}::{name}'
+    dirname = 'test_dir'
+    file_old = 'test_file_old'
+    file_new = 'test_file_new'
+
+    dst = pmng.PLUGIN_SP[scope]
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    (sp_dir / dirname).mkdir(parents=True, exist_ok=True)
+    (dst / dirname).mkdir(parents=True, exist_ok=True)
+    (sp_dir / dirname / file_old).touch()
+    (dst / dirname / file_new).touch()
+
+    pmng.install_package(name, version, scope=scope)
+    assert hasattr(mock_called, 'called')
+    assert scoped_name in pmng.installed_pkgs
+
+    assert (dst / dirname / file_old).exists()
+    assert (dst / dirname / file_new).exists()
+
+def test_install_package_install_twice(monkeypatch, mock_called):
+    """Test plugin manager installing an already installed package."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version, scope=scope)
+    assert mock_called.called
+    assert f'{scope}::{name}' in pmng.installed_pkgs
+
+    # Test that on second call the install action is skipped
+    del mock_called.called
+    pmng.install_package(name, version, scope=scope)
+    assert not hasattr(mock_called, 'called')
+
+def test_install_package_overwrite_version(monkeypatch, mock_called):
+    """Test plugin manager installing an already installed package with different version."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version, scope=scope)
+    assert mock_called.called
+    assert f'{scope}::{name}' in pmng.installed_pkgs
+
+    # Test that installing different version triggers the install action and removes the old version
+    version = '2.0'
+    del mock_called.called
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    monkeypatch.setattr(pmng, '_uninstall_package', mock_called)
+    pmng.install_package(name, version, scope=scope)
+    assert  mock_called.called
+
+def test_install_package_list_extras(monkeypatch, mock_called):
+    """Test plugin manager installing with `list` extras."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    extras = ['extra1', 'extra2']
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version, extras=extras)
+    assert mock_called.called
+    assert mock_called.args[2] == extras
+
+def test_install_package_str_extras(monkeypatch, mock_called):
+    """Test plugin manager installing with `str` extras."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    extras = 'extra1 extra2'
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version, extras=extras)
+    assert mock_called.called
+    assert mock_called.args[2] == extras.split(' ')
+
+def test_install_package_env_override_version(monkeypatch, mock_called):
+    """Test plugin manager installing overriding using envvar: version."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    override_version = '2.0'
+
+    monkeypatch.setenv(f'OCT_PKG_{pm.get_safe_name(name).upper()}_VERSION', override_version)
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version)
+
+    assert mock_called.called
+    assert mock_called.args[0] == f'{name}=={override_version}'
+
+def test_install_package_env_override_extras(monkeypatch, mock_called):
+    """Test plugin manager installing overriding using envvar: extras."""
+    # django.setup()
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    override_extras = 'abc 123'
+
+    monkeypatch.setenv(f'OCT_PKG_{pm.get_safe_name(name).upper()}_EXTRAS', override_extras)
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version)
+
+    assert mock_called.called
+    assert mock_called.args[2] == override_extras.split(' ')
+
+def test_install_package_env_override_scope(monkeypatch, device, mock_called):
+    """Test plugin manager installing overriding using envvar: scope."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pm, '_pip_install', mock_called)
+    name = 'test'
+    version = '1.0'
+    override_scope = device
+
+    monkeypatch.setenv(f'OCT_PKG_{pm.get_safe_name(name).upper()}_SCOPE', override_scope)
+
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+    pmng.install_package(name, version, scope=pm.GENERIC_SCOPE)
+
+    assert mock_called.called
+    assert f'{override_scope}::{name}' in pmng.installed_pkgs
+
+def test_uninstall_package_notinstalled(monkeypatch, mock_called):
+    """Test plugin manager uninstalling a package that is not installed."""
+    class MockDict(dict):
+        called = False
+        def pop(self, *args):
+            MockDict.called = True
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, '_installed', MockDict())
+
+    pmng.uninstall_package('test')
+    assert not MockDict.called
+
+def test_uninstall_package_installed(monkeypatch, mock_installed_file, mock_called):
+    """Test plugin manager uninstalling a package ."""
+    scope = pm.GENERIC_SCOPE
+    name = f'{scope}::pkg4'
+    pmng = pm.PluginManager()
+
+    monkeypatch.setattr(pmng, 'save_installed', mock_called)
+
+    sp_dir = pmng.PLUGIN_SP[scope]
+    files = mock_installed_file[name]['files']
+    dirs = mock_installed_file[name]['dirs']
+
+    for pth in files:
+        assert (sp_dir / pth).exists()
+    for pth in dirs:
+        assert (sp_dir / pth).exists()
+
+    pmng.uninstall_package(name)
+
+    assert name not in pmng.installed_pkgs
+    for pth in files:
+        assert not (sp_dir / pth).exists()
+    for pth in dirs:
+        assert not (sp_dir / pth).exists()
+    assert mock_called.called
+
+def test_uninstall_package_different_scope(monkeypatch, mock_called):
+    """Test plugin manager uninstalling a package ."""
+    scope = 'random_scope'
+    name = f'{scope}::pkg4'
+    pmng = pm.PluginManager()
+
+    pmng._installed = {}
+    pmng._installed[name] = {}
+    monkeypatch.setattr(pmng, 'save_installed', mock_called)
+
+    pmng.uninstall_package(name)
+    assert not hasattr(mock_called, 'called')
+    assert name in pmng.installed_pkgs
+
+def test_install_package_thread_lock(monkeypatch):
+    """Test plugin manager installing a package with thread lock."""
+    monkeypatch.setattr(pm, '_pip_install', lambda *a: time.sleep(0.25))
+
+    pmng = pm.PluginManager()
+    name = 'test'
+    version = '1.0'
+    scope = pm.GENERIC_SCOPE
+    sp_dir = pmng.plugin_dir / 'test' / 'site-packages'
+    sp_dir.mkdir(parents=True, exist_ok=True)
+
+    ran = [1,0,0,0,0]
+    def install(i):
+        nonlocal ran
+        pmng.install_package(name, version, scope=scope)
+        ran[i+1] = 1
+        assert ran[i] == 1
+
+    threads = []
+    for i in range(4):
+        t = threading.Thread(target=install, args=(i,))
+        t.start()
+        time.sleep(0.005)
+        threads.append(t)
+
+    for t in threads:
+        t.join()
+
+def test_uninstall_package_thread_lock(monkeypatch, mock_installed_file):
+    """Test plugin manager uninstalling a package with thread lock."""
+    class MockDict(dict):
+        called = False
+        def pop(self, *args):
+            MockDict.called = True
+            time.sleep(0.25)
+
+    scope = pm.GENERIC_SCOPE
+    name = f'{scope}::pkg4'
+    pmng = pm.PluginManager()
+
+    ran = [1,0,0,0,0]
+    def uninstall(i):
+        nonlocal ran
+        pmng.uninstall_package(f'{scope}::{name}')
+        ran[i+1] = 1
+        assert ran[i] == 1
+
+    threads = []
+    for i in range(4):
+        t = threading.Thread(target=uninstall, args=(i,))
+        t.start()
+        if i == 1:
+            monkeypatch.setattr(pmng, '_installed', MockDict())
+        time.sleep(0.005)
+        threads.append(t)
+
+    for t in threads:
+        t.join()
+    assert name not in pmng.installed_pkgs
+    assert not MockDict.called
+
+def test_ensure_plugins(monkeypatch, mock_called, mock_plugin_file, mock_plugin_data):
+    """Test initializer ensure plugins."""
+    pmng = pm.PluginManager()
+    monkeypatch.setattr(pmng, 'install_plugin', mock_called)
+    ensure_plugins()
+    assert mock_called.called
diff --git a/tests/test_validate_json.py b/tests/test_validate_json.py
new file mode 100644
index 0000000..73e19b1
--- /dev/null
+++ b/tests/test_validate_json.py
@@ -0,0 +1,58 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Check that the JSON files are valid."""
+
+import json
+from importlib import resources
+
+MAIN_DEPS = set([
+    'numpy',
+    'Django',
+    'Pillow',
+    'opencv-python-headless'
+])
+
+def test_loadable():
+    """Test that all json files are valide and loadable."""
+    all_jsons = resources.files('ocr_translate').glob('**/*.json')
+    for pth in all_jsons:
+        print('-'*80)
+        print(pth)
+        with pth.open(encoding='utf-8') as f:
+            json.load(f)
+
+def test_plugin_data_conflicts():
+    """Test the absence of dependencies conflicts in `plugins_data.json`"""
+    data_file = resources.files('ocr_translate') / 'plugins_data.json'
+    with data_file.open(encoding='utf-8') as f:
+        data = json.load(f)
+
+    done = {}
+    for plugin in data:
+        for dep in plugin.get('dependencies', []):
+            name = dep['name']
+            version = dep['version']
+            scope = dep.get('scope', 'generic')
+            system = dep.get('system', 'all')
+            key = (name, scope, system)
+            assert name not in MAIN_DEPS, f'Dependency {name} should not be overriden'
+            if key in done:
+                assert done[key] == version, f'Conflict in {name} {scope} version: {done[key]} != {version}'
+            else:
+                done[key] = version
diff --git a/tests/views/test_get_plugin_data.py b/tests/views/test_get_plugin_data.py
new file mode 100644
index 0000000..f202c37
--- /dev/null
+++ b/tests/views/test_get_plugin_data.py
@@ -0,0 +1,44 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test get_plugin_data serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+from django.urls import reverse
+
+
+def test_get_plugin_data_nonget(client):
+    """Test get_plugin_data with non GET request."""
+    url = reverse('ocr_translate:get_plugin_data')
+    response = client.post(url)
+    assert response.status_code == 405
+
+def test_get_plugin_data(client):
+    """Test get_plugin_data."""
+    url = reverse('ocr_translate:get_plugin_data')
+    response = client.get(url)
+    assert response.status_code == 200
+    data = response.json()
+    assert isinstance(data, dict)
+    key = list(data.keys())[0]
+    ptr = data[key]
+    assert 'name' not in ptr
+    assert 'description' in ptr
+    assert 'version' in ptr
+    assert 'installed' in ptr
+    assert 'homepage' in ptr
diff --git a/tests/views/test_get_translations.py b/tests/views/test_get_translations.py
index 39b8edc..55d9c15 100644
--- a/tests/views/test_get_translations.py
+++ b/tests/views/test_get_translations.py
@@ -1,93 +1,100 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test dget_translationsjango serverside views.handshake."""
-# pylint: disable=redefined-outer-name
-
-import pytest
-from django.urls import reverse
-
-pytestmark = pytest.mark.django_db
-
-@pytest.fixture()
-def get_kwargs(text):
-    """Data for POST request."""
-    return {
-        'text': text.text,
-    }
-
-def test_get_translations_nonget(client):
-    """Test get_translations with non GET request."""
-    url = reverse('ocr_translate:get_trans')
-    response = client.post(url)
-    assert response.status_code == 405
-
-@pytest.mark.parametrize('remove_key', ['text'])
-def test_get_translations_get_missing_required(client, get_kwargs, remove_key):
-    """Test get_translations with GET request missing required attribute."""
-    del get_kwargs[remove_key]
-    url = reverse('ocr_translate:get_trans')
-    response = client.get(url, get_kwargs)
-
-    assert response.status_code == 400
-    assert response.json() == {'error': 'no text'}
-
-def test_get_translations_get_invalid_data(client, get_kwargs):
-    """Test get_translations with GET request with non recognized attribute."""
-    get_kwargs['invalid_field'] = 'test'
-    url = reverse('ocr_translate:get_trans')
-    response = client.get(url, get_kwargs)
-
-    assert response.status_code == 400
-    assert 'invalid data: ' in response.json()['error']
-
-def test_get_translations_get_notfound(client, get_kwargs):
-    """Test get_translations with GET request with non recognized attribute."""
-    get_kwargs['text'] = 'other text'
-    url = reverse('ocr_translate:get_trans')
-    response = client.get(url, get_kwargs)
-
-    assert response.status_code == 404
-
-def test_get_translations_get_valid_notslrun(client, get_kwargs):
-    """Test get_translations with GET request with valid data. No tsl run."""
-    url = reverse('ocr_translate:get_trans')
-    response = client.get(url, get_kwargs)
-
-    assert response.status_code == 200
-    content = response.json()
-
-    assert isinstance(content, dict)
-    translations = content['translations']
-    assert isinstance(translations, list)
-    assert len(translations) == 0
-
-def test_get_translations_get_valid_tslrun(client, get_kwargs, mock_loaded, tsl_run):
-    """Test get_translations with GET request with valid data. Tsl run."""
-    url = reverse('ocr_translate:get_trans')
-    response = client.get(url, get_kwargs)
-
-    assert response.status_code == 200
-    content = response.json()
-
-    assert isinstance(content, dict)
-    translations = content['translations']
-    assert isinstance(translations, list)
-    assert len(translations) == 1
-    assert translations[0]['text'] == tsl_run.result.text
-    assert translations[0]['model'] == tsl_run.model.name
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test dget_translationsjango serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+import pytest
+from django.urls import reverse
+
+pytestmark = pytest.mark.django_db
+
+@pytest.fixture()
+def get_kwargs(text):
+    """Data for POST request."""
+    return {
+        'text': text.text,
+    }
+
+def test_get_translations_nonget(client):
+    """Test get_translations with non GET request."""
+    url = reverse('ocr_translate:get_trans')
+    response = client.post(url)
+    assert response.status_code == 405
+
+@pytest.mark.parametrize('remove_key', ['text'])
+def test_get_translations_get_missing_required(client, mock_loaded, get_kwargs, remove_key):
+    """Test get_translations with GET request missing required attribute."""
+    del get_kwargs[remove_key]
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 400
+    assert response.json() == {'error': 'text not found in GET data'}
+
+def test_get_translations_get_invalid_data(client, mock_loaded, get_kwargs):
+    """Test get_translations with GET request with non recognized attribute."""
+    get_kwargs['invalid_field'] = 'test'
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 400
+    assert 'Unexpected keys: invalid_field' in response.json()['error']
+
+def test_get_translations_get_notfound(client, mock_loaded, get_kwargs):
+    """Test get_translations with GET request with non recognized attribute."""
+    get_kwargs['text'] = 'other text'
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 404
+
+def test_get_translations_get_valid_notslrun(client, get_kwargs, mock_loaded_lang_only):
+    """Test get_translations with GET request with valid data. No tsl run."""
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 200
+    content = response.json()
+
+    assert isinstance(content, dict)
+    translations = content['translations']
+    assert isinstance(translations, list)
+    assert len(translations) == 0
+
+def test_get_translations_get_langnotloaded(client, get_kwargs, tsl_run):
+    """Test get_translations with GET request with no language loaded."""
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 512
+
+def test_get_translations_get_valid_tslrun(client, get_kwargs, mock_loaded, tsl_run):
+    """Test get_translations with GET request with valid data. Tsl run."""
+    url = reverse('ocr_translate:get_trans')
+    response = client.get(url, get_kwargs)
+
+    assert response.status_code == 200
+    content = response.json()
+
+    assert isinstance(content, dict)
+    translations = content['translations']
+    assert isinstance(translations, list)
+    assert len(translations) == 1
+    assert translations[0]['text'] == tsl_run.result.text
+    assert translations[0]['model'] == tsl_run.model.name
diff --git a/tests/views/test_handshake.py b/tests/views/test_handshake.py
index 880991d..9f9e3a3 100644
--- a/tests/views/test_handshake.py
+++ b/tests/views/test_handshake.py
@@ -1,121 +1,124 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test django serverside views.handshake."""
-
-import http.cookies
-
-import pytest
-from django.urls import reverse
-
-from ocr_translate import __version__array__
-from ocr_translate.ocr_tsl import box, lang, ocr, tsl
-
-pytestmark = pytest.mark.django_db
-
-def test_handshake_wrong_method(client):
-    """Test handshake with wrong method."""
-    url = reverse('ocr_translate:handshake')
-    response = client.post(url)
-    assert response.status_code == 405
-
-def test_handshake_clean_empty(client):
-    """Test handshake."""
-    url = reverse('ocr_translate:handshake')
-    response = client.get(url)
-    assert response.status_code == 200
-    content = response.json()
-
-    # Check that the csrf cookie is set
-    assert isinstance(response.cookies, http.cookies.SimpleCookie)
-    assert response.cookies['csrftoken']
-
-    assert content['version'] == __version__array__
-
-    for key in ['Languages', 'Languages_hr', 'BOXModels', 'OCRModels', 'TSLModels']:
-        assert content[key] == []
-    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
-        assert content[key] == ''
-    for key in ['lang_src', 'lang_dst']:
-        assert content[key] == ''
-
-def test_handshake_clean_content(client, language, box_model, ocr_model, tsl_model):
-    """Test handshake with content in the database."""
-    url = reverse('ocr_translate:handshake')
-    response = client.get(url)
-    assert response.status_code == 200
-    content = response.json()
-
-    assert content['Languages'] == ['ja']
-    assert content['Languages_hr'] == ['Japanese']
-    assert content['BOXModels'] == []
-    assert content['OCRModels'] == []
-    assert content['TSLModels'] == []
-
-    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
-        assert content[key] == ''
-    for key in ['lang_src', 'lang_dst']:
-        assert content[key] == ''
-
-def test_handshake_initialized_lang_only(monkeypatch, client, language, box_model, ocr_model, tsl_model):
-    """Test handshake with content in the database."""
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(lang, 'LANG_DST', language)
-
-    url = reverse('ocr_translate:handshake')
-    response = client.get(url)
-    assert response.status_code == 200
-    content = response.json()
-
-    assert content['Languages'] == ['ja']
-    assert content['Languages_hr'] == ['Japanese']
-    assert content['BOXModels'] == [box_model.name]
-    assert content['OCRModels'] == [ocr_model.name]
-    assert content['TSLModels'] == [tsl_model.name]
-
-    assert content['lang_src'] == 'ja'
-    assert content['lang_dst'] == 'ja'
-
-    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
-        assert content[key] == ''
-
-def test_handshake_initialized(client, monkeypatch, language, box_model, tsl_model, ocr_model):
-    """Test handshake with content + init."""
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(lang, 'LANG_DST', language)
-    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
-    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-
-    url = reverse('ocr_translate:handshake')
-    response = client.get(url)
-    assert response.status_code == 200
-    content = response.json()
-
-    assert content['Languages'] == ['ja']
-    assert content['Languages_hr'] == ['Japanese']
-    assert content['BOXModels'] == [box_model.name]
-    assert content['OCRModels'] == [ocr_model.name]
-    assert content['TSLModels'] == [tsl_model.name]
-
-    assert content['box_selected'] == box_model.name
-    assert content['ocr_selected'] == ocr_model.name
-    assert content['tsl_selected'] == tsl_model.name
-
-    assert content['lang_src'] == 'ja'
-    assert content['lang_dst'] == 'ja'
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test django serverside views.handshake."""
+
+import http.cookies
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate import __version__array__
+from ocr_translate.ocr_tsl import box, lang, ocr, tsl
+from ocr_translate.ocr_tsl.cached_lists import refresh_model_cache
+
+pytestmark = pytest.mark.django_db
+
+def test_handshake_wrong_method(client):
+    """Test handshake with wrong method."""
+    url = reverse('ocr_translate:handshake')
+    response = client.post(url)
+    assert response.status_code == 405
+
+def test_handshake_clean_empty(client):
+    """Test handshake."""
+    url = reverse('ocr_translate:handshake')
+    response = client.get(url)
+    assert response.status_code == 200
+    content = response.json()
+
+    # Check that the csrf cookie is set
+    assert isinstance(response.cookies, http.cookies.SimpleCookie)
+    assert response.cookies['csrftoken']
+
+    assert content['version'] == __version__array__
+
+    for key in ['Languages', 'Languages_hr', 'BOXModels', 'OCRModels', 'TSLModels']:
+        assert content[key] == []
+    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
+        assert content[key] == ''
+    for key in ['lang_src', 'lang_dst']:
+        assert content[key] == ''
+
+def test_handshake_clean_content(client, language, box_model, ocr_model, tsl_model):
+    """Test handshake with content in the database."""
+    url = reverse('ocr_translate:handshake')
+    response = client.get(url)
+    assert response.status_code == 200
+    content = response.json()
+
+    assert content['Languages'] == ['ja']
+    assert content['Languages_hr'] == ['Japanese']
+    assert content['BOXModels'] == []
+    assert content['OCRModels'] == []
+    assert content['TSLModels'] == []
+
+    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
+        assert content[key] == ''
+    for key in ['lang_src', 'lang_dst']:
+        assert content[key] == ''
+
+def test_handshake_initialized_lang_only(monkeypatch, client, language, box_model, ocr_model, tsl_model):
+    """Test handshake with content in the database."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    refresh_model_cache()
+
+    url = reverse('ocr_translate:handshake')
+    response = client.get(url)
+    assert response.status_code == 200
+    content = response.json()
+
+    assert content['Languages'] == ['ja']
+    assert content['Languages_hr'] == ['Japanese']
+    assert content['BOXModels'] == [box_model.name]
+    assert content['OCRModels'] == [ocr_model.name]
+    assert content['TSLModels'] == [tsl_model.name]
+
+    assert content['lang_src'] == 'ja'
+    assert content['lang_dst'] == 'ja'
+
+    for key in ['box_selected', 'ocr_selected', 'tsl_selected']:
+        assert content[key] == ''
+
+def test_handshake_initialized(client, monkeypatch, language, box_model, tsl_model, ocr_model):
+    """Test handshake with content + init."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    monkeypatch.setattr(box, 'BOX_MODEL_OBJ', box_model)
+    monkeypatch.setattr(ocr, 'OCR_MODEL_OBJ', ocr_model)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    refresh_model_cache()
+
+    url = reverse('ocr_translate:handshake')
+    response = client.get(url)
+    assert response.status_code == 200
+    content = response.json()
+
+    assert content['Languages'] == ['ja']
+    assert content['Languages_hr'] == ['Japanese']
+    assert content['BOXModels'] == [box_model.name]
+    assert content['OCRModels'] == [ocr_model.name]
+    assert content['TSLModels'] == [tsl_model.name]
+
+    assert content['box_selected'] == box_model.name
+    assert content['ocr_selected'] == ocr_model.name
+    assert content['tsl_selected'] == tsl_model.name
+
+    assert content['lang_src'] == 'ja'
+    assert content['lang_dst'] == 'ja'
diff --git a/tests/views/test_manage_plugins.py b/tests/views/test_manage_plugins.py
new file mode 100644
index 0000000..968a9f9
--- /dev/null
+++ b/tests/views/test_manage_plugins.py
@@ -0,0 +1,88 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test get_plugin_data serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate import entrypoint_manager as epm
+from ocr_translate.plugin_manager import PluginManager
+
+
+@pytest.fixture()
+def post_kwargs():
+    """Data for POST request."""
+    return {
+        'data': {
+            'plugins': {},
+        },
+        'content_type': 'application/json',
+    }
+
+def test_manage_plugins_nonpost(client):
+    """Test manage_plugins with non POST request."""
+    url = reverse('ocr_translate:manage_plugins')
+    response = client.get(url)
+    assert response.status_code == 405
+
+def test_manage_plugins_missing_required(client):
+    """Test manage_plugins with POST request missing required attribute."""
+    url = reverse('ocr_translate:manage_plugins')
+    response = client.post(url)
+    assert response.status_code == 400
+
+@pytest.mark.parametrize('mock_called', [set()], indirect=True)
+def test_manage_plugins_noaction(monkeypatch, mock_called, client, post_kwargs):
+    """Test manage_plugins with POST request: no action taken."""
+    monkeypatch.setattr(epm, 'get_group_entrypoints', mock_called)
+    url = reverse('ocr_translate:manage_plugins')
+
+    assert not hasattr(mock_called, 'called')
+    response = client.post(url, **post_kwargs)
+    assert response.status_code == 200
+    assert mock_called.called
+
+def test_manage_plugins_install(monkeypatch, epm_no_ept, mock_called, client, post_kwargs):
+    """Test manage_plugins with POST request: install a plugin."""
+    pmng = PluginManager()
+    monkeypatch.setattr(pmng, 'install_plugin', mock_called)
+
+    plugin_name = 'present'
+    post_kwargs['data']['plugins'] = {plugin_name: True}
+    url = reverse('ocr_translate:manage_plugins')
+    assert not hasattr(mock_called, 'called')
+    response = client.post(url, **post_kwargs)
+    assert response.status_code == 200
+    assert mock_called.called
+    assert mock_called.args[0] == plugin_name
+
+def test_manage_plugins_uninstall(monkeypatch, epm_no_ept, mock_called, client, post_kwargs):
+    """Test manage_plugins with POST request: uninstall a plugin."""
+    pmng = PluginManager()
+    monkeypatch.setattr(pmng, 'uninstall_plugin', mock_called)
+
+    plugin_name = 'absent'
+    post_kwargs['data']['plugins'] = {plugin_name: False}
+    url = reverse('ocr_translate:manage_plugins')
+    assert not hasattr(mock_called, 'called')
+    response = client.post(url, **post_kwargs)
+    assert response.status_code == 200
+    assert mock_called.called
+    assert mock_called.args[0] == plugin_name
diff --git a/tests/views/test_run_ocrtsl.py b/tests/views/test_run_ocrtsl.py
index dff10e2..bb9782d 100644
--- a/tests/views/test_run_ocrtsl.py
+++ b/tests/views/test_run_ocrtsl.py
@@ -1,168 +1,198 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test django serverside views.handshake."""
-# pylint: disable=redefined-outer-name
-
-import base64
-import hashlib
-import io
-
-import pytest
-from django.urls import reverse
-
-from ocr_translate import views
-
-pytestmark = pytest.mark.django_db
-
-@pytest.fixture()
-def image_b64(image_pillow):
-    """Base64 encoded image."""
-    buffer = io.BytesIO()
-    image_pillow.save(buffer, format='PNG')
-    return base64.b64encode(buffer.getvalue()).decode('utf-8')
-
-@pytest.fixture()
-def image_md5(image_b64):
-    """MD5 of image."""
-    return hashlib.md5(image_b64.encode('utf-8')).hexdigest()
-
-@pytest.fixture()
-def post_kwargs(image_b64, image_md5):
-    """Data for POST request."""
-    return {
-        'data': {
-            'contents': image_b64,
-            'md5': image_md5,
-            'force': False,
-            'options': {},
-        },
-        'content_type': 'application/json',
-    }
-
-def test_run_ocrtsl_nonpost(client):
-    """Test run_ocrtsl with non POST request."""
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.get(url)
-    assert response.status_code == 405
-
-def test_run_ocrtsl_post_noheader(client):
-    """Test run_ocrtsl with POST request without content/type."""
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url)
-
-    assert response.status_code == 400
-
-@pytest.mark.parametrize('remove_key', ['md5'])
-def test_run_ocrtsl_post_missing_required(client, post_kwargs, remove_key):
-    """Test run_ocrtsl with POST request missing required field."""
-    del post_kwargs['data'][remove_key]
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_run_ocrtsl_post_invalid_data(client, post_kwargs):
-    """Test run_ocrtsl with POST request with non recognized field."""
-    post_kwargs['data']['invalid_field'] = 'test'
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_run_ocrtsl_post_nocontent_force(client, post_kwargs):
-    """Test run_ocrtsl with POST request with no content but force."""
-    post_kwargs['data']['force'] = True
-    post_kwargs['data'].pop('contents')
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-    assert response.json()['error'] == 'Cannot force ocr without contents'
-
-def test_run_ocrtsl_post_wrong_md5(client, post_kwargs):
-    """Test run_ocrtsl with POST request with wrong md5."""
-    post_kwargs['data']['md5'] = 'wrong_md5'
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-    assert response.json()['error'] == 'md5 mismatch'
-
-def test_run_ocrtsl_post_valid_lazy_success(client, monkeypatch, post_kwargs):
-    """Test run_ocrtsl with POST request with valid data. No contents -> lazy + success"""
-    post_kwargs['data'].pop('contents')
-    def mock_ocrtsl_lazy(*args, **kwargs):
-        """Mock ocrtsl lazy pipeline."""
-        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
-    monkeypatch.setattr(views, 'ocr_tsl_pipeline_lazy', mock_ocrtsl_lazy)
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 200
-    content = response.json()
-
-    assert isinstance(content, dict)
-    result = content['result']
-    assert len(result) == 1
-    assert result[0]['ocr'] == 'test_ocr'
-    assert result[0]['tsl'] == 'test_tsl'
-    assert result[0]['box'] == [1,2,3,4]
-
-def test_run_ocrtsl_post_valid_lazy_failure(client, monkeypatch, post_kwargs):
-    """Test run_ocrtsl with POST request with valid data. No contents -> lazy + fail"""
-    post_kwargs['data'].pop('contents')
-    def mock_ocrtsl_lazy(*args, **kwargs):
-        """Mock ocrtsl lazy pipeline."""
-        raise ValueError('test')
-    monkeypatch.setattr(views, 'ocr_tsl_pipeline_lazy', mock_ocrtsl_lazy)
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 406
-
-def test_run_ocrtsl_post_valid_work_fail_modelnotloaded(client, monkeypatch, post_kwargs):
-    """Test run_ocrtsl with POST request with valid data. With contents -> work + success"""
-    def mock_ocrtsl_work(*args, **kwargs):
-        """Mock ocrtsl work pipeline."""
-        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
-    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_run_ocrtsl_post_valid_work_success(client, monkeypatch, mock_loaded, post_kwargs):
-    """Test run_ocrtsl with POST request with valid data. With contents -> work + success"""
-    def mock_ocrtsl_work(*args, **kwargs):
-        """Mock ocrtsl work pipeline."""
-        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
-    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
-
-    url = reverse('ocr_translate:run_ocrtsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 200
-    content = response.json()
-
-    assert isinstance(content, dict)
-    result = content['result']
-    assert len(result) == 1
-    assert result[0]['ocr'] == 'test_ocr'
-    assert result[0]['tsl'] == 'test_tsl'
-    assert result[0]['box'] == [1,2,3,4]
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test django serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+import base64
+import hashlib
+import io
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate import views
+
+pytestmark = pytest.mark.django_db
+
+@pytest.fixture()
+def image_b64(image_pillow):
+    """Base64 encoded image."""
+    buffer = io.BytesIO()
+    image_pillow.save(buffer, format='PNG')
+    return base64.b64encode(buffer.getvalue()).decode('utf-8')
+
+@pytest.fixture()
+def image_md5(image_b64):
+    """MD5 of image."""
+    return hashlib.md5(image_b64.encode('utf-8')).hexdigest()
+
+@pytest.fixture()
+def post_kwargs(image_b64, image_md5):
+    """Data for POST request."""
+    return {
+        'data': {
+            'contents': image_b64,
+            'md5': image_md5,
+            'force': False,
+            'options': {},
+        },
+        'content_type': 'application/json',
+    }
+
+def test_run_ocrtsl_nonpost(client):
+    """Test run_ocrtsl with non POST request."""
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.get(url)
+    assert response.status_code == 405
+
+def test_run_ocrtsl_post_noheader(client, mock_loaded):
+    """Test run_ocrtsl with POST request without content/type."""
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url)
+
+    assert response.status_code == 400
+
+@pytest.mark.parametrize('remove_key', ['md5'])
+def test_run_ocrtsl_post_missing_required(client, post_kwargs, remove_key, mock_loaded):
+    """Test run_ocrtsl with POST request missing required field."""
+    del post_kwargs['data'][remove_key]
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_run_ocrtsl_post_invalid_data(client, post_kwargs, mock_loaded):
+    """Test run_ocrtsl with POST request with non recognized field."""
+    post_kwargs['data']['invalid_field'] = 'test'
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_run_ocrtsl_post_nocontent_force(client, post_kwargs, mock_loaded):
+    """Test run_ocrtsl with POST request with no content but force."""
+    post_kwargs['data']['force'] = True
+    post_kwargs['data'].pop('contents')
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+    assert response.json()['error'] == 'Cannot force ocr without contents'
+
+def test_run_ocrtsl_post_wrong_md5(client, post_kwargs, mock_loaded):
+    """Test run_ocrtsl with POST request with wrong md5."""
+    post_kwargs['data']['md5'] = 'wrong_md5'
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+    assert response.json()['error'] == 'md5 mismatch'
+
+def test_run_ocrtsl_post_valid_lazy_success(client, monkeypatch, post_kwargs, mock_loaded):
+    """Test run_ocrtsl with POST request with valid data. No contents -> lazy + success"""
+    post_kwargs['data'].pop('contents')
+    def mock_ocrtsl_lazy(*args, **kwargs):
+        """Mock ocrtsl lazy pipeline."""
+        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_lazy', mock_ocrtsl_lazy)
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 200
+    content = response.json()
+
+    assert isinstance(content, dict)
+    result = content['result']
+    assert len(result) == 1
+    assert result[0]['ocr'] == 'test_ocr'
+    assert result[0]['tsl'] == 'test_tsl'
+    assert result[0]['box'] == [1,2,3,4]
+
+def test_run_ocrtsl_post_valid_lazy_failure(client, monkeypatch, post_kwargs, mock_loaded):
+    """Test run_ocrtsl with POST request with valid data. No contents -> lazy + fail"""
+    post_kwargs['data'].pop('contents')
+    def mock_ocrtsl_lazy(*args, **kwargs):
+        """Mock ocrtsl lazy pipeline."""
+        raise ValueError('test')
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_lazy', mock_ocrtsl_lazy)
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 406
+
+def test_run_ocrtsl_post_valid_work_fail_langnotloaded(client, monkeypatch, post_kwargs):
+    """Test run_ocrtsl with POST request with valid data. With contents -> work + success"""
+    def mock_ocrtsl_work(*args, **kwargs):
+        """Mock ocrtsl work pipeline."""
+        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 512
+
+def test_run_ocrtsl_post_valid_work_fail_modelnotloaded(client, monkeypatch, mock_loaded_lang_only, post_kwargs):
+    """Test run_ocrtsl with POST request with valid data. With contents -> work + success"""
+    def mock_ocrtsl_work(*args, **kwargs):
+        """Mock ocrtsl work pipeline."""
+        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 513
+
+def test_run_ocrtsl_post_valid_work_success(client, monkeypatch, queues_no_reuse, mock_loaded, post_kwargs):
+    """Test run_ocrtsl with POST request with valid data. With contents -> work + success"""
+    def mock_ocrtsl_work(*args, **kwargs):
+        """Mock ocrtsl work pipeline."""
+        return [{'ocr': 'test_ocr', 'tsl': 'test_tsl', 'box': (1,2,3,4)}]
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
+
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 200
+    content = response.json()
+
+    assert isinstance(content, dict)
+    result = content['result']
+    assert len(result) == 1
+    assert result[0]['ocr'] == 'test_ocr'
+    assert result[0]['tsl'] == 'test_tsl'
+    assert result[0]['box'] == [1,2,3,4]
+
+def test_run_ocrtsl_post_message_raise(client, monkeypatch, queues_no_reuse, mock_loaded, post_kwargs):
+    """Test run_ocrtsl with POST request with valid data. With contents -> raise in message handler"""
+    exc_msg = 'Test exception'
+    class NewException(Exception):
+        """New exception."""
+    def mock_ocrtsl_work(*args, **kwargs):
+        """Mock ocrtsl work pipeline."""
+        raise NewException(exc_msg)
+    monkeypatch.setattr(views, 'ocr_tsl_pipeline_work', mock_ocrtsl_work)
+
+    url = reverse('ocr_translate:run_ocrtsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+    content = response.json()
+
+    assert isinstance(content, dict)
+    assert content['error'] == exc_msg
diff --git a/tests/views/test_run_tsl.py b/tests/views/test_run_tsl.py
index 8d3a2cd..c9bf64d 100644
--- a/tests/views/test_run_tsl.py
+++ b/tests/views/test_run_tsl.py
@@ -1,83 +1,99 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test django serverside views.handshake."""
-# pylint: disable=redefined-outer-name
-
-
-import pytest
-from django.urls import reverse
-
-from ocr_translate.ocr_tsl import tsl
-
-pytestmark = pytest.mark.django_db
-
-@pytest.fixture()
-def post_kwargs(text):
-    """Data for POST request."""
-    return {
-        'data': {
-            'text': text.text,
-        },
-        'content_type': 'application/json',
-    }
-
-def test_run_tsl_nonpost(client):
-    """Test run_tsl with non POST request."""
-    url = reverse('ocr_translate:run_tsl')
-    response = client.get(url)
-    assert response.status_code == 405
-
-def test_run_tsl_post_noheader(client):
-    """Test run_tsl with POST request without content/type."""
-    url = reverse('ocr_translate:run_tsl')
-    response = client.post(url)
-
-    assert response.status_code == 400
-
-@pytest.mark.parametrize('remove_key', ['text'])
-def test_run_tsl_post_missing_required(client, post_kwargs, remove_key):
-    """Test run_tsl with POST request missing required field."""
-    del post_kwargs['data'][remove_key]
-    url = reverse('ocr_translate:run_tsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_run_tsl_post_invalid_data(client, post_kwargs):
-    """Test run_tsl with POST request with non recognized field."""
-    post_kwargs['data']['invalid_field'] = 'test'
-    url = reverse('ocr_translate:run_tsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_run_tsl_post_valid(client, monkeypatch, post_kwargs, tsl_model):
-    """Test run_tsl with POST request with valid data."""
-    def mock_tsl_run(text, *args, **kwargs):
-        """Mock translate."""
-        yield text
-    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
-    tsl_model.translate = mock_tsl_run
-    url = reverse('ocr_translate:run_tsl')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 200
-    content = response.json()
-
-    assert content['text'] == post_kwargs['data']['text']
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test django serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate.ocr_tsl import tsl
+
+pytestmark = pytest.mark.django_db
+
+@pytest.fixture()
+def post_kwargs(text):
+    """Data for POST request."""
+    return {
+        'data': {
+            'text': text.text,
+        },
+        'content_type': 'application/json',
+    }
+
+def test_run_tsl_nonpost(client):
+    """Test run_tsl with non POST request."""
+    url = reverse('ocr_translate:run_tsl')
+    response = client.get(url)
+    assert response.status_code == 405
+
+def test_run_tsl_post_noheader(client):
+    """Test run_tsl with POST request without content/type."""
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url)
+
+    assert response.status_code == 400
+
+@pytest.mark.parametrize('remove_key', ['text'])
+def test_run_tsl_post_missing_required(client, post_kwargs, remove_key):
+    """Test run_tsl with POST request missing required field."""
+    del post_kwargs['data'][remove_key]
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_run_tsl_post_invalid_data(client, post_kwargs):
+    """Test run_tsl with POST request with non recognized field."""
+    post_kwargs['data']['invalid_field'] = 'test'
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_run_tsl_langnotloaded(client, post_kwargs):
+    """Test run_tsl with POST request with language not loaded."""
+    post_kwargs['data']['text'] = 'test'
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 512
+
+def test_run_tsl_modelnotloaded(client, post_kwargs, mock_loaded_lang_only):
+    """Test run_tsl with POST request with model not loaded."""
+    post_kwargs['data']['text'] = 'test'
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 513
+
+def test_run_tsl_post_valid(client, monkeypatch, post_kwargs, mock_loaded, tsl_model):
+    """Test run_tsl with POST request with valid data."""
+    def mock_tsl_run(text, *args, **kwargs):
+        """Mock translate."""
+        yield text
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    tsl_model.translate = mock_tsl_run
+    url = reverse('ocr_translate:run_tsl')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 200
+    content = response.json()
+
+    assert content['text'] == post_kwargs['data']['text']
diff --git a/tests/views/test_run_tsl_get_xunityautotrans.py b/tests/views/test_run_tsl_get_xunityautotrans.py
new file mode 100644
index 0000000..eb0922f
--- /dev/null
+++ b/tests/views/test_run_tsl_get_xunityautotrans.py
@@ -0,0 +1,78 @@
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test django serverside views.handshake."""
+# pylint: disable=redefined-outer-name
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate import models as m
+from ocr_translate.ocr_tsl import lang, tsl
+
+pytestmark = pytest.mark.django_db
+
+def test_run_tsl_xua_nonget(client):
+    """Test run_tsl_get_xunityautotrans with non GET request."""
+    url = reverse('ocr_translate:run_tsl_get_xunityautotrans')
+    response = client.post(url)
+    assert response.status_code == 405
+
+def test_run_tsl_xua_nolang(client):
+    """Test run_tsl_get_xunityautotrans with GET request without loaded models."""
+    url = reverse('ocr_translate:run_tsl_get_xunityautotrans')
+    response = client.get(url)
+    assert response.status_code == 512
+
+def test_run_tsl_xua_no_models(client, mock_loaded_lang_only):
+    """Test run_tsl_get_xunityautotrans with GET request without loaded models."""
+    url = reverse('ocr_translate:run_tsl_get_xunityautotrans')
+    response = client.get(url)
+    assert response.status_code == 513
+
+def test_run_tsl_xua_no_text(client, mock_loaded):
+    """Test run_tsl_get_xunityautotrans with GET request without text."""
+    url = reverse('ocr_translate:run_tsl_get_xunityautotrans')
+    response = client.get(url)
+    assert response.status_code == 400
+
+class MockReturn:
+    """Mock return object of tsl_model.translate(...)."""
+    text = 'translated_hello'
+
+@pytest.mark.parametrize('mock_called', [iter([MockReturn])], indirect=True)
+def test_run_tsl_xua_ok(client, monkeypatch, mock_loaded, language, tsl_model, mock_called):
+    """Test run_tsl_get_xunityautotrans with GET request."""
+    monkeypatch.setattr(lang, 'LANG_SRC', language)
+    monkeypatch.setattr(lang, 'LANG_DST', language)
+    monkeypatch.setattr(tsl, 'TSL_MODEL_OBJ', tsl_model)
+    monkeypatch.setattr(tsl_model, 'translate', mock_called)
+
+    assert len(m.Text.objects.all()) == 0
+
+    url = reverse('ocr_translate:run_tsl_get_xunityautotrans')
+    url += '?text=hello'
+    response = client.get(url)
+
+    assert len(m.Text.objects.all()) == 1
+
+    assert response.status_code == 200
+    assert response.content == b'translated_hello'
+    assert hasattr(mock_called, 'called')
+    text_obj = m.Text.objects.first()
+    assert mock_called.args == (text_obj, language, language)
diff --git a/tests/views/test_set_manual_translation.py b/tests/views/test_set_manual_translation.py
index fa74ab8..cfb9dfb 100644
--- a/tests/views/test_set_manual_translation.py
+++ b/tests/views/test_set_manual_translation.py
@@ -1,134 +1,141 @@
-###################################################################################
-# ocr_translate - a django app to perform OCR and translation of images.          #
-# Copyright (C) 2023-present Davide Grassano                                      #
-#                                                                                 #
-# This program is free software: you can redistribute it and/or modify            #
-# it under the terms of the GNU General Public License as published by            #
-# the Free Software Foundation, either version 3 of the License.                  #
-#                                                                                 #
-# This program is distributed in the hope that it will be useful,                 #
-# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
-# GNU General Public License for more details.                                    #
-#                                                                                 #
-# You should have received a copy of the GNU General Public License               #
-# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
-#                                                                                 #
-# Home: https://github.com/Crivella/ocr_translate                                 #
-###################################################################################
-"""Test django serverside views.set_lang."""
-# pylint: disable=redefined-outer-name
-
-import pytest
-from django.urls import reverse
-
-from ocr_translate import models as m
-from ocr_translate.ocr_tsl import lang
-
-pytestmark = pytest.mark.django_db
-
-@pytest.fixture()
-def post_kwargs(language):
-    """Data for POST request."""
-    return {
-        'data': {
-            'translation': 'Ciao',
-            'text': 'Hello',
-        },
-        'content_type': 'application/json',
-    }
-
-def test_set_manual_translation_nonpost(client):
-    """Test set_manual_translation with non POST request."""
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.get(url)
-    assert response.status_code == 405
-
-def test_set_manual_translation_post_noheader(client):
-    """Test set_manual_translation with POST request without content/type."""
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url)
-
-    assert response.status_code == 400
-
-def test_set_manual_translation_post_missing_text(client, post_kwargs):
-    """Test set_manual_translation with POST request with missing text."""
-    del post_kwargs['data']['text']
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_set_manual_translation_post_missing_translation(client, post_kwargs):
-    """Test set_manual_translation with POST request with missing translation."""
-    del post_kwargs['data']['translation']
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_set_manual_translation_post_invalid_data(client, post_kwargs):
-    """Test set_manual_translation with POST request with non recognized field."""
-    post_kwargs['data']['invalid_field'] = 'test'
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 400
-
-def test_set_manual_translation_post_text_not_found(client, post_kwargs):
-    """Test set_manual_translation with POST request with text not found."""
-    post_kwargs['data']['text'] = 'invalid'
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 404
-
-def test_set_manual_translation_post_success_new(
-        monkeypatch, client, post_kwargs, text,
-        manual_model, option_dict, language
-        ):
-    """Test set_manual_translation with POST request with success."""
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(lang, 'LANG_DST', language)
-    post_kwargs['data']['text'] = text.text
-
-    assert m.TranslationRun.objects.count() == 0
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert response.status_code == 200
-    assert m.TranslationRun.objects.count() == 1
-
-@pytest.mark.django_db(transaction=True)
-def test_set_manual_translation_post_success_exist(
-        monkeypatch, client, post_kwargs, text,
-        manual_model, option_dict, language
-        ):
-    """Test set_manual_translation with POST request with success."""
-    monkeypatch.setattr(lang, 'LANG_SRC', language)
-    monkeypatch.setattr(lang, 'LANG_DST', language)
-
-    res = m.Text.objects.create(text='TEST')
-    assert m.TranslationRun.objects.count() == 0
-    m.TranslationRun.objects.create(
-        text=text,
-        result=res,
-        model=manual_model,
-        options=option_dict,
-        lang_src=language,
-        lang_dst=language,
-    )
-    assert m.TranslationRun.objects.count() == 1
-    post_kwargs['data']['text'] = text.text
-
-    url = reverse('ocr_translate:set_manual_translation')
-    response = client.post(url, **post_kwargs)
-
-    assert m.TranslationRun.objects.count() == 1
-
-    assert response.status_code == 200
+###################################################################################
+# ocr_translate - a django app to perform OCR and translation of images.          #
+# Copyright (C) 2023-present Davide Grassano                                      #
+#                                                                                 #
+# This program is free software: you can redistribute it and/or modify            #
+# it under the terms of the GNU General Public License as published by            #
+# the Free Software Foundation, either version 3 of the License.                  #
+#                                                                                 #
+# This program is distributed in the hope that it will be useful,                 #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of                  #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                   #
+# GNU General Public License for more details.                                    #
+#                                                                                 #
+# You should have received a copy of the GNU General Public License               #
+# along with this program.  If not, see {http://www.gnu.org/licenses/}.           #
+#                                                                                 #
+# Home: https://github.com/Crivella/ocr_translate                                 #
+###################################################################################
+"""Test django serverside views.set_lang."""
+# pylint: disable=redefined-outer-name
+
+import pytest
+from django.urls import reverse
+
+from ocr_translate import models as m
+
+pytestmark = pytest.mark.django_db
+
+@pytest.fixture()
+def post_kwargs(language):
+    """Data for POST request."""
+    return {
+        'data': {
+            'translation': 'Ciao',
+            'text': 'Hello',
+        },
+        'content_type': 'application/json',
+    }
+
+def test_set_manual_translation_nonpost(client):
+    """Test set_manual_translation with non POST request."""
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.get(url)
+    assert response.status_code == 405
+
+def test_set_manual_translation_post_noheader(client, mock_loaded):
+    """Test set_manual_translation with POST request without content/type."""
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url)
+
+    assert response.status_code == 400
+
+def test_set_manual_translation_post_missing_text(client, mock_loaded, post_kwargs):
+    """Test set_manual_translation with POST request with missing text."""
+    del post_kwargs['data']['text']
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_set_manual_translation_post_missing_translation(client, mock_loaded, post_kwargs):
+    """Test set_manual_translation with POST request with missing translation."""
+    del post_kwargs['data']['translation']
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_set_manual_translation_post_invalid_data(client, mock_loaded, post_kwargs):
+    """Test set_manual_translation with POST request with non recognized field."""
+    post_kwargs['data']['invalid_field'] = 'test'
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 400
+
+def test_set_manual_translation_post_text_not_found(client, mock_loaded, post_kwargs):
+    """Test set_manual_translation with POST request with text not found."""
+    post_kwargs['data']['text'] = 'invalid'
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 404
+
+def test_set_manual_translation_langnotloaded(
+        client, post_kwargs, text,
+        manual_model, option_dict, language
+        ):
+    """Test set_manual_translation with POST but no language loaded."""
+    url = reverse('ocr_translate:set_manual_translation')
+    post_kwargs['data']['text'] = text.text
+
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 512
+
+def test_set_manual_translation_post_success_new(
+        client, post_kwargs, text,
+        manual_model, option_dict, language, mock_loaded_lang_only
+        ):
+    """Test set_manual_translation with POST request with success."""
+    post_kwargs['data']['text'] = text.text
+
+    assert m.TranslationRun.objects.count() == 0
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 200
+    assert m.TranslationRun.objects.count() == 1
+
+@pytest.mark.django_db(transaction=True)
+def test_set_manual_translation_post_success_exist(
+        client, post_kwargs, text,
+        manual_model, option_dict, language, mock_loaded_lang_only
+        ):
+    """Test set_manual_translation with POST request with success."""
+
+    res = m.Text.objects.create(text='TEST')
+    assert m.TranslationRun.objects.count() == 0
+    m.TranslationRun.objects.create(
+        text=text,
+        result=res,
+        model=manual_model,
+        options=option_dict,
+        lang_src=language,
+        lang_dst=language,
+    )
+    assert m.TranslationRun.objects.count() == 1
+    post_kwargs['data']['text'] = text.text
+
+    url = reverse('ocr_translate:set_manual_translation')
+    response = client.post(url, **post_kwargs)
+
+    assert m.TranslationRun.objects.count() == 1
+
+    assert response.status_code == 200
diff --git a/tests/views/test_set_models.py b/tests/views/test_set_models.py
index bc39ed7..e42d0a5 100644
--- a/tests/views/test_set_models.py
+++ b/tests/views/test_set_models.py
@@ -78,3 +78,15 @@ def test_set_models_post_valid(client, mock_loaders, post_kwargs, box_model, tsl
     assert box.BOX_MODEL_ID == box_model.name
     assert ocr.OBJ_MODEL_ID == ocr_model.name
     assert tsl.TSL_MODEL_ID == tsl_model.name
+
+def test_set_models_post_valid_notall(client, mock_loaders, post_kwargs, tsl_model, ocr_model):
+    """Test set_models with POST valid request."""
+    url = reverse('ocr_translate:set_models')
+    post_kwargs['data'].pop('box_model_id')
+    response = client.post(url, **post_kwargs)
+
+    assert response.status_code == 200
+
+    assert box.BOX_MODEL_ID is None
+    assert ocr.OBJ_MODEL_ID == ocr_model.name
+    assert tsl.TSL_MODEL_ID == tsl_model.name