mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Add db an media backups (#3783)
* [FR] Add backup into inventrees lifecycle Fixes #3778 * Add env to testing enviros * block backup from running app commands * Add more commands * fix postgres version * Update used env * add daily task to run backups * add installer changes
This commit is contained in:
parent
2800d843e0
commit
182bc29053
@ -71,6 +71,7 @@
|
|||||||
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
"INVENTREE_DB_NAME": "/workspaces/InvenTree/dev/database.sqlite3",
|
||||||
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
"INVENTREE_MEDIA_ROOT": "/workspaces/InvenTree/dev/media",
|
||||||
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
"INVENTREE_STATIC_ROOT": "/workspaces/InvenTree/dev/static",
|
||||||
|
"INVENTREE_BACKUP_DIR": "/workspaces/InvenTree/dev/backup",
|
||||||
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
"INVENTREE_CONFIG_FILE": "/workspaces/InvenTree/dev/config.yaml",
|
||||||
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
"INVENTREE_SECRET_KEY_FILE": "/workspaces/InvenTree/dev/secret_key.txt",
|
||||||
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
"INVENTREE_PLUGIN_DIR": "/workspaces/InvenTree/dev/plugins",
|
||||||
|
1
.github/workflows/check_translations.yaml
vendored
1
.github/workflows/check_translations.yaml
vendored
@ -20,6 +20,7 @@ jobs:
|
|||||||
INVENTREE_DEBUG: info
|
INVENTREE_DEBUG: info
|
||||||
INVENTREE_MEDIA_ROOT: ./media
|
INVENTREE_MEDIA_ROOT: ./media
|
||||||
INVENTREE_STATIC_ROOT: ./static
|
INVENTREE_STATIC_ROOT: ./static
|
||||||
|
INVENTREE_BACKUP_DIR: ./backup
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
|
3
.github/workflows/qc_checks.yaml
vendored
3
.github/workflows/qc_checks.yaml
vendored
@ -22,6 +22,7 @@ env:
|
|||||||
INVENTREE_DB_NAME: inventree
|
INVENTREE_DB_NAME: inventree
|
||||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
||||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
||||||
|
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pep_style:
|
pep_style:
|
||||||
@ -199,7 +200,7 @@ jobs:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres:14
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: inventree
|
POSTGRES_USER: inventree
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: password
|
||||||
|
1
.github/workflows/translations.yml
vendored
1
.github/workflows/translations.yml
vendored
@ -17,6 +17,7 @@ jobs:
|
|||||||
INVENTREE_DEBUG: info
|
INVENTREE_DEBUG: info
|
||||||
INVENTREE_MEDIA_ROOT: ./media
|
INVENTREE_MEDIA_ROOT: ./media
|
||||||
INVENTREE_STATIC_ROOT: ./static
|
INVENTREE_STATIC_ROOT: ./static
|
||||||
|
INVENTREE_BACKUP_DIR: ./backup
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
|
@ -5,6 +5,7 @@ tasks:
|
|||||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||||
|
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||||
export PIP_USER='no'
|
export PIP_USER='no'
|
||||||
|
|
||||||
sudo apt install -y gettext
|
sudo apt install -y gettext
|
||||||
@ -24,6 +25,7 @@ tasks:
|
|||||||
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
export INVENTREE_DB_NAME='/workspace/InvenTree/dev/database.sqlite3'
|
||||||
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
export INVENTREE_MEDIA_ROOT='/workspace/InvenTree/inventree-data/media'
|
||||||
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
export INVENTREE_STATIC_ROOT='/workspace/InvenTree/dev/static'
|
||||||
|
export INVENTREE_BACKUP_DIR='/workspace/InvenTree/dev/backup'
|
||||||
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
inv server
|
inv server
|
||||||
|
@ -11,6 +11,7 @@ env:
|
|||||||
- INVENTREE_PLUGINS_ENABLED
|
- INVENTREE_PLUGINS_ENABLED
|
||||||
- INVENTREE_MEDIA_ROOT=/opt/inventree/media
|
- INVENTREE_MEDIA_ROOT=/opt/inventree/media
|
||||||
- INVENTREE_STATIC_ROOT=/opt/inventree/static
|
- INVENTREE_STATIC_ROOT=/opt/inventree/static
|
||||||
|
- INVENTREE_BACKUP_DIR=/opt/inventree/backup
|
||||||
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
|
- INVENTREE_PLUGIN_FILE=/opt/inventree/plugins.txt
|
||||||
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
- INVENTREE_CONFIG_FILE=/opt/inventree/config.yaml
|
||||||
after_install: contrib/packager.io/postinstall.sh
|
after_install: contrib/packager.io/postinstall.sh
|
||||||
|
@ -31,6 +31,7 @@ ENV INVENTREE_MNG_DIR="${INVENTREE_HOME}/InvenTree"
|
|||||||
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
ENV INVENTREE_DATA_DIR="${INVENTREE_HOME}/data"
|
||||||
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DATA_DIR}/static"
|
||||||
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DATA_DIR}/media"
|
||||||
|
ENV INVENTREE_BACKUP_DIR="${INVENTREE_DATA_DIR}/backup"
|
||||||
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
ENV INVENTREE_PLUGIN_DIR="${INVENTREE_DATA_DIR}/plugins"
|
||||||
|
|
||||||
# InvenTree configuration files
|
# InvenTree configuration files
|
||||||
|
@ -117,6 +117,12 @@ class InvenTreeConfig(AppConfig):
|
|||||||
schedule_type=Schedule.DAILY
|
schedule_type=Schedule.DAILY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Make regular backups
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'InvenTree.tasks.run_backup',
|
||||||
|
schedule_type=Schedule.DAILY,
|
||||||
|
)
|
||||||
|
|
||||||
def update_exchange_rates(self): # pragma: no cover
|
def update_exchange_rates(self): # pragma: no cover
|
||||||
"""Update exchange rates each time the server is started.
|
"""Update exchange rates each time the server is started.
|
||||||
|
|
||||||
|
@ -160,6 +160,22 @@ def get_static_dir(create=True):
|
|||||||
return sd
|
return sd
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_dir(create=True):
|
||||||
|
"""Return the absolute path for the backup directory"""
|
||||||
|
|
||||||
|
bd = get_setting('INVENTREE_BACKUP_DIR', 'backup_dir')
|
||||||
|
|
||||||
|
if not bd:
|
||||||
|
raise FileNotFoundError('INVENTREE_BACKUP_DIR not specified')
|
||||||
|
|
||||||
|
bd = Path(bd).resolve()
|
||||||
|
|
||||||
|
if create:
|
||||||
|
bd.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return bd
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_file():
|
def get_plugin_file():
|
||||||
"""Returns the path of the InvenTree plugins specification file.
|
"""Returns the path of the InvenTree plugins specification file.
|
||||||
|
|
||||||
|
@ -35,6 +35,12 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False):
|
|||||||
'collectstatic',
|
'collectstatic',
|
||||||
'makemessages',
|
'makemessages',
|
||||||
'compilemessages',
|
'compilemessages',
|
||||||
|
'backup',
|
||||||
|
'dbbackup',
|
||||||
|
'mediabackup',
|
||||||
|
'restore',
|
||||||
|
'dbrestore',
|
||||||
|
'mediarestore',
|
||||||
]
|
]
|
||||||
|
|
||||||
if not allow_test:
|
if not allow_test:
|
||||||
|
@ -131,6 +131,11 @@ STATIC_COLOR_THEMES_DIR = STATIC_ROOT.joinpath('css', 'color-themes').resolve()
|
|||||||
# Web URL endpoint for served media files
|
# Web URL endpoint for served media files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
# Backup directories
|
||||||
|
DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||||
|
DBBACKUP_STORAGE_OPTIONS = {'location': config.get_backup_dir()}
|
||||||
|
DBBACKUP_SEND_EMAIL = False
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@ -176,6 +181,7 @@ INSTALLED_APPS = [
|
|||||||
'error_report', # Error reporting in the admin interface
|
'error_report', # Error reporting in the admin interface
|
||||||
'django_q',
|
'django_q',
|
||||||
'formtools', # Form wizard tools
|
'formtools', # Form wizard tools
|
||||||
|
'dbbackup', # Backups - django-dbbackup
|
||||||
|
|
||||||
'allauth', # Base app for SSO
|
'allauth', # Base app for SSO
|
||||||
'allauth.account', # Extend user with accounts
|
'allauth.account', # Extend user with accounts
|
||||||
|
@ -9,6 +9,7 @@ from datetime import timedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import mail as django_mail
|
from django.core import mail as django_mail
|
||||||
from django.core.exceptions import AppRegistryNotReady
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
|
from django.core.management import call_command
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@ -272,6 +273,12 @@ def update_exchange_rates():
|
|||||||
logger.error(f"Error updating exchange rates: {e}")
|
logger.error(f"Error updating exchange rates: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_backup():
|
||||||
|
"""Run the backup command."""
|
||||||
|
call_command("dbbackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||||
|
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
"""Send an email with the specified subject and body, to the specified recipients list."""
|
"""Send an email with the specified subject and body, to the specified recipients list."""
|
||||||
if type(recipients) == str:
|
if type(recipients) == str:
|
||||||
|
@ -142,6 +142,9 @@ cors:
|
|||||||
# STATIC_ROOT is the local filesystem location for storing static files
|
# STATIC_ROOT is the local filesystem location for storing static files
|
||||||
#static_root: '/home/inventree/data/static'
|
#static_root: '/home/inventree/data/static'
|
||||||
|
|
||||||
|
# BACKUP_DIR is the local filesystem location for storing backups
|
||||||
|
#backup_dir: '/home/inventree/data/backup'
|
||||||
|
|
||||||
# Background worker options
|
# Background worker options
|
||||||
background:
|
background:
|
||||||
workers: 4
|
workers: 4
|
||||||
|
@ -98,6 +98,7 @@ function detect_envs() {
|
|||||||
# Parse the config file
|
# Parse the config file
|
||||||
export INVENTREE_MEDIA_ROOT=$conf | jq '.[].media_root'
|
export INVENTREE_MEDIA_ROOT=$conf | jq '.[].media_root'
|
||||||
export INVENTREE_STATIC_ROOT=$conf | jq '.[].static_root'
|
export INVENTREE_STATIC_ROOT=$conf | jq '.[].static_root'
|
||||||
|
export INVENTREE_BACKUP_DIR=$conf | jq '.[].backup_dir'
|
||||||
export INVENTREE_PLUGINS_ENABLED=$conf | jq '.[].plugins_enabled'
|
export INVENTREE_PLUGINS_ENABLED=$conf | jq '.[].plugins_enabled'
|
||||||
export INVENTREE_PLUGIN_FILE=$conf | jq '.[].plugin_file'
|
export INVENTREE_PLUGIN_FILE=$conf | jq '.[].plugin_file'
|
||||||
export INVENTREE_SECRET_KEY_FILE=$conf | jq '.[].secret_key_file'
|
export INVENTREE_SECRET_KEY_FILE=$conf | jq '.[].secret_key_file'
|
||||||
@ -119,6 +120,7 @@ function detect_envs() {
|
|||||||
|
|
||||||
export INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT:-${DATA_DIR}/media}
|
export INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT:-${DATA_DIR}/media}
|
||||||
export INVENTREE_STATIC_ROOT=${DATA_DIR}/static
|
export INVENTREE_STATIC_ROOT=${DATA_DIR}/static
|
||||||
|
export INVENTREE_BACKUP_DIR=${DATA_DIR}/backup
|
||||||
export INVENTREE_PLUGINS_ENABLED=true
|
export INVENTREE_PLUGINS_ENABLED=true
|
||||||
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
|
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
|
||||||
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.txt
|
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.txt
|
||||||
@ -137,6 +139,7 @@ function detect_envs() {
|
|||||||
echo "# Collected environment variables:"
|
echo "# Collected environment variables:"
|
||||||
echo "# INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT}"
|
echo "# INVENTREE_MEDIA_ROOT=${INVENTREE_MEDIA_ROOT}"
|
||||||
echo "# INVENTREE_STATIC_ROOT=${INVENTREE_STATIC_ROOT}"
|
echo "# INVENTREE_STATIC_ROOT=${INVENTREE_STATIC_ROOT}"
|
||||||
|
echo "# INVENTREE_BACKUP_DIR=${INVENTREE_BACKUP_DIR}"
|
||||||
echo "# INVENTREE_PLUGINS_ENABLED=${INVENTREE_PLUGINS_ENABLED}"
|
echo "# INVENTREE_PLUGINS_ENABLED=${INVENTREE_PLUGINS_ENABLED}"
|
||||||
echo "# INVENTREE_PLUGIN_FILE=${INVENTREE_PLUGIN_FILE}"
|
echo "# INVENTREE_PLUGIN_FILE=${INVENTREE_PLUGIN_FILE}"
|
||||||
echo "# INVENTREE_SECRET_KEY_FILE=${INVENTREE_SECRET_KEY_FILE}"
|
echo "# INVENTREE_SECRET_KEY_FILE=${INVENTREE_SECRET_KEY_FILE}"
|
||||||
@ -250,6 +253,8 @@ function set_env() {
|
|||||||
sed -i s=#media_root:\ \'/home/inventree/data/media\'=media_root:\ \'${INVENTREE_MEDIA_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
sed -i s=#media_root:\ \'/home/inventree/data/media\'=media_root:\ \'${INVENTREE_MEDIA_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
# Static Root
|
# Static Root
|
||||||
sed -i s=#static_root:\ \'/home/inventree/data/static\'=static_root:\ \'${INVENTREE_STATIC_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
sed -i s=#static_root:\ \'/home/inventree/data/static\'=static_root:\ \'${INVENTREE_STATIC_ROOT}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
|
# Backup dir
|
||||||
|
sed -i s=#backup_dir:\ \'/home/inventree/data/backup\'=backup_dir:\ \'${INVENTREE_BACKUP_DIR}\'=g ${INVENTREE_CONFIG_FILE}
|
||||||
# Plugins enabled
|
# Plugins enabled
|
||||||
sed -i s=plugins_enabled:\ False=plugins_enabled:\ ${INVENTREE_PLUGINS_ENABLED}=g ${INVENTREE_CONFIG_FILE}
|
sed -i s=plugins_enabled:\ False=plugins_enabled:\ ${INVENTREE_PLUGINS_ENABLED}=g ${INVENTREE_CONFIG_FILE}
|
||||||
# Plugin file
|
# Plugin file
|
||||||
|
@ -13,6 +13,11 @@ if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
|
|||||||
mkdir -p $INVENTREE_MEDIA_ROOT
|
mkdir -p $INVENTREE_MEDIA_ROOT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$INVENTREE_BACKUP_DIR" ]]; then
|
||||||
|
echo "Creating directory $INVENTREE_BACKUP_DIR"
|
||||||
|
mkdir -p $INVENTREE_BACKUP_DIR
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if "config.yaml" has been copied into the correct location
|
# Check if "config.yaml" has been copied into the correct location
|
||||||
if test -f "$INVENTREE_CONFIG_FILE"; then
|
if test -f "$INVENTREE_CONFIG_FILE"; then
|
||||||
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
echo "$INVENTREE_CONFIG_FILE exists - skipping"
|
||||||
|
@ -7,6 +7,7 @@ django-allauth-2fa # MFA / 2FA
|
|||||||
django-cleanup # Automated deletion of old / unused uploaded files
|
django-cleanup # Automated deletion of old / unused uploaded files
|
||||||
django-cors-headers # CORS headers extension for DRF
|
django-cors-headers # CORS headers extension for DRF
|
||||||
django-crispy-forms # Form helpers
|
django-crispy-forms # Form helpers
|
||||||
|
django-dbbackup # Backup / restore of database and media files
|
||||||
django-error-report # Error report viewer for the admin interface
|
django-error-report # Error report viewer for the admin interface
|
||||||
django-filter # Extended filtering options
|
django-filter # Extended filtering options
|
||||||
django-formtools # Form wizard tools
|
django-formtools # Form wizard tools
|
||||||
|
@ -48,6 +48,7 @@ django==3.2.16
|
|||||||
# django-allauth
|
# django-allauth
|
||||||
# django-allauth-2fa
|
# django-allauth-2fa
|
||||||
# django-cors-headers
|
# django-cors-headers
|
||||||
|
# django-dbbackup
|
||||||
# django-error-report
|
# django-error-report
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-formtools
|
# django-formtools
|
||||||
@ -79,6 +80,8 @@ django-cors-headers==3.13.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-crispy-forms==1.14.0
|
django-crispy-forms==1.14.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
|
django-dbbackup==4.0.2
|
||||||
|
# via -r requirements.in
|
||||||
django-error-report==0.2.0
|
django-error-report==0.2.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-filter==22.1
|
django-filter==22.1
|
||||||
@ -181,6 +184,7 @@ pytz==2022.4
|
|||||||
# via
|
# via
|
||||||
# babel
|
# babel
|
||||||
# django
|
# django
|
||||||
|
# django-dbbackup
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
pyyaml==6.0
|
pyyaml==6.0
|
||||||
# via tablib
|
# via tablib
|
||||||
|
22
tasks.py
22
tasks.py
@ -196,7 +196,27 @@ def translate(c):
|
|||||||
manage(c, "compilemessages")
|
manage(c, "compilemessages")
|
||||||
|
|
||||||
|
|
||||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
@task
|
||||||
|
def backup(c):
|
||||||
|
"""Backup the database and media files."""
|
||||||
|
|
||||||
|
print("Backing up InvenTree database...")
|
||||||
|
manage(c, "dbbackup --noinput --clean --compress")
|
||||||
|
print("Backing up InvenTree media files...")
|
||||||
|
manage(c, "mediabackup --noinput --clean --compress")
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def restore(c):
|
||||||
|
"""Restore the database and media files."""
|
||||||
|
|
||||||
|
print("Restoring InvenTree database...")
|
||||||
|
manage(c, "dbrestore --noinput --uncompress")
|
||||||
|
print("Restoring InvenTree media files...")
|
||||||
|
manage(c, "mediarestore --noinput --uncompress")
|
||||||
|
|
||||||
|
|
||||||
|
@task(pre=[backup, ], post=[rebuild_models, rebuild_thumbnails])
|
||||||
def migrate(c):
|
def migrate(c):
|
||||||
"""Performs database migrations.
|
"""Performs database migrations.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user