diff --git a/.github/scripts/version_check.py b/.github/scripts/version_check.py index 4d596395a329..0c91797fb580 100644 --- a/.github/scripts/version_check.py +++ b/.github/scripts/version_check.py @@ -16,6 +16,7 @@ import re import sys from pathlib import Path +from typing import Optional import requests @@ -48,7 +49,7 @@ def get_existing_release_tags(include_prerelease=True): tag = release['tag_name'].strip() match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', tag) - if len(match.groups()) != 3: + if not match or len(match.groups()) != 3: print(f"Version '{tag}' did not match expected pattern") continue @@ -94,11 +95,12 @@ def check_version_number(version_string, allow_duplicate=False): return highest_release -if __name__ == '__main__': +def main() -> bool: + """Main function to check the version number.""" # Ensure that we are running in GH Actions if os.environ.get('GITHUB_ACTIONS', '') != 'true': print('This script is intended to be run within a GitHub Action!') - sys.exit(1) + return False if 'only_version' in sys.argv: here = Path(__file__).parent.absolute() @@ -111,7 +113,7 @@ def check_version_number(version_string, allow_duplicate=False): if len(sys.argv) > 2 and sys.argv[2] == 'true': results[0] = str(int(results[0]) - 1) print(results[0]) - exit(0) + return True # GITHUB_REF_TYPE may be either 'branch' or 'tag' GITHUB_REF_TYPE = os.environ['GITHUB_REF_TYPE'] @@ -142,7 +144,7 @@ def check_version_number(version_string, allow_duplicate=False): if len(results) != 1: print(f'Could not find INVENTREE_SW_VERSION in {version_file}') - sys.exit(1) + return False version = results[0] @@ -164,16 +166,16 @@ def check_version_number(version_string, allow_duplicate=False): highest_release = check_version_number(version, allow_duplicate=allow_duplicate) # Determine which docker tag we are going to use - docker_tags = None + docker_tags: Optional[list[str]] = None if GITHUB_REF_TYPE == 'tag': # GITHUB_REF should be of the form /refs/heads/ - version_tag = GITHUB_REF.split('/')[-1] + version_tag: str = GITHUB_REF.split('/')[-1] print(f"Checking requirements for tagged release - '{version_tag}':") if version_tag != version: print(f"Version number '{version}' does not match tag '{version_tag}'") - sys.exit + return True docker_tags = [version_tag, 'stable'] if highest_release else [version_tag] @@ -187,11 +189,11 @@ def check_version_number(version_string, allow_duplicate=False): print('GITHUB_REF_TYPE:', GITHUB_REF_TYPE) print('GITHUB_BASE_REF:', GITHUB_BASE_REF) print('GITHUB_REF:', GITHUB_REF) - sys.exit(1) + return False if docker_tags is None: print('Docker tags could not be determined') - sys.exit(1) + return False print(f"Version check passed for '{version}'!") print(f"Docker tags: '{docker_tags}'") @@ -208,3 +210,11 @@ def check_version_number(version_string, allow_duplicate=False): if GITHUB_REF_TYPE == 'tag' and highest_release: env_file.write('stable_release=true\n') + return True + + +if __name__ == '__main__': + rslt = main() + if rslt is not True: + print('Version check failed!') + sys.exit(1) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 3429ca47ead7..13d1c68831e2 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -97,6 +97,27 @@ jobs: pip install --require-hashes -r contrib/dev_reqs/requirements.txt python3 .github/scripts/version_check.py + typecheck: + name: Style [Typecheck] + runs-on: ubuntu-24.04 + needs: [paths-filter, pre-commit] + if: needs.paths-filter.outputs.server == 'true' || needs.paths-filter.outputs.requirements == 'true' || needs.paths-filter.outputs.force == 'true' + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 + with: + persist-credentials: false + - name: Environment Setup + id: setup + uses: ./.github/actions/setup + with: + apt-dependency: gettext poppler-utils + dev-install: true + update: true + - name: Check types + run: | + ty check --python ${Python_ROOT_DIR}/bin/python3 + mkdocs: name: Style [Documentation] runs-on: ubuntu-24.04 diff --git a/docs/docs/hooks.py b/docs/docs/hooks.py index fe3f7096cf0d..090c54e51763 100644 --- a/docs/docs/hooks.py +++ b/docs/docs/hooks.py @@ -4,7 +4,7 @@ import os import re from datetime import datetime -from distutils.version import StrictVersion +from distutils.version import StrictVersion # type: ignore[import] from pathlib import Path import requests diff --git a/docs/main.py b/docs/main.py index 25b435c1919a..67ee3e245690 100644 --- a/docs/main.py +++ b/docs/main.py @@ -110,7 +110,7 @@ def check_link(url) -> bool: return False -def get_build_environment() -> str: +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': diff --git a/pyproject.toml b/pyproject.toml index 04f341b5194c..1418b38acd03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,21 @@ python-version = "3.9.2" no-strip-extras=true generate-hashes=true +[tool.ty] +src = [ + "src/backend/InvenTree", + "./", +] +[tool.ty.rules] +unresolved-reference="ignore" # 24 # see https://github.com/astral-sh/ty/issues/220 +unresolved-attribute="ignore" # 33 # need Plugin Mixin typing +call-non-callable="ignore" # 3 ## +invalid-return-type="ignore" # 12 ## +invalid-argument-type="ignore" # 15 +possibly-unbound-attribute="ignore" # 7 +unknown-argument="ignore" # 9 # need to wait for betterdjango field stubs + + [tool.coverage.run] source = ["src/backend/InvenTree", "InvenTree"] dynamic_context = "test_function" diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index e5535e26f53c..0cc2a51611d2 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -17,6 +17,7 @@ from rest_framework.serializers import ValidationError from rest_framework.views import APIView +import InvenTree.permissions import InvenTree.version from common.settings import get_global_setting from InvenTree import helpers diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index ea13d092ccc1..720e4c566f40 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -134,6 +134,9 @@ def start_background_tasks(self): tasks = InvenTree.tasks.tasks.task_list for task in tasks: + if not task: + continue # pragma: no cover + ref_name = f'{task.func.__module__}.{task.func.__name__}' if ref_name in existing_tasks: diff --git a/src/backend/InvenTree/InvenTree/cache.py b/src/backend/InvenTree/InvenTree/cache.py index a217c94fe1cc..97beb96c36d7 100644 --- a/src/backend/InvenTree/InvenTree/cache.py +++ b/src/backend/InvenTree/InvenTree/cache.py @@ -2,6 +2,7 @@ import socket import threading +from typing import Any import structlog @@ -127,7 +128,7 @@ def delete_session_cache() -> None: del thread_data.request_cache -def get_session_cache(key: str) -> any: +def get_session_cache(key: str) -> Any: """Return a cached value from the session cache.""" # Only return a cached value if the request object is available too if not hasattr(thread_data, 'request'): @@ -139,7 +140,7 @@ def get_session_cache(key: str) -> any: return val -def set_session_cache(key: str, value: any) -> None: +def set_session_cache(key: str, value: Any) -> None: """Set a cached value in the session cache.""" # Only set a cached value if the request object is available too if not hasattr(thread_data, 'request'): diff --git a/src/backend/InvenTree/InvenTree/config.py b/src/backend/InvenTree/InvenTree/config.py index 02326f0e90bd..dcc49106977f 100644 --- a/src/backend/InvenTree/InvenTree/config.py +++ b/src/backend/InvenTree/InvenTree/config.py @@ -8,6 +8,7 @@ import shutil import string from pathlib import Path +from typing import Union logger = logging.getLogger('inventree') CONFIG_DATA = None @@ -113,7 +114,7 @@ def get_config_file(create=True) -> Path: return cfg_filename -def load_config_data(set_cache: bool = False) -> map: +def load_config_data(set_cache: bool = False) -> Union[map, None]: """Load configuration data from the config file. Arguments: diff --git a/src/backend/InvenTree/InvenTree/conversion.py b/src/backend/InvenTree/InvenTree/conversion.py index 669fe950fff2..295a2e9fbc80 100644 --- a/src/backend/InvenTree/InvenTree/conversion.py +++ b/src/backend/InvenTree/InvenTree/conversion.py @@ -191,7 +191,7 @@ def convert_physical_value(value: str, unit: Optional[str] = None, strip_units=T attempts.append(f'{value}{unit}') attempts.append(f'{eng}{unit}') - value = None + value: Optional[str] = None # Run through the available "attempts", take the first successful result for attempt in attempts: diff --git a/src/backend/InvenTree/InvenTree/exceptions.py b/src/backend/InvenTree/InvenTree/exceptions.py index ac84befa93d1..07ec874ddb4c 100644 --- a/src/backend/InvenTree/InvenTree/exceptions.py +++ b/src/backend/InvenTree/InvenTree/exceptions.py @@ -50,7 +50,8 @@ def log_error(path, error_name=None, error_info=None, error_data=None): data = error_data else: try: - data = '\n'.join(traceback.format_exception(kind, info, data)) + formatted_exception = traceback.format_exception(kind, info, data) # type: ignore[no-matching-overload] + data = '\n'.join(formatted_exception) except AttributeError: data = 'No traceback information available' diff --git a/src/backend/InvenTree/InvenTree/filters.py b/src/backend/InvenTree/InvenTree/filters.py index 30745679a026..d6906279fdaf 100644 --- a/src/backend/InvenTree/InvenTree/filters.py +++ b/src/backend/InvenTree/InvenTree/filters.py @@ -6,7 +6,8 @@ from django.utils import timezone from django.utils.timezone import make_aware -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.backends as drf_backend +import django_filters.rest_framework.filters as rest_filters from rest_framework import filters import InvenTree.helpers @@ -20,7 +21,7 @@ def filter(self, qs, value): if settings.USE_TZ and value is not None: tz = timezone.get_current_timezone() value = datetime(value.year, value.month, value.day) - value = make_aware(value, tz, True) + value = make_aware(value, timezone=tz, is_dst=True) return super().filter(qs, value) @@ -160,17 +161,17 @@ def get_ordering(self, request, queryset, view): SEARCH_ORDER_FILTER = [ - rest_filters.DjangoFilterBackend, + drf_backend.DjangoFilterBackend, InvenTreeSearchFilter, filters.OrderingFilter, ] SEARCH_ORDER_FILTER_ALIAS = [ - rest_filters.DjangoFilterBackend, + drf_backend.DjangoFilterBackend, InvenTreeSearchFilter, InvenTreeOrderingFilter, ] -ORDER_FILTER = [rest_filters.DjangoFilterBackend, filters.OrderingFilter] +ORDER_FILTER = [drf_backend.DjangoFilterBackend, filters.OrderingFilter] -ORDER_FILTER_ALIAS = [rest_filters.DjangoFilterBackend, InvenTreeOrderingFilter] +ORDER_FILTER_ALIAS = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter] diff --git a/src/backend/InvenTree/InvenTree/format.py b/src/backend/InvenTree/InvenTree/format.py index 5fc8c546bb74..3ee674b216a9 100644 --- a/src/backend/InvenTree/InvenTree/format.py +++ b/src/backend/InvenTree/InvenTree/format.py @@ -107,7 +107,7 @@ def construct_format_regex(fmt_string: str) -> str: # Add a named capture group for the format entry if name: # Check if integer values are required - c = '\\d' if _fmt.endswith('d') else '.' + c = '\\d' if _fmt and _fmt.endswith('d') else '.' # Specify width # TODO: Introspect required width @@ -124,7 +124,7 @@ def construct_format_regex(fmt_string: str) -> str: return pattern -def validate_string(value: str, fmt_string: str) -> str: +def validate_string(value: str, fmt_string: str) -> bool: """Validate that the provided string matches the specified format. Args: diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index ee6ffe642c1f..8271deb70de3 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -8,7 +8,7 @@ import os.path import re from decimal import Decimal, InvalidOperation -from typing import Optional, TypeVar +from typing import Optional, TypeVar, Union from wsgiref.util import FileWrapper from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -21,6 +21,8 @@ from django.utils.translation import gettext_lazy as _ import bleach +import bleach.css_sanitizer +import bleach.sanitizer import structlog from bleach import clean from djmoney.money import Money @@ -123,7 +125,7 @@ def do_clip(value: int, clip: int, allow_negative: bool) -> int: return ref_int -def generateTestKey(test_name: str) -> str: +def generateTestKey(test_name: Union[str, None]) -> str: """Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template. Tests must be named such that they will have unique keys. @@ -362,9 +364,7 @@ def increment(value): except ValueError: pass - number = number.zfill(width) - - return prefix + number + return prefix + str(number).zfill(width) def decimal2string(d): @@ -949,7 +949,7 @@ def current_time(local=True): """ if settings.USE_TZ: now = timezone.now() - now = to_local_time(now, target_tz=server_timezone() if local else 'UTC') + now = to_local_time(now, target_tz_str=server_timezone() if local else 'UTC') return now else: return datetime.datetime.now() @@ -968,12 +968,12 @@ def server_timezone() -> str: return settings.TIME_ZONE -def to_local_time(time, target_tz: Optional[str] = None): +def to_local_time(time, target_tz_str: Optional[str] = None): """Convert the provided time object to the local timezone. Arguments: time: The time / date to convert - target_tz: The desired timezone (string) - defaults to server time + target_tz_str: The desired timezone (string) - defaults to server time Returns: A timezone aware datetime object, with the desired timezone @@ -997,11 +997,11 @@ def to_local_time(time, target_tz: Optional[str] = None): # Default to UTC if not provided source_tz = ZoneInfo('UTC') - if not target_tz: - target_tz = server_timezone() + if not target_tz_str: + target_tz_str = server_timezone() try: - target_tz = ZoneInfo(str(target_tz)) + target_tz = ZoneInfo(str(target_tz_str)) except ZoneInfoNotFoundError: target_tz = ZoneInfo('UTC') diff --git a/src/backend/InvenTree/InvenTree/helpers_email.py b/src/backend/InvenTree/InvenTree/helpers_email.py index e8fb6b6476dc..9ec41ae7470a 100644 --- a/src/backend/InvenTree/InvenTree/helpers_email.py +++ b/src/backend/InvenTree/InvenTree/helpers_email.py @@ -1,5 +1,7 @@ """Code for managing email functionality in InvenTree.""" +from typing import Optional + from django.conf import settings from django.core import mail as django_mail @@ -91,7 +93,7 @@ def send_email(subject, body, recipients, from_email=None, html_message=None): ) -def get_email_for_user(user) -> str: +def get_email_for_user(user) -> Optional[str]: """Find an email address for the specified user.""" # First check if the user has an associated email address if user.email: diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index 23a7c71f1c12..62710407d8cb 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _ import requests +import requests.exceptions import structlog from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money @@ -328,8 +329,9 @@ def notify_users( 'template': {'subject': content.name.format(**content_context)}, } - if content.template: - context['template']['html'] = content.template.format(**content_context) + tmp = content.template + if tmp: + context['template']['html'] = tmp.format(**content_context) # Create notification trigger_notification( diff --git a/src/backend/InvenTree/InvenTree/management/commands/schema.py b/src/backend/InvenTree/InvenTree/management/commands/schema.py index ae07c8b41ed7..22368c74c728 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/schema.py +++ b/src/backend/InvenTree/InvenTree/management/commands/schema.py @@ -1,7 +1,7 @@ """Extended schema generator.""" from pathlib import Path -from typing import TypeVar +from typing import TypeVar, Union from django.conf import settings @@ -26,7 +26,7 @@ def prep_name(ref): return f'{dja_ref_prefix}.{ref}' -def sub_component_name(name: T) -> T: +def sub_component_name(name: T) -> Union[T, str]: """Clean up component references.""" if not isinstance(name, str): return name diff --git a/src/backend/InvenTree/InvenTree/management/commands/wait_for_db.py b/src/backend/InvenTree/InvenTree/management/commands/wait_for_db.py index 6bfdc98b5828..68b33adfff9f 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/wait_for_db.py +++ b/src/backend/InvenTree/InvenTree/management/commands/wait_for_db.py @@ -2,9 +2,10 @@ import time +from django.core.exceptions import ImproperlyConfigured from django.core.management.base import BaseCommand from django.db import connection -from django.db.utils import ImproperlyConfigured, OperationalError +from django.db.utils import OperationalError class Command(BaseCommand): diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 24d6ea649fd9..0abcbce0b1d0 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -994,7 +994,7 @@ def assign_barcode( raise ValueError("Provide either 'barcode_hash' or 'barcode_data'") # If barcode_hash is not provided, create from supplier barcode_data - if barcode_hash is None: + if barcode_hash is None and barcode_data is not None: barcode_hash = InvenTree.helpers.hash_barcode(barcode_data) # Check for existing item diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index c78a24f38443..059bbd9ccb9b 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -249,7 +249,9 @@ def has_permission(self, request, view): def get_required_alternate_scopes(self, request, view): """Return the required scopes for the current request.""" scopes = map_scope( - only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS + only_read=True, + read_name=DEFAULT_STAFF, + map_read=list(permissions.SAFE_METHODS), ) return scopes @@ -287,7 +289,7 @@ def get_required_alternate_scopes(self, request, view): return map_scope( only_read=True, read_name=DEFAULT_SUPERUSER, - map_read=permissions.SAFE_METHODS, + map_read=list(permissions.SAFE_METHODS), ) @@ -312,7 +314,9 @@ def has_permission(self, request, view): def get_required_alternate_scopes(self, request, view): """Return the required scopes for the current request.""" return map_scope( - only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS + only_read=True, + read_name=DEFAULT_STAFF, + map_read=list(permissions.SAFE_METHODS), ) @@ -342,7 +346,7 @@ def auth_exempt(view_func): def wrapped_view(*args, **kwargs): return view_func(*args, **kwargs) - wrapped_view.auth_exempt = True + wrapped_view.auth_exempt = True # type:ignore[unresolved-attribute] return wraps(view_func)(wrapped_view) @@ -382,5 +386,7 @@ def has_permission(self, request, view): def get_required_alternate_scopes(self, request, view): """Return the required scopes for the current request.""" return map_scope( - only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS + only_read=True, + read_name=DEFAULT_STAFF, + map_read=list(permissions.SAFE_METHODS), ) diff --git a/src/backend/InvenTree/InvenTree/sanitizer.py b/src/backend/InvenTree/InvenTree/sanitizer.py index 0272b50f943c..ce093b96fec5 100644 --- a/src/backend/InvenTree/InvenTree/sanitizer.py +++ b/src/backend/InvenTree/InvenTree/sanitizer.py @@ -188,8 +188,8 @@ def sanitize_svg( file_data, strip: bool = True, - elements: str = ALLOWED_ELEMENTS_SVG, - attributes: str = ALLOWED_ATTRIBUTES_SVG, + elements: list[str] = ALLOWED_ELEMENTS_SVG, + attributes: list[str] = ALLOWED_ATTRIBUTES_SVG, ) -> str: """Sanitize a SVG file. diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 9aa653c1af58..59cbb8153274 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -373,16 +373,15 @@ def run_validation(self, data=empty): instance.full_clean() except (ValidationError, DjangoValidationError) as exc: if hasattr(exc, 'message_dict'): - data = exc.message_dict + data = {**exc.message_dict} elif hasattr(exc, 'message'): data = {'non_field_errors': [str(exc.message)]} else: data = {'non_field_errors': [str(exc)]} # Change '__all__' key (django style) to 'non_field_errors' (DRF style) - if '__all__' in data: - data['non_field_errors'] = data['__all__'] - del data['__all__'] + if hasattr(data, '__all__'): + data['non_field_errors'] = data.pop('__all__') raise ValidationError(data) diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 95a338833002..a62c997e4428 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -38,6 +38,13 @@ from . import config, locales +try: + import django_stubs_ext + + django_stubs_ext.monkeypatch() # pragma: no cover +except ImportError: # pragma: no cover + pass + checkMinPythonVersion() INVENTREE_BASE_URL = 'https://inventree.org' @@ -310,31 +317,31 @@ 'django_ical', # For exporting calendars ] -MIDDLEWARE = CONFIG.get( - 'middleware', - [ - 'django.middleware.security.SecurityMiddleware', - 'x_forwarded_for.middleware.XForwardedForMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'allauth.usersessions.middleware.UserSessionsMiddleware', # DB user sessions - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth - 'allauth.account.middleware.AccountMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'InvenTree.middleware.AuthRequiredMiddleware', - 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA - 'oauth2_provider.middleware.OAuth2TokenMiddleware', # oauth2_provider - 'maintenance_mode.middleware.MaintenanceModeMiddleware', - 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting - 'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching - 'django_structlog.middlewares.RequestMiddleware', # Structured logging - ], +default_middleware = [ + 'django.middleware.security.SecurityMiddleware', + 'x_forwarded_for.middleware.XForwardedForMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'allauth.usersessions.middleware.UserSessionsMiddleware', # DB user sessions + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth + 'allauth.account.middleware.AccountMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'InvenTree.middleware.AuthRequiredMiddleware', + 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA + 'oauth2_provider.middleware.OAuth2TokenMiddleware', # oauth2_provider + 'maintenance_mode.middleware.MaintenanceModeMiddleware', + 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting + 'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching + 'django_structlog.middlewares.RequestMiddleware', # Structured logging +] +MIDDLEWARE = ( + CONFIG.get('middleware', default_middleware) if CONFIG else default_middleware ) # In DEBUG mode, add support for django-querycount @@ -359,22 +366,25 @@ } -AUTHENTICATION_BACKENDS = CONFIG.get( - 'authentication_backends', - [ - 'oauth2_provider.backends.OAuth2Backend', # OAuth2 provider - 'django.contrib.auth.backends.RemoteUserBackend', # proxy login - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers - 'sesame.backends.ModelBackend', # Magic link login django-sesame - ], +default_auth_backends = [ + 'oauth2_provider.backends.OAuth2Backend', # OAuth2 provider + 'django.contrib.auth.backends.RemoteUserBackend', # proxy login + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers + 'sesame.backends.ModelBackend', # Magic link login django-sesame +] + +AUTHENTICATION_BACKENDS = ( + CONFIG.get('authentication_backends', default_auth_backends) + if CONFIG + else default_auth_backends ) # LDAP support LDAP_AUTH = get_boolean_setting('INVENTREE_LDAP_ENABLED', 'ldap.enabled', False) if LDAP_AUTH: - import django_auth_ldap.config - import ldap + import django_auth_ldap.config # type: ignore[unresolved-import] + import ldap # type: ignore[unresolved-import] AUTHENTICATION_BACKENDS.append('django_auth_ldap.backend.LDAPBackend') @@ -427,7 +437,7 @@ ) AUTH_LDAP_USER_SEARCH = django_auth_ldap.config.LDAPSearch( get_setting('INVENTREE_LDAP_SEARCH_BASE_DN', 'ldap.search_base_dn'), - ldap.SCOPE_SUBTREE, + ldap.SCOPE_SUBTREE, # type: ignore[unresolved-attribute] str( get_setting( 'INVENTREE_LDAP_SEARCH_FILTER_STR', @@ -463,7 +473,7 @@ ) AUTH_LDAP_GROUP_SEARCH = django_auth_ldap.config.LDAPSearch( get_setting('INVENTREE_LDAP_GROUP_SEARCH', 'ldap.group_search'), - ldap.SCOPE_SUBTREE, + ldap.SCOPE_SUBTREE, # type: ignore[unresolved-attribute] f'(objectClass={AUTH_LDAP_GROUP_OBJECT_CLASS})', ) AUTH_LDAP_GROUP_TYPE_CLASS = get_setting( @@ -590,7 +600,7 @@ logger.debug('Configuring database backend:') # Extract database configuration from the config.yaml file -db_config = CONFIG.get('database', None) +db_config = CONFIG.get('database', None) if CONFIG else None if not db_config: db_config = {} @@ -676,7 +686,9 @@ # Specific options for postgres backend if 'postgres' in db_engine: # pragma: no cover - from django.db.backends.postgresql.psycopg_any import IsolationLevel + from django.db.backends.postgresql.psycopg_any import ( # type: ignore[unresolved-import] + IsolationLevel, + ) # Connection timeout if 'connect_timeout' not in db_options: diff --git a/src/backend/InvenTree/InvenTree/sso.py b/src/backend/InvenTree/InvenTree/sso.py index c1842d70de49..441a7a6fd912 100644 --- a/src/backend/InvenTree/InvenTree/sso.py +++ b/src/backend/InvenTree/InvenTree/sso.py @@ -50,7 +50,7 @@ def check_provider(provider): if not app: return False - if allauth.app_settings.SITES_ENABLED: + if allauth.app_settings.SITES_ENABLED: # type: ignore[unresolved-attribute] # At least one matching site must be specified if not app.sites.exists(): logger.error('SocialApp %s has no sites configured', app) diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index 5c39b8e08359..cb102c3ec561 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -288,7 +288,7 @@ class ScheduledTask: QUARTERLY: str = 'Q' YEARLY: str = 'Y' - TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY) + TYPE: tuple[str] = (MINUTES, HOURLY, DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY) # type: ignore[invalid-assignment] class TaskRegister: @@ -305,7 +305,9 @@ def register(self, task, schedule, minutes: Optional[int] = None): def scheduled_task( - interval: str, minutes: Optional[int] = None, tasklist: TaskRegister = None + interval: str, + minutes: Optional[int] = None, + tasklist: Optional[TaskRegister] = None, ): """Register the given task as a scheduled task. @@ -512,7 +514,7 @@ def check_for_updates(): match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', tag) - if len(match.groups()) != 3: # pragma: no cover + if not match or len(match.groups()) != 3: # pragma: no cover logger.warning("Version '%s' did not match expected pattern", tag) return diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 289a204a94fc..95aa69054ac9 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -567,7 +567,7 @@ def test_api_license(self): self.assertIn('License file not found at', str(log.output)) - with TemporaryDirectory() as tmp: + with TemporaryDirectory() as tmp: # type: ignore[no-matching-overload] sample_file = Path(tmp, 'temp.txt') sample_file.write_text('abc') diff --git a/src/backend/InvenTree/InvenTree/tracing.py b/src/backend/InvenTree/InvenTree/tracing.py index a04a71c9e2c0..44151d470c85 100644 --- a/src/backend/InvenTree/InvenTree/tracing.py +++ b/src/backend/InvenTree/InvenTree/tracing.py @@ -4,7 +4,7 @@ import logging from typing import Optional -from opentelemetry import metrics, trace +from opentelemetry import metrics, trace # type: ignore[import] from opentelemetry.instrumentation.django import DjangoInstrumentor from opentelemetry.instrumentation.redis import RedisInstrumentor from opentelemetry.instrumentation.requests import RequestsInstrumentor diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index 23600e81ca6b..6777ee1270ca 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -76,7 +76,7 @@ def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True): oldest_num = num oldest_file = f - if exclude_extension: + if exclude_extension and oldest_file: oldest_file = oldest_file.replace('.py', '') return oldest_file @@ -518,6 +518,10 @@ def download_file( result = re.search( r'(attachment|inline); filename=[\'"]([\w\d\-.]+)[\'"]', disposition ) + if not result: + raise ValueError( + 'No filename match found in disposition' + ) # pragma: no cover fn = result.groups()[1] diff --git a/src/backend/InvenTree/InvenTree/validators.py b/src/backend/InvenTree/InvenTree/validators.py index 6a0fe6cd9d4e..3c85483c4bfa 100644 --- a/src/backend/InvenTree/InvenTree/validators.py +++ b/src/backend/InvenTree/InvenTree/validators.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ import pint +import pint.errors from moneyed import CURRENCIES import InvenTree.conversion diff --git a/src/backend/InvenTree/InvenTree/version.py b/src/backend/InvenTree/InvenTree/version.py index 5ee6f1af662f..7c9cbb10b46e 100644 --- a/src/backend/InvenTree/InvenTree/version.py +++ b/src/backend/InvenTree/InvenTree/version.py @@ -107,7 +107,7 @@ def inventreeVersionTuple(version=None): match = re.match(r'^.*(\d+)\.(\d+)\.(\d+).*$', str(version)) - return [int(g) for g in match.groups()] + return [int(g) for g in match.groups()] if match else [] def isInvenTreeDevelopmentVersion(): diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 1b017d73dcc2..f4202f921f29 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -2,17 +2,20 @@ from __future__ import annotations +from typing import Optional + from django.contrib.auth.models import User from django.db.models import F, Q from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError -import build.admin +import build.models as build_models import build.serializers import common.models import part.models as part_models @@ -27,7 +30,7 @@ from users.models import Owner -class BuildFilter(rest_filters.FilterSet): +class BuildFilter(FilterSet): """Custom filterset for BuildList API endpoint.""" class Meta: @@ -439,7 +442,7 @@ def get_serializer_context(self): return ctx -class BuildLineFilter(rest_filters.FilterSet): +class BuildLineFilter(FilterSet): """Custom filterset for the BuildLine API endpoint.""" class Meta: @@ -600,7 +603,7 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): 'bom_item__reference', ] - def get_source_build(self) -> Build | None: + def get_source_build(self) -> Optional[Build]: """Return the target build for the BuildLine queryset.""" source_build = None @@ -617,7 +620,7 @@ def get_source_build(self) -> Build | None: class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a BuildLine object.""" - def get_source_build(self) -> Build | None: + def get_source_build(self) -> Optional[Build]: """Return the target source location for the BuildLine queryset.""" return None @@ -755,7 +758,7 @@ class BuildItemDetail(RetrieveUpdateDestroyAPI): serializer_class = build.serializers.BuildItemSerializer -class BuildItemFilter(rest_filters.FilterSet): +class BuildItemFilter(FilterSet): """Custom filterset for the BuildItemList API endpoint.""" class Meta: @@ -801,7 +804,7 @@ def filter_part(self, queryset, name, part): return queryset.filter(stock_item__part=part) build = rest_filters.ModelChoiceFilter( - queryset=build.models.Build.objects.all(), + queryset=build_models.Build.objects.all(), label=_('Build Order'), field_name='build_line__build', ) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 37e3e4faa7c4..91ddebb4bd70 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -1307,7 +1307,7 @@ def stock_sort(item, bom_item, variant_parts): except (ValidationError, serializers.ValidationError) as exc: # Catch model errors and re-throw as DRF errors raise ValidationError( - detail=serializers.as_serializer_error(exc) + exc.message, detail=serializers.as_serializer_error(exc) ) if unallocated_quantity <= 0: diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index aaef6bae8182..2f83f7167ae7 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -23,6 +23,7 @@ import build.tasks import common.models +import common.settings import company.serializers import InvenTree.helpers import InvenTree.tasks diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index a29d64143a22..1c118a775490 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1,6 +1,7 @@ """Unit tests for the BuildOrder API.""" from datetime import datetime, timedelta +from typing import Optional from django.urls import reverse @@ -664,6 +665,11 @@ def test_invalid_bom_item(self): wrong_line = line break + if not wrong_line: + raise self.fail( + 'No matching BuildLine found for the given stock item' + ) # pragma: no cover + data = self.post( self.url, { @@ -691,6 +697,11 @@ def test_valid_data(self): right_line = line break + if not right_line: + raise self.fail( + 'No matching BuildLine found for the given stock item' + ) # pragma: no cover + self.post( self.url, { @@ -718,11 +729,15 @@ def test_reallocate(self): # Find the correct BuildLine si = StockItem.objects.get(pk=2) - right_line = None + right_line: Optional[BuildLine] = None for line in self.build.build_lines.all(): if line.bom_item.sub_part.pk == si.part.pk: - right_line = line + right_line: BuildLine = line break + if not right_line: + raise self.fail( + 'No matching BuildLine found for the given stock item' + ) # pragma: no cover self.post( self.url, diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 6d5027864963..d49809bb7753 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -1,6 +1,7 @@ """Provides a JSON API for common components.""" import json +import json.decoder from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -13,8 +14,9 @@ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import csrf_exempt +import django_filters.rest_framework.filters as rest_filters import django_q.models -from django_filters import rest_framework as rest_filters +from django_filters.rest_framework.filterset import FilterSet from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate from drf_spectacular.utils import OpenApiResponse, extend_schema @@ -698,7 +700,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) -class AttachmentFilter(rest_filters.FilterSet): +class AttachmentFilter(FilterSet): """Filterset for the AttachmentList API endpoint.""" class Meta: diff --git a/src/backend/InvenTree/common/currency.py b/src/backend/InvenTree/common/currency.py index b7749db39c21..d22e0bba82af 100644 --- a/src/backend/InvenTree/common/currency.py +++ b/src/backend/InvenTree/common/currency.py @@ -158,7 +158,7 @@ def get_price( - If MOQ (minimum order quantity) is required, bump quantity - If order multiples are to be observed, then we need to calculate based on that, too """ - from common.currency import currency_code_default + # from common.currency import currency_code_default if hasattr(instance, break_name): price_breaks = getattr(instance, break_name).all() diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index d498a2114397..2d06e68c435d 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -14,7 +14,7 @@ from enum import Enum from io import BytesIO from secrets import compare_digest -from typing import Any, Union +from typing import Any, Optional, Union from django.apps import apps from django.conf import settings as django_settings @@ -27,6 +27,7 @@ from django.core.files.storage import default_storage from django.core.validators import MinValueValidator from django.db import models, transaction +from django.db.models import enums from django.db.models.signals import post_delete, post_save from django.db.utils import IntegrityError, OperationalError, ProgrammingError from django.dispatch.dispatcher import receiver @@ -55,7 +56,7 @@ logger = structlog.get_logger('inventree') -class RenderMeta(models.enums.ChoicesMeta): +class RenderMeta(enums.ChoicesMeta): """Metaclass for rendering choices.""" choice_fnc = None @@ -916,7 +917,7 @@ def model_name(self) -> str: return setting.get('model', None) - def model_filters(self) -> dict: + def model_filters(self) -> Optional[dict]: """Return the model filters associated with this setting.""" setting = self.get_setting_definition( self.key, **self.get_filters_for_instance() @@ -1415,8 +1416,8 @@ def save_data(self, payload=None, headers=None, request=None): request (optional): Original request object. Defaults to None. """ return WebhookMessage.objects.create( - host=request.get_host(), - header=json.dumps(dict(headers.items())), + host=request.get_host() if request else '', + header=json.dumps(dict(headers.items())) if headers else None, body=payload, endpoint=self, ) diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index 26f1935de8b5..e274269b3126 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -33,7 +33,9 @@ class NotificationMethod: GLOBAL_SETTING = None USER_SETTING = None - def __init__(self, obj: Model, category: str, targets: list, context) -> None: + def __init__( + self, obj: Model, category: str, targets: Optional[list], context + ) -> None: """Check that the method is read. This checks that: @@ -185,7 +187,7 @@ class MethodStorageClass: Is initialized on startup as one instance named `storage` in this file. """ - methods_list = None + methods_list: list = [] user_settings = {} @property @@ -359,9 +361,7 @@ class InvenTreeNotificationBodies: ) -def trigger_notification( - obj: Model, category: Optional[str] = None, obj_ref: str = 'pk', **kwargs -): +def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', **kwargs): """Send out a notification. Args: @@ -419,7 +419,7 @@ def trigger_notification( target_exclude = set() # Collect possible targets - if not targets: + if not targets and target_fnc: targets = target_fnc(*target_args, **target_kwargs) # Convert list of targets to a list of users diff --git a/src/backend/InvenTree/common/test_notifications.py b/src/backend/InvenTree/common/test_notifications.py index b1900342891d..b56b69cbd392 100644 --- a/src/backend/InvenTree/common/test_notifications.py +++ b/src/backend/InvenTree/common/test_notifications.py @@ -36,19 +36,19 @@ def send(self): # no send / send bulk with self.assertRaises(NotImplementedError): - FalseNotificationMethod('', '', '', '') + FalseNotificationMethod('', '', None, '') # no METHOD_NAME with self.assertRaises(NotImplementedError): - NoNameNotificationMethod('', '', '', '') + NoNameNotificationMethod('', '', None, '') # a not existent context check with self.assertRaises(NotImplementedError): - WrongContextNotificationMethod('', '', '', '') + WrongContextNotificationMethod('', '', None, '') # no get_targets with self.assertRaises(NotImplementedError): - AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2}) + AnotherFalseNotificationMethod('', '', None, {'name': 1, 'message': 2}) def test_failing_passing(self): """Ensure that an error in one deliverymethod is not blocking all mehthods.""" diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index dd2d89a0a9b3..b69d56b73a12 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -18,7 +18,7 @@ from django.test.utils import override_settings from django.urls import reverse -import PIL +from PIL import Image import common.validators from common.settings import get_global_setting, set_global_setting @@ -1298,7 +1298,7 @@ def test_valid_image(self): n = NotesImage.objects.count() # Construct a simple image file - image = PIL.Image.new('RGB', (100, 100), color='red') + image = Image.new('RGB', (100, 100), color='red') with io.BytesIO() as output: image.save(output, format='PNG') diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index d4ba2c09907b..e6a1275ce1af 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -4,7 +4,8 @@ from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters +from django_filters.rest_framework.filterset import FilterSet import part.models from data_exporter.mixins import DataExportViewMixin @@ -127,7 +128,7 @@ class AddressDetail(RetrieveUpdateDestroyAPI): serializer_class = AddressSerializer -class ManufacturerPartFilter(rest_filters.FilterSet): +class ManufacturerPartFilter(FilterSet): """Custom API filters for the ManufacturerPart list endpoint.""" class Meta: @@ -204,7 +205,7 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI): serializer_class = ManufacturerPartSerializer -class ManufacturerPartParameterFilter(rest_filters.FilterSet): +class ManufacturerPartParameterFilter(FilterSet): """Custom filterset for the ManufacturerPartParameterList API endpoint.""" class Meta: @@ -259,7 +260,7 @@ class ManufacturerPartParameterDetail(RetrieveUpdateDestroyAPI): serializer_class = ManufacturerPartParameterSerializer -class SupplierPartFilter(rest_filters.FilterSet): +class SupplierPartFilter(FilterSet): """API filters for the SupplierPartList endpoint.""" class Meta: @@ -416,7 +417,7 @@ class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI): """ -class SupplierPriceBreakFilter(rest_filters.FilterSet): +class SupplierPriceBreakFilter(FilterSet): """Custom API filters for the SupplierPriceBreak list endpoint.""" class Meta: diff --git a/src/backend/InvenTree/company/migrations/0019_auto_20200413_0642.py b/src/backend/InvenTree/company/migrations/0019_auto_20200413_0642.py index 3b7c928489e3..0fc852354bcf 100644 --- a/src/backend/InvenTree/company/migrations/0019_auto_20200413_0642.py +++ b/src/backend/InvenTree/company/migrations/0019_auto_20200413_0642.py @@ -51,7 +51,7 @@ def reverse_association(apps, schema_editor): # pragma: no cover row = cursor.fetchone() - if len(row) > 0: + if row and len(row) > 0: try: manufacturer_id = int(row[0]) except (TypeError, ValueError): @@ -67,12 +67,12 @@ def reverse_association(apps, schema_editor): # pragma: no cover response = cursor.execute(f"SELECT name from company_company where id={manufacturer_id};") row = cursor.fetchone() + if row: + name = row[0] - name = row[0] + print(" - Manufacturer name: '{name}'".format(name=name)) - print(" - Manufacturer name: '{name}'".format(name=name)) - - response = cursor.execute("UPDATE part_supplierpart SET manufacturer_name='{name}' WHERE id={ID};".format(name=name, ID=supplier_part_id)) + response = cursor.execute("UPDATE part_supplierpart SET manufacturer_name='{name}' WHERE id={ID};".format(name=name, ID=supplier_part_id)) def associate_manufacturers(apps, schema_editor): """ @@ -106,7 +106,7 @@ def get_manufacturer_name(part_id): response = cursor.execute(query) row = cursor.fetchone() - if len(row) > 0: + if row and len(row) > 0: return row[0] return '' # pragma: no cover @@ -296,11 +296,11 @@ def map_part_to_manufacturer(part_id, idx, total): # Double-check if the typed name corresponds to an existing item elif response in companies.keys(): - link_part(part, companies[response]) + link_part(part_id, companies[response]) return elif response in links.keys(): - link_part(part, links[response]) + link_part(part_id, links[response]) return # No match, create a new manufacturer diff --git a/src/backend/InvenTree/data_exporter/apps.py b/src/backend/InvenTree/data_exporter/apps.py index a9eb5d8bbd9d..9cef065b15ae 100644 --- a/src/backend/InvenTree/data_exporter/apps.py +++ b/src/backend/InvenTree/data_exporter/apps.py @@ -18,7 +18,7 @@ def ready(self): def cleanup(self): """Cleanup any old export files.""" try: - from data_exporter.tasks import cleanup_old_export_outputs + from data_exporter.tasks import cleanup_old_export_outputs # type: ignore cleanup_old_export_outputs() except Exception: diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index 8274e68ee0e3..374599739b53 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -1,6 +1,7 @@ """Mixin classes for the exporter app.""" from collections import OrderedDict +from typing import Any from django.core.exceptions import ValidationError from django.core.files.base import ContentFile @@ -127,7 +128,7 @@ def arrange_export_headers(cls, headers: list) -> list: """ return headers - def get_nested_value(self, row: dict, key: str) -> any: + def get_nested_value(self, row: dict, key: str) -> Any: """Get a nested value from a dictionary. This method allows for dot notation to access nested fields. diff --git a/src/backend/InvenTree/data_exporter/serializers.py b/src/backend/InvenTree/data_exporter/serializers.py index d5f90fe48f7a..815f8fe683ec 100644 --- a/src/backend/InvenTree/data_exporter/serializers.py +++ b/src/backend/InvenTree/data_exporter/serializers.py @@ -6,7 +6,6 @@ import InvenTree.exceptions import InvenTree.helpers -import InvenTree.serializers from plugin import PluginMixinEnum, registry @@ -53,7 +52,7 @@ def __init__(self, *args, **kwargs): try: supports_export = plugin.supports_export( model_class, - user=request.user, + user=request.user if request else None, serializer_class=serializer_class, view_class=view_class, ) diff --git a/src/backend/InvenTree/generic/states/__init__.py b/src/backend/InvenTree/generic/states/__init__.py index 90922e9695ba..189150f56113 100644 --- a/src/backend/InvenTree/generic/states/__init__.py +++ b/src/backend/InvenTree/generic/states/__init__.py @@ -6,6 +6,7 @@ States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values. """ +from . import fields from .states import ColorEnum, StatusCode, StatusCodeMixin from .transition import StateTransitionMixin, TransitionMethod, storage @@ -15,5 +16,6 @@ 'StatusCode', 'StatusCodeMixin', 'TransitionMethod', + 'fields', 'storage', ] diff --git a/src/backend/InvenTree/generic/states/states.py b/src/backend/InvenTree/generic/states/states.py index 3304cc3e5c81..a252e9053df9 100644 --- a/src/backend/InvenTree/generic/states/states.py +++ b/src/backend/InvenTree/generic/states/states.py @@ -4,6 +4,7 @@ import logging import re from enum import Enum +from typing import Optional logger = logging.getLogger('inventree') @@ -297,7 +298,7 @@ def get_status(self) -> int: """Return the status code for this object.""" return getattr(self, self.STATUS_FIELD) - def get_custom_status(self) -> int: + def get_custom_status(self) -> Optional[int]: """Return the custom status code for this object.""" return getattr(self, f'{self.STATUS_FIELD}_custom_key', None) diff --git a/src/backend/InvenTree/generic/states/test_transition.py b/src/backend/InvenTree/generic/states/test_transition.py index 38f4946e32e6..050fa40b9a96 100644 --- a/src/backend/InvenTree/generic/states/test_transition.py +++ b/src/backend/InvenTree/generic/states/test_transition.py @@ -58,7 +58,7 @@ def transition(self, *args, **kwargs): storage.collect() # Ensure the class is registered - self.assertIn(RaisingImplementation, storage.list) + self.assertIn(RaisingImplementation, storage.method_list) # Ensure stuff is passed to the class with self.assertRaises(MyPrivateError) as exp: @@ -90,8 +90,8 @@ def transition(self, *args, **kwargs): return False # pragma: no cover # Return false to keep other transitions working storage.collect() - self.assertIn(ValidImplementationNoEffect, storage.list) - self.assertIn(ValidImplementation, storage.list) + self.assertIn(ValidImplementationNoEffect, storage.method_list) + self.assertIn(ValidImplementation, storage.method_list) # Ensure that the function is called self.assertEqual( diff --git a/src/backend/InvenTree/generic/states/transition.py b/src/backend/InvenTree/generic/states/transition.py index 6abc7f101025..72b7d68cc192 100644 --- a/src/backend/InvenTree/generic/states/transition.py +++ b/src/backend/InvenTree/generic/states/transition.py @@ -1,5 +1,7 @@ """Classes and functions for plugin controlled object state transitions.""" +from typing import Any, Optional + import InvenTree.helpers @@ -28,11 +30,11 @@ class TransitionMethodStorageClass: Is initialized on startup as one instance named `storage` in this file. """ - list = None + method_list: Optional[list] = None def collect(self): """Collect all classes in the environment that are transition methods.""" - filtered_list = {} + filtered_list: dict[str, Any] = {} for item in InvenTree.helpers.inheritors(TransitionMethod): # Try if valid try: @@ -41,11 +43,11 @@ def collect(self): continue filtered_list[f'{item.__module__}.{item.__qualname__}'] = item - self.list = list(filtered_list.values()) + self.method_list: list = list(filtered_list.values()) # Ensure the list has items - if not self.list: - self.list = [] + if not self.method_list: + self.method_list = [] storage = TransitionMethodStorageClass() @@ -76,13 +78,15 @@ def handle_transition( instance: Object instance default_action: Default action to be taken if none of the transitions returns a boolean true value """ - # Check if there is a custom override function for this transition - for override in storage.list: - rslt = override.transition( - current_state, target_state, instance, default_action, **kwargs - ) - if rslt: - return rslt + list_vals = storage.method_list + if list_vals: + # Check if there is a custom override function for this transition + for override in list_vals: + rslt = override.transition( + current_state, target_state, instance, default_action, **kwargs + ) + if rslt: + return rslt # Default action return default_action(current_state, target_state, instance, **kwargs) diff --git a/src/backend/InvenTree/importer/models.py b/src/backend/InvenTree/importer/models.py index c634f741cdd9..b7bdcd81bfdc 100644 --- a/src/backend/InvenTree/importer/models.py +++ b/src/backend/InvenTree/importer/models.py @@ -294,9 +294,7 @@ def import_data(self) -> None: if not any(row_data.values()): continue - row = importer.models.DataImportRow( - session=self, row_data=row_data, row_index=idx - ) + row = DataImportRow(session=self, row_data=row_data, row_index=idx) row.extract_data( field_mapping=field_mapping, @@ -308,7 +306,7 @@ def import_data(self) -> None: imported_rows.append(row) # Perform database writes as a single operation - importer.models.DataImportRow.objects.bulk_create(imported_rows) + DataImportRow.objects.bulk_create(imported_rows) # Mark the import task as "PROCESSING" self.status = DataImportStatusCode.PROCESSING.value diff --git a/src/backend/InvenTree/importer/operations.py b/src/backend/InvenTree/importer/operations.py index dcb79e8baad7..0efca9017c59 100644 --- a/src/backend/InvenTree/importer/operations.py +++ b/src/backend/InvenTree/importer/operations.py @@ -1,9 +1,12 @@ """Data import operational functions.""" +from typing import Optional + from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ import tablib +import tablib.core import InvenTree.helpers @@ -82,7 +85,7 @@ def extract_column_names(data_file) -> list: return headers -def get_field_label(field) -> str: +def get_field_label(field) -> Optional[str]: """Return the label for a field in a serializer class. Check for labels in the following order of descending priority: diff --git a/src/backend/InvenTree/machine/models.py b/src/backend/InvenTree/machine/models.py index 0e4852c64e47..e08f90550a2a 100755 --- a/src/backend/InvenTree/machine/models.py +++ b/src/backend/InvenTree/machine/models.py @@ -1,7 +1,7 @@ """Models for the machine app.""" import uuid -from typing import Literal +from typing import Literal, Optional from django.contrib import admin from django.db import models @@ -186,7 +186,7 @@ def get_setting_definition(cls, key, **kwargs): If not provided, we'll look at the machine registry to see what settings this machine driver requires """ if 'settings' not in kwargs: - machine_config: MachineConfig = kwargs.pop('machine_config', None) + machine_config: Optional[MachineConfig] = kwargs.pop('machine_config', None) if machine_config and machine_config.machine: config_type = kwargs.get('config_type') if config_type == cls.ConfigType.DRIVER: diff --git a/src/backend/InvenTree/machine/registry.py b/src/backend/InvenTree/machine/registry.py index af4a61b9928e..aca853039a96 100644 --- a/src/backend/InvenTree/machine/registry.py +++ b/src/backend/InvenTree/machine/registry.py @@ -276,7 +276,9 @@ def _calculate_registry_hash(self): # If the plugin registry has changed, the machine registry hash will change plugin_registry.update_plugin_hash() - data.update(plugin_registry.registry_hash.encode()) + current_hash = plugin_registry.registry_hash + if current_hash: + data.update(current_hash.encode()) for pk, machine in self.machines.items(): data.update(str(pk).encode()) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 283e616cd9ad..87248ac2c05f 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -11,8 +11,9 @@ from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ +import django_filters.rest_framework.filters as rest_filters import rest_framework.serializers -from django_filters import rest_framework as rest_filters +from django_filters.rest_framework.filterset import FilterSet from django_ical.views import ICalFeed from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field @@ -97,7 +98,7 @@ def create(self, request, *args, **kwargs): ) -class OrderFilter(rest_filters.FilterSet): +class OrderFilter(FilterSet): """Base class for custom API filters for the OrderList endpoint.""" # Filter against order status @@ -255,7 +256,7 @@ def filter_max_date(self, queryset, name, value): return queryset.filter(q1 | q2 | q3 | q4).distinct() -class LineItemFilter(rest_filters.FilterSet): +class LineItemFilter(FilterSet): """Base class for custom API filters for order line item list(s).""" # Filter by order status @@ -1087,7 +1088,7 @@ class SalesOrderAllocate(SalesOrderContextMixin, CreateAPI): serializer_class = serializers.SalesOrderShipmentAllocationSerializer -class SalesOrderAllocationFilter(rest_filters.FilterSet): +class SalesOrderAllocationFilter(FilterSet): """Custom filterset for the SalesOrderAllocationList endpoint.""" class Meta: @@ -1240,7 +1241,7 @@ class SalesOrderAllocationDetail(SalesOrderAllocationMixin, RetrieveUpdateDestro """API endpoint for detali view of a SalesOrderAllocation object.""" -class SalesOrderShipmentFilter(rest_filters.FilterSet): +class SalesOrderShipmentFilter(FilterSet): """Custom filterset for the SalesOrderShipmentList endpoint.""" class Meta: diff --git a/src/backend/InvenTree/order/tasks.py b/src/backend/InvenTree/order/tasks.py index 21babfe2c166..7d315f2b00b4 100644 --- a/src/backend/InvenTree/order/tasks.py +++ b/src/backend/InvenTree/order/tasks.py @@ -1,6 +1,7 @@ """Background tasks for the 'order' app.""" from datetime import datetime, timedelta +from typing import Union from django.contrib.auth.models import Group, User from django.db import transaction @@ -99,7 +100,7 @@ def check_overdue_purchase_orders(): def notify_overdue_sales_order(so: order.models.SalesOrder) -> None: """Notify appropriate users that a SalesOrder has just become 'overdue'.""" - targets: list[User, Group, Owner] = [] + targets: list[Union[User, Group, Owner]] = [] if so.created_by: targets.append(so.created_by) @@ -164,7 +165,7 @@ def check_overdue_sales_orders(): def notify_overdue_return_order(ro: order.models.ReturnOrder) -> None: """Notify appropriate users that a ReturnOrder has just become 'overdue'.""" - targets: list[User, Group, Owner] = [] + targets: list[Union[User, Group, Owner]] = [] if ro.created_by: targets.append(ro.created_by) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index e82a1ac76792..d3ba68ce1358 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -4,6 +4,7 @@ import io import json from datetime import date, datetime, timedelta +from typing import Optional from django.core.exceptions import ValidationError from django.db import connection @@ -2036,14 +2037,19 @@ def check_template(line_item): return line_item.part.is_template for line in filter(check_template, self.order.lines.all()): - stock_item = None + stock_item: Optional[StockItem] = None # Allocate a matching variant - parts = Part.objects.filter(salable=True).filter(variant_of=line.part.pk) + parts: list[Part] = Part.objects.filter(salable=True).filter( + variant_of=line.part.pk + ) for part in parts: - stock_item = part.stock_items.last() + stock_item: StockItem = part.stock_items.last() break + if stock_item is None: + raise self.fail('No stock item found for part') # pragma: no cover + # Fully-allocate each line data['items'].append({ 'line_item': line.pk, diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index f61f9da261bf..e23fd32f484a 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -8,8 +8,9 @@ from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -102,7 +103,7 @@ def get_serializer_context(self): return ctx -class CategoryFilter(rest_filters.FilterSet): +class CategoryFilter(FilterSet): """Custom filterset class for the PartCategoryList endpoint.""" class Meta: @@ -286,11 +287,11 @@ def destroy(self, request, *args, **kwargs): return super().destroy( request, *args, - **dict( - kwargs, - delete_parts=delete_parts, - delete_child_categories=delete_child_categories, - ), + **{ + **kwargs, + 'delete_parts': delete_parts, + 'delete_child_categories': delete_child_categories, + }, ) @@ -403,7 +404,7 @@ class PartInternalPriceList(DataExportViewMixin, ListCreateAPI): ordering = 'quantity' -class PartTestTemplateFilter(rest_filters.FilterSet): +class PartTestTemplateFilter(FilterSet): """Custom filterset class for the PartTestTemplateList endpoint.""" class Meta: @@ -891,7 +892,7 @@ def update(self, request, *args, **kwargs): return Response({'checksum': part.bom_checksum}) -class PartFilter(rest_filters.FilterSet): +class PartFilter(FilterSet): """Custom filters for the PartList endpoint. Uses the django_filters extension framework @@ -1435,7 +1436,7 @@ def update(self, request, *args, **kwargs): return response -class PartRelatedFilter(rest_filters.FilterSet): +class PartRelatedFilter(FilterSet): """FilterSet for PartRelated objects.""" class Meta: @@ -1482,7 +1483,7 @@ class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI): """API endpoint for accessing detail view of a PartRelated object.""" -class PartParameterTemplateFilter(rest_filters.FilterSet): +class PartParameterTemplateFilter(FilterSet): """FilterSet for PartParameterTemplate objects.""" class Meta: @@ -1609,7 +1610,7 @@ def get_serializer(self, *args, **kwargs): return super().get_serializer(*args, **kwargs) -class PartParameterFilter(rest_filters.FilterSet): +class PartParameterFilter(FilterSet): """Custom filters for the PartParameterList API endpoint.""" class Meta: @@ -1670,7 +1671,7 @@ class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a single PartParameter object.""" -class PartStocktakeFilter(rest_filters.FilterSet): +class PartStocktakeFilter(FilterSet): """Custom filter for the PartStocktakeList endpoint.""" class Meta: @@ -1753,7 +1754,7 @@ def get_serializer_context(self): return context -class BomFilter(rest_filters.FilterSet): +class BomFilter(FilterSet): """Custom filters for the BOM list.""" class Meta: diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 81f673daca28..cb0b738e5569 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -807,7 +807,7 @@ def validate_serial_number( if not check_duplicates: return - from part.models import Part + # from part.models import Part from stock.models import StockItem if get_global_setting('SERIAL_NUMBER_GLOBALLY_UNIQUE', False): @@ -838,7 +838,7 @@ def validate_serial_number( def find_conflicting_serial_numbers(self, serials: list) -> list: """For a provided list of serials, return a list of those which are conflicting.""" - from part.models import Part + # from part.models import Part from stock.models import StockItem conflicts = [] diff --git a/src/backend/InvenTree/part/stocktake.py b/src/backend/InvenTree/part/stocktake.py index 4878265f8fe9..97f307cdc585 100644 --- a/src/backend/InvenTree/part/stocktake.py +++ b/src/backend/InvenTree/part/stocktake.py @@ -14,6 +14,7 @@ import common.currency import common.models +import common.notifications import InvenTree.helpers import part.models import stock.models diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 4d8af0584645..d4b22940c12e 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -11,7 +11,7 @@ from django.test.utils import CaptureQueriesContext from django.urls import reverse -import PIL +from PIL import Image from rest_framework.test import APIClient import build.models @@ -66,7 +66,7 @@ def create_test_image(self): fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png' - img = PIL.Image.new('RGB', (128, 128), color='blue') + img = Image.new('RGB', (128, 128), color='blue') img.save(fn) with open(fn, 'rb') as img_file: @@ -1694,7 +1694,7 @@ def test_image_upload(self): for fmt in ['jpg', 'j2k', 'png', 'bmp', 'webp']: fn = f'{test_path}.{fmt}' - img = PIL.Image.new('RGB', (128, 128), color='red') + img = Image.new('RGB', (128, 128), color='red') img.save(fn) with open(fn, 'rb') as dummy_image: @@ -1744,7 +1744,7 @@ def test_update_existing_image(self): fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png' - img = PIL.Image.new('RGB', (128, 128), color='blue') + img = Image.new('RGB', (128, 128), color='blue') img.save(fn) # Upload the image to a part diff --git a/src/backend/InvenTree/plugin/admin.py b/src/backend/InvenTree/plugin/admin.py index 49afa1aed611..5584e11ac0dd 100644 --- a/src/backend/InvenTree/plugin/admin.py +++ b/src/backend/InvenTree/plugin/admin.py @@ -2,8 +2,8 @@ from django.contrib import admin -import plugin.registry as pl_registry from plugin import models +from plugin.registry import registry as pl_registry def plugin_update(queryset, new_status: bool): diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index dacd6e2ac8c9..b7fbcbe34853 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -6,8 +6,9 @@ from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.exceptions import NotFound @@ -36,7 +37,7 @@ from plugin.registry import registry -class PluginFilter(rest_filters.FilterSet): +class PluginFilter(FilterSet): """Filter for the PluginConfig model. Provides custom filtering options for the FilterList API endpoint. diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index 301667c50904..579b9abddf51 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ import structlog -from django_filters import rest_framework as rest_filters +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.exceptions import PermissionDenied, ValidationError @@ -770,7 +770,7 @@ def get_queryset(self): return queryset -class BarcodeScanResultFilter(rest_filters.FilterSet): +class BarcodeScanResultFilter(FilterSet): """Custom filterset for the BarcodeScanResult API.""" class Meta: diff --git a/src/backend/InvenTree/plugin/base/barcodes/mixins.py b/src/backend/InvenTree/plugin/base/barcodes/mixins.py index 848f55258544..f60b735c67bc 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/mixins.py +++ b/src/backend/InvenTree/plugin/base/barcodes/mixins.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Optional + from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -113,7 +115,7 @@ def get_field_value(self, key, backup_value=None): return fields.get(key, backup_value) - def get_part(self) -> Part | None: + def get_part(self) -> Optional[Part]: """Extract the Part object from the barcode fields.""" # TODO: Implement this return None @@ -128,7 +130,7 @@ def supplier_part_number(self): """Return the supplier part number from the barcode fields.""" return self.get_field_value(self.SUPPLIER_PART_NUMBER) - def get_supplier_part(self) -> SupplierPart | None: + def get_supplier_part(self) -> Optional[SupplierPart]: """Return the SupplierPart object for the scanned barcode. Returns: @@ -172,7 +174,7 @@ def manufacturer_part_number(self): """Return the manufacturer part number from the barcode fields.""" return self.get_field_value(self.MANUFACTURER_PART_NUMBER) - def get_manufacturer_part(self) -> ManufacturerPart | None: + def get_manufacturer_part(self) -> Optional[ManufacturerPart]: """Return the ManufacturerPart object for the scanned barcode. Returns: @@ -213,7 +215,7 @@ def supplier_order_number(self): """Return the supplier order number from the barcode fields.""" return self.get_field_value(self.SUPPLIER_ORDER_NUMBER) - def get_purchase_order(self) -> PurchaseOrder | None: + def get_purchase_order(self) -> Optional[PurchaseOrder]: """Extract the PurchaseOrder object from the barcode fields. Inspect the customer_order_number and supplier_order_number fields, @@ -260,7 +262,7 @@ def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]: 'extract_barcode_fields must be implemented by each plugin' ) - def scan(self, barcode_data: str) -> dict: + def scan(self, barcode_data: str) -> Optional[dict]: """Perform a generic 'scan' operation on a supplier barcode. The supplier barcode may provide sufficient information to match against @@ -319,7 +321,7 @@ def scan_receive_item( location=None, auto_allocate: bool = True, **kwargs, - ) -> dict | None: + ) -> Optional[dict]: """Attempt to receive an item against a PurchaseOrder via barcode scanning. Arguments: @@ -430,7 +432,7 @@ def scan_receive_item( return response - def get_supplier(self, cache: bool = False) -> Company | None: + def get_supplier(self, cache: bool = False) -> Optional[Company]: """Get the supplier for the SUPPLIER_ID set in the plugin settings. If it's not defined, try to guess it and set it if possible. diff --git a/src/backend/InvenTree/plugin/base/integration/APICallMixin.py b/src/backend/InvenTree/plugin/base/integration/APICallMixin.py index d67bc64857b2..85cd21825f83 100644 --- a/src/backend/InvenTree/plugin/base/integration/APICallMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/APICallMixin.py @@ -163,7 +163,8 @@ def api_call( url = f'{self.api_url}/{endpoint}' # build kwargs for call - kwargs.update({'url': url, 'headers': headers}) + kwargs.update({'headers': headers}) + kwargs.pop('url', None) if data and json: raise ValueError('You can either pass `data` or `json` to this function.') @@ -175,7 +176,7 @@ def api_call( kwargs['data'] = data # run command - response = requests.request(method, **kwargs) + response = requests.request(method, url=url, **kwargs) # return if simple_response: diff --git a/src/backend/InvenTree/plugin/base/integration/DataExport.py b/src/backend/InvenTree/plugin/base/integration/DataExport.py index 6d1eadb6e185..603f5d234d75 100644 --- a/src/backend/InvenTree/plugin/base/integration/DataExport.py +++ b/src/backend/InvenTree/plugin/base/integration/DataExport.py @@ -1,7 +1,7 @@ """Plugin class for custom data exporting.""" from collections import OrderedDict -from typing import Union +from typing import Optional, Union from django.contrib.auth.models import User from django.db.models import QuerySet @@ -36,8 +36,8 @@ def supports_export( self, model_class: type, user: User, - serializer_class: serializers.Serializer = None, - view_class: views.APIView = None, + serializer_class: Optional[serializers.Serializer] = None, + view_class: Optional[views.APIView] = None, *args, **kwargs, ) -> bool: diff --git a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py index 341696bf6258..0678bb3260b8 100644 --- a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py @@ -72,7 +72,7 @@ def validate_model_deletion(self, instance: Model) -> None: def validate_model_instance( self, instance: Model, deltas: Optional[dict] = None - ) -> None: + ) -> Optional[bool]: """Run custom validation on a database model instance. This method is called when a model instance is being validated. @@ -90,7 +90,7 @@ def validate_model_instance( """ return None - def validate_part_name(self, name: str, part: part.models.Part) -> None: + def validate_part_name(self, name: str, part: part.models.Part) -> Optional[bool]: """Perform validation on a proposed Part name. Arguments: @@ -105,7 +105,7 @@ def validate_part_name(self, name: str, part: part.models.Part) -> None: """ return None - def validate_part_ipn(self, ipn: str, part: part.models.Part) -> None: + def validate_part_ipn(self, ipn: str, part: part.models.Part) -> Optional[bool]: """Perform validation on a proposed Part IPN (internal part number). Arguments: @@ -122,7 +122,7 @@ def validate_part_ipn(self, ipn: str, part: part.models.Part) -> None: def validate_batch_code( self, batch_code: str, item: stock.models.StockItem - ) -> None: + ) -> Optional[bool]: """Validate the supplied batch code. Arguments: @@ -137,7 +137,7 @@ def validate_batch_code( """ return None - def generate_batch_code(self, **kwargs) -> str: + def generate_batch_code(self, **kwargs) -> Optional[str]: """Generate a new batch code. This method is called when a new batch code is required. @@ -154,8 +154,8 @@ def validate_serial_number( self, serial: str, part: part.models.Part, - stock_item: stock.models.StockItem = None, - ) -> None: + stock_item: Optional[stock.models.StockItem] = None, + ) -> Optional[bool]: """Validate the supplied serial number. Arguments: @@ -171,7 +171,7 @@ def validate_serial_number( """ return None - def convert_serial_to_int(self, serial: str) -> int: + def convert_serial_to_int(self, serial: str) -> Optional[int]: """Convert a serial number (string) into an integer representation. This integer value is used for efficient sorting based on serial numbers. @@ -192,7 +192,7 @@ def convert_serial_to_int(self, serial: str) -> int: """ return None - def get_latest_serial_number(self, part, **kwargs): + def get_latest_serial_number(self, part, **kwargs) -> Optional[str]: """Return the 'latest' serial number for a given Part instance. A plugin which implements this method can either return: @@ -209,8 +209,8 @@ def get_latest_serial_number(self, part, **kwargs): return None def increment_serial_number( - self, serial: str, part: part.models.Part = None, **kwargs - ) -> str: + self, serial: str, part: Optional[part.models.Part] = None, **kwargs + ) -> Optional[str]: """Return the next sequential serial based on the provided value. A plugin which implements this method can either return: @@ -229,7 +229,7 @@ def increment_serial_number( def validate_part_parameter( self, parameter: part.models.PartParameter, data: str - ) -> None: + ) -> Optional[bool]: """Validate a parameter value. Arguments: diff --git a/src/backend/InvenTree/plugin/broken/broken_file.py b/src/backend/InvenTree/plugin/broken/broken_file.py index f56932e876c4..83437025cf47 100644 --- a/src/backend/InvenTree/plugin/broken/broken_file.py +++ b/src/backend/InvenTree/plugin/broken/broken_file.py @@ -7,4 +7,4 @@ class BrokenFileIntegrationPlugin(InvenTreePlugin): """An very broken plugin.""" -aaa = bb # noqa: F821 +aaa = bb # noqa: F821 # type: ignore[unresolved-reference] diff --git a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py index d5fbc385bdc4..5a4f906c57b1 100644 --- a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py +++ b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py @@ -1,5 +1,7 @@ """Multi-level BOM exporter plugin.""" +from typing import Optional + from django.utils.translation import gettext_lazy as _ import rest_framework.serializers as serializers @@ -179,7 +181,7 @@ def export_data( return self.bom_data - def process_bom_row(self, bom_item, level, **kwargs) -> list: + def process_bom_row(self, bom_item, level, **kwargs) -> Optional[list]: """Process a single BOM row. Arguments: diff --git a/src/backend/InvenTree/plugin/helpers.py b/src/backend/InvenTree/plugin/helpers.py index d3f45c4a1921..ddb0bf722f17 100644 --- a/src/backend/InvenTree/plugin/helpers.py +++ b/src/backend/InvenTree/plugin/helpers.py @@ -51,7 +51,7 @@ class MixinNotImplementedError(NotImplementedError): def log_error(error, reference: str = 'general'): """Log an plugin error.""" - from plugin import registry + from plugin.registry import registry # make sure the registry is set up if reference not in registry.errors: diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index c5947c24f519..4e40213b9a7e 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -2,6 +2,7 @@ import inspect import warnings +from typing import Optional from django.conf import settings from django.contrib import admin @@ -195,7 +196,7 @@ def is_package(self) -> bool: return getattr(self.plugin, 'is_package', False) @property - def admin_source(self) -> str: + def admin_source(self) -> Optional[str]: """Return the path to the javascript file which renders custom admin content for this plugin. - It is required that the file provides a 'renderPluginSettings' function! @@ -215,7 +216,7 @@ def admin_source(self) -> str: return None @property - def admin_context(self) -> dict: + def admin_context(self) -> Optional[dict]: """Return the context data for the admin integration.""" if not self.plugin: return None diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 2ba7de00b962..6aeb43c2c4a5 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -4,7 +4,7 @@ import inspect import warnings from datetime import datetime -from distutils.sysconfig import get_python_lib +from distutils.sysconfig import get_python_lib # type: ignore[import] from importlib.metadata import PackageNotFoundError, metadata from pathlib import Path from typing import Optional, Union @@ -468,8 +468,9 @@ def define_package(self): package = {} # process date - if package.get('date'): - package['date'] = datetime.fromisoformat(package.get('date')) + date = package.get('date') + if date: + package['date'] = datetime.fromisoformat(date) # set variables self.package = package @@ -489,7 +490,7 @@ def plugin_static_file(self, *args): return url - def get_admin_source(self) -> str: + def get_admin_source(self) -> Union[str, None]: """Return a path to a JavaScript file which contains custom UI settings. The frontend code expects that this file provides a function named 'renderPluginSettings'. @@ -499,7 +500,7 @@ def get_admin_source(self) -> str: return self.plugin_static_file(self.ADMIN_SOURCE) - def get_admin_context(self) -> dict: + def get_admin_context(self) -> Union[dict, None]: """Return a context dictionary for the admin panel settings. This is an optional method which can be overridden by the plugin. diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 63ba3f4bdc47..92bca831c807 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -84,12 +84,12 @@ def __init__(self) -> None: ] = {} # List of all plugin instances # Keep an internal hash of the plugin registry state - self.registry_hash = None + self.registry_hash: Optional[str] = None self.plugin_modules: list[InvenTreePlugin] = [] # Holds all discovered plugins self.mixin_modules: dict[str, Any] = {} # Holds all discovered mixins - self.errors = {} # Holds discovering errors + self.errors: dict[str, list[Any]] = {} # Holds discovering errors self.loading_lock = Lock() # Lock to prevent multiple loading at the same time @@ -220,7 +220,7 @@ def call_plugin_function(self, slug: str, func: str, *args, **kwargs): # region registry functions def with_mixin( - self, mixin: str, active: bool = True, builtin: Optional[bool] = None + self, mixin: str, active: Optional[bool] = True, builtin: Optional[bool] = None ) -> list: """Returns reference to all plugins that have a specified mixin enabled. @@ -538,10 +538,9 @@ def safe_reference(plugin, key: str, active: bool = True): package_name = getattr(plugin, 'package_name', None) # Auto-enable default builtin plugins - if builtin and plg_db and plg_db.is_mandatory(): - if not plg_db.active: - plg_db.active = True - plg_db.save() + if builtin and plg_db and plg_db.is_mandatory() and not plg_db.active: + plg_db.active = True + plg_db.save() # Save the package_name attribute to the plugin if plg_db.package_name != package_name: @@ -611,9 +610,9 @@ def safe_reference(plugin, key: str, active: bool = True): f"Plugin '{p}' is not compatible with the current InvenTree version {v}" ) if v := plg_i.MIN_VERSION: - _msg += _(f'Plugin requires at least version {v}') + _msg += _(f'Plugin requires at least version {v}') # type: ignore[unsupported-operator] if v := plg_i.MAX_VERSION: - _msg += _(f'Plugin requires at most version {v}') + _msg += _(f'Plugin requires at most version {v}') # type: ignore[unsupported-operator] # Log to error stack log_error(_msg, reference='init') else: @@ -656,7 +655,7 @@ def _init_plugins(self): logger.exception( '[PLUGIN] Encountered an error with %s:\n%s', - error.path, + getattr(error, 'path', None), str(error), ) @@ -929,11 +928,14 @@ def _load_source(modname, filename): # loader = importlib.machinery.SourceFileLoader(modname, filename) spec = importlib.util.spec_from_file_location(modname, filename) # , loader=loader) + if spec is None: + raise ImportError(f"Cannot find module '{modname}'") # pragma: no cover module = importlib.util.module_from_spec(spec) sys.modules[module.__name__] = module - if spec.loader: - spec.loader.exec_module(module) + loader = spec.loader + if loader is not None: + loader.exec_module(module) return module diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index e7eb2aea2f5d..bee0d6ba2976 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -7,6 +7,7 @@ import textwrap from datetime import datetime from pathlib import Path +from typing import Optional from unittest import mock from unittest.mock import patch @@ -14,7 +15,8 @@ from django.test import TestCase, override_settings import plugin.templatetags.plugin_extras as plugin_tags -from plugin import InvenTreePlugin, registry +from plugin import InvenTreePlugin +from plugin.registry import registry from plugin.samples.integration.another_sample import ( NoIntegrationPlugin, WrongIntegrationPlugin, @@ -201,7 +203,9 @@ def test_version(self): self.assertFalse(self.plugin_version.check_version([0, 1, 4])) plug = registry.plugins_full.get('sampleversion') - self.assertEqual(plug.is_active(), False) + self.assertIsNotNone(plug) + if plug: + self.assertEqual(plug.is_active(), False) class RegistryTests(TestCase): @@ -247,7 +251,7 @@ def test_subfolder_loading(self): def test_folder_loading(self): """Test that plugins in folders outside of BASE_DIR get loaded.""" # Run in temporary directory -> always a new random name - with tempfile.TemporaryDirectory() as tmp: + with tempfile.TemporaryDirectory() as tmp: # type: ignore[no-matching-overload] # Fill directory with sample data new_dir = Path(tmp).joinpath('mock') shutil.copytree(self.mockDir(), new_dir) @@ -285,14 +289,15 @@ def test_broken_samples(self): # There should be at least one discovery error in the module `broken_file` self.assertGreater(len(registry.errors.get('discovery')), 0) self.assertEqual( - registry.errors.get('discovery')[0]['broken_file'], + registry.errors.get('discovery')[0]['broken_file'], # type: ignore[call-possibly-unbound-method] "name 'bb' is not defined", ) # There should be at least one load error with an intentional KeyError self.assertGreater(len(registry.errors.get('init')), 0) self.assertEqual( - registry.errors.get('init')[0]['broken_sample'], "'This is a dummy error'" + registry.errors.get('init')[0]['broken_sample'], # type: ignore[call-possibly-unbound-method] + "'This is a dummy error'", ) @override_settings(PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True) @@ -337,7 +342,7 @@ class DummyCIPlugin(InvenTreePlugin): def create_plugin_file( version: str, enabled: bool = True, reload: bool = True - ) -> str: + ) -> Optional[str]: """Create a plugin file with the given version. Arguments: diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index b873cde37f69..77adf2f80784 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -6,8 +6,9 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework.filterset import FilterSet from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -32,7 +33,7 @@ class TemplatePermissionMixin: permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope] -class ReportFilterBase(rest_filters.FilterSet): +class ReportFilterBase(FilterSet): """Base filter class for label and report templates.""" enabled = rest_filters.BooleanFilter() diff --git a/src/backend/InvenTree/report/apps.py b/src/backend/InvenTree/report/apps.py index cf7d679ffd92..c227a6e50935 100644 --- a/src/backend/InvenTree/report/apps.py +++ b/src/backend/InvenTree/report/apps.py @@ -67,7 +67,7 @@ def ready(self): def cleanup(self): """Cleanup old label and report outputs.""" try: - from report.tasks import cleanup_old_report_outputs + from report.tasks import cleanup_old_report_outputs # type: ignore[import] cleanup_old_report_outputs() except Exception: diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 8cc046c91d5d..1bc97ab5d3fb 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -225,7 +225,7 @@ def save(self, *args, **kwargs): ), ) - def generate_filename(self, context, **kwargs): + def generate_filename(self, context, **kwargs) -> str: """Generate a filename for this report.""" template_string = Template(self.filename_pattern) @@ -391,7 +391,8 @@ def get_report_size(self) -> str: def get_context(self, instance, request=None, **kwargs): """Supply context data to the report template for rendering.""" base_context = super().get_context(instance, request) - report_context: ReportContextExtension = { + + report_context: ReportContextExtension = { # type: ignore[invalid-assignment] 'page_size': self.get_report_size(), 'landscape': self.landscape, } @@ -438,7 +439,7 @@ def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput: debug_mode = get_global_setting('REPORT_DEBUG_MODE', False) # Start with a default report name - report_name = None + report_name: Optional[str] = None report_plugins = registry.with_mixin(PluginMixinEnum.REPORT) @@ -513,6 +514,9 @@ def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput: 'path': request.path if request else None, }) + if not report_name: + report_name = '' # pragma: no cover + if not report_name.endswith('.pdf'): report_name += '.pdf' @@ -541,7 +545,8 @@ def print(self, items: list, request=None, output=None, **kwargs) -> DataOutput: # Save the generated report to the database output.complete = True - output.output = ContentFile(data, report_name) + if data: + output.output = ContentFile(data, report_name) output.save() return output @@ -598,7 +603,7 @@ def generate_page_style(self, **kwargs): def get_context(self, instance, request=None, **kwargs): """Supply context data to the label template for rendering.""" base_context = super().get_context(instance, request, **kwargs) - label_context: LabelContextExtension = { + label_context: LabelContextExtension = { # type: ignore[invalid-assignment] 'width': self.width, 'height': self.height, 'page_style': None, diff --git a/src/backend/InvenTree/report/templatetags/barcode.py b/src/backend/InvenTree/report/templatetags/barcode.py index 75237bbd7a56..9a87adb3b3fe 100644 --- a/src/backend/InvenTree/report/templatetags/barcode.py +++ b/src/backend/InvenTree/report/templatetags/barcode.py @@ -4,6 +4,7 @@ from django.utils.safestring import mark_safe import barcode as python_barcode +import barcode.writer as python_barcode_writer import qrcode.constants as ECL from PIL import Image, ImageColor from qrcode.main import QRCode @@ -122,7 +123,7 @@ def barcode(data: str, barcode_class='code128', **kwargs) -> str: data = str(data).zfill(constructor.digits) - writer = python_barcode.writer.ImageWriter + writer = python_barcode_writer.ImageWriter barcode_image = constructor(data, writer=writer()) @@ -148,7 +149,7 @@ def datamatrix(data: str, **kwargs) -> str: Returns: image (str): base64 encoded image data """ - from ppf.datamatrix import DataMatrix + from ppf.datamatrix.datamatrix import DataMatrix data = str(data).strip() diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index ed6b5c3da801..5c7973730212 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -50,7 +50,7 @@ def filter_queryset(queryset: QuerySet, **kwargs) -> QuerySet: @register.simple_tag() -def filter_db_model(model_name: str, **kwargs) -> QuerySet: +def filter_db_model(model_name: str, **kwargs) -> Optional[QuerySet]: """Filter a database model based on the provided keyword arguments. Arguments: @@ -102,7 +102,7 @@ def getindex(container: list, index: int) -> Any: @register.simple_tag() -def getkey(container: dict, key: str, backup_value: Optional[any] = None) -> Any: +def getkey(container: dict, key: str, backup_value: Optional[Any] = None) -> Any: """Perform key lookup in the provided dict object. This function is provided to get around template rendering limitations. @@ -301,14 +301,13 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa if type(part) is not Part: raise TypeError(_('part_image tag requires a Part instance')) - if not part.image: + part_img = part.image + if not part_img: img = None elif preview: - img = None if not hasattr(part.image, 'preview') else part.image.preview.name + img = None if not hasattr(part.image, 'preview') else part_img.preview.name elif thumbnail: - img = ( - None if not hasattr(part.image, 'thumbnail') else part.image.thumbnail.name - ) + img = None if not hasattr(part.image, 'thumbnail') else part_img.thumbnail.name else: img = part.image.name @@ -316,7 +315,7 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa @register.simple_tag() -def part_parameter(part: Part, parameter_name: str) -> str: +def part_parameter(part: Part, parameter_name: str) -> Optional[str]: """Return a PartParameter object for the given part and parameter name. Arguments: @@ -348,12 +347,15 @@ def company_image( if type(company) is not Company: raise TypeError(_('company_image tag requires a Company instance')) - if preview: - img = company.image.preview.name + cmp_img = company.image + if not cmp_img: + img = None + elif preview: + img = cmp_img.preview.name elif thumbnail: - img = company.image.thumbnail.name + img = cmp_img.thumbnail.name else: - img = company.image.name + img = cmp_img.name return uploaded_image(img, **kwargs) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 81c9bea06d99..5544ecbfb237 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -9,7 +9,8 @@ from django.urls import include, path from django.utils.translation import gettext_lazy as _ -from django_filters import rest_framework as rest_filters +import django_filters.rest_framework.filters as rest_filters +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import status @@ -228,7 +229,7 @@ def get_serializer_context(self): return ctx -class StockLocationFilter(rest_filters.FilterSet): +class StockLocationFilter(FilterSet): """Base class for custom API filters for the StockLocation endpoint.""" class Meta: @@ -396,11 +397,11 @@ def destroy(self, request, *args, **kwargs): return super().destroy( request, *args, - **dict( - kwargs, - delete_sub_locations=delete_sub_locations, - delete_stock_items=delete_stock_items, - ), + **{ + **kwargs, + 'delete_sub_locations': delete_sub_locations, + 'delete_stock_items': delete_stock_items, + }, ) @@ -476,7 +477,7 @@ def get_queryset(self): return queryset -class StockFilter(rest_filters.FilterSet): +class StockFilter(FilterSet): """FilterSet for StockItem LIST API.""" class Meta: @@ -1272,7 +1273,7 @@ class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyA """Detail endpoint for StockItemTestResult.""" -class StockItemTestResultFilter(rest_filters.FilterSet): +class StockItemTestResultFilter(FilterSet): """API filter for the StockItemTestResult list.""" class Meta: diff --git a/src/backend/InvenTree/stock/generators.py b/src/backend/InvenTree/stock/generators.py index 7b11dc7db8c6..9c0a7aef6eb2 100644 --- a/src/backend/InvenTree/stock/generators.py +++ b/src/backend/InvenTree/stock/generators.py @@ -1,6 +1,7 @@ """Generator functions for the stock app.""" from inspect import signature +from typing import Optional from django.core.exceptions import ValidationError @@ -74,7 +75,7 @@ def generate_batch_code(**kwargs): return Template(batch_template).render(context) -def generate_serial_number(part=None, quantity=1, **kwargs) -> str: +def generate_serial_number(part=None, quantity=1, **kwargs) -> Optional[str]: """Generate a default 'serial number' for a new StockItem.""" quantity = quantity or 1 diff --git a/src/backend/InvenTree/stock/migrations/0061_auto_20210511_0911.py b/src/backend/InvenTree/stock/migrations/0061_auto_20210511_0911.py index ba44e9c1bd0b..e5d2b823999a 100644 --- a/src/backend/InvenTree/stock/migrations/0061_auto_20210511_0911.py +++ b/src/backend/InvenTree/stock/migrations/0061_auto_20210511_0911.py @@ -51,10 +51,10 @@ def update_history(apps, schema_editor): q = entry.quantity - if idx == 0 or not q == quantity: + if idx == 0 or q != quantity: try: - deltas['quantity']: float(q) + deltas['quantity']= float(q) updated = True except Exception: print(f"WARNING: Error converting quantity '{q}'") diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 168a684eaacf..b08a4f6e1880 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -593,7 +593,7 @@ def _create_serial_numbers(cls, serials: list, **kwargs) -> QuerySet: return StockItem.objects.filter(part=part, serial__in=serials) @staticmethod - def convert_serial_to_int(serial: str) -> int: + def convert_serial_to_int(serial: str) -> Optional[int]: """Convert the provided serial number to an integer value. This function hooks into the plugin system to allow for custom serial number conversion. @@ -1644,7 +1644,7 @@ def add_tracking_entry( self, entry_type: int, user: User, - deltas: dict | None = None, + deltas: Optional[dict] = None, notes: str = '', commit: bool = True, **kwargs, diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 89eaf4bf4a3f..743692b72daf 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -20,6 +20,7 @@ import company.models import company.serializers as company_serializers import InvenTree.helpers +import InvenTree.ready import InvenTree.serializers import order.models import part.filters as part_filters diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index cc39ba9ec35b..019891b8700f 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -47,7 +47,9 @@ def user_model_str(self): if settings.LDAP_AUTH: - from django_auth_ldap.backend import populate_user + from django_auth_ldap.backend import ( # type: ignore[unresolved-import] + populate_user, + ) @receiver(populate_user) def create_email_address(user, **kwargs): diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 5950fb286dd6..522d4ac649be 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -295,7 +295,7 @@ def get_permissions(self, group: Group) -> dict: class ExtendedUserSerializer(UserSerializer): """Serializer for a User with a bit more info.""" - from users.serializers import GroupSerializer + # from users.serializers import GroupSerializer class Meta(UserSerializer.Meta): """Metaclass defines serializer fields.""" diff --git a/src/backend/InvenTree/web/templatetags/__init__.py b/src/backend/InvenTree/web/templatetags/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/web/templatetags/spa_helper.py b/src/backend/InvenTree/web/templatetags/spa_helper.py index 743d1ef17bd1..0355ca906046 100644 --- a/src/backend/InvenTree/web/templatetags/spa_helper.py +++ b/src/backend/InvenTree/web/templatetags/spa_helper.py @@ -1,6 +1,7 @@ """Template tag to render SPA imports.""" import json +import json.decoder from pathlib import Path from typing import Union diff --git a/src/backend/requirements-dev.in b/src/backend/requirements-dev.in index 42ddbb679c03..d9782f1a19d8 100644 --- a/src/backend/requirements-dev.in +++ b/src/backend/requirements-dev.in @@ -9,3 +9,6 @@ pip-tools # Compile pip requirements pre-commit # Git pre-commit setuptools # Standard dependency pdfminer.six # PDF validation +ty # type checking +django-types # typing +django-stubs # typing diff --git a/src/backend/requirements-dev.txt b/src/backend/requirements-dev.txt index b63c8d890695..e6439abd1d81 100644 --- a/src/backend/requirements-dev.txt +++ b/src/backend/requirements-dev.txt @@ -6,6 +6,7 @@ asgiref==3.8.1 \ # via # -c src/backend/requirements.txt # django + # django-stubs build==1.2.2.post1 \ --hash=sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5 \ --hash=sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7 @@ -301,16 +302,30 @@ django==4.2.21 \ # via # -c src/backend/requirements.txt # django-slowtests + # django-stubs + # django-stubs-ext django-querycount==0.8.3 \ --hash=sha256:0782484e8a1bd29498fa0195a67106e47cdcc98fafe80cebb1991964077cb694 # via -r src/backend/requirements-dev.in django-slowtests==1.1.1 \ --hash=sha256:3c6936d420c9df444ac03625b41d97de043c662bbde61fbcd33e4cd407d0c247 # via -r src/backend/requirements-dev.in +django-stubs==5.1.3 \ + --hash=sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78 \ + --hash=sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a + # via -r src/backend/requirements-dev.in +django-stubs-ext==5.1.3 \ + --hash=sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0 \ + --hash=sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd + # via django-stubs django-test-migrations==1.4.0 \ --hash=sha256:294dff98f6d43d020d4046b971bac5339e7c71458a35e9ad6450c388fe16ed6b \ --hash=sha256:f0c9c92864ed27d0c9a582e92056637e91227f54bd868a50cb9a1726668c563e # via -r src/backend/requirements-dev.in +django-types==0.20.0 \ + --hash=sha256:4e55d2c56155e3d69d75def9eb1d95a891303f2ac19fccf6fe8056da4293fae7 \ + --hash=sha256:a0b5c2c9a1e591684bb21a93b64e50ca6cb2d3eab48f49faff1eac706bd3a9c7 + # via -r src/backend/requirements-dev.in filelock==3.18.0 \ --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de @@ -480,13 +495,44 @@ tomli==2.2.1 \ # -c src/backend/requirements.txt # build # coverage + # django-stubs # pip-tools +ty==0.0.1a3 \ + --hash=sha256:015657bdb165ad01f5baf822ef430c8dec98c5a70f8d86e32cf20ff679bdd57a \ + --hash=sha256:1b6c38f7252b38d6c3b5517f33b8a5736bdf1313d96d72d2bf2cf7ed96d3e3ee \ + --hash=sha256:225dcfb52cd757aab1585caa94602e5f87edbab6bd260bbea5c8a821b389baa1 \ + --hash=sha256:23e9b730ba9c8772801ea16fe5da6007cd7f5cae504b9eb746782600b910c389 \ + --hash=sha256:28a09ae6c97c38f540af87df5a37e71b91e153dceb7128ff40bfd6742c9a01c5 \ + --hash=sha256:56e73d152888576932c67c61b295a5428636e3ef15e8a19a83977822b3a8a762 \ + --hash=sha256:5f7f63794a660cf7d87562bedf124152a9a284b637d80488c13a1cb5ed6aaf85 \ + --hash=sha256:7f16a4fae298f6886a5dad8e81919bb848b0bb0839f39715a0e0723665092efe \ + --hash=sha256:8abf6f1c391fe4f506bf36f9e66b3f95844a4c74c01c037a80e71f440073c1e5 \ + --hash=sha256:90c068448d5a66b8aa0a533380fe8f10d6d6522bc2a20b41ba713029c775f9da \ + --hash=sha256:aa754e4ff7685910769812d92c1e31b664142c7067b79487e58889aca67fc3ae \ + --hash=sha256:aef675b51212ee2f5c731fd4b26191f13ef7d51509aafd959685d5716781c875 \ + --hash=sha256:bb04a11d2b772ddd680f09fffd60c3ee827e5b020a20dd8ce07f2c64ed0d554a \ + --hash=sha256:c04c852d8382712a8235b05984b22176f9397d58e596d69abf4073729b3404c6 \ + --hash=sha256:cb0c3d664840d1fefecc63b1cf5a62a2039b039ddca9a868eac51ce3cc1b3ab5 \ + --hash=sha256:dc7b7617b4ae3471546543e3b5922bfffcfb138c34b19f41d0c102bf6c3f064f \ + --hash=sha256:f04dd8d6a1db1e8cf40270619a555ea306f7b6bfc265e0919a7b89ee1a467232 \ + --hash=sha256:f45db3b40b265fd4e4f67d26e038dcdd1a10a9246e09c1b0f310150fc01e101a + # via -r src/backend/requirements-dev.in +types-psycopg2==2.9.21.20250318 \ + --hash=sha256:7296d111ad950bbd2fc979a1ab0572acae69047f922280e77db657c00d2c79c0 \ + --hash=sha256:eb6eac5bfb16adfd5f16b818918b9e26a40ede147e0f2bbffdf53a6ef7025a87 + # via django-types +types-pyyaml==6.0.12.20250402 \ + --hash=sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681 \ + --hash=sha256:d7c13c3e6d335b6af4b0122a01ff1d270aba84ab96d1a1a1063ecba3e13ec075 + # via django-stubs typing-extensions==4.13.2 \ --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef # via # -c src/backend/requirements.txt # asgiref + # django-stubs + # django-stubs-ext # django-test-migrations virtualenv==20.31.2 \ --hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \ diff --git a/tasks.py b/tasks.py index 5e7133c54c50..f7ade23e63fe 100644 --- a/tasks.py +++ b/tasks.py @@ -448,7 +448,9 @@ def check_file_existence(filename: Path, overwrite: bool = False): @state_logger('TASK01') def plugins(c, uv=False): """Installs all plugins as specified in 'plugins.txt'.""" - from src.backend.InvenTree.InvenTree.config import get_plugin_file + from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import] + get_plugin_file, + ) plugin_file = get_plugin_file() @@ -552,7 +554,9 @@ def rebuild_models(c): @task def rebuild_thumbnails(c): """Rebuild missing image thumbnails.""" - from src.backend.InvenTree.InvenTree.config import get_media_dir + from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import] + get_media_dir, + ) info(f'Rebuilding image thumbnails in {get_media_dir()}') manage(c, 'rebuild_thumbnails', pty=True) @@ -1133,7 +1137,7 @@ def test_translations(c): info('Fill in dummy translations...') file_path = pathlib.Path(settings.LOCALE_PATHS[0], 'xx', 'LC_MESSAGES', 'django.po') - new_file_path = str(file_path) + '_new' + new_file_path = Path(str(file_path) + '_new') # compile regex reg = re.compile( @@ -1269,7 +1273,9 @@ def setup_test( path='inventree-demo-dataset', ): """Setup a testing environment.""" - from src.backend.InvenTree.InvenTree.config import get_media_dir + from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import] + get_media_dir, + ) if not ignore_update: update(c) @@ -1416,8 +1422,8 @@ def export_definitions(c, basedir: str = ''): @task(default=True) def version(c): """Show the current version of InvenTree.""" - import src.backend.InvenTree.InvenTree.version as InvenTreeVersion - from src.backend.InvenTree.InvenTree.config import ( + import src.backend.InvenTree.InvenTree.version as InvenTreeVersion # type: ignore[import] + from src.backend.InvenTree.InvenTree.config import ( # type: ignore[import] get_backup_dir, get_config_file, get_media_dir,