2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +00:00

Docstring checks in QC checks (#3089)

* Add pre-commit to the stack

* exclude static

* Add locales to excludes

* fix style errors

* rename pipeline steps

* also wait on precommit

* make template matching simpler

* Use the same code for python setup everywhere

* use step and cache for python setup

* move regular settings up into general envs

* just use full update

* Use invoke instead of static references

* make setup actions more similar

* use python3

* refactor names to be similar

* fix runner version

* fix references

* remove incidential change

* use matrix for os

* Github can't do this right now

* ignore docstyle errors

* Add seperate docstring test

* update flake call

* do not fail on docstring

* refactor setup into workflow

* update reference

* switch to action

* resturcture

* add bash statements

* remove os from cache

* update input checks

* make code cleaner

* fix boolean

* no relative paths

* install wheel by python

* switch to install

* revert back to simple wheel

* refactor import export tests

* move setup keys back to not disturbe tests

* remove docstyle till that is fixed

* update references

* continue on error

* add docstring test

* use relativ action references

* Change step / job docstrings

* update to merge

* reformat comments 1

* fix docstrings 2

* fix docstrings 3

* fix docstrings 4

* fix docstrings 5

* fix docstrings 6

* fix docstrings 7

* fix docstrings 8

* fix docstirns 9

* fix docstrings 10

* docstring adjustments

* update the remaining docstrings

* small docstring changes

* fix function name

* update support files for docstrings

* Add missing args to docstrings

* Remove outdated function

* Add docstrings for the 'build' app

* Make API code cleaner

* add more docstrings for plugin app

* Remove dead code for plugin settings
No idea what that was even intended for

* ignore __init__ files for docstrings

* More docstrings

* Update docstrings for the 'part' directory

* Fixes for related_part functionality

* Fix removed stuff from merge 99676ee

* make more consistent

* Show statistics for docstrings

* add more docstrings

* move specific register statements to make them clearer to understant

* More docstrings for common

* and more docstrings

* and more

* simpler call

* docstrings for notifications

* docstrings for common/tests

* Add docs for common/models

* Revert "move specific register statements to make them clearer to understant"

This reverts commit ca96654622.

* use typing here

* Revert "Make API code cleaner"

This reverts commit 24fb68bd3e.

* docstring updates for the 'users' app

* Add generic Meta info to simple Meta classes

* remove unneeded unique_together statements

* More simple metas

* Remove unnecessary format specifier

* Remove extra json format specifiers

* Add docstrings for the 'plugin' app

* Docstrings for the 'label' app

* Add missing docstrings for the 'report' app

* Fix build test regression

* Fix top-level files

* docstrings for InvenTree/InvenTree

* reduce unneeded code

* add docstrings

* and more docstrings

* more docstrings

* more docstrings for stock

* more docstrings

* docstrings for order/views

* Docstrings for various files in the 'order' app

* Docstrings for order/test_api.py

* Docstrings for order/serializers.py

* Docstrings for order/admin.py

* More docstrings for the order app

* Add docstrings for the 'company' app

* Add unit tests for rebuilding the reference fields

* Prune out some more dead code

* remove more dead code

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2022-06-01 17:37:39 +02:00
committed by GitHub
parent 66a6915213
commit 0c97a50e47
223 changed files with 4416 additions and 6980 deletions

View File

@ -1,3 +1,5 @@
"""Admin for the common app."""
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
@ -6,14 +8,12 @@ import common.models
class SettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeSetting."""
list_display = ('key', 'value')
def get_readonly_fields(self, request, obj=None): # pragma: no cover
"""
Prevent the 'key' field being edited once the setting is created
"""
"""Prevent the 'key' field being edited once the setting is created."""
if obj:
return ['key']
else:
@ -21,14 +21,12 @@ class SettingsAdmin(ImportExportModelAdmin):
class UserSettingsAdmin(ImportExportModelAdmin):
"""Admin settings for InvenTreeUserSetting."""
list_display = ('key', 'value', 'user', )
def get_readonly_fields(self, request, obj=None): # pragma: no cover
"""
Prevent the 'key' field being edited once the setting is created
"""
"""Prevent the 'key' field being edited once the setting is created."""
if obj:
return ['key']
else:
@ -36,16 +34,19 @@ class UserSettingsAdmin(ImportExportModelAdmin):
class WebhookAdmin(ImportExportModelAdmin):
"""Admin settings for Webhook."""
list_display = ('endpoint_id', 'name', 'active', 'user')
class NotificationEntryAdmin(admin.ModelAdmin):
"""Admin settings for NotificationEntry."""
list_display = ('key', 'uid', 'updated', )
class NotificationMessageAdmin(admin.ModelAdmin):
"""Admin settings for NotificationMessage."""
list_display = ('age_human', 'user', 'category', 'name', 'read', 'target_object', 'source_object', )

View File

@ -1,6 +1,4 @@
"""
Provides a JSON API for common components.
"""
"""Provides a JSON API for common components."""
import json
@ -24,25 +22,23 @@ from plugin.serializers import NotificationUserSettingSerializer
class CsrfExemptMixin(object):
"""
Exempts the view from CSRF requirements.
"""
"""Exempts the view from CSRF requirements."""
@method_decorator(csrf_exempt)
def dispatch(self, *args, **kwargs):
return super(CsrfExemptMixin, self).dispatch(*args, **kwargs)
"""Overwrites dispatch to be extempt from csrf checks."""
return super().dispatch(*args, **kwargs)
class WebhookView(CsrfExemptMixin, APIView):
"""
Endpoint for receiving webhooks.
"""
"""Endpoint for receiving webhooks."""
authentication_classes = []
permission_classes = []
model_class = common.models.WebhookEndpoint
run_async = False
def post(self, request, endpoint, *args, **kwargs):
"""Process incomming webhook."""
# get webhook definition
self._get_webhook(endpoint, request, *args, **kwargs)
@ -101,6 +97,10 @@ class WebhookView(CsrfExemptMixin, APIView):
class SettingsList(generics.ListAPIView):
"""Generic ListView for settings.
This is inheritted by all list views for settings.
"""
filter_backends = [
DjangoFilterBackend,
@ -120,24 +120,17 @@ class SettingsList(generics.ListAPIView):
class GlobalSettingsList(SettingsList):
"""
API endpoint for accessing a list of global settings objects
"""
"""API endpoint for accessing a list of global settings objects."""
queryset = common.models.InvenTreeSetting.objects.all()
serializer_class = common.serializers.GlobalSettingsSerializer
class GlobalSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user is "staff"
"""
"""Special permission class to determine if the user is "staff"."""
def has_permission(self, request, view):
"""
Check that the requesting user is 'admin'
"""
"""Check that the requesting user is 'admin'."""
try:
user = request.user
@ -152,8 +145,7 @@ class GlobalSettingsPermissions(permissions.BasePermission):
class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "global setting" object.
"""Detail view for an individual "global setting" object.
- User must have 'staff' status to view / edit
"""
@ -163,10 +155,7 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
serializer_class = common.serializers.GlobalSettingsSerializer
def get_object(self):
"""
Attempt to find a global setting object with the provided key.
"""
"""Attempt to find a global setting object with the provided key."""
key = self.kwargs['key']
if key not in common.models.InvenTreeSetting.SETTINGS.keys():
@ -181,18 +170,13 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
class UserSettingsList(SettingsList):
"""
API endpoint for accessing a list of user settings objects
"""
"""API endpoint for accessing a list of user settings objects."""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
def filter_queryset(self, queryset):
"""
Only list settings which apply to the current user
"""
"""Only list settings which apply to the current user."""
try:
user = self.request.user
except AttributeError: # pragma: no cover
@ -206,12 +190,10 @@ class UserSettingsList(SettingsList):
class UserSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user can view / edit a particular setting
"""
"""Special permission class to determine if the user can view / edit a particular setting."""
def has_object_permission(self, request, view, obj):
"""Check if the user that requested is also the object owner."""
try:
user = request.user
except AttributeError: # pragma: no cover
@ -221,8 +203,7 @@ class UserSettingsPermissions(permissions.BasePermission):
class UserSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "user setting" object
"""Detail view for an individual "user setting" object.
- User can only view / edit settings their own settings objects
"""
@ -232,10 +213,7 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
serializer_class = common.serializers.UserSettingsSerializer
def get_object(self):
"""
Attempt to find a user setting object with the provided key.
"""
"""Attempt to find a user setting object with the provided key."""
key = self.kwargs['key']
if key not in common.models.InvenTreeUserSetting.SETTINGS.keys():
@ -249,18 +227,13 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
class NotificationUserSettingsList(SettingsList):
"""
API endpoint for accessing a list of notification user settings objects
"""
"""API endpoint for accessing a list of notification user settings objects."""
queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer
def filter_queryset(self, queryset):
"""
Only list settings which apply to the current user
"""
"""Only list settings which apply to the current user."""
try:
user = self.request.user
except AttributeError:
@ -272,8 +245,7 @@ class NotificationUserSettingsList(SettingsList):
class NotificationUserSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "notification user setting" object
"""Detail view for an individual "notification user setting" object.
- User can only view / edit settings their own settings objects
"""
@ -287,6 +259,8 @@ class NotificationUserSettingsDetail(generics.RetrieveUpdateAPIView):
class NotificationList(generics.ListAPIView):
"""List view for all notifications of the current user."""
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer
@ -313,10 +287,7 @@ class NotificationList(generics.ListAPIView):
]
def filter_queryset(self, queryset):
"""
Only list notifications which apply to the current user
"""
"""Only list notifications which apply to the current user."""
try:
user = self.request.user
except AttributeError:
@ -328,8 +299,7 @@ class NotificationList(generics.ListAPIView):
class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
"""
Detail view for an individual notification object
"""Detail view for an individual notification object.
- User can only view / delete their own notification objects
"""
@ -342,9 +312,7 @@ class NotificationDetail(generics.RetrieveUpdateDestroyAPIView):
class NotificationReadEdit(generics.CreateAPIView):
"""
general API endpoint to manipulate read state of a notification
"""
"""General API endpoint to manipulate read state of a notification."""
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationReadSerializer
@ -354,12 +322,14 @@ class NotificationReadEdit(generics.CreateAPIView):
]
def get_serializer_context(self):
"""Add instance to context so it can be accessed in the serializer."""
context = super().get_serializer_context()
if self.request:
context['instance'] = self.get_object()
return context
def perform_create(self, serializer):
"""Set the `read` status to the target value."""
message = self.get_object()
try:
message.read = self.target
@ -369,23 +339,17 @@ class NotificationReadEdit(generics.CreateAPIView):
class NotificationRead(NotificationReadEdit):
"""
API endpoint to mark a notification as read.
"""
"""API endpoint to mark a notification as read."""
target = True
class NotificationUnread(NotificationReadEdit):
"""
API endpoint to mark a notification as unread.
"""
"""API endpoint to mark a notification as unread."""
target = False
class NotificationReadAll(generics.RetrieveAPIView):
"""
API endpoint to mark all notifications as read.
"""
"""API endpoint to mark all notifications as read."""
queryset = common.models.NotificationMessage.objects.all()
@ -394,6 +358,7 @@ class NotificationReadAll(generics.RetrieveAPIView):
]
def get(self, request, *args, **kwargs):
"""Set all messages for the current user as read."""
try:
self.queryset.filter(user=request.user, read=False).update(read=True)
return Response({'status': 'ok'})

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
"""App config for common app."""
import logging
@ -8,17 +8,19 @@ logger = logging.getLogger('inventree')
class CommonConfig(AppConfig):
"""AppConfig for common app.
Clears system wide flags on ready.
"""
name = 'common'
def ready(self):
"""Initialize restart flag clearance on startup."""
self.clear_restart_flag()
def clear_restart_flag(self):
"""
Clear the SERVER_RESTART_REQUIRED setting
"""
"""Clear the SERVER_RESTART_REQUIRED setting."""
try:
import common.models

View File

@ -1,6 +1,4 @@
"""
Files management tools.
"""
"""Files management tools."""
import os
@ -12,7 +10,7 @@ from rapidfuzz import fuzz
class FileManager:
""" Class for managing an uploaded file """
"""Class for managing an uploaded file."""
name = ''
@ -32,8 +30,7 @@ class FileManager:
HEADERS = []
def __init__(self, file, name=None):
""" Initialize the FileManager class with a user-uploaded file object """
"""Initialize the FileManager class with a user-uploaded file object."""
# Set name
if name:
self.name = name
@ -46,8 +43,7 @@ class FileManager:
@classmethod
def validate(cls, file):
""" Validate file extension and data """
"""Validate file extension and data."""
cleaned_data = None
ext = os.path.splitext(file.name)[-1].lower().replace('.', '')
@ -79,21 +75,15 @@ class FileManager:
return cleaned_data
def process(self, file):
""" Process file """
"""Process file."""
self.data = self.__class__.validate(file)
def update_headers(self):
""" Update headers """
"""Update headers."""
self.HEADERS = self.REQUIRED_HEADERS + self.ITEM_MATCH_HEADERS + self.OPTIONAL_MATCH_HEADERS + self.OPTIONAL_HEADERS
def setup(self):
"""
Setup headers
should be overriden in usage to set the Different Headers
"""
"""Setup headers should be overriden in usage to set the Different Headers."""
if not self.name:
return
@ -101,14 +91,15 @@ class FileManager:
self.update_headers()
def guess_header(self, header, threshold=80):
"""
Try to match a header (from the file) to a list of known headers
"""Try to match a header (from the file) to a list of known headers.
Args:
header - Header name to look for
threshold - Match threshold for fuzzy search
"""
header (Any): Header name to look for
threshold (int, optional): Match threshold for fuzzy search. Defaults to 80.
Returns:
Any: Matched headers
"""
# Replace null values with empty string
if header is None:
header = ''
@ -143,7 +134,7 @@ class FileManager:
return None
def columns(self):
""" Return a list of headers for the thingy """
"""Return a list of headers for the thingy."""
headers = []
for header in self.data.headers:
@ -170,21 +161,21 @@ class FileManager:
return headers
def col_count(self):
"""Return the number of columns in the file."""
if self.data is None:
return 0
return len(self.data.headers)
def row_count(self):
""" Return the number of rows in the file. """
"""Return the number of rows in the file."""
if self.data is None:
return 0
return len(self.data)
def rows(self):
""" Return a list of all rows """
"""Return a list of all rows."""
rows = []
for i in range(self.row_count()):
@ -221,15 +212,14 @@ class FileManager:
return rows
def get_row_data(self, index):
""" Retrieve row data at a particular index """
"""Retrieve row data at a particular index."""
if self.data is None or index >= len(self.data):
return None
return self.data[index]
def get_row_dict(self, index):
""" Retrieve a dict object representing the data row at a particular offset """
"""Retrieve a dict object representing the data row at a particular offset."""
if self.data is None or index >= len(self.data):
return None

View File

@ -1,6 +1,4 @@
"""
Django forms for interacting with common objects
"""
"""Django forms for interacting with common objects."""
from django import forms
from django.utils.translation import gettext as _
@ -12,11 +10,11 @@ from .models import InvenTreeSetting
class SettingEditForm(HelperForm):
"""
Form for creating / editing a settings object
"""
"""Form for creating / editing a settings object."""
class Meta:
"""Metaclassoptions for SettingEditForm."""
model = InvenTreeSetting
fields = [
@ -25,7 +23,7 @@ class SettingEditForm(HelperForm):
class UploadFileForm(forms.Form):
""" Step 1 of FileManagementFormView """
"""Step 1 of FileManagementFormView."""
file = forms.FileField(
label=_('File'),
@ -33,8 +31,7 @@ class UploadFileForm(forms.Form):
)
def __init__(self, *args, **kwargs):
""" Update label and help_text """
"""Update label and help_text."""
# Get file name
name = None
if 'name' in kwargs:
@ -48,11 +45,10 @@ class UploadFileForm(forms.Form):
self.fields['file'].help_text = _(f'Select {name} file to upload')
def clean_file(self):
"""
Run tabular file validation.
If anything is wrong with the file, it will raise ValidationError
"""
"""Run tabular file validation.
If anything is wrong with the file, it will raise ValidationError
"""
file = self.cleaned_data['file']
# Validate file using FileManager class - will perform initial data validation
@ -63,10 +59,10 @@ class UploadFileForm(forms.Form):
class MatchFieldForm(forms.Form):
""" Step 2 of FileManagementFormView """
"""Step 2 of FileManagementFormView."""
def __init__(self, *args, **kwargs):
"""Setup filemanager and check columsn."""
# Get FileManager
file_manager = None
if 'file_manager' in kwargs:
@ -96,10 +92,10 @@ class MatchFieldForm(forms.Form):
class MatchItemForm(forms.Form):
""" Step 3 of FileManagementFormView """
"""Step 3 of FileManagementFormView."""
def __init__(self, *args, **kwargs):
"""Setup filemanager and create fields."""
# Get FileManager
file_manager = None
if 'file_manager' in kwargs:
@ -194,6 +190,5 @@ class MatchItemForm(forms.Form):
)
def get_special_field(self, col_guess, row, file_manager):
""" Function to be overriden in inherited forms to add specific form settings """
"""Function to be overriden in inherited forms to add specific form settings."""
return None

View File

@ -1,5 +1,5 @@
"""
Common database model definitions.
"""Common database model definitions.
These models are 'generic' and do not fit a particular business logic object.
"""
@ -42,9 +42,10 @@ logger = logging.getLogger('inventree')
class EmptyURLValidator(URLValidator):
"""Validator for filed with url - that can be empty."""
def __call__(self, value):
"""Make sure empty values pass."""
value = str(value).strip()
if len(value) == 0:
@ -55,21 +56,17 @@ class EmptyURLValidator(URLValidator):
class BaseInvenTreeSetting(models.Model):
"""
An base InvenTreeSetting object is a key:value pair used for storing
single values (e.g. one-off settings values).
"""
"""An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values)."""
SETTINGS = {}
class Meta:
"""Meta options for BaseInvenTreeSetting -> abstract stops creation of database entry."""
abstract = True
def save(self, *args, **kwargs):
"""
Enforce validation and clean before saving
"""
"""Enforce validation and clean before saving."""
self.key = str(self.key).upper()
self.clean(**kwargs)
@ -79,14 +76,12 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def allValues(cls, user=None, exclude_hidden=False):
"""
Return a dict of "all" defined global settings.
"""Return a dict of "all" defined global settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
results = cls.objects.all()
# Optionally filter by user
@ -131,28 +126,23 @@ class BaseInvenTreeSetting(models.Model):
return settings
def get_kwargs(self):
"""
Construct kwargs for doing class-based settings lookup,
depending on *which* class we are.
"""Construct kwargs for doing class-based settings lookup, depending on *which* class we are.
This is necessary to abtract the settings object
from the implementing class (e.g plugins)
Subclasses should override this function to ensure the kwargs are correctly set.
"""
return {}
@classmethod
def get_setting_definition(cls, key, **kwargs):
"""
Return the 'definition' of a particular settings value, as a dict object.
"""Return the 'definition' of a particular settings value, as a dict object.
- The 'settings' dict can be passed as a kwarg
- If not passed, look for cls.SETTINGS
- Returns an empty dict if the key is not found
"""
settings = kwargs.get('settings', cls.SETTINGS)
key = str(key).strip().upper()
@ -164,69 +154,56 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def get_setting_name(cls, key, **kwargs):
"""
Return the name of a particular setting.
"""Return the name of a particular setting.
If it does not exist, return an empty string.
"""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('name', '')
@classmethod
def get_setting_description(cls, key, **kwargs):
"""
Return the description for a particular setting.
"""Return the description for a particular setting.
If it does not exist, return an empty string.
"""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('description', '')
@classmethod
def get_setting_units(cls, key, **kwargs):
"""
Return the units for a particular setting.
"""Return the units for a particular setting.
If it does not exist, return an empty string.
"""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('units', '')
@classmethod
def get_setting_validator(cls, key, **kwargs):
"""
Return the validator for a particular setting.
"""Return the validator for a particular setting.
If it does not exist, return None
"""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('validator', None)
@classmethod
def get_setting_default(cls, key, **kwargs):
"""
Return the default value for a particular setting.
"""Return the default value for a particular setting.
If it does not exist, return an empty string
"""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('default', '')
@classmethod
def get_setting_choices(cls, key, **kwargs):
"""
Return the validator choices available for a particular setting.
"""
"""Return the validator choices available for a particular setting."""
setting = cls.get_setting_definition(key, **kwargs)
choices = setting.get('choices', None)
@ -239,13 +216,11 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def get_setting_object(cls, key, **kwargs):
"""
Return an InvenTreeSetting object matching the given key.
"""Return an InvenTreeSetting object matching the given key.
- Key is case-insensitive
- Returns None if no match is made
"""
key = str(key).strip().upper()
settings = cls.objects.all()
@ -311,11 +286,10 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def get_setting(cls, key, backup_value=None, **kwargs):
"""
Get the value of a particular setting.
"""Get the value of a particular setting.
If it does not exist, return the backup value (default = None)
"""
# If no backup value is specified, atttempt to retrieve a "default" value
if backup_value is None:
backup_value = cls.get_setting_default(key, **kwargs)
@ -343,9 +317,7 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def set_setting(cls, key, value, change_user, create=True, **kwargs):
"""
Set the value of a particular setting.
If it does not exist, option to create it.
"""Set the value of a particular setting. If it does not exist, option to create it.
Args:
key: settings key
@ -353,7 +325,6 @@ class BaseInvenTreeSetting(models.Model):
change_user: User object (must be staff member to update a core setting)
create: If True, create a new setting if the specified key does not exist.
"""
if change_user is not None and not change_user.is_staff:
return
@ -397,26 +368,26 @@ class BaseInvenTreeSetting(models.Model):
@property
def name(self):
"""Return name for setting."""
return self.__class__.get_setting_name(self.key, **self.get_kwargs())
@property
def default_value(self):
"""Return default_value for setting."""
return self.__class__.get_setting_default(self.key, **self.get_kwargs())
@property
def description(self):
"""Return description for setting."""
return self.__class__.get_setting_description(self.key, **self.get_kwargs())
@property
def units(self):
"""Return units for setting."""
return self.__class__.get_setting_units(self.key, **self.get_kwargs())
def clean(self, **kwargs):
"""
If a validator (or multiple validators) are defined for a particular setting key,
run them against the 'value' field.
"""
"""If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field."""
super().clean()
# Encode as native values
@ -437,10 +408,7 @@ class BaseInvenTreeSetting(models.Model):
raise ValidationError(_("Chosen value is not a valid option"))
def run_validator(self, validator):
"""
Run a validator against the 'value' field for this InvenTreeSetting object.
"""
"""Run a validator against the 'value' field for this InvenTreeSetting object."""
if validator is None:
return
@ -485,15 +453,11 @@ class BaseInvenTreeSetting(models.Model):
validator(value)
def validate_unique(self, exclude=None, **kwargs):
"""
Ensure that the key:value pair is unique.
In addition to the base validators, this ensures that the 'key'
is unique, using a case-insensitive comparison.
"""Ensure that the key:value pair is unique. In addition to the base validators, this ensures that the 'key' is unique, using a case-insensitive comparison.
Note that sub-classes (UserSetting, PluginSetting) use other filters
to determine if the setting is 'unique' or not
"""
super().validate_unique(exclude)
filters = {
@ -520,17 +484,11 @@ class BaseInvenTreeSetting(models.Model):
pass
def choices(self):
"""
Return the available choices for this setting (or None if no choices are defined)
"""
"""Return the available choices for this setting (or None if no choices are defined)."""
return self.__class__.get_setting_choices(self.key, **self.get_kwargs())
def valid_options(self):
"""
Return a list of valid options for this setting
"""
"""Return a list of valid options for this setting."""
choices = self.choices()
if not choices:
@ -539,21 +497,17 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices]
def is_choice(self):
"""
Check if this setting is a "choice" field
"""
"""Check if this setting is a "choice" field."""
return self.__class__.get_setting_choices(self.key, **self.get_kwargs()) is not None
def as_choice(self):
"""
Render this setting as the "display" value of a choice field,
e.g. if the choices are:
"""Render this setting as the "display" value of a choice field.
E.g. if the choices are:
[('A4', 'A4 paper'), ('A3', 'A3 paper')],
and the value is 'A4',
then display 'A4 paper'
"""
choices = self.get_setting_choices(self.key, **self.get_kwargs())
if not choices:
@ -566,30 +520,23 @@ class BaseInvenTreeSetting(models.Model):
return self.value
def is_model(self):
"""
Check if this setting references a model instance in the database
"""
"""Check if this setting references a model instance in the database."""
return self.model_name() is not None
def model_name(self):
"""
Return the model name associated with this setting
"""
"""Return the model name associated with this setting."""
setting = self.get_setting_definition(self.key, **self.get_kwargs())
return setting.get('model', None)
def model_class(self):
"""
Return the model class associated with this setting, if (and only if):
"""Return the model class associated with this setting.
If (and only if):
- It has a defined 'model' parameter
- The 'model' parameter is of the form app.model
- The 'model' parameter has matches a known app model
"""
model_name = self.model_name()
if not model_name:
@ -617,11 +564,7 @@ class BaseInvenTreeSetting(models.Model):
return model
def api_url(self):
"""
Return the API url associated with the linked model,
if provided, and valid!
"""
"""Return the API url associated with the linked model, if provided, and valid!"""
model_class = self.model_class()
if model_class:
@ -634,28 +577,20 @@ class BaseInvenTreeSetting(models.Model):
return None
def is_bool(self):
"""
Check if this setting is required to be a boolean value
"""
"""Check if this setting is required to be a boolean value."""
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_bool(validator)
def as_bool(self):
"""
Return the value of this setting converted to a boolean value.
"""Return the value of this setting converted to a boolean value.
Warning: Only use on values where is_bool evaluates to true!
"""
return InvenTree.helpers.str2bool(self.value)
def setting_type(self):
"""
Return the field type identifier for this setting object
"""
"""Return the field type identifier for this setting object."""
if self.is_bool():
return 'boolean'
@ -670,7 +605,7 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def validator_is_bool(cls, validator):
"""Return if validator is for bool."""
if validator == bool:
return True
@ -682,17 +617,14 @@ class BaseInvenTreeSetting(models.Model):
return False
def is_int(self,):
"""
Check if the setting is required to be an integer value:
"""
"""Check if the setting is required to be an integer value."""
validator = self.__class__.get_setting_validator(self.key, **self.get_kwargs())
return self.__class__.validator_is_int(validator)
@classmethod
def validator_is_int(cls, validator):
"""Return if validator is for int."""
if validator == int:
return True
@ -704,12 +636,10 @@ class BaseInvenTreeSetting(models.Model):
return False
def as_int(self):
"""
Return the value of this setting converted to a boolean value.
"""Return the value of this setting converted to a boolean value.
If an error occurs, return the default value
"""
try:
value = int(self.value)
except (ValueError, TypeError):
@ -719,41 +649,34 @@ class BaseInvenTreeSetting(models.Model):
@classmethod
def is_protected(cls, key, **kwargs):
"""
Check if the setting value is protected
"""
"""Check if the setting value is protected."""
setting = cls.get_setting_definition(key, **kwargs)
return setting.get('protected', False)
@property
def protected(self):
"""Returns if setting is protected from rendering."""
return self.__class__.is_protected(self.key, **self.get_kwargs())
def settings_group_options():
"""
Build up group tuple for settings based on your choices
"""
"""Build up group tuple for settings based on your choices."""
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
class InvenTreeSetting(BaseInvenTreeSetting):
"""
An InvenTreeSetting object is a key:value pair used for storing
single values (e.g. one-off settings values).
"""An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values).
The class provides a way of retrieving the value for a particular key,
even if that key does not exist.
"""
def save(self, *args, **kwargs):
"""
When saving a global setting, check to see if it requires a server restart.
"""When saving a global setting, check to see if it requires a server restart.
If so, set the "SERVER_RESTART_REQUIRED" setting to True
"""
super().save()
if self.requires_restart():
@ -1235,6 +1158,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}
class Meta:
"""Meta options for InvenTreeSetting."""
verbose_name = "InvenTree Setting"
verbose_name_plural = "InvenTree Settings"
@ -1246,18 +1171,11 @@ class InvenTreeSetting(BaseInvenTreeSetting):
)
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
"""Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
return self.__class__.get_setting(self.key)
def requires_restart(self):
"""
Return True if this setting requires a server restart after changing
"""
"""Return True if this setting requires a server restart after changing."""
options = InvenTreeSetting.SETTINGS.get(self.key, None)
if options:
@ -1267,9 +1185,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
class InvenTreeUserSetting(BaseInvenTreeSetting):
"""
An InvenTreeSetting object with a usercontext
"""
"""An InvenTreeSetting object with a usercontext."""
SETTINGS = {
'HOMEPAGE_PART_STARRED': {
@ -1561,6 +1477,8 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
}
class Meta:
"""Meta options for InvenTreeUserSetting."""
verbose_name = "InvenTree User Setting"
verbose_name_plural = "InvenTree User Settings"
constraints = [
@ -1584,36 +1502,30 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
@classmethod
def get_setting_object(cls, key, user=None):
"""Return setting object for provided user."""
return super().get_setting_object(key, user=user)
def validate_unique(self, exclude=None, **kwargs):
"""Return if the setting (including key) is unique."""
return super().validate_unique(exclude=exclude, user=self.user)
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
"""Return the "pythonic" value, e.g. convert "True" to True, and "1" to 1."""
return self.__class__.get_setting(self.key, user=self.user)
def get_kwargs(self):
"""
Explicit kwargs required to uniquely identify a particular setting object,
in addition to the 'key' parameter
"""
"""Explicit kwargs required to uniquely identify a particular setting object, in addition to the 'key' parameter."""
return {
'user': self.user,
}
class PriceBreak(models.Model):
"""
Represents a PriceBreak model
"""
"""Represents a PriceBreak model."""
class Meta:
"""Define this as abstract -> no DB entry is created."""
abstract = True
quantity = InvenTree.fields.RoundingDecimalField(
@ -1634,13 +1546,11 @@ class PriceBreak(models.Model):
)
def convert_to(self, currency_code):
"""
Convert the unit-price at this price break to the specified currency code.
"""Convert the unit-price at this price break to the specified currency code.
Args:
currency_code - The currency code to convert to (e.g "USD" or "AUD")
currency_code: The currency code to convert to (e.g "USD" or "AUD")
"""
try:
converted = convert_money(self.price, currency_code)
except MissingRate:
@ -1651,7 +1561,7 @@ class PriceBreak(models.Model):
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
""" Calculate the price based on quantity price breaks.
"""Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
@ -1721,7 +1631,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None, break
class ColorTheme(models.Model):
""" Color Theme Setting """
"""Color Theme Setting."""
name = models.CharField(max_length=20,
default='',
blank=True)
@ -1731,7 +1641,7 @@ class ColorTheme(models.Model):
@classmethod
def get_color_themes_choices(cls):
""" Get all color themes from static folder """
"""Get all color themes from static folder."""
if settings.TESTING and not os.path.exists(settings.STATIC_COLOR_THEMES_DIR):
logger.error('Theme directory does not exsist')
return []
@ -1750,7 +1660,7 @@ class ColorTheme(models.Model):
@classmethod
def is_valid_choice(cls, user_color_theme):
""" Check if color theme is valid choice """
"""Check if color theme is valid choice."""
try:
user_color_theme_name = user_color_theme.name
except AttributeError:
@ -1764,13 +1674,15 @@ class ColorTheme(models.Model):
class VerificationMethod:
"""Class to hold method references."""
NONE = 0
TOKEN = 1
HMAC = 2
class WebhookEndpoint(models.Model):
""" Defines a Webhook entdpoint
"""Defines a Webhook entdpoint.
Attributes:
endpoint_id: Path to the webhook,
@ -1835,9 +1747,19 @@ class WebhookEndpoint(models.Model):
# To be overridden
def init(self, request, *args, **kwargs):
"""Set verification method.
Args:
request: Original request object.
"""
self.verify = self.VERIFICATION_METHOD
def process_webhook(self):
"""Process the webhook incomming.
This does not deal with the data itself - that happens in process_payload.
Do not touch or pickle data here - it was not verified to be safe.
"""
if self.token:
self.verify = VerificationMethod.TOKEN
if self.secret:
@ -1845,6 +1767,10 @@ class WebhookEndpoint(models.Model):
return True
def validate_token(self, payload, headers, request):
"""Make sure that the provided token (if any) confirms to the setting for this endpoint.
This can be overridden to create your own token validation method.
"""
token = headers.get(self.TOKEN_NAME, "")
# no token
@ -1866,7 +1792,14 @@ class WebhookEndpoint(models.Model):
return True
def save_data(self, payload, headers=None, request=None):
def save_data(self, payload=None, headers=None, request=None):
"""Safes payload to database.
Args:
payload (optional): Payload that was send along. Defaults to None.
headers (optional): Headers that were send along. Defaults to None.
request (optional): Original request object. Defaults to None.
"""
return WebhookMessage.objects.create(
host=request.get_host(),
header=json.dumps({key: val for key, val in headers.items()}),
@ -1874,15 +1807,35 @@ class WebhookEndpoint(models.Model):
endpoint=self,
)
def process_payload(self, message, payload=None, headers=None):
def process_payload(self, message, payload=None, headers=None) -> bool:
"""Process a payload.
Args:
message: DB entry for this message mm
payload (optional): Payload that was send along. Defaults to None.
headers (optional): Headers that were included. Defaults to None.
Returns:
bool: Was the message processed
"""
return True
def get_return(self, payload, headers=None, request=None):
def get_return(self, payload=None, headers=None, request=None) -> str:
"""Returns the message that should be returned to the endpoint caller.
Args:
payload (optional): Payload that was send along. Defaults to None.
headers (optional): Headers that were send along. Defaults to None.
request (optional): Original request object. Defaults to None.
Returns:
str: Message for caller.
"""
return self.MESSAGE_OK
class WebhookMessage(models.Model):
""" Defines a webhook message
"""Defines a webhook message.
Attributes:
message_id: Unique identifier for this message,
@ -1939,8 +1892,7 @@ class WebhookMessage(models.Model):
class NotificationEntry(models.Model):
"""
A NotificationEntry records the last time a particular notifaction was sent out.
"""A NotificationEntry records the last time a particular notifaction was sent out.
It is recorded to ensure that notifications are not sent out "too often" to users.
@ -1951,6 +1903,8 @@ class NotificationEntry(models.Model):
"""
class Meta:
"""Meta options for NotificationEntry."""
unique_together = [
('key', 'uid'),
]
@ -1970,10 +1924,7 @@ class NotificationEntry(models.Model):
@classmethod
def check_recent(cls, key: str, uid: int, delta: timedelta):
"""
Test if a particular notification has been sent in the specified time period
"""
"""Test if a particular notification has been sent in the specified time period."""
since = datetime.now().date() - delta
entries = cls.objects.filter(
@ -1986,10 +1937,7 @@ class NotificationEntry(models.Model):
@classmethod
def notify(cls, key: str, uid: int):
"""
Notify the database that a particular notification has been sent out
"""
"""Notify the database that a particular notification has been sent out."""
entry, created = cls.objects.get_or_create(
key=key,
uid=uid
@ -1999,8 +1947,7 @@ class NotificationEntry(models.Model):
class NotificationMessage(models.Model):
"""
A NotificationEntry records the last time a particular notifaction was sent out.
"""A NotificationEntry records the last time a particular notifaction was sent out.
It is recorded to ensure that notifications are not sent out "too often" to users.
@ -2073,13 +2020,14 @@ class NotificationMessage(models.Model):
@staticmethod
def get_api_url():
"""Return API endpoint."""
return reverse('api-notifications-list')
def age(self):
"""age of the message in seconds"""
"""Age of the message in seconds."""
delta = now() - self.creation
return delta.seconds
def age_human(self):
"""humanized age"""
"""Humanized age."""
return naturaltime(self.creation)

View File

@ -1,3 +1,5 @@
"""Base classes and functions for notifications."""
import logging
from datetime import timedelta
@ -12,9 +14,7 @@ logger = logging.getLogger('inventree')
# region methods
class NotificationMethod:
"""
Base class for notification methods
"""
"""Base class for notification methods."""
METHOD_NAME = ''
METHOD_ICON = None
@ -24,6 +24,13 @@ class NotificationMethod:
USER_SETTING = None
def __init__(self, obj, category, targets, context) -> None:
"""Check that the method is read.
This checks that:
- All needed functions are implemented
- The method is not disabled via plugin
- All needed contaxt values were provided
"""
# Check if a sending fnc is defined
if (not hasattr(self, 'send')) and (not hasattr(self, 'send_bulk')):
raise NotImplementedError('A NotificationMethod must either define a `send` or a `send_bulk` method')
@ -47,6 +54,7 @@ class NotificationMethod:
self.targets = self.get_targets()
def check_context(self, context):
"""Check that all values defined in the methods CONTEXT were provided in the current context."""
def check(ref, obj):
# the obj is not accesible so we are on the end
if not isinstance(obj, (list, dict, tuple, )):
@ -82,21 +90,33 @@ class NotificationMethod:
return context
def get_targets(self):
"""Returns targets for notifications.
Processes `self.targets` to extract all users that should be notified.
"""
raise NotImplementedError('The `get_targets` method must be implemented!')
def setup(self):
"""Set up context before notifications are send.
This is intended to be overridden in method implementations.
"""
return True
def cleanup(self):
"""Clean up context after all notifications were send.
This is intended to be overridden in method implementations.
"""
return True
# region plugins
def get_plugin(self):
"""Returns plugin class"""
"""Returns plugin class."""
return False
def global_setting_disable(self):
"""Check if the method is defined in a plugin and has a global setting"""
"""Check if the method is defined in a plugin and has a global setting."""
# Check if plugin has a setting
if not self.GLOBAL_SETTING:
return False
@ -115,29 +135,45 @@ class NotificationMethod:
return False
def usersetting(self, target):
"""
Returns setting for this method for a given user
"""
"""Returns setting for this method for a given user."""
return NotificationUserSetting.get_setting(f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}', user=target, method=self.METHOD_NAME)
# endregion
class SingleNotificationMethod(NotificationMethod):
"""NotificationMethod that sends notifications one by one."""
def send(self, target):
"""This function must be overriden."""
raise NotImplementedError('The `send` method must be overriden!')
class BulkNotificationMethod(NotificationMethod):
"""NotificationMethod that sends all notifications in bulk."""
def send_bulk(self):
"""This function must be overriden."""
raise NotImplementedError('The `send` method must be overriden!')
# endregion
class MethodStorageClass:
"""Class that works as registry for all available notification methods in InvenTree.
Is initialized on startup as one instance named `storage` in this file.
"""
liste = None
user_settings = {}
def collect(self, selected_classes=None):
"""Collect all classes in the enviroment that are notification methods.
Can be filtered to only include provided classes for testing.
Args:
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
"""
logger.info('collecting notification methods')
current_method = inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
@ -155,7 +191,17 @@ class MethodStorageClass:
storage.liste = list(filtered_list.values())
logger.info(f'found {len(storage.liste)} notification methods')
def get_usersettings(self, user):
def get_usersettings(self, user) -> list:
"""Returns all user settings for a specific user.
This is needed to show them in the settings UI.
Args:
user (User): User that should be used as a filter.
Returns:
list: All applicablae notification settings.
"""
methods = []
for item in storage.liste:
if item.USER_SETTING:
@ -186,12 +232,16 @@ storage = MethodStorageClass()
class UIMessageNotification(SingleNotificationMethod):
"""Delivery method for sending specific users notifications in the notification pain in the web UI."""
METHOD_NAME = 'ui_message'
def get_targets(self):
"""Just return the targets - no tricks here."""
return self.targets
def send(self, target):
"""Send a UI notification to a user."""
NotificationMessage.objects.create(
target_object=self.obj,
source_object=target,
@ -204,10 +254,7 @@ class UIMessageNotification(SingleNotificationMethod):
def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
"""
Send out a notification
"""
"""Send out a notification."""
targets = kwargs.get('targets', None)
target_fnc = kwargs.get('target_fnc', None)
target_args = kwargs.get('target_args', [])
@ -267,6 +314,15 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
def deliver_notification(cls: NotificationMethod, obj, category: str, targets, context: dict):
"""Send notification with the provided class.
This:
- Intis the method
- Checks that there are valid targets
- Runs the delivery setup
- Sends notifications either via `send_bulk` or send`
- Runs the delivery cleanup
"""
# Init delivery method
method = cls(obj, category, targets, context)

View File

@ -1,6 +1,4 @@
"""
JSON serializers for common components
"""
"""JSON serializers for common components."""
from rest_framework import serializers
@ -11,9 +9,7 @@ from InvenTree.serializers import InvenTreeModelSerializer
class SettingsSerializer(InvenTreeModelSerializer):
"""
Base serializer for a settings object
"""
"""Base serializer for a settings object."""
key = serializers.CharField(read_only=True)
@ -30,10 +26,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
api_url = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""
Returns the choices available for a given item
"""
"""Returns the choices available for a given item."""
results = []
choices = obj.choices()
@ -48,10 +41,7 @@ class SettingsSerializer(InvenTreeModelSerializer):
return results
def get_value(self, obj):
"""
Make sure protected values are not returned
"""
"""Make sure protected values are not returned."""
# never return protected values
if obj.protected:
result = '***'
@ -62,11 +52,11 @@ class SettingsSerializer(InvenTreeModelSerializer):
class GlobalSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeSetting model
"""
"""Serializer for the InvenTreeSetting model."""
class Meta:
"""Meta options for GlobalSettingsSerializer."""
model = InvenTreeSetting
fields = [
'pk',
@ -82,13 +72,13 @@ class GlobalSettingsSerializer(SettingsSerializer):
class UserSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeUserSetting model
"""
"""Serializer for the InvenTreeUserSetting model."""
user = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
"""Meta options for UserSettingsSerializer."""
model = InvenTreeUserSetting
fields = [
'pk',
@ -105,8 +95,7 @@ class UserSettingsSerializer(SettingsSerializer):
class GenericReferencedSettingSerializer(SettingsSerializer):
"""
Serializer for a GenericReferencedSetting model
"""Serializer for a GenericReferencedSetting model.
Args:
MODEL: model class for the serializer
@ -118,9 +107,9 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
EXTRA_FIELDS = None
def __init__(self, *args, **kwargs):
"""Init overrides the Meta class to make it dynamic"""
"""Init overrides the Meta class to make it dynamic."""
class CustomMeta:
"""Scaffold for custom Meta class"""
"""Scaffold for custom Meta class."""
fields = [
'pk',
'key',
@ -144,9 +133,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
class NotificationMessageSerializer(InvenTreeModelSerializer):
"""
Serializer for the InvenTreeUserSetting model
"""
"""Serializer for the InvenTreeUserSetting model."""
target = serializers.SerializerMethodField(read_only=True)
@ -169,12 +156,16 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
read = serializers.BooleanField(read_only=True)
def get_target(self, obj):
"""Function to resolve generic object reference to target."""
return get_objectreference(obj, 'target_content_type', 'target_object_id')
def get_source(self, obj):
"""Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id')
class Meta:
"""Meta options for NotificationMessageSerializer."""
model = NotificationMessage
fields = [
'pk',
@ -192,8 +183,10 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
class NotificationReadSerializer(NotificationMessageSerializer):
"""Serializer for reading a notification."""
def is_valid(self, raise_exception=False):
"""Ensure instance data is available for view and let validation pass."""
self.instance = self.context['instance'] # set instance that should be returned
self._validated_data = True
return True

View File

@ -1,6 +1,4 @@
"""
User-configurable settings for the common app
"""
"""User-configurable settings for the common app."""
from django.conf import settings
@ -8,9 +6,7 @@ from moneyed import CURRENCIES
def currency_code_default():
"""
Returns the default currency code (or USD if not specified)
"""
"""Returns the default currency code (or USD if not specified)"""
from django.db.utils import ProgrammingError
from common.models import InvenTreeSetting
@ -28,23 +24,17 @@ def currency_code_default():
def currency_code_mappings():
"""
Returns the current currency choices
"""
"""Returns the current currency choices."""
return [(a, CURRENCIES[a].name) for a in settings.CURRENCIES]
def currency_codes():
"""
Returns the current currency codes
"""
"""Returns the current currency codes."""
return [a for a in settings.CURRENCIES]
def stock_expiry_enabled():
"""
Returns True if the stock expiry feature is enabled
"""
"""Returns True if the stock expiry feature is enabled."""
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')

View File

@ -1,3 +1,5 @@
"""Tasks (processes that get offloaded) for common app."""
import logging
from datetime import datetime, timedelta
@ -7,12 +9,10 @@ logger = logging.getLogger('inventree')
def delete_old_notifications():
"""
Remove old notifications from the database.
"""Remove old notifications from the database.
Anything older than ~3 months is removed
"""
try:
from common.models import NotificationEntry
except AppRegistryNotReady: # pragma: no cover

View File

@ -1,3 +1,5 @@
"""Tests for basic notification methods and functions in InvenTree."""
import plugin.templatetags.plugin_extras as plugin_tags
from common.notifications import (BulkNotificationMethod, NotificationMethod,
SingleNotificationMethod, storage)
@ -6,9 +8,10 @@ from plugin.models import NotificationUserSetting
class BaseNotificationTests(BaseNotificationIntegrationTest):
"""Tests for basic NotificationMethod."""
def test_NotificationMethod(self):
"""ensure the implementation requirements are tested"""
"""Ensure the implementation requirements are tested."""
class FalseNotificationMethod(NotificationMethod):
METHOD_NAME = 'FalseNotification'
@ -17,12 +20,12 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
METHOD_NAME = 'AnotherFalseNotification'
def send(self):
"""a comment so we do not need a pass"""
"""A comment so we do not need a pass."""
class NoNameNotificationMethod(NotificationMethod):
def send(self):
"""a comment so we do not need a pass"""
"""A comment so we do not need a pass."""
class WrongContextNotificationMethod(NotificationMethod):
METHOD_NAME = 'WrongContextNotification'
@ -34,7 +37,7 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
]
def send(self):
"""a comment so we do not need a pass"""
"""A comment so we do not need a pass."""
# no send / send bulk
with self.assertRaises(NotImplementedError):
@ -53,11 +56,12 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2, }, )
def test_failing_passing(self):
"""Ensure that an error in one deliverymethod is not blocking all mehthods."""
# cover failing delivery
self._notification_run()
def test_errors_passing(self):
"""ensure that errors do not kill the whole delivery"""
"""Ensure that errors do not kill the whole delivery."""
class ErrorImplementation(SingleNotificationMethod):
METHOD_NAME = 'ErrorImplementation'
@ -72,10 +76,14 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
"""Tests for BulkNotificationMethod classes specifically.
General tests for NotificationMethods are in BaseNotificationTests.
"""
def test_BulkNotificationMethod(self):
"""
Ensure the implementation requirements are tested.
"""Ensure the implementation requirements are tested.
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
"""
@ -90,10 +98,14 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
"""Tests for SingleNotificationMethod classes specifically.
General tests for NotificationMethods are in BaseNotificationTests.
"""
def test_SingleNotificationMethod(self):
"""
Ensure the implementation requirements are tested.
"""Ensure the implementation requirements are tested.
MixinNotImplementedError needs to raise if the send() method is not set.
"""
@ -110,14 +122,15 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
class NotificationUserSettingTests(BaseNotificationIntegrationTest):
""" Tests for NotificationUserSetting """
"""Tests for NotificationUserSetting."""
def setUp(self):
"""Setup for all tests."""
super().setUp()
self.client.login(username=self.user.username, password='password')
def test_setting_attributes(self):
"""check notification method plugin methods: usersettings and tags """
"""Check notification method plugin methods: usersettings and tags."""
class SampleImplementation(BulkNotificationMethod):
METHOD_NAME = 'test'

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
"""Tests for tasks in app common."""
from django.test import TestCase
from common.models import NotificationEntry
@ -8,12 +9,10 @@ from . import tasks as common_tasks
class TaskTest(TestCase):
"""
Tests for common tasks
"""
"""Tests for common tasks."""
def test_delete(self):
"""Test that the task `delete_old_notifications` runs through without errors."""
# check empty run
self.assertEqual(NotificationEntry.objects.all().count(), 0)
offload_task(common_tasks.delete_old_notifications,)

View File

@ -1,3 +1 @@
"""
Unit tests for the views associated with the 'common' app
"""
"""Unit tests for the views associated with the 'common' app."""

View File

@ -1,3 +1,4 @@
"""Tests for mechanisms in common."""
import json
from datetime import timedelta
@ -19,16 +20,14 @@ CONTENT_TYPE_JSON = 'application/json'
class SettingsTest(InvenTreeTestCase):
"""
Tests for the 'settings' model
"""
"""Tests for the 'settings' model."""
fixtures = [
'settings',
]
def test_settings_objects(self):
"""Test fixture loading and lookup for settings."""
# There should be two settings objects in the database
settings = InvenTreeSetting.objects.all()
@ -42,9 +41,7 @@ class SettingsTest(InvenTreeTestCase):
self.assertEqual(InvenTreeSetting.get_setting_object('iNvEnTrEE_inSTanCE').pk, 1)
def test_settings_functions(self):
"""
Test settings functions and properties
"""
"""Test settings functions and properties."""
# define settings to check
instance_ref = 'INVENTREE_INSTANCE'
instance_obj = InvenTreeSetting.get_setting_object(instance_ref)
@ -90,9 +87,7 @@ class SettingsTest(InvenTreeTestCase):
self.assertEqual(stale_days.to_native_value(), 0)
def test_allValues(self):
"""
Make sure that the allValues functions returns correctly
"""
"""Make sure that the allValues functions returns correctly."""
# define testing settings
# check a few keys
@ -103,7 +98,13 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn('SIGNUP_GROUP', result)
def run_settings_check(self, key, setting):
"""Test that all settings are valid.
- Ensure that a name is set and that it is translated
- Ensure that a description is set
- Ensure that every setting key is valid
- Ensure that a validator is supplied
"""
self.assertTrue(type(setting) is dict)
name = setting.get('name', None)
@ -147,11 +148,11 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn(default, [True, False])
def test_setting_data(self):
"""
"""Test for settings data.
- Ensure that every setting has a name, which is translated
- Ensure that every setting has a description, which is translated
"""
for key, setting in InvenTreeSetting.SETTINGS.items():
try:
@ -168,10 +169,7 @@ class SettingsTest(InvenTreeTestCase):
raise exc
def test_defaults(self):
"""
Populate the settings with default values
"""
"""Populate the settings with default values."""
for key in InvenTreeSetting.SETTINGS.keys():
value = InvenTreeSetting.get_setting_default(key)
@ -192,14 +190,10 @@ class SettingsTest(InvenTreeTestCase):
class GlobalSettingsApiTest(InvenTreeAPITestCase):
"""
Tests for the global settings API
"""
"""Tests for the global settings API."""
def test_global_settings_api_list(self):
"""
Test list URL for global settings
"""
"""Test list URL for global settings."""
url = reverse('api-global-setting-list')
# Read out each of the global settings value, to ensure they are instantiated in the database
@ -212,7 +206,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), len(InvenTreeSetting.SETTINGS.keys()))
def test_company_name(self):
"""Test a settings object lifecyle e2e."""
setting = InvenTreeSetting.get_setting_object('INVENTREE_COMPANY_NAME')
# Check default value
@ -245,8 +239,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
self.assertEqual(setting.value, val)
def test_api_detail(self):
"""Test that we can access the detail view for a setting based on the <key>"""
"""Test that we can access the detail view for a setting based on the <key>."""
# These keys are invalid, and should return 404
for key in ["apple", "carrot", "dog"]:
response = self.get(
@ -287,28 +280,22 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
class UserSettingsApiTest(InvenTreeAPITestCase):
"""
Tests for the user settings API
"""
"""Tests for the user settings API."""
def test_user_settings_api_list(self):
"""
Test list URL for user settings
"""
"""Test list URL for user settings."""
url = reverse('api-user-setting-list')
self.get(url, expected_code=200)
def test_user_setting_invalid(self):
"""Test a user setting with an invalid key"""
"""Test a user setting with an invalid key."""
url = reverse('api-user-setting-detail', kwargs={'key': 'DONKEY'})
self.get(url, expected_code=404)
def test_user_setting_init(self):
"""Test we can retrieve a setting which has not yet been initialized"""
"""Test we can retrieve a setting which has not yet been initialized."""
key = 'HOMEPAGE_PART_LATEST'
# Ensure it does not actually exist in the database
@ -328,10 +315,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
self.assertEqual(setting.to_native_value(), False)
def test_user_setting_boolean(self):
"""
Test a boolean user setting value
"""
"""Test a boolean user setting value."""
# Ensure we have a boolean setting available
setting = InvenTreeUserSetting.get_setting_object(
'SEARCH_PREVIEW_SHOW_PARTS',
@ -395,7 +379,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
self.assertFalse(str2bool(response.data['value']))
def test_user_setting_choice(self):
"""Test a user setting with choices."""
setting = InvenTreeUserSetting.get_setting_object(
'DATE_DISPLAY_FORMAT',
user=self.user
@ -434,7 +418,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
self.assertIn('Chosen value is not a valid option', str(response.data))
def test_user_setting_integer(self):
"""Test a integer user setting value."""
setting = InvenTreeUserSetting.get_setting_object(
'SEARCH_PREVIEW_RESULTS',
user=self.user
@ -480,25 +464,25 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
"""Tests for the notification user settings API"""
"""Tests for the notification user settings API."""
def test_api_list(self):
"""Test list URL"""
"""Test list URL."""
url = reverse('api-notifcation-setting-list')
self.get(url, expected_code=200)
def test_setting(self):
"""Test the string name for NotificationUserSetting"""
"""Test the string name for NotificationUserSetting."""
test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user)
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): ')
class PluginSettingsApiTest(InvenTreeAPITestCase):
"""Tests for the plugin settings API"""
"""Tests for the plugin settings API."""
def test_plugin_list(self):
"""List installed plugins via API"""
"""List installed plugins via API."""
url = reverse('api-plugin-list')
# Simple request
@ -508,13 +492,13 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
self.get(url, expected_code=200, data={'mixin': 'settings'})
def test_api_list(self):
"""Test list URL"""
"""Test list URL."""
url = reverse('api-plugin-setting-list')
self.get(url, expected_code=200)
def test_valid_plugin_slug(self):
"""Test that an valid plugin slug runs through"""
"""Test that an valid plugin slug runs through."""
# load plugin configs
fixtures = PluginConfig.objects.all()
if not fixtures:
@ -544,26 +528,30 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data))
def test_invalid_setting_key(self):
"""Test that an invalid setting key returns a 404"""
"""Test that an invalid setting key returns a 404."""
...
def test_uninitialized_setting(self):
"""Test that requesting an uninitialized setting creates the setting"""
"""Test that requesting an uninitialized setting creates the setting."""
...
class WebhookMessageTests(TestCase):
"""Tests for webhooks."""
def setUp(self):
"""Setup for all tests."""
self.endpoint_def = WebhookEndpoint.objects.create()
self.url = f'/api/webhook/{self.endpoint_def.endpoint_id}/'
self.client = Client(enforce_csrf_checks=True)
def test_bad_method(self):
"""Test that a wrong HTTP method does not work."""
response = self.client.get(self.url)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
def test_missing_token(self):
"""Tests that token checks work."""
response = self.client.post(
self.url,
content_type=CONTENT_TYPE_JSON,
@ -575,6 +563,7 @@ class WebhookMessageTests(TestCase):
)
def test_bad_token(self):
"""Test that a wrong token is not working."""
response = self.client.post(
self.url,
content_type=CONTENT_TYPE_JSON,
@ -585,6 +574,7 @@ class WebhookMessageTests(TestCase):
assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
def test_bad_url(self):
"""Test that a wrongly formed url is not working."""
response = self.client.post(
'/api/webhook/1234/',
content_type=CONTENT_TYPE_JSON,
@ -593,6 +583,7 @@ class WebhookMessageTests(TestCase):
assert response.status_code == HTTPStatus.NOT_FOUND
def test_bad_json(self):
"""Test that malformed JSON is not accepted."""
response = self.client.post(
self.url,
data="{'this': 123}",
@ -606,6 +597,7 @@ class WebhookMessageTests(TestCase):
)
def test_success_no_token_check(self):
"""Test that a endpoint without a token set does not require one."""
# delete token
self.endpoint_def.token = ''
self.endpoint_def.save()
@ -620,6 +612,7 @@ class WebhookMessageTests(TestCase):
assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
def test_bad_hmac(self):
"""Test that a malformed HMAC does not pass."""
# delete token
self.endpoint_def.token = ''
self.endpoint_def.secret = '123abc'
@ -635,6 +628,7 @@ class WebhookMessageTests(TestCase):
assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
def test_success_hmac(self):
"""Test with a valid HMAC provided."""
# delete token
self.endpoint_def.token = ''
self.endpoint_def.secret = '123abc'
@ -651,6 +645,10 @@ class WebhookMessageTests(TestCase):
assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
def test_success(self):
"""Test full e2e webhook call.
The message should go through and save the json payload.
"""
response = self.client.post(
self.url,
data={"this": "is a message"},
@ -665,9 +663,10 @@ class WebhookMessageTests(TestCase):
class NotificationTest(InvenTreeAPITestCase):
"""Tests for NotificationEntriy."""
def test_check_notification_entries(self):
"""Test that notification entries can be created."""
# Create some notification entries
self.assertEqual(NotificationEntry.objects.count(), 0)
@ -684,21 +683,16 @@ class NotificationTest(InvenTreeAPITestCase):
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
def test_api_list(self):
"""Test list URL"""
"""Test list URL."""
url = reverse('api-notifications-list')
self.get(url, expected_code=200)
class LoadingTest(TestCase):
"""
Tests for the common config
"""
"""Tests for the common config."""
def test_restart_flag(self):
"""
Test that the restart flag is reset on start
"""
"""Test that the restart flag is reset on start."""
import common.models
from plugin import registry
@ -713,10 +707,10 @@ class LoadingTest(TestCase):
class ColorThemeTest(TestCase):
"""Tests for ColorTheme"""
"""Tests for ColorTheme."""
def test_choices(self):
"""Test that default choices are returned"""
"""Test that default choices are returned."""
result = ColorTheme.get_color_themes_choices()
# skip
@ -725,7 +719,7 @@ class ColorThemeTest(TestCase):
self.assertIn(('default', 'Default'), result)
def test_valid_choice(self):
"""Check that is_valid_choice works correctly"""
"""Check that is_valid_choice works correctly."""
result = ColorTheme.get_color_themes_choices()
# skip

View File

@ -1,6 +1,4 @@
"""
URL lookup for common views
"""
"""URL lookup for common views."""
common_urls = [
]

View File

@ -1,6 +1,4 @@
"""
Django views for interacting with common models
"""
"""Django views for interacting with common models."""
import os
@ -18,10 +16,10 @@ from .files import FileManager
class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form
"""Setup basic methods of multi-step form.
form_list: list of forms
form_steps_description: description for each form
form_list: list of forms
form_steps_description: description for each form
"""
form_steps_template = []
@ -31,14 +29,13 @@ class MultiStepFormView(SessionWizardView):
file_storage = FileSystemStorage(settings.MEDIA_ROOT)
def __init__(self, *args, **kwargs):
""" Override init method to set media folder """
"""Override init method to set media folder."""
super().__init__(**kwargs)
self.process_media_folder()
def process_media_folder(self):
""" Process media folder """
"""Process media folder."""
if self.media_folder:
media_folder_abs = os.path.join(settings.MEDIA_ROOT, self.media_folder)
if not os.path.exists(media_folder_abs):
@ -46,8 +43,7 @@ class MultiStepFormView(SessionWizardView):
self.file_storage = FileSystemStorage(location=media_folder_abs)
def get_template_names(self):
""" Select template """
"""Select template."""
try:
# Get template
template = self.form_steps_template[self.steps.index]
@ -57,8 +53,7 @@ class MultiStepFormView(SessionWizardView):
return template
def get_context_data(self, **kwargs):
""" Update context data """
"""Update context data."""
# Retrieve current context
context = super().get_context_data(**kwargs)
@ -74,7 +69,9 @@ class MultiStepFormView(SessionWizardView):
class FileManagementFormView(MultiStepFormView):
""" Setup form wizard to perform the following steps:
"""File management form wizard.
Perform the following steps:
1. Upload tabular data file
2. Match headers to InvenTree fields
3. Edit row data and match InvenTree items
@ -95,8 +92,7 @@ class FileManagementFormView(MultiStepFormView):
extra_context_data = {}
def __init__(self, *args, **kwargs):
""" Initialize the FormView """
"""Initialize the FormView."""
# Perform all checks and inits for MultiStepFormView
super().__init__(self, *args, **kwargs)
@ -105,8 +101,7 @@ class FileManagementFormView(MultiStepFormView):
raise NotImplementedError('A subclass of a file manager class needs to be set!')
def get_context_data(self, form=None, **kwargs):
""" Handle context data """
"""Handle context data."""
if form is None:
form = self.get_form()
@ -136,8 +131,7 @@ class FileManagementFormView(MultiStepFormView):
return context
def get_file_manager(self, step=None, form=None):
""" Get FileManager instance from uploaded file """
"""Get FileManager instance from uploaded file."""
if self.file_manager:
return
@ -151,8 +145,7 @@ class FileManagementFormView(MultiStepFormView):
self.file_manager = self.file_manager_class(file=file, name=self.name)
def get_form_kwargs(self, step=None):
""" Update kwargs to dynamically build forms """
"""Update kwargs to dynamically build forms."""
# Always retrieve FileManager instance from uploaded file
self.get_file_manager(step)
@ -191,7 +184,7 @@ class FileManagementFormView(MultiStepFormView):
return super().get_form_kwargs()
def get_form(self, step=None, data=None, files=None):
""" add crispy-form helper to form """
"""Add crispy-form helper to form."""
form = super().get_form(step=step, data=data, files=files)
form.helper = FormHelper()
@ -200,17 +193,14 @@ class FileManagementFormView(MultiStepFormView):
return form
def get_form_table_data(self, form_data):
""" Extract table cell data from form data and fields.
These data are used to maintain state between sessions.
"""Extract table cell data from form data and fields. These data are used to maintain state between sessions.
Table data keys are as follows:
col_name_<idx> - Column name at idx as provided in the uploaded file
col_guess_<idx> - Column guess at idx as selected
row_<x>_col<y> - Cell data as provided in the uploaded file
"""
# Map the columns
self.column_names = {}
self.column_selections = {}
@ -264,8 +254,7 @@ class FileManagementFormView(MultiStepFormView):
self.row_data[row_id][col_id] = value
def set_form_table_data(self, form=None):
""" Set the form table data """
"""Set the form table data."""
if self.column_names:
# Re-construct the column data
self.columns = []
@ -324,10 +313,10 @@ class FileManagementFormView(MultiStepFormView):
row[field_key] = field_key + '-' + str(row['index'])
def get_column_index(self, name):
""" Return the index of the column with the given name.
"""Return the index of the column with the given name.
It named column is not found, return -1
"""
try:
idx = list(self.column_selections.values()).index(name)
except ValueError:
@ -336,9 +325,7 @@ class FileManagementFormView(MultiStepFormView):
return idx
def get_field_selection(self):
""" Once data columns have been selected, attempt to pre-select the proper data from the database.
This function is called once the field selection has been validated.
The pre-fill data are then passed through to the part selection form.
"""Once data columns have been selected, attempt to pre-select the proper data from the database. This function is called once the field selection has been validated. The pre-fill data are then passed through to the part selection form.
This method is very specific to the type of data found in the file,
therefore overwrite it in the subclass.
@ -346,7 +333,7 @@ class FileManagementFormView(MultiStepFormView):
pass
def get_clean_items(self):
""" returns dict with all cleaned values """
"""Returns dict with all cleaned values."""
items = {}
for form_key, form_value in self.get_all_cleaned_data().items():
@ -373,8 +360,7 @@ class FileManagementFormView(MultiStepFormView):
return items
def check_field_selection(self, form):
""" Check field matching """
"""Check field matching."""
# Are there any missing columns?
missing_columns = []
@ -422,8 +408,7 @@ class FileManagementFormView(MultiStepFormView):
return valid
def validate(self, step, form):
""" Validate forms """
"""Validate forms."""
valid = True
# Get form table data
@ -442,8 +427,7 @@ class FileManagementFormView(MultiStepFormView):
return valid
def post(self, request, *args, **kwargs):
""" Perform validations before posting data """
"""Perform validations before posting data."""
wizard_goto_step = self.request.POST.get('wizard_goto_step', None)
form = self.get_form(data=self.request.POST, files=self.request.FILES)
@ -458,14 +442,21 @@ class FileManagementFormView(MultiStepFormView):
class FileManagementAjaxView(AjaxView):
""" Use a FileManagementFormView as base for a AjaxView
Inherit this class before inheriting the base FileManagementFormView
"""Use a FileManagementFormView as base for a AjaxView Inherit this class before inheriting the base FileManagementFormView.
ajax_form_steps_template: templates for rendering ajax
validate: function to validate the current form -> normally point to the same function in the base FileManagementFormView
"""
def post(self, request):
"""Handle wizard step call.
Possible actions:
- Step back -> render previous step
- Invalid form -> render error
- Valid form and not done -> render next step
- Valid form and done -> render final step
"""
# check if back-step button was selected
wizard_back = self.request.POST.get('act-btn_back', None)
if wizard_back:
@ -497,6 +488,7 @@ class FileManagementAjaxView(AjaxView):
return self.renderJsonResponse(request, data={'form_valid': None})
def get(self, request):
"""Reset storage if flag is set, proceed to render JsonResponse."""
if 'reset' in request.GET:
# reset form
self.storage.reset()
@ -504,11 +496,12 @@ class FileManagementAjaxView(AjaxView):
return self.renderJsonResponse(request)
def renderJsonResponse(self, request, form=None, data={}, context=None):
""" always set the right templates before rendering """
"""Always set the right templates before rendering."""
self.setTemplate()
return super().renderJsonResponse(request, form=form, data=data, context=context)
def get_data(self):
def get_data(self) -> dict:
"""Get extra context data."""
data = super().get_data()
data['hideErrorMessage'] = '1' # hide the error
buttons = [{'name': 'back', 'title': _('Previous Step')}] if self.get_step_index() > 0 else []
@ -516,9 +509,13 @@ class FileManagementAjaxView(AjaxView):
return data
def setTemplate(self):
""" set template name and title """
"""Set template name and title."""
self.ajax_template_name = self.ajax_form_steps_template[self.get_step_index()]
self.ajax_form_title = self.form_steps_description[self.get_step_index()]
def validate(self, obj, form, **kwargs):
"""Generic validate action.
This is the point to process provided userinput.
"""
raise NotImplementedError('This function needs to be overridden!')