mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-13 14:58:47 +00:00
Invoke verbosity (#11706)
* Reduce verbosity of invoke tasks - Suppress some django messages which are not useful to most users - Verbosity can be added with --verbose flag * Further improvements * Better messaging * Extra options * No!
This commit is contained in:
@@ -13,25 +13,39 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""Wait till the database is ready."""
|
||||
self.stdout.write('Waiting for database...')
|
||||
|
||||
connected = False
|
||||
verbose = int(kwargs.get('verbosity', 0)) > 0
|
||||
attempts = kwargs.get('attempts', 10)
|
||||
|
||||
while not connected:
|
||||
time.sleep(2)
|
||||
if verbose:
|
||||
self.stdout.write('Waiting for database connection...')
|
||||
self.stdout.flush()
|
||||
|
||||
while not connected and attempts > 0:
|
||||
attempts -= 1
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
|
||||
connected = True
|
||||
|
||||
except OperationalError as e:
|
||||
self.stdout.write(f'Could not connect to database: {e}')
|
||||
except ImproperlyConfigured as e:
|
||||
self.stdout.write(f'Improperly configured: {e}')
|
||||
except (OperationalError, ImproperlyConfigured):
|
||||
if verbose:
|
||||
self.stdout.write('Database connection failed, retrying ...')
|
||||
self.stdout.flush()
|
||||
else:
|
||||
if not connection.is_usable():
|
||||
if verbose:
|
||||
self.stdout.write('Database configuration is not usable')
|
||||
self.stdout.flush()
|
||||
|
||||
if connected:
|
||||
if verbose:
|
||||
self.stdout.write('Database connection successful!')
|
||||
self.stdout.flush()
|
||||
else:
|
||||
time.sleep(1)
|
||||
|
||||
if not connected:
|
||||
self.stderr.write('Failed to connect to database after multiple attempts')
|
||||
self.stderr.flush()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { __INVENTREE_VERSION_INFO__ } from './version-info';
|
||||
const IS_IN_WSL = platform().includes('WSL') || release().includes('WSL');
|
||||
|
||||
if (IS_IN_WSL) {
|
||||
console.log('WSL detected: using polling for file system events');
|
||||
console.debug('WSL detected: using polling for file system events');
|
||||
}
|
||||
|
||||
// Output directory for the built files
|
||||
|
||||
206
tasks.py
206
tasks.py
@@ -9,6 +9,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from platform import python_version
|
||||
@@ -200,9 +201,13 @@ def state_logger(fn=None, method_name=None):
|
||||
do_log = is_debug_environment()
|
||||
if do_log:
|
||||
info(f'# task | {func.method_name} | start')
|
||||
|
||||
t1 = time.time()
|
||||
func(c, *args, **kwargs)
|
||||
t2 = time.time()
|
||||
|
||||
if do_log:
|
||||
info(f'# task | {func.method_name} | done')
|
||||
info(f'# task | {func.method_name} | done | elapsed: {t2 - t1:.2f}s')
|
||||
|
||||
return wrapped
|
||||
|
||||
@@ -464,7 +469,7 @@ def run(
|
||||
return result
|
||||
|
||||
|
||||
def manage(c, cmd, pty: bool = False, env=None, **kwargs):
|
||||
def manage(c, cmd, pty: bool = False, env=None, verbose: bool = False, **kwargs):
|
||||
"""Runs a given command against django's "manage.py" script.
|
||||
|
||||
Args:
|
||||
@@ -472,7 +477,14 @@ def manage(c, cmd, pty: bool = False, env=None, **kwargs):
|
||||
cmd: Django command to run.
|
||||
pty (bool, optional): Run an interactive session. Defaults to False.
|
||||
env (dict, optional): Environment variables to pass to the command. Defaults to None.
|
||||
verbose (bool, optional): Print verbose output from the command. Defaults to False.
|
||||
"""
|
||||
if verbose:
|
||||
info(f'Running command: python3 manage.py {cmd}')
|
||||
cmd += ' -v 1'
|
||||
else:
|
||||
cmd += ' -v 0'
|
||||
|
||||
return run(
|
||||
c, f'python3 manage.py {cmd}', manage_py_dir(), pty=pty, env=env, **kwargs
|
||||
)
|
||||
@@ -499,8 +511,19 @@ def run_install(
|
||||
run_preflight=True,
|
||||
version_check=False,
|
||||
pinned=True,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Run the installation of python packages from a requirements file."""
|
||||
"""Run the installation of python packages from a requirements file.
|
||||
|
||||
Arguments:
|
||||
c: Command line context.
|
||||
uv: Whether to use UV (experimental package manager) instead of pip.
|
||||
install_file: Path to the requirements file to install from.
|
||||
run_preflight: Whether to run the preflight installation step (installing pip/uv itself). Default is True.
|
||||
version_check: Whether to check for a version-specific requirements file. Default is False.
|
||||
pinned: Whether to use the --require-hashes option when installing packages. Default is True.
|
||||
verbose: Whether to print verbose output from pip install commands. Default is False.
|
||||
"""
|
||||
if version_check:
|
||||
# Test if there is a version specific requirements file
|
||||
sys_ver_s = python_version().split('.')
|
||||
@@ -518,25 +541,28 @@ def run_install(
|
||||
|
||||
# Install required Python packages with PIP
|
||||
if not uv:
|
||||
# Optionally run preflight first
|
||||
if run_preflight:
|
||||
run(
|
||||
c,
|
||||
'pip3 install --no-cache-dir --disable-pip-version-check -U pip setuptools',
|
||||
f'pip3 install --no-cache-dir --disable-pip-version-check -U pip setuptools {"" if verbose else "--quiet"}',
|
||||
)
|
||||
info('Installed package manager')
|
||||
|
||||
run(
|
||||
c,
|
||||
f'pip3 install --no-cache-dir --disable-pip-version-check -U {"--require-hashes" if pinned else ""} -r {install_file}',
|
||||
f'pip3 install --no-cache-dir --disable-pip-version-check -U {"--require-hashes" if pinned else ""} -r {install_file} {"" if verbose else "--quiet"}',
|
||||
)
|
||||
else:
|
||||
if run_preflight:
|
||||
run(
|
||||
c,
|
||||
'pip3 install --no-cache-dir --disable-pip-version-check -U uv setuptools',
|
||||
f'pip3 install --no-cache-dir --disable-pip-version-check -U uv setuptools {"" if verbose else "--quiet"}',
|
||||
)
|
||||
info('Installed package manager')
|
||||
run(
|
||||
c,
|
||||
f'uv pip install -U {"--require-hashes" if pinned else ""} -r {install_file}',
|
||||
f'uv pip install -U {"--require-hashes" if pinned else ""} -r {install_file} {"" if verbose else "--quiet"}',
|
||||
)
|
||||
|
||||
|
||||
@@ -605,28 +631,34 @@ def check_file_existence(filename: Path, overwrite: bool = False):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@task
|
||||
@task(help={'verbose': 'Print verbose output from the command'})
|
||||
@state_logger
|
||||
def wait(c):
|
||||
def wait(c, verbose: bool = False):
|
||||
"""Wait until the database connection is ready."""
|
||||
info('Waiting for database connection...')
|
||||
return manage(c, 'wait_for_db')
|
||||
return manage(c, 'wait_for_db', verbose=verbose)
|
||||
|
||||
|
||||
# Install tasks
|
||||
# region tasks
|
||||
@task(help={'uv': 'Use UV (experimental package manager)'})
|
||||
@task(
|
||||
help={
|
||||
'uv': 'Use UV (experimental package manager)',
|
||||
'verbose': 'Print verbose output from installation commands',
|
||||
}
|
||||
)
|
||||
@state_logger
|
||||
def plugins(c, uv=False):
|
||||
def plugins(c, uv: bool = False, verbose: bool = False):
|
||||
"""Installs all plugins as specified in 'plugins.txt'."""
|
||||
from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import]
|
||||
get_plugin_file,
|
||||
)
|
||||
|
||||
run_install(c, uv, get_plugin_file(), run_preflight=False, pinned=False)
|
||||
run_install(
|
||||
c, uv, get_plugin_file(), run_preflight=False, pinned=False, verbose=verbose
|
||||
)
|
||||
|
||||
# Collect plugin static files
|
||||
manage(c, 'collectplugins')
|
||||
manage(c, 'collectplugins', verbose=verbose)
|
||||
|
||||
|
||||
@task(
|
||||
@@ -634,29 +666,51 @@ def plugins(c, uv=False):
|
||||
'uv': 'Use UV package manager (experimental)',
|
||||
'skip_plugins': 'Skip plugin installation',
|
||||
'dev': 'Install development requirements instead of production requirements',
|
||||
'verbose': 'Print verbose output from pip install commands',
|
||||
}
|
||||
)
|
||||
@state_logger
|
||||
def install(c, uv=False, skip_plugins=False, dev=False):
|
||||
"""Installs required python packages."""
|
||||
def install(
|
||||
c,
|
||||
uv: bool = False,
|
||||
skip_plugins: bool = False,
|
||||
dev: bool = False,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Install required python packages for InvenTree.
|
||||
|
||||
Arguments:
|
||||
c: Command line context.
|
||||
uv: Use UV package manager (experimental) instead of pip. Default is False.
|
||||
skip_plugins: Skip plugin installation. Default is False.
|
||||
dev: Install development requirements instead of production requirements. Default is False.
|
||||
verbose: Print verbose output from pip install commands. Default is False.
|
||||
"""
|
||||
info('Installing required python packages...')
|
||||
|
||||
if dev:
|
||||
run_install(
|
||||
c,
|
||||
uv,
|
||||
local_dir().joinpath('src/backend/requirements-dev.txt'),
|
||||
version_check=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
success('Dependency installation complete')
|
||||
return
|
||||
|
||||
# Ensure path is relative to *this* directory
|
||||
run_install(
|
||||
c, uv, local_dir().joinpath('src/backend/requirements.txt'), version_check=True
|
||||
c,
|
||||
uv,
|
||||
local_dir().joinpath('src/backend/requirements.txt'),
|
||||
version_check=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
# Run plugins install
|
||||
if not skip_plugins:
|
||||
plugins(c, uv=uv)
|
||||
plugins(c, uv=uv, verbose=verbose)
|
||||
|
||||
# Compile license information
|
||||
lic_path = manage_py_dir().joinpath('InvenTree', 'licenses.txt')
|
||||
@@ -668,24 +722,26 @@ def install(c, uv=False, skip_plugins=False, dev=False):
|
||||
success('Dependency installation complete')
|
||||
|
||||
|
||||
@task(help={'tests': 'Set up test dataset at the end'})
|
||||
def setup_dev(c, tests=False):
|
||||
@task(
|
||||
help={
|
||||
'tests': 'Set up test dataset at the end',
|
||||
'verbose': 'Print verbose output from commands',
|
||||
}
|
||||
)
|
||||
def setup_dev(c, tests: bool = False, verbose: bool = False):
|
||||
"""Sets up everything needed for the dev environment."""
|
||||
# Install required Python packages with PIP
|
||||
install(c, uv=False, skip_plugins=True, dev=True)
|
||||
install(c, uv=False, skip_plugins=True, dev=True, verbose=verbose)
|
||||
|
||||
# Install pre-commit hook
|
||||
info('Installing pre-commit for checks before git commits...')
|
||||
run(c, 'pre-commit install')
|
||||
|
||||
# Update all the hooks
|
||||
run(c, 'pre-commit autoupdate')
|
||||
|
||||
success('pre-commit set up complete')
|
||||
|
||||
# Set up test-data if flag is set
|
||||
if tests:
|
||||
setup_test(c)
|
||||
setup_test(c, verbose=verbose)
|
||||
|
||||
|
||||
# Setup / maintenance tasks
|
||||
@@ -725,7 +781,7 @@ def rebuild_thumbnails(c):
|
||||
@state_logger
|
||||
def clean_settings(c):
|
||||
"""Clean the setting tables of old settings."""
|
||||
info('Cleaning old settings from the database')
|
||||
info('Cleaning old settings from the database...')
|
||||
manage(c, 'clean_settings')
|
||||
success('Settings cleaned successfully')
|
||||
|
||||
@@ -797,12 +853,12 @@ def translate(c, ignore_static=False, no_frontend=False):
|
||||
success('Translation files built successfully')
|
||||
|
||||
|
||||
@task
|
||||
@task(help={'verbose': 'Print verbose output'})
|
||||
@state_logger('backend_trans')
|
||||
def backend_trans(c):
|
||||
def backend_trans(c, verbose: bool = False):
|
||||
"""Compile backend Django translation files."""
|
||||
info('Compiling backend translations')
|
||||
manage(c, 'compilemessages')
|
||||
info('Compiling backend translations...')
|
||||
manage(c, 'compilemessages', verbose=verbose)
|
||||
success('Backend translations compiled successfully')
|
||||
|
||||
|
||||
@@ -944,20 +1000,33 @@ def listbackups(c):
|
||||
manage(c, 'listbackups')
|
||||
|
||||
|
||||
@task(pre=[wait], post=[rebuild_models, rebuild_thumbnails])
|
||||
@task(
|
||||
pre=[wait],
|
||||
post=[rebuild_models, rebuild_thumbnails],
|
||||
help={
|
||||
'verbose': 'Print verbose output from migration commands',
|
||||
'detect': 'Detect and create new migrations based on changes to models',
|
||||
},
|
||||
)
|
||||
@state_logger
|
||||
def migrate(c):
|
||||
def migrate(c, detect: bool = True, verbose: bool = False):
|
||||
"""Performs database migrations.
|
||||
|
||||
This is a critical step if the database schema have been altered!
|
||||
"""
|
||||
info('Running InvenTree database migrations...')
|
||||
|
||||
# Run custom management command which wraps migrations in "maintenance mode"
|
||||
manage(c, 'makemigrations')
|
||||
manage(c, 'runmigrations', pty=True)
|
||||
manage(c, 'migrate --run-syncdb')
|
||||
manage(c, 'remove_stale_contenttypes --include-stale-apps --no-input', pty=True)
|
||||
if detect:
|
||||
manage(c, 'makemigrations', verbose=verbose)
|
||||
|
||||
manage(c, 'runmigrations', pty=True, verbose=verbose)
|
||||
manage(c, 'migrate --run-syncdb', verbose=verbose)
|
||||
manage(
|
||||
c,
|
||||
'remove_stale_contenttypes --include-stale-apps --no-input',
|
||||
pty=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
success('InvenTree database migrations completed')
|
||||
|
||||
@@ -978,6 +1047,7 @@ def showmigrations(c, app=''):
|
||||
'no_frontend': 'Skip frontend compilation/download step',
|
||||
'skip_static': 'Skip static file collection step',
|
||||
'uv': 'Use UV (experimental package manager)',
|
||||
'verbose': 'Print verbose output from installation commands',
|
||||
},
|
||||
)
|
||||
@state_logger
|
||||
@@ -990,6 +1060,7 @@ def update(
|
||||
no_frontend: bool = False,
|
||||
skip_static: bool = False,
|
||||
uv: bool = False,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Update InvenTree installation.
|
||||
|
||||
@@ -1009,7 +1080,7 @@ def update(
|
||||
info('Updating InvenTree installation...')
|
||||
|
||||
# Ensure required components are installed
|
||||
install(c, uv=uv)
|
||||
install(c, uv=uv, verbose=verbose)
|
||||
|
||||
# Skip backend translation compilation on docker, unless explicitly requested.
|
||||
# Users can also forcefully disable the step via `--no-backend`.
|
||||
@@ -1019,7 +1090,7 @@ def update(
|
||||
else:
|
||||
info('Skipping backend translation compilation (INVENTREE_DOCKER flag set)')
|
||||
else:
|
||||
backend_trans(c)
|
||||
backend_trans(c, verbose=verbose)
|
||||
|
||||
if not skip_backup:
|
||||
backup(c)
|
||||
@@ -1052,7 +1123,7 @@ def update(
|
||||
# Note: frontend has already been compiled if required
|
||||
static(c, frontend=False)
|
||||
|
||||
success('InvenTree update complete!')
|
||||
success('InvenTree update complete')
|
||||
|
||||
|
||||
# Data tasks
|
||||
@@ -1066,8 +1137,8 @@ def update(
|
||||
'exclude_plugins': 'Exclude plugin data from the output file (default = False)',
|
||||
'include_sso': 'Include SSO token data in the output file (default = False)',
|
||||
'include_session': 'Include user session data in the output file (default = False)',
|
||||
},
|
||||
pre=[wait],
|
||||
'verbose': 'Print verbose output from management commands',
|
||||
}
|
||||
)
|
||||
def export_records(
|
||||
c,
|
||||
@@ -1079,6 +1150,7 @@ def export_records(
|
||||
exclude_plugins: bool = False,
|
||||
include_sso: bool = False,
|
||||
include_session: bool = False,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Export all database records to a file."""
|
||||
# Get an absolute path to the file
|
||||
@@ -1088,6 +1160,8 @@ def export_records(
|
||||
|
||||
info(f"Exporting database records to file '{target}'")
|
||||
|
||||
wait(c, verbose=verbose)
|
||||
|
||||
check_file_existence(target, overwrite)
|
||||
|
||||
excludes = content_excludes(
|
||||
@@ -1104,8 +1178,7 @@ def export_records(
|
||||
cmd = f"dumpdata --natural-foreign --indent 2 --output '{tmpfile.name}' {excludes}"
|
||||
|
||||
# Dump data to temporary file
|
||||
manage(c, cmd, pty=True)
|
||||
|
||||
manage(c, cmd, pty=True, verbose=verbose)
|
||||
info('Running data post-processing step...')
|
||||
|
||||
# Post-process the file, to remove any "permissions" specified for a user or group
|
||||
@@ -1217,6 +1290,7 @@ def validate_import_metadata(
|
||||
'ignore_nonexistent': 'Ignore non-existent database models (default = False)',
|
||||
'exclude_plugins': 'Exclude plugin data from the import process (default = False)',
|
||||
'skip_migrations': 'Skip the migration step after clearing data (default = False)',
|
||||
'verbose': 'Print verbose output from management commands',
|
||||
},
|
||||
pre=[wait],
|
||||
post=[rebuild_models, rebuild_thumbnails],
|
||||
@@ -1230,6 +1304,7 @@ def import_records(
|
||||
exclude_plugins: bool = False,
|
||||
ignore_nonexistent: bool = False,
|
||||
skip_migrations: bool = False,
|
||||
verbose: bool = False,
|
||||
):
|
||||
"""Import database records from a file."""
|
||||
# Get an absolute path to the supplied filename
|
||||
@@ -1243,10 +1318,10 @@ def import_records(
|
||||
sys.exit(1)
|
||||
|
||||
if clear:
|
||||
delete_data(c, force=True, migrate=True)
|
||||
delete_data(c, force=True, migrate=True, verbose=verbose)
|
||||
|
||||
if not skip_migrations:
|
||||
migrate(c)
|
||||
migrate(c, verbose=verbose)
|
||||
|
||||
info(f"Importing database records from '{target}'")
|
||||
|
||||
@@ -1274,6 +1349,7 @@ def import_records(
|
||||
) -> tempfile.NamedTemporaryFile:
|
||||
"""Helper function to save data to a temporary file, and then load into the database."""
|
||||
nonlocal ignore_nonexistent
|
||||
nonlocal verbose
|
||||
nonlocal c
|
||||
|
||||
# Skip if there is no data to load
|
||||
@@ -1299,7 +1375,7 @@ def import_records(
|
||||
if excludes:
|
||||
cmd += f' -i {excludes}'
|
||||
|
||||
manage(c, cmd, pty=True)
|
||||
manage(c, cmd, pty=True, verbose=verbose)
|
||||
|
||||
# Iterate through each entry in the provided data file, and separate out into different categories based on the model type
|
||||
for entry in data:
|
||||
@@ -1363,22 +1439,22 @@ def import_records(
|
||||
help={
|
||||
'force': 'Force deletion of all data without confirmation',
|
||||
'migrate': 'Run migrations before deleting data (default = False)',
|
||||
'verbose': 'Print verbose output from management commands',
|
||||
}
|
||||
)
|
||||
def delete_data(c, force: bool = False, migrate: bool = False):
|
||||
def delete_data(c, force: bool = False, migrate: bool = False, verbose: bool = False):
|
||||
"""Delete all database records!
|
||||
|
||||
Warning: This will REALLY delete all records in the database!!
|
||||
"""
|
||||
info('Deleting all data from InvenTree database...')
|
||||
info('Deleting existing data from InvenTree database...')
|
||||
|
||||
if migrate:
|
||||
manage(c, 'migrate --run-syncdb')
|
||||
manage(c, 'migrate --run-syncdb', verbose=verbose)
|
||||
|
||||
if force:
|
||||
manage(c, 'flush --noinput')
|
||||
else:
|
||||
manage(c, 'flush')
|
||||
manage(c, f'flush{" --noinput" if force else ""}', verbose=verbose)
|
||||
|
||||
success('Existing data deleted')
|
||||
|
||||
|
||||
@task(post=[rebuild_models, rebuild_thumbnails])
|
||||
@@ -1640,6 +1716,7 @@ def test(
|
||||
'validate_files': 'Validate media files are correctly copied',
|
||||
'use_ssh': 'Use SSH protocol for cloning the demo dataset (requires SSH key)',
|
||||
'branch': 'Specify branch of demo-dataset to clone (default = main)',
|
||||
'verbose': 'Print verbose output from management commands',
|
||||
}
|
||||
)
|
||||
def setup_test(
|
||||
@@ -1648,6 +1725,7 @@ def setup_test(
|
||||
dev=False,
|
||||
validate_files=False,
|
||||
use_ssh=False,
|
||||
verbose=False,
|
||||
path='inventree-demo-dataset',
|
||||
branch='main',
|
||||
):
|
||||
@@ -1657,13 +1735,12 @@ def setup_test(
|
||||
)
|
||||
|
||||
if not ignore_update:
|
||||
update(c)
|
||||
update(c, verbose=verbose)
|
||||
|
||||
template_dir = local_dir().joinpath(path)
|
||||
|
||||
# Remove old data directory
|
||||
if template_dir.exists():
|
||||
info('Removing old data ...')
|
||||
run(c, f'rm {template_dir} -r')
|
||||
|
||||
URL = 'https://github.com/inventree/demo-dataset'
|
||||
@@ -1678,11 +1755,16 @@ def setup_test(
|
||||
|
||||
# Make sure migrations are done - might have just deleted sqlite database
|
||||
if not ignore_update:
|
||||
migrate(c)
|
||||
migrate(c, verbose=verbose)
|
||||
|
||||
# Load data
|
||||
info('Loading database records ...')
|
||||
import_records(c, filename=template_dir.joinpath('inventree_data.json'), clear=True)
|
||||
import_records(
|
||||
c,
|
||||
filename=template_dir.joinpath('inventree_data.json'),
|
||||
clear=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
# Copy media files
|
||||
src = template_dir.joinpath('media')
|
||||
@@ -1718,7 +1800,7 @@ def setup_test(
|
||||
|
||||
# Set up development setup if flag is set
|
||||
if dev:
|
||||
setup_dev(c)
|
||||
setup_dev(c, verbose=verbose)
|
||||
|
||||
|
||||
@task(
|
||||
@@ -1929,7 +2011,7 @@ def frontend_build(c):
|
||||
Args:
|
||||
c: Context variable
|
||||
"""
|
||||
info('Building frontend')
|
||||
info('Building frontend...')
|
||||
yarn(c, 'yarn run build')
|
||||
|
||||
def write_info(path: Path, content: str):
|
||||
@@ -1957,6 +2039,8 @@ def frontend_build(c):
|
||||
except Exception:
|
||||
warning('Failed to write frontend version marker')
|
||||
|
||||
success('Frontend build complete')
|
||||
|
||||
|
||||
@task
|
||||
def frontend_server(c):
|
||||
|
||||
Reference in New Issue
Block a user