From fce94a2c4481170244c68257bbde6bc458f78a31 Mon Sep 17 00:00:00 2001 From: LavissaWoW Date: Tue, 19 Mar 2024 23:52:33 +0100 Subject: [PATCH] Move files and add tests --- .github/workflows/ci.yml | 75 ++++ README.md | 30 +- {src => ipn_generator}/__init__.py | 0 {src => ipn_generator}/generator.py | 128 ++++--- ipn_generator/tests/__init__.py | 0 ipn_generator/tests/test_IPNGenerator.py | 456 +++++++++++++++++++++++ pyproject.toml | 6 +- 7 files changed, 643 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml rename {src => ipn_generator}/__init__.py (100%) rename {src => ipn_generator}/generator.py (60%) create mode 100644 ipn_generator/tests/__init__.py create mode 100644 ipn_generator/tests/test_IPNGenerator.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f43e289 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + pull_request: + +jobs: + style: + name: "Style Checks" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install flake8 + pip install pep8-naming + + - name: Perform Style Check + run: | + flake8 . + + test-plugin: + name: "Unit Tests" + runs-on: ubuntu-latest + + container: + image: inventree/inventree:latest + env: + INVENTREE_DB_ENGINE: postgresql + INVENTREE_DB_NAME: inventree + INVENTREE_DB_HOST: db + INVENTREE_DB_PORT: 5432 + INVENTREE_DB_USER: inventree + INVENTREE_DB_PASSWORD: inventree + INVENTREE_PLUGINS_ENABLED: True + INVENTREE_PLUGIN_TESTING: True + PLUGIN_TESTING_EVENTS: True + INVENTREE_PLUGIN_TESTING_SETUP: True + + services: + db: + image: postgres:13 + ports: + - 5432:5432 + env: + POSTGRES_USER: inventree + POSTGRES_PASSWORD: inventree + POSTGRES_DB: inventree + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup InvenTree + run: | + cd /home/inventree + pip3 install --no-cache-dir --disable-pip-version-check -U -r requirements.txt + + - name: Setup IPN Generator Plugin + run: | + cd /home/inventree + pip3 install -e $GITHUB_WORKSPACE + + - name: Run Tests + run: | + cd /home/inventree + invoke test diff --git a/README.md b/README.md index 244cdca..4713866 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,36 @@ This is a plugin for [InvenTree](https://github.com/inventree/InvenTree/). Installing this plugin enables the automatic generation if Internal Part Numbers (IPN) for parts. +## Installation +NOT YET PUBLISHED! + +To automatically install the plugin when running `invoke install`: +Add `inventree-ipn-generator` to your plugins.txt file. + +Or, install the plugin manually: + +``` +pip install inventree-ipn-generator +``` + +## Settings + +- Active - Enables toggling of plugin without having to disable it +- On Create - If on, the plugin will assign IPNs to newly created parts +- On Change - If on, the plugin will assign IPNs to parts after a change has been made. +Enabling this setting will remove the ability to have parts without IPNs. + ## Pattern Part Number patterns follow three basic groups. Literals, Numerics, and characters. When incrementing a part number, the rightmost group that is mutable will be incremented. +All groups can be combined in any order. + +A pattern cannot consist of _only_ Literals. -### Literals (Characters that won't change) -Anything encased in `()` will be rendered as is. no change will be made to anything within. +### Literals (Immutable) +Anything encased in `()` will be rendered as-is. no change will be made to anything within. -Example: `(A5C)` will _always_ render as "A5C", regardless of other groups +Example: `(A6C)` will _always_ render as "A6C", regardless of other groups ### Numeric Numbers that should change over time should be encased in `{}` @@ -31,5 +53,5 @@ These two directives can be combined. ### Examples 1. `(AB){3}[ab]` -> AB001a, AB001b, AB002a, AB021b, AB032a, etc -2. `{2}[Aq](BD)` -> 01ABD, 01bBD, 02ABD, 02bBD, etc +2. `{2}[Aq](BD)` -> 01ABD, 01qBD, 02ABD, 02qBD, etc 3. `{1}[a-d]{8+}` -> 1a8, 1a9, 1b8, 1b9, 1c8, 1c9, 1d8, 1d9, 2a8, etc diff --git a/src/__init__.py b/ipn_generator/__init__.py similarity index 100% rename from src/__init__.py rename to ipn_generator/__init__.py diff --git a/src/generator.py b/ipn_generator/generator.py similarity index 60% rename from src/generator.py rename to ipn_generator/generator.py index 22b5bbd..727923b 100644 --- a/src/generator.py +++ b/ipn_generator/generator.py @@ -4,14 +4,21 @@ from plugin.mixins import EventMixin, SettingsMixin from part.models import Part +from django.core.exceptions import ValidationError + import logging import re -# translation -from django.utils.translation import ugettext_lazy as _ - logger = logging.getLogger('inventree') +def validate_pattern(pattern): + """Validates pattern groups""" + regex = re.compile(r"(\{\d+\+?\})|(\[(?!\w\])(?:\w+|(?:\w-\w)+)+\])") + if not regex.search(pattern): + raise ValidationError("Pattern must include more than Literals") + + return True + class AutoGenIPNPlugin(EventMixin, SettingsMixin, InvenTreePlugin): """Plugin to generate IPN automatically""" @@ -25,27 +32,28 @@ class AutoGenIPNPlugin(EventMixin, SettingsMixin, InvenTreePlugin): SETTINGS = { 'ACTIVE': { - 'name': _('Active'), - 'description': _('IPN generator is active'), + 'name': ('Active'), + 'description': ('IPN generator is active'), 'validator': bool, 'default': True }, 'ON_CREATE': { - 'name': _('On Create'), - 'description': _('Active when creating new parts'), + 'name': ('On Create'), + 'description': ('Active when creating new parts'), 'validator': bool, 'default': True }, 'ON_CHANGE': { - 'name': _('On Edit'), - 'description': _('Active when editing existing parts'), + 'name': ('On Edit'), + 'description': ('Active when editing existing parts'), 'validator': bool, 'default': True }, 'PATTERN': { - 'name': _('IPN pattern'), - 'description': _('Pattern for IPN generation'), - 'default': '' + 'name': ('IPN pattern'), + 'description': ('Pattern for IPN generation'), + 'default': "(12)[a-z][a-d]", + 'validator': validate_pattern }, } @@ -54,42 +62,59 @@ class AutoGenIPNPlugin(EventMixin, SettingsMixin, InvenTreePlugin): skip_chars = range(ord('['), ord('a')) def wants_process_event(self, event): + """Lets InvenTree know what events to listen for.""" if not self.get_setting('ACTIVE'): return False - return event in ['part_part.saved', 'part_part.created'] + if (event == 'part_part.saved'): + return self.get_setting('ON_CHANGE') + + if (event == 'part_part.created'): + return self.get_setting('ON_CREATE') + + return False def process_event(self, event, *args, **kwargs): + """Main plugin handler function""" + + if not self.get_setting('ACTIVE'): + return False + id = kwargs.pop('id', None) model = kwargs.pop('model', None) + # Events can fire on unrelated models if model != "Part": + logger.debug('IPN Generator: Event Model is not part') return + # Don't create IPNs for parts with IPNs part = Part.objects.get(id=id) if part.IPN: return - expression = self.construct_regex() - + expression = self.construct_regex(True) latest = Part.objects.filter(IPN__regex=expression).order_by('-IPN').first() if not latest: part.IPN = self.construct_first_ipn() else: - part.IPN = self.increment_ipn(expression, latest.IPN) - + grouped_expression = self.construct_regex() + part.IPN = self.increment_ipn(grouped_expression, latest.IPN) + part.save() return - def construct_regex(self): - + def construct_regex(self, disable_groups=False): + """Constructs a valid regex from provided IPN pattern. + This regex is used to find the latest assigned IPN + """ regex = '^' - m = re.findall(r"(\{\d+\+?\})|(\([^\d\(\)]+\))|(\[(?:\w+|\w-\w)+\])", self.get_setting('PATTERN')) + m = re.findall(r"(\{\d+\+?\})|(\([\w\(\)]+\))|(\[(?:\w+|\w-\w)+\])", self.get_setting('PATTERN')) for idx, group in enumerate(m): numeric, literal, character = group @@ -98,24 +123,33 @@ def construct_regex(self): start = "+" in numeric r = '' g = numeric.strip("{}+") - if start: - regex += f'(?P' + if start: + regex += '(' + if not disable_groups: + regex += f'?P' for char in g: regex += f'[{char}-9]' else: - regex += f'(?P' + regex += '(' + if not disable_groups: + regex += f'?P' regex += f'\d{ {int(g)} }' regex += ')' - + # Literal, won't change if literal: l = literal.strip("()") - regex += f'(?P{re.escape(l)})' + regex += '(' + if not disable_groups: + regex += f'?P' + regex += f'{re.escape(l)})' # Letters, a collection or sequence # Sequences incremented using ASCII if character: - regex += f'(?P' + + + if not disable_groups: + regex += f'{"_".join(exp).replace("-", "")}i{idx}>' regex += f'[{"".join(exp)}]' regex += ')' @@ -138,7 +173,7 @@ def construct_regex(self): return regex def increment_ipn(self, exp, latest): - + """Deconstructs IPN pattern based on latest IPN and constructs a the next IPN in the series.""" m: re.Match = re.match(exp, latest) ipn_list = [] @@ -167,7 +202,7 @@ def increment_ipn(self, exp, latest): if not ranges: if choices.index(val) == len(choices) - 1: ipn_list.append(choices[0]) - else: + else: ipn_list.append(choices[choices.index(val) + 1]) incremented = True else: @@ -176,7 +211,7 @@ def increment_ipn(self, exp, latest): min = ord(choice[0]) max = ord(choice[1]) if integerized_char in range(min, max + 1): - if integerized_char == max -1: + if integerized_char == max: ipn_list.append(choice[0]) else: ipn_list.append(chr(integerized_char + 1)) @@ -188,8 +223,18 @@ def increment_ipn(self, exp, latest): break elif type.startswith('N'): - num = int(type[1:]) - if len(str(int(val) + 1)) > num: + if type[1] == 'p': + num = int(type[2:]) + else: + num = int(type[1:]) + if type[1] == 'p': + starts = int(type[2:]) + next = int(val) + 1 + if len(str(next)) > len(type[2:]): + ipn_list.append(type[2:]) + else: + ipn_list.append(str(next)) + elif len(str(int(val) + 1)) > num: ipn_list.append(str(1).zfill(num)) else: ipn_list.append(str(int(val)+1).zfill(num)) @@ -201,17 +246,16 @@ def increment_ipn(self, exp, latest): def construct_first_ipn(self): - - m = re.findall(r"(\{\d+\+?\})|(\([^\d\(\)]+\))|(\[(?:\w+|(?:\w-\w)+)\])", self.get_setting('PATTERN')) + """No IPNs matching the pattern were found. Constructing the first IPN.""" + m = re.findall(r"(\{\d+\+?\})|(\([\w\(\)]+\))|(\[(?:\w+|(?:\w-\w)+)\])", self.get_setting('PATTERN')) ipn = '' for group in m: numeric, literal, character = group - if numeric: num = numeric.strip("{}+") - if "+" in numeric: + if "+" in numeric: ipn += num else: ipn += str(1).zfill(int(num)) @@ -220,12 +264,6 @@ def construct_first_ipn(self): ipn += literal.strip("()") if character: - ipn += character[0] + ipn += character.strip("[]")[0] return ipn - - - - - - diff --git a/ipn_generator/tests/__init__.py b/ipn_generator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipn_generator/tests/test_IPNGenerator.py b/ipn_generator/tests/test_IPNGenerator.py new file mode 100644 index 0000000..6694b1b --- /dev/null +++ b/ipn_generator/tests/test_IPNGenerator.py @@ -0,0 +1,456 @@ +from django.test import TestCase +from django.core.exceptions import ValidationError +import logging + +from django.conf import settings +from ..generator import AutoGenIPNPlugin +from part.models import Part, PartCategory +from common.models import InvenTreeSetting + +from plugin import registry + +logger = logging.getLogger('inventree') + +def setup_func(cls): + settings.PLUGIN_TESTING_EVENTS = True + settings.TESTING_TABLE_EVENTS = True + InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True) + cls.plugin = registry.get_plugin('ipngen') + conf = cls.plugin.plugin_config() + conf.active = True + conf.save() + +def teardown_func(): + settings.PLUGIN_TESTING_EVENTS = False + settings.TESTING_TABLE_EVENTS = False + InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', False) + + +class IPNGeneratorPatternTests(TestCase): + """Tests for verifying IPN pattern validation works properly""" + def setUp(self): + """Set up test environment""" + setup_func(self) + + def tearDown(self): + """Teardown test environment""" + teardown_func() + + def test_cannot_add_only_literal(self): + """Verify that setting PATTERN to only literals fails validation""" + with self.assertRaises(ValidationError): + self.plugin.set_setting('PATTERN', '(123)') + + def test_cannot_add_only_random_string(self): + """Verify that setting PATTERN to an invalid string""" + with self.assertRaises(ValidationError): + self.plugin.set_setting('PATTERN', 'asldkferljgjtdS:DfS_D:fE_SD:FA_;G') + + def test_numeric_setting_length_1(self): + """Verify that numeric regex accepts more than 1 int.""" + # Single digit + try: + self.plugin.set_setting('PATTERN', '{1}') + except ValidationError: + self.fail("Correct numeric syntax raised a ValidationError") + + def test_numeric_setting_length_2(self): + # Two digits + try: + self.plugin.set_setting('PATTERN', '{15}') + except ValidationError: + self.fail("Correct numeric syntax raised a ValidationError") + + + def test_numeric_setting_length_3(self): + # Multiple digits + try: + self.plugin.set_setting('PATTERN', '{125}') + except ValidationError: + self.fail("Correct numeric syntax raised a ValidationError") + + + def text_numeric_setting_prefix_zero(self): + """Zeroes should be filtered out when prefixed to numerics""" + try: + self.plugin.set_setting('PATTERN', '{05}') + except ValidationError: + self.fail("Numeric with 0 prefix raised a ValidationError") + + + def test_numeric_setting_with_start(self): + """Appending a + to numerics should work""" + try: + self.plugin.set_setting('PATTERN', '{25+}') + except ValidationError: + self.fail("Numeric with + suffix raised a ValidationError") + + def test_character_must_contain_more_than_one_character(self): + """Verify that character groups must contain more than 1 character""" + with self.assertRaises(ValidationError): + self.plugin.set_setting('PATTERN', '[a]') + + def test_character_invalid_format(self): + """Verify that character ranges are properly formatted""" + with self.assertRaises(ValidationError): + self.plugin.set_setting('PATTERN', '[a-]') + + with self.assertRaises(ValidationError): + self.plugin.set_setting('PATTERN', '[aa-]') + + def test_character_range_valid(self): + """Verify that properly formatted character ranges are accepted""" + try: + self.plugin.set_setting('PATTERN', '[a-b]') + except ValidationError: + self.fail("Valid character group range raised a ValidationError") + + def test_character_list_valid(self): + """Verify that list of individual characters are accepted""" + try: + self.plugin.set_setting('PATTERN', '[abcsd]') + except ValidationError: + self.fail("Valid character list raised a ValidationError") + + def test_pattern_combinations(self): + """""" + try: + self.plugin.set_setting('PATTERN', '(1b)[a-b]{2}') + except ValidationError: + self.fail("Valid pattern (1b)[a-b]{2} raised a ValidationError") + + try: + self.plugin.set_setting('PATTERN', '[ab][a-d]{2}{3}') + except ValidationError: + self.fail("Valid pattern [ab][a-d]{2}{3} raised a ValidationError") + + try: + self.plugin.set_setting('PATTERN', '{2}[bc](a2)[a-c]') + except ValidationError: + self.fail("Valid pattern {2}[bc](a2)[a-c] raised a ValidationError") + + try: + self.plugin.set_setting('PATTERN', '[a-b](1s){2}(3d)') + except ValidationError: + self.fail("Valid pattern [a-b](1s){2}(3d) raised a ValidationError") + + try: + self.plugin.set_setting('PATTERN', '{1}[aa]{2}(1r)') + except ValidationError: + self.fail("Valid pattern {1}[aa]{2}(1r) raised a ValidationError") + + +class InvenTreeIPNGeneratorNumericGroupTests(TestCase): + """Tests verifying that numeric groupe behave properly""" + + def setUp(self): + """Set up test environment""" + setup_func(self) + + def tearDown(self): + """Teardown test environment""" + teardown_func() + + def test_add_numeric(self): + """Verify that numeric patterns work.""" + + self.plugin.set_setting('PATTERN', '{1}') + + cat = PartCategory.objects.all().first() + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=new_part.pk) + + self.assertIsNotNone(part.IPN) + + self.assertEqual(part.IPN, '1') + + def test_add_numeric_with_start(self): + """Verify that Numeric patterns with start number works.""" + + self.plugin.set_setting('PATTERN', '{11+}') + + cat = PartCategory.objects.all().first() + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + self.assertEqual(Part.objects.get(pk=new_part.pk).IPN, '11') + + def test_add_numeric_incrementing(self): + """Verify that numeric patterns increment on subsequent parts.""" + + self.plugin.set_setting('PATTERN', '{1}') + + self.assertEqual(self.plugin.get_setting('PATTERN'), '{1}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name='PartName' + ) + + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=new_part.pk) + + self.assertEqual(part.IPN, '2') + + def test_add_numeric_incrementing_with_start(self): + """Verify that numeric patterns with start number increment on subsequent parts.""" + self.plugin.set_setting('PATTERN', '{11+}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name='PartName' + ) + + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=new_part.pk) + + self.assertEqual(part.IPN, '12') + + def test_add_numeric_with_prepend_zero(self): + """Verify that numeric patterns work.""" + + self.plugin.set_setting('PATTERN', '{3}') + + cat = PartCategory.objects.all().first() + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=new_part.pk) + + self.assertIsNotNone(part.IPN) + + self.assertEqual(part.IPN, '001') + + def test_numeric_rollover(self): + """Verify that numeric groups rollover when reaching max""" + + self.plugin.set_setting('PATTERN', '{2}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name=f'PartName', + IPN='99' + ) + + p = Part.objects.create( + category=cat, + name=f'PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, '01') + + def test_numeric_with_start_rollover(self): + """Verify that numeric groups with start number rollover when reaching max""" + + self.plugin.set_setting('PATTERN', '{26+}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name=f'PartName', + IPN='99' + ) + + p = Part.objects.create( + category=cat, + name=f'PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, '26') + + +class InvenTreeIPNGeneratorLiteralsTests(TestCase): + """Tests verifying that literals function as they should""" + + def setUp(self): + """Set up test environment""" + setup_func(self) + + def tearDown(self): + """Teardown test environment""" + teardown_func() + + def test_literal_persists(self): + """Verify literals do not change""" + + self.plugin.set_setting('PATTERN', '{1}(1v3)') + + cat = PartCategory.objects.all().first() + + Part.objects.create( + category=cat, + name='PartName' + ) + + new_part = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=new_part.pk) + + self.assertEqual(part.IPN, '21v3') + + +class InvenTreeIPNGeneratorCharacterTests(TestCase): + """Verify that character groups perform as they should""" + + def setUp(self): + """Set up test environment""" + setup_func(self) + + def tearDown(self): + """Teardown test environment""" + teardown_func() + + def test_character_list(self): + """Verify that lists of characters are looped through.""" + + self.plugin.set_setting('PATTERN', '[abc]') + + cat = PartCategory.objects.all().first() + + def gen_part(expected_ipn): + p = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, expected_ipn) + + gen_part('a') + gen_part('b') + gen_part('c') + + def test_character_list_rollover(self): + """Verify that character lists restart after reaching the end""" + + self.plugin.set_setting('PATTERN', '[abc]') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name=f'PartName', + IPN='c' + ) + + p = Part.objects.create( + category=cat, + name=f'PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, 'a') + + def test_character_range(self): + """Verify that ranges of characters are looped through.""" + + self.plugin.set_setting('PATTERN', '[a-c]') + + cat = PartCategory.objects.all().first() + + def gen_part(expected_ipn): + p = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, expected_ipn) + + gen_part('a') + gen_part('b') + gen_part('c') + + def test_character_range_rollover(self): + """Verify that character ranges loop around after reaching the end.""" + + self.plugin.set_setting('PATTERN', '[a-c]') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name='PartName', + IPN='c' + ) + + p = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, 'a') + + +class IPNGeneratorCombiningTests(TestCase): + """Verify that combining different groups works properly""" + def setUp(self): + """Set up test environment""" + setup_func(self) + + def tearDown(self): + """Teardown test environment""" + teardown_func() + + def test_literal_and_number(self): + """Verify literals and numbers work together""" + + self.plugin.set_setting('PATTERN', '(AB){2}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name='PartName', + IPN='AB12' + ) + + p = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, 'AB13') + + def test_only_last_incrementable_is_changed(self): + """Verify that only last group in pattern gets incremented""" + + self.plugin.set_setting('PATTERN', '[abc]{2}') + + cat = PartCategory.objects.all().first() + Part.objects.create( + category=cat, + name='PartName', + IPN='a25' + ) + + p = Part.objects.create( + category=cat, + name='PartName' + ) + + part = Part.objects.get(pk=p.pk) + self.assertEqual(part.IPN, 'a26') diff --git a/pyproject.toml b/pyproject.toml index 75d5ec9..ac10a7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = 'setuptools.build_meta' [project] name = "inventree-ipn-generator" version = "0.1" -description = "InvenTree plugin to autogen ipn" +description = "InvenTree plugin to automatically generate and assign Internal Part Numbers to parts" authors = [ {name = "Nichlas Walsøe", email = "lavissawow@gmail.com"} ] @@ -16,10 +16,10 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] -requires-python = ">=3.11" +requires-python = ">=3.10" [project.urls] "Homepage" = "https://github.com/LavissaWoW/inventree-ipn-generator" [project.entry-points."inventree_plugins"] -IPNGenerator = 'generator:AutoGenIPNPlugin' \ No newline at end of file +IPNGenerator = 'ipn_generator.generator:AutoGenIPNPlugin'