diff --git a/InvenTree/company/migrations/0019_auto_20200413_0642.py b/InvenTree/company/migrations/0019_auto_20200413_0642.py new file mode 100644 index 0000000000..e818d647cb --- /dev/null +++ b/InvenTree/company/migrations/0019_auto_20200413_0642.py @@ -0,0 +1,264 @@ +# Generated by Django 2.2.10 on 2020-04-13 06:42 + +import os +from rapidfuzz import fuzz + +from django.db import migrations +from company.models import Company, SupplierPart +from django.db.utils import OperationalError, ProgrammingError + + +def clear(): + os.system('cls' if os.name == 'nt' else 'clear') + + +def reverse_association(apps, schema_editor): + """ + This is the 'reverse' operation of the manufacturer reversal. + This operation is easier: + + For each SupplierPart object, copy the name of the 'manufacturer' field + into the 'manufacturer_name' field. + """ + + print("Reversing migration for manufacturer association") + + try: + for part in SupplierPart.objects.all(): + if part.manufacturer is not None: + part.manufacturer_name = part.manufacturer.name + + part.save() + + except (OperationalError, ProgrammingError): + # An exception might be called if the database is empty + pass + + +def associate_manufacturers(apps, schema_editor): + """ + This migration is the "middle step" in migration of the "manufacturer" field for the SupplierPart model. + + Previously the "manufacturer" field was a simple text field with the manufacturer name. + This is quite insufficient. + The new "manufacturer" field is a link to Company object which has the "is_manufacturer" parameter set to True + + This migration requires user interaction to create new "manufacturer" Company objects, + based on the text value in the "manufacturer_name" field (which was created in the previous migration). + + It uses fuzzy pattern matching to help the user out as much as possible. + """ + + # Link a 'manufacturer_name' to a 'Company' + links = {} + + # Map company names to company objects + companies = {} + + for company in Company.objects.all(): + companies[company.name] = company + + # List of parts which will need saving + parts = [] + + + def link_part(part, name): + """ Attempt to link Part to an existing Company """ + + # Matches a company name directly + if name in companies.keys(): + print(" -> '{n}' maps to existing manufacturer".format(n=name)) + part.manufacturer = companies[name] + part.save() + return True + + # Have we already mapped this + if name in links.keys(): + print(" -> Mapped '{n}' -> '{c}'".format(n=name, c=links[name].name)) + part.manufacturer = links[name] + part.save() + return True + + # Mapping not possible + return False + + def create_manufacturer(part, input_name, company_name): + """ Create a new manufacturer """ + + company = Company(name=company_name, description=company_name, is_manufacturer=True) + + company.is_manufacturer = True + + # Map both names to the same company + links[input_name] = company + links[company_name] = company + + companies[company_name] = company + + # Save the company BEFORE we associate the part, otherwise the PK does not exist + company.save() + + # Save the manufacturer reference link + part.manufacturer = company + part.save() + + print(" -> Created new manufacturer: '{name}'".format(name=company_name)) + + + def find_matches(text, threshold=65): + """ + Attempt to match a 'name' to an existing Company. + A list of potential matches will be returned. + """ + + matches = [] + + for name in companies.keys(): + # Case-insensitive matching + ratio = fuzz.partial_ratio(name.lower(), text.lower()) + + if ratio > threshold: + matches.append({'name': name, 'match': ratio}) + + if len(matches) > 0: + return [match['name'] for match in sorted(matches, key=lambda item: item['match'], reverse=True)] + else: + return [] + + + def map_part_to_manufacturer(part, idx, total): + + name = str(part.manufacturer_name) + + # Skip empty names + if not name or len(name) == 0: + return + + # Can be linked to an existing manufacturer + if link_part(part, name): + return + + # Find a list of potential matches + matches = find_matches(name) + + clear() + + # Present a list of options + print("----------------------------------") + print("Checking part {idx} of {total}".format(idx=idx+1, total=total)) + print("Manufacturer name: '{n}'".format(n=name)) + print("----------------------------------") + print("Select an option from the list below:") + + print("0) - Create new manufacturer '{n}'".format(n=name)) + print("") + + for i, m in enumerate(matches[:10]): + print("{i}) - Use manufacturer '{opt}'".format(i=i+1, opt=m)) + + print("") + print("OR - Type a new custom manufacturer name") + + + while (1): + response = str(input("> ")).strip() + + # Attempt to parse user response as an integer + try: + n = int(response) + + # Option 0) is to create a new manufacturer with the current name + if n == 0: + + create_manufacturer(part, name, name) + return + + # Options 1) -> n) select an existing manufacturer + else: + n = n - 1 + + if n < len(matches): + # Get the company which matches the selected options + company_name = matches[n] + company = companies[company_name] + + # Ensure the company is designated as a manufacturer + company.is_manufacturer = True + company.save() + + # Link the company to the part + part.manufacturer = company + part.save() + + # Link the name to the company + links[name] = company + links[company_name] = company + + print(" -> Linked '{n}' to manufacturer '{m}'".format(n=name, m=company_name)) + + return + + except ValueError: + # User has typed in a custom name! + + if not response or len(response) == 0: + # Response cannot be empty! + print("Please select an option") + + # Double-check if the typed name corresponds to an existing item + elif response in companies.keys(): + link_part(part, companies[response]) + return + + elif response in links.keys(): + link_part(part, links[response]) + return + + # No match, create a new manufacturer + else: + create_manufacturer(part, name, response) + return + + clear() + print("") + clear() + + print("---------------------------------------") + print("The SupplierPart model needs to be migrated,") + print("as the new 'manufacturer' field maps to a 'Company' reference.") + print("The existing 'manufacturer_name' field will be used to match") + print("against possible companies.") + print("This process requires user input.") + print("") + print("To cancel this mapping process, press Ctrl-C in the terminal.") + print("Note: This process MUST be completed to migrate the database.") + print("---------------------------------------") + print("") + + input("Press to continue.") + + clear() + + part_count = SupplierPart.objects.count() + + # Create a unique set of manufacturer names + for idx, part in enumerate(SupplierPart.objects.all()): + + if part.manufacturer is not None: + print(" -> Part '{p}' already has a manufacturer associated (skipping)".format(p=part)) + continue + + map_part_to_manufacturer(part, idx, part_count) + parts.append(part) + + print("Done!") + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0018_supplierpart_manufacturer'), + ] + + operations = [ + migrations.RunPython(associate_manufacturers, reverse_code=reverse_association) + ]