-
-
Notifications
You must be signed in to change notification settings - Fork 971
Supplier Mixin #9761
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Supplier Mixin #9761
Conversation
✅ Deploy Preview for inventree-web-pui-preview canceled.
|
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9761 +/- ##
==========================================
+ Coverage 86.33% 86.36% +0.03%
==========================================
Files 1234 1239 +5
Lines 54276 54624 +348
Branches 2242 2238 -4
==========================================
+ Hits 46860 47177 +317
- Misses 6847 6878 +31
Partials 569 569
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
// Update pricing when quantity changes | ||
useEffect(() => { | ||
if (quantity === null || quantity === undefined || !pricing) return; | ||
|
||
// Find the highest price break that is less than or equal to the quantity | ||
const priceBreak = Object.entries(pricing) | ||
.sort(([a], [b]) => Number.parseInt(b) - Number.parseInt(a)) | ||
.find(([br]) => quantity >= Number.parseInt(br)); | ||
|
||
if (priceBreak) { | ||
setPurchasePrice(priceBreak[1][0]); | ||
setPurchasePriceCurrency(priceBreak[1][1]); | ||
} | ||
}, [pricing, quantity]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@SchrodingersGat do I understand this wrong how this form framework works? I want to hook up the provided price breaks to the purchase price input field. Meaning if I change the quantity, it should auto fill the purchase price from the provided price breaks. I have debugged this issue like 2h now and still cannot figure out why the price does not get updated in the field, even now the field definition updates the value correctly (after the change in ApiForm.tsx
). Do you have any idea here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can test this now with the integrated sample plugin very easily. Just go to a part table and open the new import wizard there next to the create new part button. Then search for e.g. M5
and import any bolt. When you are at the stock step, click the plus icon and update the quantity and see that the pricing does not change. However when logging the stockItemFields to the console, the value actually updates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wolflu05 I'm not setup to test this right now but the code you have written here looks like it should work. This is the approach we use elsewhere...
And do you have any idea, why the PUI tests fail, I havent touched them in a while and I do net get anything from the error. There seems to be tests failing where I havent changed anything... I would really appriciate some pointers on how to fix them. Otherwise this PR is ready for a first review. |
without looking at it deeply - there is a lot of coverage on the backend missing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very good quality, as always. A few questions / nitpicks; nothing really blocking.
I think a few lines of user docs on this would be good too - if you have time.
This is a great start, there are a few things that I think could be more sketched out (in future PRs, by anyone):
- Caching
- Handling of rate limits
- oAuth (digikey and a few other have that)
- parameter normalizing among suppliers
- data structures to handle multiple supplier accounts
@dataclass | ||
class SearchResult: | ||
"""Data class to represent a search result from a supplier.""" | ||
|
||
sku: str | ||
name: str | ||
exact: bool | ||
description: Optional[str] = None | ||
price: Optional[str] = None | ||
link: Optional[str] = None | ||
image_url: Optional[str] = None | ||
id: Optional[str] = None | ||
existing_part: Optional[part_models.Part] = None | ||
|
||
def __post_init__(self): | ||
"""Post-initialization to set the ID if not provided.""" | ||
if not self.id: | ||
self.id = self.sku | ||
|
||
@dataclass | ||
class ImportParameter: | ||
"""Data class to represent a parameter for a part during import.""" | ||
|
||
name: str | ||
value: str | ||
on_category: Optional[bool] = False | ||
parameter_template: Optional[part_models.PartParameterTemplate] = None | ||
|
||
def __post_init__(self): | ||
"""Post-initialization to fetch the parameter template if not provided.""" | ||
if not self.parameter_template: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: Why are these in the mixin itself and not module context? Is there a need to have separate instances per plugin?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As python classes inside classes, are nothing more special than just having the abillity to call them by SupplierMixin.ImportParameter
, I have thought this is just an easy way to expose them via the plugin interface without needing to export a bunch of things. If you dont like that approach, please show me in detail, where I should export them, and make sure, that this export does not break in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This way, we have multiples of each of these classes in the server process when more than 1 supplier plugin is used (which I think is the whole idea behind the mixin). Seeing how the plugin mechanism works that might be a problem and make interactions with classes unreliable as any plugin might override them and lead to hard to debug bugs.
I would move them into the module context of this file and export them in the plugin.mixin
space, just like BulkNotificationMethod
.
make sure, that this export does not break in the future.
I am not the one introducing unstable interfaces that are relied upon like they are stable. If this way breaks on the regular, I know who designed them
def download_image(self, img_url: str): | ||
"""Download an image from the given URL and return it as a ContentFile.""" | ||
img_r = download_image_from_url(img_url) | ||
fmt = img_r.format or 'PNG' | ||
buffer = io.BytesIO() | ||
img_r.save(buffer, format=fmt) | ||
|
||
return ContentFile(buffer.getvalue()), fmt | ||
|
||
def get_template_part( | ||
self, other_variants: list[part_models.Part], template_kwargs: dict[str, Any] | ||
) -> part_models.Part: | ||
"""Helper function to handle variant parts. | ||
|
||
This helper function identifies all roots for the provided 'other_variants' list | ||
- for no root => root part will be created using the 'template_kwargs' | ||
- for one root | ||
- root is a template => return it | ||
- root is no template, create a new template like if there is no root | ||
and assign it to only root that was found and return it | ||
- for multiple roots => error raised | ||
""" | ||
root_set = {v.get_root() for v in other_variants} | ||
|
||
# check how much roots for the variant parts exist to identify the parent_part | ||
parent_part = None # part that should be used as parent_part | ||
root_part = None # part that was discovered as root part in root_set | ||
if len(root_set) == 1: | ||
root_part = next(iter(root_set)) | ||
if root_part.is_template: | ||
parent_part = root_part | ||
|
||
if len(root_set) == 0 or (root_part and not root_part.is_template): | ||
parent_part = part_models.Part.objects.create(**template_kwargs) | ||
|
||
if not parent_part: | ||
raise SupplierMixin.PartImportError( | ||
f'A few variant parts from the supplier are already imported, but have different InvenTree variant root parts, try to merge them to the same root variant template part (parts: {", ".join(str(p.pk) for p in other_variants)}).' | ||
) | ||
|
||
# assign parent_part to root_part if root_part has no variant of already | ||
if root_part and not root_part.is_template and not root_part.variant_of: | ||
root_part.variant_of = parent_part | ||
root_part.save() | ||
|
||
return parent_part | ||
|
||
def create_related_parts( | ||
self, part: part_models.Part, related_parts: list[part_models.Part] | ||
): | ||
"""Create relationships between the given part and related parts.""" | ||
for p in related_parts: | ||
try: | ||
part_models.PartRelated.objects.create(part_1=part, part_2=p) | ||
except ValidationError: | ||
pass # pass, duplicate relationship detected |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q(nb): do these need to be overrideable or could they be placed in the module scope?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not really, just thought this might be some helpers to simplify the importing process for plugin developers. Do you have a specific place in mind where I can place them?
@@ -1700,6 +1701,23 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP | |||
] | |||
|
|||
|
|||
class PartParameterBulkCreate(CreateAPIView): | |||
"""Bulk create part parameters. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@wolflu05 it might be worth looking into a BulkCreateMixin
class here, to make this a generic approach.
I recently added in a BulkUpdateMixin
- #9313 - which employs a very similar approach. The intent here is to:
- Reduce API calls for better throughput and atomicity
- Utilize the already defined serializer classes for back-end validation
So, thoughts? A BulkCreateMixin
would complement the BulkUpdateMixin
and BulkDeleteMixin
classess nicely!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea, I'll see what I can do and if I need any pointers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you submit that as a separate PR first? I'd like to be able to review that separately
|
||
supplier = None | ||
for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER): | ||
if plugin.slug == supplier_slug: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the "supplier" supposed to be the name of the supplier (e.g. digikey
) or the slug for the plugin?
def supplier(self): | ||
"""Return the supplier company object.""" | ||
return company.models.Company.objects.get( | ||
pk=self.get_setting('SUPPLIER', cache=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will throw an error if a supplier is not linked - or has been deleted - are you happy with that?
You could alternatively call objects.filter(...).first()
to return None
if there is no match
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, yes I'll raise a specific error instead now, as this is crucial for a supplier part creation in the import process.
color='green' | ||
tooltip={t`Import supplier part`} | ||
onClick={() => importPartWizard.openWizard()} | ||
hidden={!user.hasAddRole(UserRoles.part) || !params?.part} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this button should only be visible if there is an import plugin associated with the linked supplier.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea to hide the button if there is no supplier mixin plugin available.
Thanks for that feedback, that are some good ideas. I have collected them in the initial PR description now, but I want to make sure that I full understand them:
What exactly do you mean by that?
I think that should be the goal of the SettingsMixin and UIMixin, also with the oauth stuff. And still, I would really appreciate some help with the PUI tests, do you have any idea @SchrodingersGat ? |
This Adds a supplier mixin so that plugin can provide functions to import data from supplier APIs.
TODO
Future Ideas