mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	* Display PendingShipments panel - Overview of all outstanding shipments * Update UI tests * add separate page for troubleshooting * Restructure menus a bit * Fix top-level page for mobile app * Add option to override default value in docs * Update global.md * Formatting updates * Update sales pages * Move order images to "purchasing" * Small tweaks * Make "Concepts" a top-level documentation item * Update image
		
			
				
	
	
		
			509 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			509 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """Main entry point for the documentation build process."""
 | |
| 
 | |
| import json
 | |
| import logging
 | |
| import os
 | |
| import subprocess
 | |
| import textwrap
 | |
| from pathlib import Path
 | |
| from typing import Literal, Optional
 | |
| 
 | |
| import requests
 | |
| import yaml
 | |
| 
 | |
| # Debugging output - useful for diagnosing CI build issues
 | |
| print('loading ./docs/main.py...')
 | |
| 
 | |
| logging.getLogger('openapidocs').setLevel(logging.ERROR)
 | |
| 
 | |
| # Print out some useful debugging information
 | |
| # Ref: https://docs.readthedocs.io/en/stable/reference/environment-variables.html
 | |
| for key in [
 | |
|     'GITHUB_ACTIONS',
 | |
|     'GITHUB_REF',
 | |
|     'READTHEDOCS',
 | |
|     'READTHEDOCS_GIT_IDENTIFIER',
 | |
|     'READTHEDOCS_GIT_CLONE_URL',
 | |
|     'READTHEDOCS_GIT_COMMIT_HASH',
 | |
|     'READTHEDOCS_PROJECT',
 | |
|     'READTHEDOCS_VERSION',
 | |
|     'READTHEDOCS_VERSION_NAME',
 | |
|     'READTHEDOCS_VERSION_TYPE',
 | |
| ]:
 | |
|     val = os.environ.get(key, None) or '-- MISSING --'
 | |
|     print(f' - {key}: {val}')
 | |
| 
 | |
| # Cached settings dict values
 | |
| global GLOBAL_SETTINGS
 | |
| global USER_SETTINGS
 | |
| global TAGS
 | |
| global FILTERS
 | |
| global REPORT_CONTEXT
 | |
| 
 | |
| # Read in the InvenTree settings file
 | |
| here = Path(__file__).parent
 | |
| 
 | |
| gen_base = here.joinpath('generated')
 | |
| 
 | |
| # File where we expect to find the settings definitions
 | |
| settings_file = gen_base.joinpath('inventree_settings.json')
 | |
| 
 | |
| # File where we will *store* information on the settings we have observed
 | |
| observed_settings_file = gen_base.joinpath('observed_settings.json')
 | |
| 
 | |
| # Overwrite the observed settings file
 | |
| with open(observed_settings_file, 'w', encoding='utf-8') as f:
 | |
|     data = {'global': {}, 'user': {}}
 | |
| 
 | |
|     # Write an empty dict to the file
 | |
|     # This is used to track which settings we have observed during the build process
 | |
|     f.write(json.dumps(data, indent=4))
 | |
| 
 | |
| with open(settings_file, encoding='utf-8') as sf:
 | |
|     settings = json.load(sf)
 | |
| 
 | |
|     GLOBAL_SETTINGS = settings['global']
 | |
|     USER_SETTINGS = settings['user']
 | |
| 
 | |
| # Tags
 | |
| with open(gen_base.joinpath('inventree_tags.yml'), encoding='utf-8') as f:
 | |
|     TAGS = yaml.load(f, yaml.BaseLoader)
 | |
| # Filters
 | |
| with open(gen_base.joinpath('inventree_filters.yml'), encoding='utf-8') as f:
 | |
|     FILTERS = yaml.load(f, yaml.BaseLoader)
 | |
| # Report context
 | |
| with open(gen_base.joinpath('inventree_report_context.json'), encoding='utf-8') as f:
 | |
|     REPORT_CONTEXT = json.load(f)
 | |
| 
 | |
| 
 | |
| def get_repo_url(raw=False):
 | |
|     """Return the repository URL for the current project."""
 | |
|     mkdocs_yml = here.joinpath('mkdocs.yml')
 | |
| 
 | |
|     with open(mkdocs_yml, encoding='utf-8') as f:
 | |
|         mkdocs_config = yaml.load(f, yaml.BaseLoader)
 | |
|         repo_name = mkdocs_config['repo_name']
 | |
| 
 | |
|     if raw:
 | |
|         return f'https://raw.githubusercontent.com/{repo_name}'
 | |
|     else:
 | |
|         return f'https://github.com/{repo_name}'
 | |
| 
 | |
| 
 | |
| def check_link(url) -> bool:
 | |
|     """Check that a provided URL is valid.
 | |
| 
 | |
|     We allow a number attempts and a lengthy timeout,
 | |
|     as we do not want false negatives.
 | |
|     """
 | |
|     CACHE_FILE = gen_base.joinpath('url_cache.txt')
 | |
| 
 | |
|     # Keep a local cache file of URLs we have already checked
 | |
|     if CACHE_FILE.exists():
 | |
|         with open(CACHE_FILE, encoding='utf-8') as f:
 | |
|             cache = f.read().splitlines()
 | |
| 
 | |
|         if url in cache:
 | |
|             return True
 | |
| 
 | |
|     attempts = 5
 | |
| 
 | |
|     while attempts > 0:
 | |
|         response = requests.head(url, timeout=5000)
 | |
| 
 | |
|         # Ensure GH is not causing issues
 | |
|         if response.status_code in (200, 429):
 | |
|             # Update the cache file
 | |
|             with open(CACHE_FILE, 'a', encoding='utf-8') as f:
 | |
|                 f.write(f'{url}\n')
 | |
| 
 | |
|             return True
 | |
| 
 | |
|         attempts -= 1
 | |
| 
 | |
|         print(f'URL check failed with status code {response.status_code}')
 | |
| 
 | |
|     return False
 | |
| 
 | |
| 
 | |
| def get_build_environment() -> Optional[str]:
 | |
|     """Returns the branch we are currently building on, based on the environment variables of the various CI platforms."""
 | |
|     # Check if we are in ReadTheDocs
 | |
|     if os.environ.get('READTHEDOCS') == 'True':
 | |
|         for var in ['READTHEDOCS_GIT_COMMIT_HASH', 'READTHEDOCS_GIT_IDENTIFIER']:
 | |
|             if val := os.environ.get(var):
 | |
|                 return val
 | |
|     # We are in GitHub Actions
 | |
|     elif os.environ.get('GITHUB_ACTIONS') == 'true':
 | |
|         return os.environ.get('GITHUB_REF')
 | |
| 
 | |
|     # Default to 'master' branch
 | |
|     return 'master'
 | |
| 
 | |
| 
 | |
| def define_env(env):
 | |
|     """Define custom environment variables for the documentation build process."""
 | |
|     config = env.config
 | |
|     assets_dir = config.get('assets_dir', None)
 | |
| 
 | |
|     if assets_dir is None:
 | |
|         # Construct the assets directory based on the current build environment
 | |
|         rtd_version = os.environ.get('READTHEDOCS_VERSION')
 | |
|         rtd_language = os.environ.get('READTHEDOCS_LANGUAGE')
 | |
| 
 | |
|         if rtd_version and rtd_language:
 | |
|             assets_dir = f'/{rtd_language}/{rtd_version}/assets'
 | |
|         else:
 | |
|             assets_dir = '/assets'
 | |
| 
 | |
|     @env.macro
 | |
|     def sourcedir(dirname: str, branch=None):
 | |
|         """Return a link to a directory within the source code repository.
 | |
| 
 | |
|         Arguments:
 | |
|             dirname: The name of the directory to link to (relative to the top-level directory)
 | |
|             branch: The branch of the repository to link to (defaults to the current build environment)
 | |
| 
 | |
|         Returns:
 | |
|             A fully qualified URL to the source code directory on GitHub
 | |
| 
 | |
|         Raises:
 | |
|             FileNotFoundError: If the directory does not exist, or the generated URL is invalid
 | |
|         """
 | |
|         if branch == None:
 | |
|             branch = get_build_environment()
 | |
| 
 | |
|         if dirname.startswith('/'):
 | |
|             dirname = dirname[1:]
 | |
| 
 | |
|         # This file exists at ./docs/main.py, so any directory we link to must be relative to the top-level directory
 | |
|         directory = here.parent.joinpath(dirname)
 | |
|         if not directory.exists() or not directory.is_dir():
 | |
|             raise FileNotFoundError(f'Source directory {dirname} does not exist.')
 | |
| 
 | |
|         repo_url = get_repo_url()
 | |
| 
 | |
|         url = f'{repo_url}/tree/{branch}/{dirname}'
 | |
| 
 | |
|         # Check that the URL exists before returning it
 | |
|         if not check_link(url):
 | |
|             raise FileNotFoundError(f'URL {url} does not exist.')
 | |
| 
 | |
|         return url
 | |
| 
 | |
|     @env.macro
 | |
|     def sourcefile(filename, branch=None, raw=False):
 | |
|         """Return a link to a file within the source code repository.
 | |
| 
 | |
|         Arguments:
 | |
|             filename: The name of the file to link to (relative to the top-level directory)
 | |
|             branch: The branch of the repository to link to (defaults to the current build environment)
 | |
|             raw: If True, return the raw URL to the file (defaults to False)
 | |
| 
 | |
|         Returns:
 | |
|             A fully qualified URL to the source code file on GitHub
 | |
| 
 | |
|         Raises:
 | |
|             FileNotFoundError: If the file does not exist, or the generated URL is invalid
 | |
|         """
 | |
|         if branch == None:
 | |
|             branch = get_build_environment()
 | |
| 
 | |
|         if filename.startswith('/'):
 | |
|             filename = filename[1:]
 | |
| 
 | |
|         # This file exists at ./docs/main.py, so any file we link to must be relative to the top-level directory
 | |
|         file_path = here.parent.joinpath(filename)
 | |
|         if not file_path.exists():
 | |
|             raise FileNotFoundError(f'Source file {filename} does not exist.')
 | |
| 
 | |
|         # Construct repo URL
 | |
|         repo_url = get_repo_url(raw=False)
 | |
|         url = f'{repo_url}/blob/{branch}/{filename}'
 | |
| 
 | |
|         # Check that the URL exists before returning it
 | |
|         if not check_link(url):
 | |
|             raise FileNotFoundError(f'URL {url} does not exist.')
 | |
| 
 | |
|         if raw:
 | |
|             # If requesting the 'raw' URL, take this into account here...
 | |
|             repo_url = get_repo_url(raw=True)
 | |
|             url = f'{repo_url}/{branch}/{filename}'
 | |
| 
 | |
|         return url
 | |
| 
 | |
|     @env.macro
 | |
|     def invoke_commands(command: str = '--list'):
 | |
|         """Provides an output of the available commands."""
 | |
|         tasks = here.parent.joinpath('tasks.py')
 | |
|         output = gen_base.joinpath('invoke-commands.txt')
 | |
| 
 | |
|         command = f'invoke -f {tasks} {command} > {output}'
 | |
| 
 | |
|         assert subprocess.call(command, shell=True) == 0
 | |
| 
 | |
|         with open(output, encoding='utf-8') as f:
 | |
|             content = f.read()
 | |
| 
 | |
|         return content
 | |
| 
 | |
|     @env.macro
 | |
|     def listimages(subdir):
 | |
|         """Return a listing of all asset files in the provided subdir."""
 | |
|         directory = here.joinpath('docs', 'assets', 'images', subdir)
 | |
| 
 | |
|         assets = []
 | |
| 
 | |
|         allowed = ['.png', '.jpg']
 | |
| 
 | |
|         for asset in directory.iterdir():
 | |
|             if any(str(asset).endswith(x) for x in allowed):
 | |
|                 assets.append(str(subdir / asset.relative_to(directory)))
 | |
| 
 | |
|         return assets
 | |
| 
 | |
|     @env.macro
 | |
|     def includefile(filename: str, title: str, fmt: str = ''):
 | |
|         """Include a file in the documentation, in a 'collapse' block.
 | |
| 
 | |
|         Arguments:
 | |
|             filename: The name of the file to include (relative to the top-level directory)
 | |
|             title: The title of the collapse block in the documentation
 | |
|             fmt: The format of the included file (e.g., 'python', 'html', etc.)
 | |
|         """
 | |
|         path = here.parent.joinpath(filename)
 | |
| 
 | |
|         if not path.exists():
 | |
|             raise FileNotFoundError(f'Required file {path} does not exist.')
 | |
| 
 | |
|         with open(path, encoding='utf-8') as f:
 | |
|             content = f.read()
 | |
| 
 | |
|         data = f'??? abstract "{title}"\n\n'
 | |
|         data += f'    ```{fmt}\n'
 | |
|         data += textwrap.indent(content, '    ')
 | |
|         data += '\n\n'
 | |
|         data += '    ```\n\n'
 | |
| 
 | |
|         return data
 | |
| 
 | |
|     @env.macro
 | |
|     def templatefile(filename):
 | |
|         """Include code for a provided template file."""
 | |
|         base = Path(filename).name
 | |
|         fn = Path('src', 'backend', 'InvenTree', 'report', 'templates', filename)
 | |
| 
 | |
|         return includefile(fn, f'Template: {base}', fmt='html')
 | |
| 
 | |
|     def observe_setting(key: str, group: str):
 | |
|         """Record that a particular setting has been observed.
 | |
| 
 | |
|         This is used to ensure that all settings are documented in the generated documentation.
 | |
| 
 | |
|         Arguments:
 | |
|             key: The name of the setting to observe
 | |
|             group: The group of the setting (e.g. 'global', 'user')
 | |
|         """
 | |
|         # Read the observed settings file
 | |
|         with open(observed_settings_file, encoding='utf-8') as f:
 | |
|             data = json.load(f)
 | |
| 
 | |
|         if group not in data:
 | |
|             data[group] = {}
 | |
| 
 | |
|         data[group][key] = True
 | |
| 
 | |
|         # Write the updated data back to the file
 | |
|         with open(observed_settings_file, 'w', encoding='utf-8') as f:
 | |
|             json.dump(data, f, indent=4)
 | |
| 
 | |
|     @env.macro
 | |
|     def rendersetting(
 | |
|         key: str, setting: dict, short: bool = False, default: Optional[str] = None
 | |
|     ):
 | |
|         """Render a provided setting object into a table row.
 | |
| 
 | |
|         Arguments:
 | |
|             key: The name of the setting to extract information for.
 | |
|             setting: The setting object to render.
 | |
|             short: If True, return a short version of the setting (default: False)
 | |
|             default: An optional default value to override the setting's default display value.
 | |
|         """
 | |
|         name = setting['name']
 | |
|         description = setting['description']
 | |
|         default = default or setting.get('default')
 | |
|         units = setting.get('units')
 | |
| 
 | |
|         default = f'`{default}`' if default else ''
 | |
|         units = f'`{units}`' if units else ''
 | |
| 
 | |
|         if short:
 | |
|             return f'<span title="{key}"><strong>{name}</strong></span>'
 | |
| 
 | |
|         return f'| <div title="{key}"><strong>{name}</strong></div> | {description} | {default} | {units} |'
 | |
| 
 | |
|     @env.macro
 | |
|     def globalsetting(key: str, short: bool = False, default: Optional[str] = None):
 | |
|         """Extract information on a particular global setting.
 | |
| 
 | |
|         Arguments:
 | |
|             key: The name of the global setting to extract information for.
 | |
|             short: If True, return a short version of the setting (default: False)
 | |
|             default: An optional default value to override the setting's default display value.
 | |
|         """
 | |
|         global GLOBAL_SETTINGS
 | |
|         setting = GLOBAL_SETTINGS[key]
 | |
| 
 | |
|         # Settings are only 'observed' if they are displayed in full
 | |
|         if not short:
 | |
|             observe_setting(key, 'global')
 | |
| 
 | |
|         return rendersetting(key, setting, short=short, default=default)
 | |
| 
 | |
|     @env.macro
 | |
|     def usersetting(key: str, short: bool = False):
 | |
|         """Extract information on a particular user setting.
 | |
| 
 | |
|         Arguments:
 | |
|             key: The name of the user setting to extract information for.
 | |
|             short: If True, return a short version of the setting (default: False)
 | |
|         """
 | |
|         global USER_SETTINGS
 | |
|         setting = USER_SETTINGS[key]
 | |
| 
 | |
|         # Settings are only 'observed' if they are displayed in full
 | |
|         if not short:
 | |
|             observe_setting(key, 'user')
 | |
| 
 | |
|         return rendersetting(key, setting, short=short)
 | |
| 
 | |
|     @env.macro
 | |
|     def tags_and_filters():
 | |
|         """Return a list of all tags and filters."""
 | |
|         global TAGS
 | |
|         global FILTERS
 | |
| 
 | |
|         ret_data = ''
 | |
|         for ref in [['Tags', TAGS], ['Filters', FILTERS]]:
 | |
|             ret_data += f'### {ref[0]}\n\n| Namespace | Name | Description |\n| --- | --- | --- |\n'
 | |
|             for value in ref[1]:
 | |
|                 title = (
 | |
|                     value['title']
 | |
|                     .replace('\n', ' ')
 | |
|                     .replace('<', '<')
 | |
|                     .replace('>', '>')
 | |
|                 )
 | |
|                 ret_data += f'| {value["library"]} | {value["name"]} | {title} |\n'
 | |
|             ret_data += '\n'
 | |
|         ret_data += '\n'
 | |
| 
 | |
|         return ret_data
 | |
| 
 | |
|     @env.macro
 | |
|     def report_context(type_: Literal['models', 'base'], model: str):
 | |
|         """Extract information on a particular report context."""
 | |
|         global REPORT_CONTEXT
 | |
| 
 | |
|         context = REPORT_CONTEXT.get(type_).get(model)
 | |
| 
 | |
|         ret_data = '| Variable | Type | Description |\n| --- | --- | --- |\n'
 | |
|         for k, v in context['context'].items():
 | |
|             ret_data += f'| {k} | `{v["type"]}` | {v["description"]} |\n'
 | |
| 
 | |
|         return ret_data
 | |
| 
 | |
|     @env.macro
 | |
|     def icon(
 | |
|         source: str,
 | |
|         size: str = '20px',
 | |
|         color: Optional[str] = None,
 | |
|         title: Optional[str] = None,
 | |
|     ):
 | |
|         """Return a tabler icon for a given source.
 | |
| 
 | |
|         Arguments:
 | |
|             source: The name of the icon to display (e.g. 'check', 'cross', etc.)
 | |
|             size: The size of the icon (default: 20px)
 | |
|             color: The color of the icon (default: None)
 | |
|             title: The title of the icon (default: None)
 | |
| 
 | |
|         Requires CSS to be loaded from:
 | |
|         https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.31.0/dist/tabler-icons.min.css
 | |
|         """
 | |
|         c = f' color: {color};' if color else ''
 | |
|         t = f' <i>{title}</i>' if title else ''
 | |
| 
 | |
|         return f"<i class='ti ti-{source}' style='font-size: {size};{c}'></i>{t}"
 | |
| 
 | |
|     @env.macro
 | |
|     def image(
 | |
|         source: str,
 | |
|         title: Optional[str] = '',
 | |
|         iid: Optional[str] = '',
 | |
|         alt: Optional[str] = '',
 | |
|         base: Optional[str] = '',
 | |
|         maxwidth: Optional[str] = '',
 | |
|         maxheight: Optional[str] = '',
 | |
|     ):
 | |
|         """Render an image within the documentation.
 | |
| 
 | |
|         Arguments:
 | |
|             title: The title of the image (default: '')
 | |
|             source: The name of the image to display (e.g. 'check', 'cross', etc.)
 | |
|             iid: The ID of the image (default: '')
 | |
|             alt: The alt text for the image (default: '')
 | |
|             base: The base directory for the image (default: './assets/images/')
 | |
|             maxwidth: The maximum width of the image (default: '')
 | |
|             maxheight: The maximum height of the image (default: '')
 | |
| 
 | |
|         - This will render an image which can be clicked on to expand to full size.
 | |
|         - It will also validate that the image exists in the specified directory.
 | |
| 
 | |
|         The image must be located in the './docs/assets/images/' directory
 | |
|         """
 | |
|         # Allow external images too - without validation
 | |
|         if source.startswith('http'):
 | |
|             img = source
 | |
|         else:
 | |
|             basedir = os.path.dirname(__file__)
 | |
|             basedir = os.path.join(basedir, 'docs', 'assets', 'images')
 | |
| 
 | |
|             if base:
 | |
|                 basedir = os.path.abspath(os.path.join(basedir, base))
 | |
|             filename = os.path.join(basedir, source)
 | |
| 
 | |
|             if not os.path.exists(filename):
 | |
|                 raise FileNotFoundError(f'Image {filename} does not exist.')
 | |
| 
 | |
|             # Now, create a proper URL to the image
 | |
|             img = os.path.join(assets_dir, 'images', base, source)
 | |
| 
 | |
|         if not title:
 | |
|             title = os.path.splitext(source)[0]
 | |
| 
 | |
|         if not iid:
 | |
|             iid = title.replace(' ', '_').replace('-', '_')
 | |
| 
 | |
|         if not alt:
 | |
|             alt = iid
 | |
| 
 | |
|         styles = []
 | |
| 
 | |
|         if maxwidth:
 | |
|             styles.append(f'max-width: {maxwidth};')
 | |
|         if maxheight:
 | |
|             styles.append(f'max-height: {maxheight};')
 | |
| 
 | |
|         style = f"style='{' '.join(styles)}' " if styles else ''
 | |
| 
 | |
|         return textwrap.dedent(f"""
 | |
|         <figure class='image image-inventree'>
 | |
|             <a href='#{iid}'>
 | |
|                 <img class='img-inline' src='{img}' alt='{alt}' title='{title}' {style}/>
 | |
|             </a>
 | |
|             <a href='#_' class='overlay' id='{iid}'>
 | |
|                 <img src='{img}' alt='{alt}' />
 | |
|             </a>
 | |
|         </figure>
 | |
|         """)
 |