diff --git a/kintree/config/config_interface.py b/kintree/config/config_interface.py index 0533f30e..a4620983 100644 --- a/kintree/config/config_interface.py +++ b/kintree/config/config_interface.py @@ -392,10 +392,10 @@ def sync_inventree_supplier_categories(inventree_config_path: str, supplier_conf def add_supplier_category(categories: dict, supplier_config_path: str) -> bool: ''' Add Supplier category mapping to Supplier settings file - categories = { - 'Capacitors': - { 'Tantalum': 'Tantalum Capacitors' } - } + categories = { + 'Capacitors': + { 'Tantalum': 'Tantalum Capacitors' } + } ''' try: supplier_categories = load_file(supplier_config_path) diff --git a/kintree/config/inventree/categories.yaml b/kintree/config/inventree/categories.yaml index fa8da515..f542b888 100644 --- a/kintree/config/inventree/categories.yaml +++ b/kintree/config/inventree/categories.yaml @@ -1,113 +1,88 @@ -# Coding -CODES: - Assemblies: PCA - Capacitors: CAP - Circuit Protections: PRO - Connectors: CON - Crystals and Oscillators: CLK - Diodes: DIO - Inductors: IND - Integrated Circuits: ICS - Mechanicals: MEC - Miscellaneous: MIS - Modules: MOD - Power Management: PWR - Printed-Circuit Boards: PCB - RF: RFC - Resistors: RES - Transistors: TRA -# Parents and Children Categories CATEGORIES: Assemblies: - - Printed-Circuit Board Assembly - - Product + Printed-Circuit Board Assembly: null + Product: null Capacitors: + Aluminium: null Ceramic: - '0603': - 10V: - - X5R - - X7R - 16V: - - X5R - - X7R - '0402': - 10V: - - X5R - - X7R - 16V: - - X5R - - X7R - Aluminium: - Tantalum: - Polymer: - Super Capacitors: + '0402': null + '0603': null + '0805': null + Polymer: null + Super Capacitors: null + Tantalum: null Circuit Protections: - - TVS - - Fuses - - PTC + Fuses: null + PTC: null + TVS: null Connectors: - - Battery - - Board-to-Board - - Interface - - Coaxial - - FPC - - Header -# - Wire-to-Board -# - High-Speed -# - RF -# - Power + Battery: null + Board-to-Board: null + Coaxial: null + FPC: null + Header: null + Interface: null Crystals and Oscillators: - - Crystals - - Oscillators + Crystals: null + Oscillators: null Diodes: - - Standard - - Schottky - - Zener - - LED + LED: null + Schottky: null + Standard: null + Zener: null Inductors: - - Power - - Ferrite Bead -# - RF + Ferrite Bead: null + Power: null Integrated Circuits: - - Logic - - Interface - - Microcontroller - - Memory - - Sensor -# - SoC-CPU-GPU -# - FPGA-ASIC-CPLD -# - Driver + Interface: null + Logic: null + Memory: null + Microcontroller: null + Sensor: null Mechanicals: - - Switch - - Standoff - - Nuts - - Screws + Nuts: null + Screws: null + Standoff: null + Switch: null Miscellaneous: - - Batteries + Batteries: null Modules: null - Printed-Circuit Boards: null Power Management: - - PMIC - - LDO - - Buck - - Boost + Boost: null + Buck: null + LDO: null + PMIC: null Printed-Circuit Boards: null RF: - - Chipset - - Antenna - - Filter - - Shield + Antenna: null + Chipset: null + Filter: null + Shield: null Resistors: - - Surface Mount - - Through Hole - - Potentiometers - - NTC -# - Array + NTC: null + Potentiometers: null + Surface Mount: null + Through Hole: null Transistors: - - N-Channel FET - - P-Channel FET - - NPN - - PNP - - Load Switches -# - N+N-Channel FET -# - N+P-Channel FET \ No newline at end of file + Load Switches: null + N-Channel FET: null + NPN: null + P-Channel FET: null + PNP: null +CODES: + Assemblies: PCA + Capacitors: CAP + Circuit Protections: PRO + Connectors: CON + Crystals and Oscillators: CLK + Diodes: DIO + Inductors: IND + Integrated Circuits: ICS + Mechanicals: MEC + Miscellaneous: MIS + Modules: MOD + Power Management: PWR + Printed-Circuit Boards: PCB + RF: RFC + Resistors: RES + Transistors: TRA \ No newline at end of file diff --git a/kintree/config/settings.py b/kintree/config/settings.py index 4c4c0d14..7cedccd5 100644 --- a/kintree/config/settings.py +++ b/kintree/config/settings.py @@ -338,14 +338,18 @@ def set_enable_flag(key: str, value: bool): global CONFIG_GENERAL user_settings = CONFIG_GENERAL - if key == 'kicad': - user_settings['ENABLE_KICAD'] = value - elif key == 'inventree': - user_settings['ENABLE_INVENTREE'] = value - elif key == 'alternate': - user_settings['ENABLE_ALTERNATE'] = value - - # Save - config_interface.dump_file(user_settings, os.path.join(CONFIG_USER_FILES, 'general.yaml')) + if key in ['kicad', 'inventree', 'alternate']: + if key == 'kicad': + user_settings['ENABLE_KICAD'] = value + elif key == 'inventree': + user_settings['ENABLE_INVENTREE'] = value + elif key == 'alternate': + user_settings['ENABLE_ALTERNATE'] = value + + # Save + config_interface.dump_file( + data=user_settings, + file_path=os.path.join(CONFIG_USER_FILES, 'general.yaml'), + ) return reload_enable_flags() diff --git a/kintree/database/inventree_api.py b/kintree/database/inventree_api.py index d8860d42..b811dd6e 100644 --- a/kintree/database/inventree_api.py +++ b/kintree/database/inventree_api.py @@ -72,6 +72,41 @@ def get_inventree_category_id(category_tree: list) -> int: return -1 +def get_categories() -> dict: + '''Fetch InvenTree categories''' + global inventree_api + + categories = {} + # Get all categories (list) + db_categories = PartCategory.list(inventree_api) + + def deep_add(tree: dict, keys: list, item: dict): + if len(keys) == 1: + try: + tree[keys[0]].update(item) + except (KeyError, AttributeError): + tree[keys[0]] = item + return + return deep_add(tree.get(keys[0]), keys[1:], item) + + for category in db_categories: + parent = category.getParentCategory() + children = category.getChildCategories() + + if not parent and not children: + categories[category.name] = None + continue + elif parent: + parent_list = [] + while parent: + parent_list.insert(0, parent.name) + parent = parent.getParentCategory() + cat = {category.name: None} + deep_add(categories, parent_list, cat) + + return categories + + def get_category_parameters(category_id: int) -> list: ''' Get all default parameter templates for category ''' global inventree_api diff --git a/kintree/database/inventree_interface.py b/kintree/database/inventree_interface.py index dfebfc9d..7257d9a4 100644 --- a/kintree/database/inventree_interface.py +++ b/kintree/database/inventree_interface.py @@ -8,6 +8,8 @@ from fuzzywuzzy import fuzz from ..search import search_api, digikey_api, mouser_api, element14_api, lcsc_api +category_separator = '/' + def connect_to_server(timeout=5) -> bool: ''' Connect to InvenTree server using user settings ''' @@ -41,6 +43,58 @@ def connect_to_server(timeout=5) -> bool: return connect +def category_tree(tree: str) -> str: + import re + find_prefix = re.match(r'^-+ (.+?)$', tree) + if find_prefix: + return find_prefix.group(1) + return tree + + +def split_category_tree(tree: str) -> list: + return category_tree(tree).split(category_separator) + + +def build_category_tree(reload=False, category=None) -> dict: + '''Build InvenTree category tree from database data''' + + category_data = config_interface.load_file(settings.CONFIG_CATEGORIES) + + def build_tree(tree, left_to_go, level) -> list: + try: + last_entry = f' {category_tree(tree[-1])}{category_separator}' + except IndexError: + last_entry = '' + if type(left_to_go) == dict: + for key, value in left_to_go.items(): + tree.append(f'{"-" * level}{last_entry}{key}') + build_tree(tree, value, level + 1) + elif type(left_to_go) == list: + # Supports legacy structure + for item in left_to_go: + tree.append(f'{"-" * level}{last_entry}{item}') + elif left_to_go is None: + pass + return + + if reload: + categories = inventree_api.get_categories() + category_data.update({'CATEGORIES': categories}) + config_interface.dump_file(category_data, settings.CONFIG_CATEGORIES) + else: + categories = category_data.get('CATEGORIES', {}) + + # Get specified branch + if category: + categories = {category: categories.get(category, {})} + + inventree_categories = [] + # Build category tree + build_tree(inventree_categories, categories, 0) + + return inventree_categories + + def get_categories_from_supplier_data(part_info: dict, supplier_only=False) -> list: ''' Find categories from part supplier data, use "somewhat automatic" matching ''' categories = [None, None] @@ -598,10 +652,6 @@ def inventree_create_alternate(part_info: dict, part_id='', part_ipn='', show_pr cprint('\n[MAIN]\tSearching for original part in database', silent=settings.SILENT) part = inventree_api.fetch_part(part_id, part_ipn) - # Progress Update - if not progress.update_progress_bar(show_progress, increment=0.05): - return - if part: part_pk = part.pk part_description = part.description @@ -624,7 +674,7 @@ def inventree_create_alternate(part_info: dict, part_id='', part_ipn='', show_pr description=part_description) # Progress Update - if not progress.update_progress_bar(show_progress, increment=0.05): + if not progress.update_progress_bar(show_progress, increment=0.5): return supplier_name = part_info.get('supplier_name', '') diff --git a/kintree/gui/views/common.py b/kintree/gui/views/common.py index aed48bea..a38dc281 100644 --- a/kintree/gui/views/common.py +++ b/kintree/gui/views/common.py @@ -1,3 +1,4 @@ +from enum import Enum from math import pi from typing import Optional @@ -11,10 +12,11 @@ 'nav_rail_text_size': 16, 'nav_rail_padding': 10, 'textfield_width': 600, - 'textfield_dense': False, + 'textfield_dense': True, 'textfield_space_after': 3, 'dropdown_width': 600, 'dropdown_dense': False, + 'searchfield_width': 300, 'button_width': 100, 'button_height': 56, 'icon_size': 40, @@ -24,6 +26,12 @@ data_from_views = {} +class DialogType(Enum): + VALID = 'valid' + WARNING = 'warning' + ERROR = 'error' + + def handle_transition(page: ft.Page, transition: bool, update_page=False, timeout=0): # print(f'{transition=} | {update_page=} | {timeout=}') if transition: @@ -77,6 +85,7 @@ class CommonView(ft.View): column = None fields = None data = None + dialog = None def __init__(self, page: ft.Page, appbar: ft.AppBar, navigation_rail: ft.NavigationRail): # Store page pointer @@ -108,36 +117,60 @@ def _build(self): expand=True, ), ] + + def build_dialog(self): + return None - def build_snackbar(self, dialog_success: bool, dialog_text: str): - if dialog_success: + def build_snackbar(self, d_type: DialogType, message: str): + if d_type == DialogType.VALID: self.dialog = ft.SnackBar( bgcolor=ft.colors.GREEN_100, content=ft.Text( - dialog_text, + message, color=ft.colors.GREEN_700, size=GUI_PARAMS['nav_rail_text_size'], weight=ft.FontWeight.BOLD, ), ) - else: + elif d_type == DialogType.WARNING: self.dialog = ft.SnackBar( - bgcolor=ft.colors.RED_ACCENT_100, + bgcolor=ft.colors.AMBER_100, content=ft.Text( - dialog_text, - color=ft.colors.RED_ACCENT_700, + message, + color=ft.colors.AMBER_800, + size=GUI_PARAMS['nav_rail_text_size'], + weight=ft.FontWeight.BOLD, + ), + ) + elif d_type == DialogType.ERROR: + self.dialog = ft.SnackBar( + bgcolor=ft.colors.RED_100, + content=ft.Text( + message, + color=ft.colors.RED_700, size=GUI_PARAMS['nav_rail_text_size'], weight=ft.FontWeight.BOLD, ), ) - def show_dialog(self, open=True): - if type(self.dialog) == ft.Banner: - self.page.banner = self.dialog - self.page.banner.open = open - elif type(self.dialog) == ft.SnackBar: + def show_dialog( + self, + d_type: Optional[DialogType] = None, + message: Optional[str] = None, + snackbar=True, + open=True, + ): + if snackbar: + self.build_snackbar(d_type, message) + if type(self.dialog) == ft.SnackBar: self.page.snack_bar = self.dialog self.page.snack_bar.open = True + elif type(self.dialog) == ft.Banner: + self.page.banner = self.dialog + self.page.banner.open = open + elif type(self.dialog) == ft.AlertDialog: + self.page.dialog = self.dialog + self.dialog.open = open self.page.update() diff --git a/kintree/gui/views/main.py b/kintree/gui/views/main.py index 8353d62b..2c6211b1 100644 --- a/kintree/gui/views/main.py +++ b/kintree/gui/views/main.py @@ -6,7 +6,9 @@ from ... import __version__ # Common view from .common import GUI_PARAMS, data_from_views -from .common import handle_transition, CommonView, DropdownWithSearch +from .common import DialogType, CommonView, DropdownWithSearch +from .common import handle_transition +# Tools from ...common.tools import cprint # Settings from ...common import progress @@ -15,6 +17,8 @@ from ...database import inventree_interface # KiCad from ...kicad import kicad_interface +# SnapEDA +from ...search import snapeda_api # Main AppBar main_appbar = ft.AppBar( @@ -184,24 +188,22 @@ def find_libraries(self, type: str) -> list: found_libraries = list(self.get_footprint_libraries().keys()) return found_libraries - def show_error_dialog(self, message): - self.build_snackbar(False, message) - self.show_dialog() - if 'create' in self.fields: - self.enable_create(True) + def process_enable(self, e, value=None, ignore=['enable']): + disabled = False + if e.data.lower() == 'false': + disabled = True - def process_enable(self, e, ignore=['enable']): - disable = True - if e.data.lower() == 'true': - disable = False + # Overwrite with value + if value is not None: + disabled = not value - # print(e.control.label, not(disable)) + # print(e.control.label, not disabled) key = e.control.label.lower() - settings.set_enable_flag(key, not disable) + settings.set_enable_flag(key, not disabled) for name, field in self.fields.items(): if name not in ignore: - field.disabled = disable + field.disabled = disabled field.update() self.push_data(e) @@ -219,8 +221,19 @@ def push_data(self, e=None, label=None, value=None): # Push data_from_views[self.title] = self.data - def did_mount(self): + def did_mount(self, enable=False): handle_transition(self.page, transition=False, update_page=True) + if self.fields.get('enable', None) is not None: + # Create enable event + e = ft.ControlEvent( + target=None, + name='did_mount_enable', + data='true' if enable else 'false', + page=self.page, + control=self.fields['enable'], + ) + # Process enable + self.process_enable(e) return super().did_mount() @@ -278,10 +291,13 @@ def run_search(self, e): # Validate form if bool(self.fields['part_number'].value) != bool(self.fields['supplier'].value): if not self.fields['part_number'].value: - self.build_snackbar(dialog_success=False, dialog_text='Missing Part Number') + error_msg = 'Missing Part Number' else: - self.build_snackbar(dialog_success=False, dialog_text='Missing Supplier') - self.show_dialog() + error_msg = 'Missing Supplier' + self.show_dialog( + d_type=DialogType.ERROR, + message=error_msg, + ) else: self.page.splash.visible = True self.page.update() @@ -321,10 +337,17 @@ def run_search(self, e): # Add to data buffer self.push_data() self.page.splash.visible = False + + if self.data['searched_part_number'].lower() != self.data['manufacturer_part_number'].lower(): + self.show_dialog( + d_type=DialogType.WARNING, + message='Found part number does not match the requested part number', + ) self.page.update() return def push_data(self, e=None): + self.data['searched_part_number'] = self.fields['part_number'].value for key, field in self.fields['search_form'].items(): self.data[key] = field.value data_from_views[self.title] = self.data @@ -372,65 +395,122 @@ class InventreeView(MainView): title = 'InvenTree' fields = { - 'enable': ft.Switch(label='InvenTree', value=settings.ENABLE_INVENTREE, on_change=None), - 'alternate': ft.Switch(label='Alternate', value=False, disabled=True), - 'load_categories': ft.ElevatedButton('Reload InvenTree Categories', height=36, icon=ft.icons.REPLAY, disabled=True), - 'Category': DropdownWithSearch(label='Category', dr_width=400, sr_width=400, dense=True, options=[]), + 'enable': ft.Switch( + label='InvenTree', + value=settings.ENABLE_INVENTREE, + ), + 'alternate': ft.Switch( + label='Alternate', + value=settings.ENABLE_ALTERNATE if settings.ENABLE_INVENTREE else False, + disabled=not settings.ENABLE_INVENTREE, + ), + 'load_categories': ft.ElevatedButton( + 'Reload InvenTree Categories', + width=GUI_PARAMS['button_width'] * 2.6, + height=36, + icon=ft.icons.REPLAY, + disabled=False, + ), + 'Category': DropdownWithSearch( + label='Category', + dr_width=GUI_PARAMS['textfield_width'], + sr_width=GUI_PARAMS['searchfield_width'], + dense=GUI_PARAMS['textfield_dense'], + disabled=settings.ENABLE_ALTERNATE, + options=[], + ), + 'Existing Part ID': ft.TextField( + label='Existing Part ID', + width=GUI_PARAMS['textfield_width'] / 2 - 5, + dense=GUI_PARAMS['textfield_dense'], + visible=settings.ENABLE_INVENTREE and settings.ENABLE_ALTERNATE, + ), + 'Existing Part IPN': ft.TextField( + label='Existing Part IPN', + width=GUI_PARAMS['textfield_width'] / 2 - 5, + dense=GUI_PARAMS['textfield_dense'], + visible=settings.ENABLE_INVENTREE and settings.ENABLE_ALTERNATE, + ), } - category_separator = '/' - - def clean_category_tree(self, category_tree: str) -> str: - import re - find_prefix = re.match(r'^-+ (.+?)$', category_tree) - if find_prefix: - return find_prefix.group(1) - return category_tree - - def clean_split_category_tree(self, category_tree: str) -> list: - return self.clean_category_tree(category_tree).split(self.category_separator) def sanitize_data(self): category_tree = self.data.get('Category', None) if category_tree: - self.data['Category'] = self.clean_split_category_tree(category_tree) + self.data['Category'] = inventree_interface.split_category_tree(category_tree) + + def process_enable(self, e): + # Switch control + if e.data.lower() == 'false': + self.fields['alternate'].value = False + self.fields['alternate'].update() + self.process_alternate(e, value=False) + # View mounting control + if self.fields['alternate'].value: + # Alternate mode enabled: disable eveything except the alternate fields + super().process_enable(e, value=True, ignore=['enable', 'Existing Part ID', 'Existing Part IPN']) + self.process_alternate(e, value=True) + else: + super().process_enable(e, value=self.fields['enable'].value, ignore=['enable']) + + def process_alternate(self, e, value=None): + if value: + visible = value + else: + visible = False + if e.data.lower() == 'true': + visible = True + if visible: + self.show_dialog( + d_type=DialogType.WARNING, + message='Enter Existing Part ID or Part IPN', + ) + settings.set_enable_flag('alternate', visible) + self.fields['Existing Part ID'].visible = visible + self.fields['Existing Part ID'].update() + self.fields['Existing Part IPN'].visible = visible + self.fields['Existing Part IPN'].update() + + # Process category dropdown and load category button + if visible: + self.fields['Category'].value = None + self.fields['Category'].disabled = visible + self.fields['Category'].update() + self.fields['load_categories'].disabled = visible + self.fields['load_categories'].update() - def process_enable(self, e, ignore=['enable', 'alternate', 'load_categories']): - return super().process_enable(e, ignore) + self.push_data(e) + def get_category_options(self, reload=False): + return [ + ft.dropdown.Option(category) + for category in inventree_interface.build_category_tree(reload=reload) + ] + def reload_categories(self, e): - # TODO: Implement pulling categories from InvenTree - print('Loading categories from InvenTree...') + self.page.splash.visible = True + self.page.update() - def build_column(self): - def build_tree(tree, left_to_go, level): - try: - last_entry = f' {self.clean_category_tree(tree[-1])}{self.category_separator}' - except IndexError: - last_entry = '' - if type(left_to_go) == dict: - for key, value in left_to_go.items(): - tree.append(f'{"-" * level}{last_entry}{key}') - build_tree(tree, value, level + 1) - elif type(left_to_go) == list: - for item in left_to_go: - tree.append(f'{"-" * level}{last_entry}{item}') - elif left_to_go is None: - pass - return - - categories = config_interface.load_file(settings.CONFIG_CATEGORIES).get('CATEGORIES', {}) + # Check connection + if not inventree_interface.connect_to_server(): + self.show_dialog(DialogType.ERROR, 'ERROR: Failed to connect to InvenTree server') + else: + self.fields['Category'].options = self.get_category_options(reload=True) + self.fields['Category'].update() - inventree_categories = [] - # Build category tree - build_tree(inventree_categories, categories, 0) + self.page.splash.visible = False + self.page.update() - category_options = [ft.dropdown.Option(category) for category in inventree_categories] - # Update dropdown - self.fields['Category'].options = category_options + def build_column(self): + # Update dropdown with category options + self.fields['Category'].options = self.get_category_options() self.fields['Category'].on_change = self.push_data + self.fields['alternate'].on_change = self.process_alternate self.fields['load_categories'].on_click = self.reload_categories + self.fields['Existing Part ID'].on_change = self.push_data + self.fields['Existing Part IPN'].on_change = self.push_data + self.column = ft.Column( controls=[ ft.Row(), @@ -439,11 +519,20 @@ def build_tree(tree, left_to_go, level): self.fields['enable'], self.fields['alternate'], self.fields['load_categories'], - ] + ], + width=GUI_PARAMS['dropdown_width'], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), - self.fields['Category'], + ft.Row([self.fields['Category'],]), + ft.Row([ + self.fields['Existing Part ID'], + self.fields['Existing Part IPN'], + ]), ], ) + + def did_mount(self): + return super().did_mount(enable=settings.ENABLE_INVENTREE) class KicadView(MainView): @@ -451,14 +540,119 @@ class KicadView(MainView): title = 'KiCad' fields = { - 'enable': ft.Switch(label='KiCad', value=settings.ENABLE_KICAD, on_change=None), - 'Symbol Library': DropdownWithSearch(label='', dr_width=400, sr_width=400, dense=True, options=[]), - 'Symbol Template': DropdownWithSearch(label='', dr_width=400, sr_width=400, dense=True, options=[]), - 'Footprint Library': DropdownWithSearch(label='', dr_width=400, sr_width=400, dense=True, options=[]), - 'Footprint': DropdownWithSearch(label='', dr_width=400, sr_width=400, dense=True, options=[]), - 'New Footprint Name': ft.TextField(label='New Footprint Name', width=400, dense=True), + 'enable': ft.Switch( + label='KiCad', + value=settings.ENABLE_KICAD, + ), + 'Symbol Library': DropdownWithSearch( + label='', + dr_width=GUI_PARAMS['textfield_width'], + sr_width=GUI_PARAMS['searchfield_width'], + dense=GUI_PARAMS['textfield_dense'], + options=[], + ), + 'Symbol Template': DropdownWithSearch( + label='', + dr_width=GUI_PARAMS['textfield_width'], + sr_width=GUI_PARAMS['searchfield_width'], + dense=GUI_PARAMS['textfield_dense'], + options=[], + ), + 'Footprint Library': DropdownWithSearch( + label='', + dr_width=GUI_PARAMS['textfield_width'], + sr_width=GUI_PARAMS['searchfield_width'], + dense=GUI_PARAMS['textfield_dense'], + options=[], + ), + 'Footprint': DropdownWithSearch( + label='', + dr_width=GUI_PARAMS['textfield_width'], + sr_width=GUI_PARAMS['searchfield_width'], + dense=GUI_PARAMS['textfield_dense'], + options=[], + ), + 'New Footprint Name': ft.TextField( + label='New Footprint Name', + width=GUI_PARAMS['textfield_width'], + dense=GUI_PARAMS['textfield_dense'], + ), + 'Check SnapEDA': ft.ElevatedButton( + content=ft.Row( + [ + ft.Icon('search'), + ft.Text('Check SnapEDA', size=16), + ] + ), + height=GUI_PARAMS['button_height'], + width=GUI_PARAMS['button_width'] * 2, + ), } + def build_alert_dialog(self, symbol: str, footprint: str, download: str, single_result=False): + modal_content = ft.Row() + modal_msg = ft.Text('Symbol and footprint are not available on SnapEDA') + # Build content + if symbol: + modal_content.controls.append(ft.Image(symbol)) + modal_msg = ft.Text('Symbol is available on SnapEDA') + if footprint: + modal_content.controls.append(ft.Image(footprint)) + if symbol: + modal_msg = ft.Text('Symbol and footprint are available on SnapEDA') + else: + modal_msg = ft.Text('Footprint is available on SnapEDA') + # Build actions + modal_actions = [] + if download: + if not symbol and not footprint: + if single_result: + modal_actions.append(ft.TextButton('Check Part', on_click=lambda _: self.page.launch_url(download))) + else: + modal_msg = ft.Text('Multiple matches found on SnapEDA') + modal_actions.append(ft.TextButton('See Results', on_click=lambda _: self.page.launch_url(download))) + else: + modal_actions.append(ft.TextButton('Download', on_click=lambda _: self.page.launch_url(download))) + modal_actions.append(ft.TextButton('Close', on_click=lambda _: self.show_dialog(open=False))) + + return ft.AlertDialog( + modal=True, + title=modal_msg, + content=modal_content, + actions=modal_actions, + actions_alignment=ft.MainAxisAlignment.END, + # on_dismiss=None, + ) + + def check_snapeda(self, e): + if not data_from_views.get('Part Search', {}).get('manufacturer_part_number', ''): + self.show_dialog( + d_type=DialogType.ERROR, + message='Missing Part Data', + ) + return + + self.page.splash.visible = True + self.page.update() + + response = snapeda_api.fetch_snapeda_part_info(data_from_views['Part Search']['manufacturer_part_number']) + data = snapeda_api.parse_snapeda_response(response) + + images = {} + if data['has_symbol'] or data['has_footprint']: + images = snapeda_api.download_snapeda_images(data) + + self.page.splash.visible = False + self.page.update() + + self.dialog = self.build_alert_dialog( + images.get('symbol', ''), + images.get('footprint', ''), + data.get('part_url', ''), + data.get('has_single_result', False), + ) + self.show_dialog(snackbar=False, open=True) + def update_footprint_options(self, library: str): footprint_options = [] # Load paths @@ -505,8 +699,13 @@ def build_column(self): ) kicad_inputs = [] for name, field in self.fields.items(): - if name != 'enable': - field.on_change = self.push_data + # Update callbacks + if type(field) == ft.ElevatedButton: + field.on_click = self.check_snapeda + else: + if name != 'enable': + field.on_change = self.push_data + # Update options if type(field) == DropdownWithSearch: field.label = name if name == 'Symbol Library': @@ -520,6 +719,21 @@ def build_column(self): self.column.controls.extend(kicad_inputs) + def did_mount(self): + if 'InvenTree' in data_from_views: + # Get value of alternate switch + if data_from_views['InvenTree'].get('alternate', False): + self.fields['enable'].disabled = True + self.fields['enable'].value = False + self.show_dialog( + d_type=DialogType.ERROR, + message='InvenTree Alternate switch is enabled', + ) + return super().did_mount(enable=False) + else: + self.fields['enable'].disabled = False + return super().did_mount(enable=settings.ENABLE_KICAD) + class CreateView(MainView): '''Create view''' @@ -557,6 +771,11 @@ class CreateView(MainView): kicad_progress_row = None create_continue = True + def show_dialog(self, type: DialogType, message: str): + if 'create' in self.fields: + self.enable_create(True) + return super().show_dialog(type, message) + def enable_create(self, enable=True): self.fields['create'].disabled = not enable self.fields['create'].update() @@ -586,7 +805,7 @@ def process_cancel(self): # if self.fields['kicad_progress'].value < 1.0: # self.fields['kicad_progress'].color = "red" # self.fields['kicad_progress'].update() - self.show_error_dialog('Action Cancelled') + self.show_dialog(DialogType.ERROR, 'Action Cancelled') self.create_continue = True self.enable_create(True) return @@ -608,18 +827,24 @@ def reset_progress_bars(self): # Reset progress bar progress.reset_progress_bar(self.fields['kicad_progress']) self.kicad_progress_row.current.update() + + if not settings.ENABLE_INVENTREE and not settings.ENABLE_KICAD: + self.fields['create'].disabled = True + else: + self.fields['create'].disabled = False + self.fields['create'].update() def create_part(self, e=None): self.reset_progress_bars() if not settings.ENABLE_INVENTREE and not settings.ENABLE_KICAD: - self.show_error_dialog('Both InvenTree and KiCad are disabled (nothing to create)') + self.show_dialog(DialogType.ERROR, 'Both InvenTree and KiCad are disabled (nothing to create)') # print('data_from_views='); cprint(data_from_views) # Check data is present if not data_from_views.get('Part Search', None): - self.show_error_dialog('Missing Part Data (nothing to create)') + self.show_dialog(DialogType.ERROR, 'Missing Part Data (nothing to create)') return # Custom part check @@ -630,7 +855,7 @@ def create_part(self, e=None): # Part number check part_number = data_from_views['Part Search'].get('manufacturer_part_number', None) if not part_number: - self.show_error_dialog('Missing Part Number') + self.show_dialog(DialogType.ERROR, 'Missing Part Number') return # Button update @@ -640,10 +865,10 @@ def create_part(self, e=None): symbol = None template = None footprint = None - if settings.ENABLE_KICAD: + if settings.ENABLE_KICAD and not settings.ENABLE_ALTERNATE: # Check data is present if not data_from_views.get('KiCad', None): - self.show_error_dialog('Missing KiCad Data') + self.show_dialog(DialogType.ERROR, 'Missing KiCad Data') return # Process symbol @@ -666,7 +891,7 @@ def create_part(self, e=None): # print(symbol, template, footprint) if not symbol or not template or not footprint: - self.show_error_dialog('Missing KiCad Data') + self.show_dialog(DialogType.ERROR, 'Missing KiCad Data') return if not self.create_continue: @@ -676,60 +901,85 @@ def create_part(self, e=None): if settings.ENABLE_INVENTREE: # Check data is present if not data_from_views.get('InvenTree', None): - self.show_error_dialog('Missing InvenTree Data') + self.show_dialog(DialogType.ERROR, 'Missing InvenTree Data') return # Check connection if not inventree_interface.connect_to_server(): - self.show_error_dialog('ERROR: Failed to connect to InvenTree server') - return - # Check mandatory data - if not data_from_views['Part Search'].get('name', None): - self.show_error_dialog('Missing Part Name') - return - if not data_from_views['Part Search'].get('description', None): - self.show_error_dialog('Missing Part Description') + self.show_dialog(DialogType.ERROR, 'ERROR: Failed to connect to InvenTree server') return - # Get relevant data - category_tree = data_from_views['InvenTree'].get('Category', None) - if not category_tree: - # Check category is present - self.show_error_dialog('Missing InvenTree Category') - return - else: - part_info['category_tree'] = category_tree - # Create part - new_part, part_pk, part_info = inventree_interface.inventree_create( - part_info=part_info, - kicad=settings.ENABLE_KICAD, - symbol=symbol, - footprint=footprint, - show_progress=self.fields['inventree_progress'], - is_custom=custom, - ) - # print(new_part, part_pk) - # cprint(part_info) - - if part_pk: - # Update symbol - if symbol: - symbol = f'{symbol.split(":")[0]}:{part_info["IPN"]}' - - if not new_part: + if settings.ENABLE_ALTERNATE: + # Check mandatory data + if not data_from_views['InvenTree']['Existing Part ID'] and not data_from_views['InvenTree']['Existing Part IPN']: + self.show_dialog(DialogType.ERROR, 'Missing Existing Part ID and Part IPN') + return + # Create alternate + alt_result = inventree_interface.inventree_create_alternate( + part_info=part_info, + part_id=data_from_views['InvenTree']['Existing Part ID'], + part_ipn=data_from_views['InvenTree']['Existing Part IPN'], + show_progress=self.fields['inventree_progress'], + ) + else: + # Check mandatory data + if not data_from_views['Part Search'].get('name', None): + self.show_dialog(DialogType.ERROR, 'Missing Part Name') + return + if not data_from_views['Part Search'].get('description', None): + self.show_dialog(DialogType.ERROR, 'Missing Part Description') + return + # Get relevant data + category_tree = data_from_views['InvenTree'].get('Category', None) + if not category_tree: + # Check category is present + self.show_dialog(DialogType.ERROR, 'Missing InvenTree Category') + return + else: + part_info['category_tree'] = category_tree + # Create new part + new_part, part_pk, part_info = inventree_interface.inventree_create( + part_info=part_info, + kicad=settings.ENABLE_KICAD, + symbol=symbol, + footprint=footprint, + show_progress=self.fields['inventree_progress'], + is_custom=custom, + ) + # print(new_part, part_pk) + # cprint(part_info) + + if settings.ENABLE_ALTERNATE: + if alt_result: + # Update InvenTree URL + if data_from_views['InvenTree']['Existing Part IPN']: + part_ref = data_from_views['InvenTree']['Existing Part IPN'] + else: + part_ref = data_from_views['InvenTree']['Existing Part ID'] + part_info['inventree_url'] = f'{settings.PART_URL_ROOT}{part_ref}/' + else: self.fields['inventree_progress'].color = "amber" - self.fields['inventree_progress'].update() # Complete add operation self.fields['inventree_progress'].value = progress.MAX_PROGRESS - self.fields['inventree_progress'].update() else: - self.fields['inventree_progress'].color = "red" - self.fields['inventree_progress'].update() + if part_pk: + # Update symbol + if symbol: + symbol = f'{symbol.split(":")[0]}:{part_info["IPN"]}' + + if not new_part: + self.fields['inventree_progress'].color = "amber" + # Complete add operation + self.fields['inventree_progress'].value = progress.MAX_PROGRESS + else: + self.fields['inventree_progress'].color = "red" + + self.fields['inventree_progress'].update() if not self.create_continue: return self.process_cancel() # KiCad data processing - if settings.ENABLE_KICAD: + if settings.ENABLE_KICAD and not settings.ENABLE_ALTERNATE: part_info['Symbol'] = symbol part_info['Template'] = template.split('/') part_info['Footprint'] = footprint @@ -801,7 +1051,7 @@ def build_column(self): alignment=ft.MainAxisAlignment.CENTER, width=600, ), - ft.Text('Progress', style=ft.TextThemeStyle.HEADLINE_SMALL), + ft.Row(height=16), ft.Row( ref=self.inventree_progress_row, controls=[ diff --git a/kintree/gui/views/settings.py b/kintree/gui/views/settings.py index 0bd43041..00f4af90 100644 --- a/kintree/gui/views/settings.py +++ b/kintree/gui/views/settings.py @@ -1,8 +1,9 @@ import flet as ft # Common view -from .common import handle_transition, CommonView +from .common import DialogType, CommonView from .common import GUI_PARAMS +from .common import handle_transition # Settings from ...config import settings as global_settings from ...config import config_interface @@ -207,12 +208,10 @@ def save(self): config_interface.dump_file(updated_settings, self.settings_file) # Alert user - if not self.dialog: - self.build_snackbar( - dialog_success=True, - dialog_text=f'{self.title} successfully saved', - ) - self.show_dialog() + self.show_dialog( + d_type=DialogType.VALID, + message=f'{self.title} successfully saved', + ) def on_dialog_result(self, e: ft.FilePickerResultEvent): '''Populate field with user-selected system path''' @@ -330,7 +329,11 @@ class UserSettingsView(SettingsView): settings_file = global_settings.USER_CONFIG_FILE def __init__(self, page: ft.Page): - self.dialog = ft.Banner( + super().__init__(page) + self.dialog = self.build_dialog() + + def build_dialog(self): + return ft.Banner( bgcolor=ft.colors.AMBER_100, leading=ft.Icon(ft.icons.WARNING_AMBER_ROUNDED, color=ft.colors.AMBER, size=GUI_PARAMS['icon_size']), content=ft.Text('Restart Ki-nTree to load the new user paths', weight=ft.FontWeight.BOLD), @@ -338,8 +341,9 @@ def __init__(self, page: ft.Page): ft.TextButton('Discard', on_click=lambda _: self.show_dialog(open=False)), ], ) - - super().__init__(page) + + def show_dialog(self, d_type=None, message=None, snackbar=False, open=True): + return super().show_dialog(d_type, message, snackbar, open) def did_mount(self): # Reset Index @@ -404,11 +408,10 @@ def save_s(self, e: ft.ControlEvent, supplier: str, show_dialog=True): config_interface.dump_file(lcsc_settings, global_settings.CONFIG_LCSC_API) if show_dialog: - self.build_snackbar( - dialog_success=True, - dialog_text=f'{supplier} Settings successfully saved', + self.show_dialog( + d_type=DialogType.VALID, + message=f'{supplier} Settings successfully saved', ) - self.show_dialog() def test_s(self, e: ft.ControlEvent, supplier: str): '''Test supplier API settings''' @@ -429,16 +432,15 @@ def test_s(self, e: ft.ControlEvent, supplier: str): result = lcsc_api.test_api() if result: - self.build_snackbar( - dialog_success=result, - dialog_text=f'Successfully connected to {supplier} API' + self.show_dialog( + d_type=DialogType.VALID, + message=f'Successfully connected to {supplier} API' ) else: - self.build_snackbar( - dialog_success=result, - dialog_text=f'ERROR: Failed to connect to {supplier} API. Verify the {supplier} credentials and re-try' + self.show_dialog( + d_type=DialogType.ERROR, + message=f'ERROR: Failed to connect to {supplier} API. Verify the {supplier} credentials and re-try' ) - self.show_dialog() def build_column(self): # Title and separator @@ -528,27 +530,25 @@ def save(self, dialog=True): global_settings.load_inventree_settings() # Alert user if dialog: - self.build_snackbar( - dialog_success=True, - dialog_text=f'{self.title} successfully saved', + self.show_dialog( + d_type=DialogType.VALID, + message=f'{self.title} successfully saved', ) - self.show_dialog() def test(self): from ...database import inventree_interface self.save(dialog=False) connection = inventree_interface.connect_to_server() if connection: - self.build_snackbar( - dialog_success=connection, - dialog_text='Sucessfully connected to InvenTree server' + self.show_dialog( + d_type=DialogType.VALID, + message='Sucessfully connected to InvenTree server', ) else: - self.build_snackbar( - dialog_success=connection, - dialog_text='ERROR: Failed to connect to InvenTree server. Verify the InvenTree credentials are correct and server is running' + self.show_dialog( + d_type=DialogType.ERROR, + message='Failed to connect to InvenTree server. Check InvenTree credentials are correct and server is running', ) - self.show_dialog() def __init__(self, page: ft.Page): # Load InvenTree settings diff --git a/kintree/kicad/kicad_symbol.py b/kintree/kicad/kicad_symbol.py index 271f4ed8..99f36271 100644 --- a/kintree/kicad/kicad_symbol.py +++ b/kintree/kicad/kicad_symbol.py @@ -44,8 +44,8 @@ def add_symbol_to_library_from_inventree(self, symbol_data, template_path=None, return part_in_lib, new_part if not template_path: - category = symbol_data['category_tree'][0] - subcategory = symbol_data['category_tree'][1] + category = symbol_data['Template'][0] + subcategory = symbol_data['Template'][1] # Fetch template path try: diff --git a/kintree/search/element14_api.py b/kintree/search/element14_api.py index a7997311..962b77a5 100644 --- a/kintree/search/element14_api.py +++ b/kintree/search/element14_api.py @@ -251,11 +251,11 @@ def test_api(store_url=None) -> bool: }, { 'store_url': 'au.element14.com', - 'part_number': '2N7002-7-F', + 'part_number': '2N7002K-T1-GE3', 'expected': { - 'displayName': 'Power MOSFET, N Channel, 60 V, 115 mA, 13.5 ohm, SOT-23, Surface Mount', - 'brandName': 'DIODES INC.', - 'translatedManufacturerPartNumber': '2N7002-7-F', + 'displayName': 'Power MOSFET, N Channel, 60 V, 190 mA, 2 ohm, SOT-23, Surface Mount', + 'brandName': 'VISHAY', + 'translatedManufacturerPartNumber': '2N7002K-T1-GE3', } }, ] diff --git a/kintree/setup_inventree.py b/kintree/setup_inventree.py index ff03790e..ea7cad56 100644 --- a/kintree/setup_inventree.py +++ b/kintree/setup_inventree.py @@ -10,11 +10,21 @@ def setup_inventree(): SETUP_CATEGORIES = True SETUP_PARAMETERS = True + def create_categories(parent, name, categories): + category_pk, is_category_new = inventree_api.create_category(parent=parent, name=name) + if is_category_new: + cprint(f'[TREE]\tSuccess: Category "{name}" was added to InvenTree') + else: + cprint(f'[TREE]\tWarning: Category "{name}" already exists') + + if categories[name]: + for cat in categories[name]: + create_categories(parent=name, name=cat, categories=categories[name]) + if SETUP_CATEGORIES or SETUP_PARAMETERS: cprint('\n[MAIN]\tStarting InvenTree setup', silent=settings.SILENT) # Load category configuration file categories = config_interface.load_file(settings.CONFIG_CATEGORIES)['CATEGORIES'] - # cprint(categories) cprint('[MAIN]\tConnecting to Inventree', silent=settings.SILENT) inventree_connect = inventree_interface.connect_to_server() @@ -24,22 +34,8 @@ def setup_inventree(): if SETUP_CATEGORIES: for category in categories.keys(): - cprint(f'\n[MAIN]\tCreating {category.upper()}') - category_pk, is_category_new = inventree_api.create_category(parent=None, name=category) - if is_category_new: - cprint(f'[TREE]\tSuccess: Category "{category}" was added to InvenTree') - else: - cprint(f'[TREE]\tWarning: Category "{category}" already exists') - - if categories[category]: - cprint('[MAIN]\tCreating Subcategories') - for subcategory in categories[category]: - sub_category_pk, is_subcategory_new = inventree_api.create_category(parent=category, name=subcategory) - - if is_subcategory_new: - cprint(f'[TREE]\tSuccess: Subcategory "{category}/{subcategory}" was added to InvenTree') - else: - cprint(f'[TREE]\tWarning: Subcategory "{category}/{subcategory}" already exists') + cprint(f'\n[MAIN]\tCreating categories in {category.upper()}') + create_categories(parent=None, name=category, categories=categories) if SETUP_PARAMETERS: # Load parameter configuration file diff --git a/run_tests.py b/run_tests.py index 6dde0e7c..3e6bb81c 100644 --- a/run_tests.py +++ b/run_tests.py @@ -166,6 +166,8 @@ def check_result(status: str, new_part: bool) -> bool: }) # Update categories part_info['category_tree'] = inventree_interface.get_categories_from_supplier_data(part_info) + # Needed for tests + part_info['Template'] = part_info['category_tree'] # Display part to be tested pretty_test_print(f'[INFO]\tChecking "{number}" ({status})') @@ -259,6 +261,7 @@ def check_result(status: str, new_part: bool) -> bool: 'Add invalid alternate supplier part using part IPN', 'Save InvenTree settings', 'Load configuration files with incorrect paths', + 'Build InvenTree category tree (file, db and branch)', ] method_success = True # Line return @@ -409,6 +412,27 @@ def check_result(status: str, new_part: bool) -> bool: if config_interface.load_user_config_files('', ''): method_success = False + elif method_idx == 15: + # Reload categories from file + cat_from_file = inventree_interface.build_category_tree(reload=False) + if type(cat_from_file) != list: + print(f'{type(cat_from_file)} != list') + method_success = False + + if method_success: + # Reload categories from InvenTree database + cat_from_db = inventree_interface.build_category_tree(reload=True) + if len(cat_from_db) != len(cat_from_file): + print(f'{len(cat_from_db)} != {len(cat_from_file)}') + method_success = False + + if method_success: + # Reload category branch + cat_branch = inventree_interface.build_category_tree(category='Crystals and Oscillators') + if len(cat_branch) != 3: + print(f'{len(cat_branch)} != 3') + method_success = False + if method_success: cprint('[ PASS ]') else: