diff --git a/lib/ProductOpener/API.pm b/lib/ProductOpener/API.pm index b2cbe30253f26..f73757b2046db 100644 --- a/lib/ProductOpener/API.pm +++ b/lib/ProductOpener/API.pm @@ -74,6 +74,7 @@ use ProductOpener::Packaging qw/:all/; use ProductOpener::APIProductRead qw/:all/; use ProductOpener::APIProductWrite qw/:all/; +use ProductOpener::APIProductServices qw/:all/; use ProductOpener::APITagRead qw/:all/; use ProductOpener::APITaxonomySuggestions qw/:all/; @@ -202,7 +203,7 @@ sub decode_json_request_body ($request_ref) { $request_ref->{api_response}, { message => {id => "invalid_json_in_request_body"}, - field => {id => "body", value => $request_ref->{body}}, + field => {id => "body", value => $request_ref->{body}, error => $@}, impact => {id => "failure"}, } ); @@ -388,6 +389,19 @@ sub process_api_request ($request_ref) { add_invalid_method_error($response_ref, $request_ref); } } + # Product services + elsif ($request_ref->{api_action} eq "product_services") { + + if ($request_ref->{api_method} eq "OPTIONS") { + # Just return CORS headers + } + elsif ($request_ref->{api_method} eq "POST") { + product_services_api($request_ref); + } + else { + add_invalid_method_error($response_ref, $request_ref); + } + } # Taxonomy suggestions elsif ($request_ref->{api_action} eq "taxonomy_suggestions") { @@ -589,7 +603,7 @@ sub customize_packagings ($request_ref, $product_ref) { return $customized_packagings_ref; } -=head2 customize_response_for_product ( $request_ref, $product_ref, $fields ) +=head2 customize_response_for_product ( $request_ref, $product_ref, $fields_comma_separated_list, $fields_ref ) Using the fields parameter, API product or search queries can request a specific set of fields to be returned. @@ -608,46 +622,54 @@ Reference to the request object. Reference to the product object (retrieved from disk or from a MongoDB query) -=head4 $fields (input) +=head4 $fields_comma_separated_list (input) -Comma separated list of fields, default to none. +Comma separated list of fields (usually from GET query parameters), default to none. Special values: - none: no fields are returned - all: all fields are returned, and special fields (e.g. attributes, knowledge panels) are not computed - updated: fields that were updated by a WRITE request +=head4 $fields_ref (input) + +Reference to a list of fields (alternative way to provide fields, e.g. from a JSON body). + =head3 Return value Reference to the customized product object. =cut -sub customize_response_for_product ($request_ref, $product_ref, $fields) { +sub customize_response_for_product ($request_ref, $product_ref, $fields_comma_separated_list, $fields_ref = undef) { + + # Fields can be in a comma separated list (if provided as a query parameter) + # or in a array reference (if provided in a JSON body) + + my @fields = (); + if (defined $fields_comma_separated_list) { + push @fields, split(/,/, $fields_comma_separated_list); + } + if (defined $fields_ref) { + push @fields, @$fields_ref; + } my $customized_product_ref = {}; my $carbon_footprint_computed = 0; - if ((not defined $fields) or ($fields eq "none")) { - return {}; - } - elsif ($fields eq "raw") { - # Return the raw product data, as stored in the .sto files and database - return $product_ref; - } + # Special case if fields is empty, or contains only "none" or "raw": we do not need to localize the Eco-Score - if ($fields =~ /\ball\b/) { - # Return all fields of the product, with processing that depends on the API version used - # e.g. in API v3, the "packagings" structure is more verbose than the stored version - $fields = $` . join(",", sort keys %{$product_ref}) . $'; + if ((scalar @fields) == 0) { + return {}; } - - # Callers of the API V3 WRITE product can send fields = updated to get only updated fields - if ($fields =~ /\bupdated\b/) { - if (defined $request_ref->{updated_product_fields}) { - $fields = $` . join(',', sort keys %{$request_ref->{updated_product_fields}}) . $'; - $log->debug("returning only updated fields", {fields => $fields}) if $log->is_debug(); + if ((scalar @fields) == 1) { + if ($fields[0] eq "none") { + return {}; + } + if ($fields[0] eq "raw") { + # Return the raw product data, as stored in the .sto files and database + return $product_ref; } } @@ -655,29 +677,49 @@ sub customize_response_for_product ($request_ref, $product_ref, $fields) { localize_ecoscore($cc, $product_ref); # lets compute each requested field - foreach my $field (split(/,/, $fields)) { + foreach my $field (@fields) { + + if ($field eq 'all') { + # Return all fields of the product, with processing that depends on the API version used + # e.g. in API v3, the "packagings" structure is more verbose than the stored version + push @fields, sort keys %{$product_ref}; + next; + } + + # Callers of the API V3 WRITE product can send fields = updated to get only updated fields + if ($field eq "updated") { + if (defined $request_ref->{updated_product_fields}) { + push @fields, sort keys %{$request_ref->{updated_product_fields}}; + $log->debug("returning only updated fields", {fields => \@fields}) if $log->is_debug(); + } + next; + } + if ($field eq "product_display_name") { $customized_product_ref->{$field} = remove_tags_and_quote(product_name_brand_quantity($product_ref)); + next; } # Allow apps to request a HTML nutrition table by passing &fields=nutrition_table_html - elsif ($field eq "nutrition_table_html") { + if ($field eq "nutrition_table_html") { $customized_product_ref->{$field} = display_nutrition_table($product_ref, undef); + next; } # Eco-Score details in simple HTML - elsif ($field eq "ecoscore_details_simple_html") { + if ($field eq "ecoscore_details_simple_html") { if ((1 or $show_ecoscore) and (defined $product_ref->{ecoscore_data})) { $customized_product_ref->{$field} = display_ecoscore_calculation_details_simple_html($cc, $product_ref->{ecoscore_data}); } + next; } # fields in %language_fields can have different values by language # by priority, return the first existing value in the language requested, # possibly multiple languages if sent ?lc=fr,nl for instance, # and otherwise fallback on the main language of the product - elsif (defined $language_fields{$field}) { + if (defined $language_fields{$field}) { foreach my $preferred_lc (@lcs, $product_ref->{lc}) { if ( (defined $product_ref->{$field . "_" . $preferred_lc}) and ($product_ref->{$field . "_" . $preferred_lc} ne '')) @@ -686,10 +728,15 @@ sub customize_response_for_product ($request_ref, $product_ref, $fields) { last; } } + # Also copy the field for the main language if it exists + if (defined $product_ref->{$field}) { + $customized_product_ref->{$field} = $product_ref->{$field}; + } + next; } # [language_field]_languages : return a value with all existing values for a specific language field - elsif ($field =~ /^(.*)_languages$/) { + if ($field =~ /^(.*)_languages$/) { my $language_field = $1; $customized_product_ref->{$field} = {}; @@ -701,10 +748,11 @@ sub customize_response_for_product ($request_ref, $product_ref, $fields) { } } } + next; } # Taxonomy fields requested in a specific language - elsif ($field =~ /^(.*)_tags_([a-z]{2})$/) { + if ($field =~ /^(.*)_tags_([a-z]{2})$/) { my $tagtype = $1; my $target_lc = $2; if (defined $product_ref->{$tagtype . "_tags"}) { @@ -713,13 +761,14 @@ sub customize_response_for_product ($request_ref, $product_ref, $fields) { push @{$customized_product_ref->{$field}}, display_taxonomy_tag($target_lc, $tagtype, $tagid); } } + next; } # Apps can request the full nutriments hash # or specific nutrients: # - saturated-fat_prepared_100g : return field at top level # - nutrients|nutriments.sugars_serving : return field in nutrients / nutriments hash - elsif ($field =~ /^((nutrients|nutriments)\.)?((.*)_(100g|serving))$/) { + if ($field =~ /^((nutrients|nutriments)\.)?((.*)_(100g|serving))$/) { my $return_hash = $2; my $nutrient = $3; if ((defined $product_ref->{nutriments}) and (defined $product_ref->{nutriments}{$nutrient})) { @@ -733,39 +782,49 @@ sub customize_response_for_product ($request_ref, $product_ref, $fields) { $customized_product_ref->{$nutrient} = $product_ref->{nutriments}{$nutrient}; } } + next; } + # Product attributes requested in a specific language (or data only) - elsif ($field =~ /^attribute_groups_([a-z]{2}|data)$/) { + if ($field =~ /^attribute_groups_([a-z]{2}|data)$/) { my $target_lc = $1; compute_attributes($product_ref, $target_lc, $cc, $attributes_options_ref); $customized_product_ref->{$field} = $product_ref->{$field}; + next; } + # Product attributes in the $lc language - elsif ($field eq "attribute_groups") { + if ($field eq "attribute_groups") { compute_attributes($product_ref, $lc, $cc, $attributes_options_ref); $customized_product_ref->{$field} = $product_ref->{"attribute_groups_" . $lc}; + next; } + # Knowledge panels in the $lc language - elsif ($field eq "knowledge_panels") { + if ($field eq "knowledge_panels") { initialize_knowledge_panels_options($knowledge_panels_options_ref, $request_ref); create_knowledge_panels($product_ref, $lc, $cc, $knowledge_panels_options_ref); $customized_product_ref->{$field} = $product_ref->{"knowledge_panels_" . $lc}; + next; } # Images to update in a specific language - elsif ($field =~ /^images_to_update_([a-z]{2})$/) { + if ($field =~ /^images_to_update_([a-z]{2})$/) { my $target_lc = $1; $customized_product_ref->{$field} = get_images_to_update($product_ref, $target_lc); + next; } # Packagings data - elsif ($field eq "packagings") { + if ($field eq "packagings") { $customized_product_ref->{$field} = customize_packagings($request_ref, $product_ref); + next; } # straight fields - elsif ((not defined $customized_product_ref->{$field}) and (defined $product_ref->{$field})) { + if ((not defined $customized_product_ref->{$field}) and (defined $product_ref->{$field})) { $customized_product_ref->{$field} = $product_ref->{$field}; + next; } # TODO: it would be great to return errors when the caller requests fields that are invalid (e.g. typos) diff --git a/lib/ProductOpener/APIProductServices.pm b/lib/ProductOpener/APIProductServices.pm new file mode 100644 index 0000000000000..a94b12d669241 --- /dev/null +++ b/lib/ProductOpener/APIProductServices.pm @@ -0,0 +1,233 @@ +# This file is part of Product Opener. +# +# Product Opener +# Copyright (C) 2011-2023 Association Open Food Facts +# Contact: contact@openfoodfacts.org +# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France +# +# Product Opener is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +=head1 NAME + +ProductOpener::APIProductServices - Microservices to enrich a product object + +=head1 DESCRIPTION + +This module implements a microservice API for operations done on an product object. + +Applications can send product data (for instance a nested list of ingredients), +ask for one or more services to be executed on the input product data +(for instance computing the min, max and estimated percentages of each ingredient), +and get back resulting product data (possibly filtered to get only specific fields back). + +=head2 INTERFACE + +=head3 Request + +The Routing.pm and API.pm module offer an HTTP interface of this form: +POST /api/v3/product_services + +The POST body is a JSON object with those fields: + +=head4 services + +An array list of services to perform. + +Currently implemented services: + +- echo : does nothing, mostly for testing +- estimate_ingredients_percent : compute percent_min, percent_max, percent_estimate for each ingredient in the ingredients object + +=head4 product + +A product object + +=head4 fields + +An array list of fields to return. If empty, only fields that can be created or updated by the service are returned. +e.g. a service to parse the ingredients text list will return the "ingredients" object. + +=head3 Response + +The response is in the JSON API v3 response format, with a resulting product object. + +=cut + +package ProductOpener::APIProductServices; + +use ProductOpener::PerlStandards; +use Exporter qw< import >; + +use Log::Any qw($log); + +BEGIN { + use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); + @EXPORT_OK = qw( + &product_services_api + ); # symbols to export on request + %EXPORT_TAGS = (all => [@EXPORT_OK]); +} + +use vars @EXPORT_OK; + +use ProductOpener::Config qw/:all/; +use ProductOpener::Display qw/:all/; +use ProductOpener::Users qw/:all/; +use ProductOpener::Lang qw/:all/; +use ProductOpener::Products qw/:all/; +use ProductOpener::API qw/:all/; + +use Encode; + +=head2 echo_service ($product_ref) + +Echo service that returns the input product unchanged. + +=cut + +sub echo_service ($product_ref, $updated_product_fields_ref) { + + return; +} + +my %service_functions = ( + echo => \&echo_service, + parse_ingredients_text => \&ProductOpener::Ingredients::parse_ingredients_text_service, + estimate_ingredients_percent => \&ProductOpener::Ingredients::estimate_ingredients_percent_service, + analyze_ingredients => \&ProductOpener::Ingredients::analyze_ingredients_service, +); + +sub check_product_services_api_input ($request_ref) { + + my $response_ref = $request_ref->{api_response}; + my $request_body_ref = $request_ref->{body_json}; + + my $error = 0; + + # Check that we have an input body + if (not defined $request_body_ref) { + $log->error("product_services_api - missing or invalid input body", {}) if $log->is_error(); + $error = 1; + } + else { + # Check that we have the input body fields we expect + + if (not defined $request_body_ref->{product}) { + $log->error("product_services_api - missing input product", {request_body => $request_body_ref}) + if $log->is_error(); + add_error( + $response_ref, + { + message => {id => "missing_field"}, + field => {id => "product"}, + impact => {id => "failure"}, + } + ); + $error = 1; + } + + if (not defined $request_body_ref->{services}) { + $log->error("product_services_api - missing services", {request_body => $request_body_ref}) + if $log->is_error(); + add_error( + $response_ref, + { + message => {id => "missing_field"}, + field => {id => "services"}, + impact => {id => "failure"}, + } + ); + $error = 1; + } + elsif (ref($request_body_ref->{services}) ne 'ARRAY') { + add_error( + $response_ref, + { + message => {id => "invalid_type_must_be_array"}, + field => {id => "services"}, + impact => {id => "failure"}, + } + ); + $error = 1; + } + else { + # Echo back the services that were requested + $response_ref->{services} = $request_body_ref->{services}; + } + } + return $error; +} + +=head2 product_services_api() + +Process API v3 product services requests. + +=cut + +sub product_services_api ($request_ref) { + + $log->debug("product_services_api - start", {request => $request_ref}) if $log->is_debug(); + + my $response_ref = $request_ref->{api_response}; + my $request_body_ref = $request_ref->{body_json}; + + $log->debug("product_services_api - body", {request_body => $request_body_ref}) if $log->is_debug(); + + my $error = check_product_services_api_input($request_ref); + + # If we did not get a fatal error, we can execute the services on the input product object + if (not $error) { + + my $product_ref = $request_body_ref->{product}; + + # We will track of fields updated by the services so that we can return only those fields + # if the fields parameter value is "updated" + $request_ref->{updated_product_fields} = {}; + + foreach my $service (@{$request_body_ref->{services}}) { + my $service_function = $service_functions{$service}; + if (defined $service_function) { + &$service_function($product_ref, $request_ref->{updated_product_fields}); + } + else { + add_error( + $response_ref, + { + message => {id => "unknown_service"}, + field => {id => "services", value => $service}, + impact => {id => "failure"}, + } + ); + } + } + + # Select / compute only the fields requested by the caller, default to updated fields + my $fields_ref = request_param($request_ref, 'fields'); + if (not defined $fields_ref) { + $fields_ref = ["updated"]; + } + $log->debug("product_services_api - before customize", {fields_ref => $fields_ref, product_ref => $product_ref}) + if $log->is_debug(); + $response_ref->{product} = customize_response_for_product($request_ref, $product_ref, undef, $fields_ref); + + # Echo back the services that were executed + $response_ref->{fields} = $fields_ref; + } + + $log->debug("product_services_api - stop", {request => $request_ref}) if $log->is_debug(); + + return; +} + +1; diff --git a/lib/ProductOpener/Ingredients.pm b/lib/ProductOpener/Ingredients.pm index 11ddecc9ee446..c371c164085f2 100644 --- a/lib/ProductOpener/Ingredients.pm +++ b/lib/ProductOpener/Ingredients.pm @@ -55,6 +55,10 @@ use Exporter qw< import >; BEGIN { use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS); @EXPORT_OK = qw( + + &estimate_ingredients_percent_service + &analyze_ingredients_service + &extract_ingredients_from_image &separate_additive_class @@ -71,16 +75,15 @@ BEGIN { &normalize_enumeration &extract_ingredients_classes_from_text - &extract_ingredients_from_text &preparse_ingredients_text - &parse_ingredients_text - &analyze_ingredients + &parse_ingredients_text_service + &flatten_sub_ingredients &compute_ingredients_tags &get_percent_or_quantity_and_normalized_quantity - &compute_ingredients_percent_values + &compute_ingredients_percent_min_max_values &init_percent_values &set_percent_min_values &set_percent_max_values @@ -1440,26 +1443,33 @@ sub get_percent_or_quantity_and_normalized_quantity ($percent_or_quantity_value, return ($percent, $quantity, $quantity_g); } -=head2 parse_ingredients_text ( product_ref ) +=head2 parse_ingredients_text_service ( $product_ref, $updated_product_fields_ref ) Parse the ingredients_text field to extract individual ingredients. -=head3 Return values +This function is a product service that can be run through ProductOpener::ApiProductServices -=head4 ingredients structure +=head3 Arguments -Nested structure of ingredients and sub-ingredients +=head4 $product_ref -=head4 +product object reference + +=head4 $updated_product_fields_ref + +reference to a hash of product fields that have been created or updated =cut -sub parse_ingredients_text ($product_ref) { +sub parse_ingredients_text_service ($product_ref, $updated_product_fields_ref) { my $debug_ingredients = 0; delete $product_ref->{ingredients}; + # and indicate that the service is creating the "ingredients" structure + $updated_product_fields_ref->{ingredients} = 1; + return if ((not defined $product_ref->{ingredients_text}) or ($product_ref->{ingredients_text} eq "")); my $text = $product_ref->{ingredients_text}; @@ -2510,6 +2520,8 @@ sub parse_ingredients_text ($product_ref) { $analyze_ingredients_function->($analyze_ingredients_function, $product_ref->{ingredients}, 0, $text); + $log->debug("ingredients: ", {ingredients => $product_ref->{ingredients}}) if $log->is_debug(); + return; } @@ -2664,14 +2676,14 @@ sub compute_ingredients_tags ($product_ref) { This function calls: -- parse_ingredients_text() to parse the ingredients text in the main language of the product +- parse_ingredients_text_service() to parse the ingredients text in the main language of the product to extract individual ingredients and sub-ingredients -- compute_ingredients_percent_values() to create the ingredients array with nested sub-ingredients arrays +- compute_ingredients_percent_min_max_values() to create the ingredients array with nested sub-ingredients arrays - compute_ingredients_tags() to create a flat array ingredients_original_tags and ingredients_tags (with parents) -- analyze_ingredients() to analyze ingredients to see the ones that are vegan, vegetarian, from palm oil etc. +- analyze_ingredients_service() to analyze ingredients to see the ones that are vegan, vegetarian, from palm oil etc. and to compute the resulting value for the complete product =cut @@ -2702,7 +2714,7 @@ sub extract_ingredients_from_text ($product_ref) { # Parse the ingredients list to extract individual ingredients and sub-ingredients # to create the ingredients array with nested sub-ingredients arrays - parse_ingredients_text($product_ref); + parse_ingredients_text_service($product_ref, {}); if (defined $product_ref->{ingredients}) { @@ -2712,19 +2724,8 @@ sub extract_ingredients_from_text ($product_ref) { # Obtain Ciqual codes ready for ingredients estimation from nutrients assign_ciqual_codes($product_ref); - # Compute minimum and maximum percent ranges for each ingredient and sub ingredient - - if (compute_ingredients_percent_values(100, 100, $product_ref->{ingredients}) < 0) { - - # The computation yielded seemingly impossible values, delete the values - delete_ingredients_percent_values($product_ref->{ingredients}); - $product_ref->{ingredients_percent_analysis} = -1; - } - else { - $product_ref->{ingredients_percent_analysis} = 1; - } - - compute_ingredients_percent_estimates(100, $product_ref->{ingredients}); + # Compute minimum and maximum percent ranges and percent estimates for each ingredient and sub ingredient + estimate_ingredients_percent_service($product_ref, {}); estimate_nutriscore_2021_fruits_vegetables_nuts_percent_from_ingredients($product_ref); estimate_nutriscore_2023_fruits_vegetables_legumes_percent_from_ingredients($product_ref); @@ -2762,7 +2763,7 @@ sub extract_ingredients_from_text ($product_ref) { # Analyze ingredients to see the ones that are vegan, vegetarian, from palm oil etc. # and compute the resulting value for the complete product - analyze_ingredients($product_ref); + analyze_ingredients_service($product_ref, {}); # Delete specific ingredients if empty if ((exists $product_ref->{specific_ingredients}) and (scalar @{$product_ref->{specific_ingredients}} == 0)) { @@ -2800,11 +2801,50 @@ sub get_missing_ciqual_codes ($ingredients_ref) { return @ingredients_without_ciqual_codes; } +=head2 estimate_ingredients_percent_service ( $product_ref, $updated_product_fields_ref ) + +Compute minimum and maximum percent ranges and percent estimates for each ingredient and sub ingredient. + +This function is a product service that can be run through ProductOpener::ApiProductServices + +=head3 Arguments + +=head4 $product_ref + +product object reference + +=head4 $updated_product_fields_ref + +reference to a hash of product fields that have been created or updated + +=cut + +sub estimate_ingredients_percent_service ($product_ref, $updated_product_fields_ref) { + + if (compute_ingredients_percent_min_max_values(100, 100, $product_ref->{ingredients}) < 0) { + + # The computation yielded seemingly impossible values, delete the values + delete_ingredients_percent_values($product_ref->{ingredients}); + $product_ref->{ingredients_percent_analysis} = -1; + } + else { + $product_ref->{ingredients_percent_analysis} = 1; + } + + compute_ingredients_percent_estimates(100, $product_ref->{ingredients}); + + # Indicate which fields were created or updated + $updated_product_fields_ref->{ingredients} = 1; + $updated_product_fields_ref->{ingredients_percent_analysis} = 1; + + return; +} + =head2 delete_ingredients_percent_values ( ingredients_ref ) This function deletes the percent_min and percent_max values of all ingredients. -It is called if the compute_ingredients_percent_values() encountered impossible +It is called if the compute_ingredients_percent_min_max_values() encountered impossible values (e.g. "Water, Sugar 80%" -> Water % should be greater than 80%, but the total would be more than 100%) @@ -2827,7 +2867,7 @@ sub delete_ingredients_percent_values ($ingredients_ref) { return; } -=head2 compute_ingredients_percent_values ( total_min, total_max, ingredients_ref ) +=head2 compute_ingredients_percent_min_max_values ( total_min, total_max, ingredients_ref ) This function computes the possible minimum and maximum ranges for the percent values of each ingredient and sub-ingredients. @@ -2867,7 +2907,7 @@ The return value is the number of times we adjusted min and max values for ingre =cut -sub compute_ingredients_percent_values ($total_min, $total_max, $ingredients_ref) { +sub compute_ingredients_percent_min_max_values ($total_min, $total_max, $ingredients_ref) { init_percent_values($total_min, $total_max, $ingredients_ref); @@ -2897,7 +2937,7 @@ sub compute_ingredients_percent_values ($total_min, $total_max, $ingredients_ref if ($i > 5) { $log->debug( - "compute_ingredients_percent_values - too many loops, bail out", + "compute_ingredients_percent_min_max_values - too many loops, bail out", { ingredients_ref => $ingredients_ref, total_min => $total_min, @@ -2910,7 +2950,7 @@ sub compute_ingredients_percent_values ($total_min, $total_max, $ingredients_ref } $log->debug( - "compute_ingredients_percent_values - done", + "compute_ingredients_percent_min_max_values - done", { ingredients_ref => $ingredients_ref, total_min => $total_min, @@ -3326,7 +3366,7 @@ sub set_percent_sub_ingredients ($ingredients_ref) { # Set values for sub-ingredients from ingredient values - $changed += compute_ingredients_percent_values( + $changed += compute_ingredients_percent_min_max_values( $ingredient_ref->{percent_min}, $ingredient_ref->{percent_max}, $ingredient_ref->{ingredients} @@ -3365,7 +3405,7 @@ sub set_percent_sub_ingredients ($ingredients_ref) { This function computes a possible estimate for the percent values of each ingredient and sub-ingredients. -The sum of all estimates must be 100%, and the estimates try to match the min and max constraints computed previously with the compute_ingredients_percent_values() function. +The sum of all estimates must be 100%, and the estimates try to match the min and max constraints computed previously with the compute_ingredients_percent_min_max_values() function. =head3 Arguments @@ -3429,22 +3469,39 @@ sub compute_ingredients_percent_estimates ($total, $ingredients_ref) { return; } -=head2 analyze_ingredients ( product_ref ) +=head2 analyze_ingredients ( $product_ref, $updated_product_fields_ref ) -This function analyzes ingredients to see the ones that are vegan, vegetarian, from palm oil etc. +Analyzes ingredients to see the ones that are vegan, vegetarian, from palm oil etc. and computes the resulting value for the complete product. -The results are overrode by labels like "Vegan", "Vegetarian" or "Palm oil free" +The results are overridden by labels like "Vegan", "Vegetarian" or "Palm oil free" Results are stored in the ingredients_analysis_tags array. +This function is a product service that can be run through ProductOpener::ApiProductServices + +=head3 Arguments + +=head4 $product_ref + +product object reference + +=head4 $updated_product_fields_ref + +reference to a hash of product fields that have been created or updated + =cut -sub analyze_ingredients ($product_ref) { +sub analyze_ingredients_service ($product_ref, $updated_product_fields_ref) { + # Delete any existing values for the ingredients analysis fields delete $product_ref->{ingredients_analysis}; delete $product_ref->{ingredients_analysis_tags}; + # and indicate that the service is creating or updatiing them + $updated_product_fields_ref->{ingredients_analysis} = 1; + $updated_product_fields_ref->{ingredients_analysis_tags} = 1; + my @properties = ("from_palm_oil", "vegan", "vegetarian"); my %properties_unknown_tags = ( "from_palm_oil" => "en:palm-oil-content-unknown", diff --git a/stop_words.txt b/stop_words.txt index cac8f21990145..b84d3f6927bce 100644 --- a/stop_words.txt +++ b/stop_words.txt @@ -139,6 +139,9 @@ margarines matche md métal +microservice +microservices +Microservices mongoDB Mousquetaires msgctxt diff --git a/tests/integration/api_v3_product_services.t b/tests/integration/api_v3_product_services.t new file mode 100644 index 0000000000000..c0856fa3e22c8 --- /dev/null +++ b/tests/integration/api_v3_product_services.t @@ -0,0 +1,118 @@ +#!/usr/bin/perl -w + +use ProductOpener::PerlStandards; + +use Test::More; +use ProductOpener::APITest qw/:all/; +use ProductOpener::Test qw/:all/; +use ProductOpener::TestDefaults qw/:all/; + +use File::Basename "dirname"; + +use Storable qw(dclone); + +wait_application_ready(); + +# Sample product + +my $product_hazelnut_spread_json = ' + "product": { + "product_name_en": "My hazelnut spread", + "product_name_fr": "Ma pâte aux noisettes", + "ingredients": [ + { + "id": "en:sugar", + "text": "Sucre", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "ciqual_food_code": "16129", + "from_palm_oil": "yes", + "id": "en:palm-oil", + "text": "huile de palme", + "vegan": "yes", + "vegetarian": "yes" + }, + { + "ciqual_food_code": "17210", + "from_palm_oil": "no", + "id": "en:hazelnut-oil", + "percent": 13, + "text": "huile de NOISETTES", + "vegan": "yes", + "vegetarian": "yes" + } + ] + } +'; + +# Note: expected results are stored in json files, see execute_api_tests +my $tests_ref = [ + { + test_case => 'unknown-service', + method => 'POST', + path => '/api/v3/product_services', + body => '{ + "services":["unknown"], + "product":{} + }', + }, + # echo service + { + test_case => 'service-no-body', + method => 'POST', + path => '/api/v3/product_services', + }, + { + test_case => 'echo-service-hazelnut-spread', + method => 'POST', + path => '/api/v3/product_services/echo', + body => '{ + "services":["echo"], + "fields":["all"],' + . $product_hazelnut_spread_json . '}', + }, + { + test_case => 'echo-service-hazelnut-spread-fields', + method => 'POST', + path => '/api/v3/product_services', + body => '{ + "services":["echo"], + "fields": ["product_name_en","product_name_fr"],' + . $product_hazelnut_spread_json . '}', + }, + # estimate_ingredients_percent service + # no fields parameter, should get back only updated fields + { + test_case => 'estimate-ingredients-percent-service-hazelnut-spread', + method => 'POST', + path => '/api/v3/product_services', + body => '{ + "services":["estimate_ingredients_percent"],' + . $product_hazelnut_spread_json . '}', + }, + # Get back only specific fields + { + test_case => 'estimate-ingredients-percent-service-hazelnut-spread-specific-fields', + method => 'POST', + path => '/api/v3/product_services', + body => '{ + "services":["estimate_ingredients_percent"], + "fields": ["ingredients_percent_analysis"],' + . $product_hazelnut_spread_json . '}', + }, + # estimate_ingredients_percent + analyze_ingredients + { + test_case => 'estimate-ingredients-percent-analyze-ingredients-services-hazelnut-spread', + method => 'POST', + path => '/api/v3/product_services', + body => '{ + "services":["estimate_ingredients_percent", "analyze_ingredients"],' + . $product_hazelnut_spread_json . '}', + }, +]; + +execute_api_tests(__FILE__, $tests_ref); + +done_testing(); diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-fields-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v2_product_read/get-fields-all-knowledge-panels.json index d78835b4d1a03..fb216b7bdccc1 100644 --- a/tests/integration/expected_test_results/api_v2_product_read/get-fields-all-knowledge-panels.json +++ b/tests/integration/expected_test_results/api_v2_product_read/get-fields-all-knowledge-panels.json @@ -2161,13 +2161,13 @@ "packagings" : [ { "material" : "en:wood", - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : "en:recycle", "shape" : "en:box" }, { "material" : "en:glass", - "number_of_units" : 6, + "number_of_units" : "6", "quantity_per_unit" : "25cl", "quantity_per_unit_unit" : "cl", "quantity_per_unit_value" : 25, @@ -2176,13 +2176,13 @@ }, { "material" : "en:steel", - "number_of_units" : 3, + "number_of_units" : "3", "recycling" : "en:recycle", "shape" : "en:lid" }, { "material" : "en:plastic", - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : "en:discard", "shape" : "en:film" } @@ -2213,7 +2213,7 @@ "quantity" : "100 g", "removed_countries_tags" : [], "rev" : 1, - "serving_quantity" : "10", + "serving_quantity" : 10, "serving_size" : "10 g", "states" : "en:to-be-completed, en:nutrition-facts-to-be-completed, en:ingredients-completed, en:expiration-date-to-be-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-to-be-completed, en:packaging-to-be-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-uploaded", "states_hierarchy" : [ diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json index e34313fba3f11..785102403c65c 100644 --- a/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json +++ b/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json @@ -351,6 +351,330 @@ "name" : "Labels" } ], + "attribute_groups_en" : [ + { + "attributes" : [ + { + "description" : "", + "description_short" : "Missing data to compute the Nutri-Score", + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutriscore-unknown.svg", + "id" : "nutriscore", + "match" : 0, + "name" : "Nutri-Score", + "panel_id" : "nutriscore", + "status" : "unknown", + "title" : "Nutri-Score unknown" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-salt-unknown.svg", + "id" : "low_salt", + "missing" : "Missing nutrition facts", + "name" : "Salt", + "status" : "unknown", + "title" : "Salt in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-fat-unknown.svg", + "id" : "low_fat", + "missing" : "Missing nutrition facts", + "name" : "Fat", + "status" : "unknown", + "title" : "Fat in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-sugars-unknown.svg", + "id" : "low_sugars", + "missing" : "Missing nutrition facts", + "name" : "Sugars", + "status" : "unknown", + "title" : "Sugars in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-saturated-fat-unknown.svg", + "id" : "low_saturated_fat", + "missing" : "Missing nutrition facts", + "name" : "Saturated fat", + "status" : "unknown", + "title" : "Saturated fat in unknown quantity" + } + ], + "id" : "nutritional_quality", + "name" : "Nutritional quality" + }, + { + "attributes" : [ + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-gluten.svg", + "id" : "allergens_no_gluten", + "match" : 100, + "name" : "Gluten", + "status" : "known", + "title" : "Does not contain: Gluten" + }, + { + "debug" : "en:milk in allergens", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-milk.svg", + "id" : "allergens_no_milk", + "match" : 0, + "name" : "Milk", + "status" : "known", + "title" : "Contains: Milk" + }, + { + "debug" : "en:eggs in allergens", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-eggs.svg", + "id" : "allergens_no_eggs", + "match" : 0, + "name" : "Eggs", + "status" : "known", + "title" : "Contains: Eggs" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-nuts.svg", + "id" : "allergens_no_nuts", + "match" : 100, + "name" : "Nuts", + "status" : "known", + "title" : "Does not contain: Nuts" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-peanuts.svg", + "id" : "allergens_no_peanuts", + "match" : 100, + "name" : "Peanuts", + "status" : "known", + "title" : "Does not contain: Peanuts" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-sesame-seeds.svg", + "id" : "allergens_no_sesame_seeds", + "match" : 100, + "name" : "Sesame seeds", + "status" : "known", + "title" : "Does not contain: Sesame seeds" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-soybeans.svg", + "id" : "allergens_no_soybeans", + "match" : 100, + "name" : "Soybeans", + "status" : "known", + "title" : "Does not contain: Soybeans" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-celery.svg", + "id" : "allergens_no_celery", + "match" : 100, + "name" : "Celery", + "status" : "known", + "title" : "Does not contain: Celery" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-mustard.svg", + "id" : "allergens_no_mustard", + "match" : 100, + "name" : "Mustard", + "status" : "known", + "title" : "Does not contain: Mustard" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-lupin.svg", + "id" : "allergens_no_lupin", + "match" : 100, + "name" : "Lupin", + "status" : "known", + "title" : "Does not contain: Lupin" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-fish.svg", + "id" : "allergens_no_fish", + "match" : 100, + "name" : "Fish", + "status" : "known", + "title" : "Does not contain: Fish" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-crustaceans.svg", + "id" : "allergens_no_crustaceans", + "match" : 100, + "name" : "Crustaceans", + "status" : "known", + "title" : "Does not contain: Crustaceans" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-molluscs.svg", + "id" : "allergens_no_molluscs", + "match" : 100, + "name" : "Molluscs", + "status" : "known", + "title" : "Does not contain: Molluscs" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-sulphur-dioxide-and-sulphites.svg", + "id" : "allergens_no_sulphur_dioxide_and_sulphites", + "match" : 100, + "name" : "Sulphur dioxide and sulphites", + "status" : "known", + "title" : "Does not contain: Sulphur dioxide and sulphites" + } + ], + "id" : "allergens", + "name" : "Allergens", + "warning" : "There is always a possibility that data about allergens may be missing, incomplete, incorrect or that the product's composition has changed. If you are allergic, always check the information on the actual product packaging." + }, + { + "attributes" : [ + { + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/non-vegan.svg", + "id" : "vegan", + "match" : 0, + "name" : "Vegan", + "panel_id" : "ingredients_analysis_en:non-vegan", + "status" : "known", + "title" : "Non-vegan" + }, + { + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/vegetarian.svg", + "id" : "vegetarian", + "match" : 100, + "name" : "Vegetarian", + "panel_id" : "ingredients_analysis_en:vegetarian", + "status" : "known", + "title" : "Vegetarian" + }, + { + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-palm-oil.svg", + "id" : "palm_oil_free", + "match" : 0, + "name" : "Palm oil free", + "panel_id" : "ingredients_analysis_en:palm-oil", + "status" : "known", + "title" : "Palm oil" + } + ], + "id" : "ingredients_analysis", + "name" : "Ingredients" + }, + { + "attributes" : [ + { + "description" : "", + "description_short" : "Processed foods", + "grade" : "b", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nova-group-3.svg", + "id" : "nova", + "match" : 75, + "name" : "NOVA group", + "panel_id" : "nova", + "status" : "known", + "title" : "NOVA 3" + }, + { + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/0-additives.svg", + "id" : "additives", + "match" : 100, + "name" : "Additives", + "panel_id" : "additives", + "status" : "known", + "title" : "Without additives" + } + ], + "id" : "processing", + "name" : "Food processing" + }, + { + "attributes" : [ + { + "description" : "", + "description_short" : "Moderate environmental impact", + "grade" : "c", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/ecoscore-c.svg", + "id" : "ecoscore", + "match" : 47, + "name" : "Eco-Score", + "panel_id" : "ecoscore", + "status" : "known", + "title" : "Eco-Score C" + }, + { + "description" : "", + "description_short" : "Almost no risk of deforestation", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/forest-footprint-a.svg", + "id" : "forest_footprint", + "match" : 99.9416666666667, + "name" : "Forest footprint", + "status" : "known", + "title" : "Very small forest footprint" + } + ], + "id" : "environment", + "name" : "Environment" + }, + { + "attributes" : [ + { + "description" : "Organic farming aims to protect the environment and to conserve biodiversity by prohibiting or limiting the use of synthetic fertilizers, pesticides and food additives.", + "description_short" : "Promotes ecological sustainability and biodiversity.", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/organic.svg", + "id" : "labels_organic", + "match" : 100, + "name" : "Organic farming", + "status" : "known", + "title" : "Organic product" + }, + { + "description" : "When you buy fair trade products, producers in developing countries are paid an higher and fairer price, which helps them improve and sustain higher social and often environmental standards.", + "description_short" : "Fair trade products help producers in developing countries.", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/not-fair-trade.svg", + "id" : "labels_fair_trade", + "match" : 0, + "name" : "Fair trade", + "status" : "known", + "title" : "Not a fair trade product" + } + ], + "id" : "labels", + "name" : "Labels" + } + ], "categories" : "cookies", "categories_hierarchy" : [ "en:snacks", @@ -2485,13 +2809,13 @@ "packagings" : [ { "material" : "en:wood", - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : "en:recycle", "shape" : "en:box" }, { "material" : "en:glass", - "number_of_units" : 6, + "number_of_units" : "6", "quantity_per_unit" : "25cl", "quantity_per_unit_unit" : "cl", "quantity_per_unit_value" : 25, @@ -2500,13 +2824,13 @@ }, { "material" : "en:steel", - "number_of_units" : 3, + "number_of_units" : "3", "recycling" : "en:recycle", "shape" : "en:lid" }, { "material" : "en:plastic", - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : "en:discard", "shape" : "en:film" } @@ -2537,7 +2861,7 @@ "quantity" : "100 g", "removed_countries_tags" : [], "rev" : 1, - "serving_quantity" : "10", + "serving_quantity" : 10, "serving_size" : "10 g", "states" : "en:to-be-completed, en:nutrition-facts-to-be-completed, en:ingredients-completed, en:expiration-date-to-be-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-to-be-completed, en:packaging-to-be-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-uploaded", "states_hierarchy" : [ diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json index 6e624435649e0..05b2f8486f63f 100644 --- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json +++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json @@ -2165,7 +2165,7 @@ "material" : { "id" : "en:wood" }, - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : { "id" : "en:recycle" }, @@ -2177,7 +2177,7 @@ "material" : { "id" : "en:glass" }, - "number_of_units" : 6, + "number_of_units" : "6", "quantity_per_unit" : "25cl", "quantity_per_unit_unit" : "cl", "quantity_per_unit_value" : 25, @@ -2192,7 +2192,7 @@ "material" : { "id" : "en:steel" }, - "number_of_units" : 3, + "number_of_units" : "3", "recycling" : { "id" : "en:recycle" }, @@ -2204,7 +2204,7 @@ "material" : { "id" : "en:plastic" }, - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : { "id" : "en:discard" }, @@ -2239,7 +2239,7 @@ "quantity" : "100 g", "removed_countries_tags" : [], "rev" : 1, - "serving_quantity" : "10", + "serving_quantity" : 10, "serving_size" : "10 g", "states" : "en:to-be-completed, en:nutrition-facts-to-be-completed, en:ingredients-completed, en:expiration-date-to-be-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-to-be-completed, en:packaging-to-be-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-uploaded", "states_hierarchy" : [ diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json index 76aa78cfb1ac0..9dfb5cf5f2b07 100644 --- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json +++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json @@ -352,6 +352,330 @@ "name" : "Labels" } ], + "attribute_groups_en" : [ + { + "attributes" : [ + { + "description" : "", + "description_short" : "Missing data to compute the Nutri-Score", + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutriscore-unknown.svg", + "id" : "nutriscore", + "match" : 0, + "name" : "Nutri-Score", + "panel_id" : "nutriscore", + "status" : "unknown", + "title" : "Nutri-Score unknown" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-salt-unknown.svg", + "id" : "low_salt", + "missing" : "Missing nutrition facts", + "name" : "Salt", + "status" : "unknown", + "title" : "Salt in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-fat-unknown.svg", + "id" : "low_fat", + "missing" : "Missing nutrition facts", + "name" : "Fat", + "status" : "unknown", + "title" : "Fat in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-sugars-unknown.svg", + "id" : "low_sugars", + "missing" : "Missing nutrition facts", + "name" : "Sugars", + "status" : "unknown", + "title" : "Sugars in unknown quantity" + }, + { + "grade" : "unknown", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nutrient-level-saturated-fat-unknown.svg", + "id" : "low_saturated_fat", + "missing" : "Missing nutrition facts", + "name" : "Saturated fat", + "status" : "unknown", + "title" : "Saturated fat in unknown quantity" + } + ], + "id" : "nutritional_quality", + "name" : "Nutritional quality" + }, + { + "attributes" : [ + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-gluten.svg", + "id" : "allergens_no_gluten", + "match" : 100, + "name" : "Gluten", + "status" : "known", + "title" : "Does not contain: Gluten" + }, + { + "debug" : "en:milk in allergens", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-milk.svg", + "id" : "allergens_no_milk", + "match" : 0, + "name" : "Milk", + "status" : "known", + "title" : "Contains: Milk" + }, + { + "debug" : "en:eggs in allergens", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-eggs.svg", + "id" : "allergens_no_eggs", + "match" : 0, + "name" : "Eggs", + "status" : "known", + "title" : "Contains: Eggs" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-nuts.svg", + "id" : "allergens_no_nuts", + "match" : 100, + "name" : "Nuts", + "status" : "known", + "title" : "Does not contain: Nuts" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-peanuts.svg", + "id" : "allergens_no_peanuts", + "match" : 100, + "name" : "Peanuts", + "status" : "known", + "title" : "Does not contain: Peanuts" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-sesame-seeds.svg", + "id" : "allergens_no_sesame_seeds", + "match" : 100, + "name" : "Sesame seeds", + "status" : "known", + "title" : "Does not contain: Sesame seeds" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-soybeans.svg", + "id" : "allergens_no_soybeans", + "match" : 100, + "name" : "Soybeans", + "status" : "known", + "title" : "Does not contain: Soybeans" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-celery.svg", + "id" : "allergens_no_celery", + "match" : 100, + "name" : "Celery", + "status" : "known", + "title" : "Does not contain: Celery" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-mustard.svg", + "id" : "allergens_no_mustard", + "match" : 100, + "name" : "Mustard", + "status" : "known", + "title" : "Does not contain: Mustard" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-lupin.svg", + "id" : "allergens_no_lupin", + "match" : 100, + "name" : "Lupin", + "status" : "known", + "title" : "Does not contain: Lupin" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-fish.svg", + "id" : "allergens_no_fish", + "match" : 100, + "name" : "Fish", + "status" : "known", + "title" : "Does not contain: Fish" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-crustaceans.svg", + "id" : "allergens_no_crustaceans", + "match" : 100, + "name" : "Crustaceans", + "status" : "known", + "title" : "Does not contain: Crustaceans" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-molluscs.svg", + "id" : "allergens_no_molluscs", + "match" : 100, + "name" : "Molluscs", + "status" : "known", + "title" : "Does not contain: Molluscs" + }, + { + "debug" : "4 ingredients (0 unknown)", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/no-sulphur-dioxide-and-sulphites.svg", + "id" : "allergens_no_sulphur_dioxide_and_sulphites", + "match" : 100, + "name" : "Sulphur dioxide and sulphites", + "status" : "known", + "title" : "Does not contain: Sulphur dioxide and sulphites" + } + ], + "id" : "allergens", + "name" : "Allergens", + "warning" : "There is always a possibility that data about allergens may be missing, incomplete, incorrect or that the product's composition has changed. If you are allergic, always check the information on the actual product packaging." + }, + { + "attributes" : [ + { + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/non-vegan.svg", + "id" : "vegan", + "match" : 0, + "name" : "Vegan", + "panel_id" : "ingredients_analysis_en:non-vegan", + "status" : "known", + "title" : "Non-vegan" + }, + { + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/vegetarian.svg", + "id" : "vegetarian", + "match" : 100, + "name" : "Vegetarian", + "panel_id" : "ingredients_analysis_en:vegetarian", + "status" : "known", + "title" : "Vegetarian" + }, + { + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/contains-palm-oil.svg", + "id" : "palm_oil_free", + "match" : 0, + "name" : "Palm oil free", + "panel_id" : "ingredients_analysis_en:palm-oil", + "status" : "known", + "title" : "Palm oil" + } + ], + "id" : "ingredients_analysis", + "name" : "Ingredients" + }, + { + "attributes" : [ + { + "description" : "", + "description_short" : "Processed foods", + "grade" : "b", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/nova-group-3.svg", + "id" : "nova", + "match" : 75, + "name" : "NOVA group", + "panel_id" : "nova", + "status" : "known", + "title" : "NOVA 3" + }, + { + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/0-additives.svg", + "id" : "additives", + "match" : 100, + "name" : "Additives", + "panel_id" : "additives", + "status" : "known", + "title" : "Without additives" + } + ], + "id" : "processing", + "name" : "Food processing" + }, + { + "attributes" : [ + { + "description" : "", + "description_short" : "Moderate environmental impact", + "grade" : "c", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/ecoscore-c.svg", + "id" : "ecoscore", + "match" : 47, + "name" : "Eco-Score", + "panel_id" : "ecoscore", + "status" : "known", + "title" : "Eco-Score C" + }, + { + "description" : "", + "description_short" : "Almost no risk of deforestation", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/forest-footprint-a.svg", + "id" : "forest_footprint", + "match" : 99.9416666666667, + "name" : "Forest footprint", + "status" : "known", + "title" : "Very small forest footprint" + } + ], + "id" : "environment", + "name" : "Environment" + }, + { + "attributes" : [ + { + "description" : "Organic farming aims to protect the environment and to conserve biodiversity by prohibiting or limiting the use of synthetic fertilizers, pesticides and food additives.", + "description_short" : "Promotes ecological sustainability and biodiversity.", + "grade" : "a", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/organic.svg", + "id" : "labels_organic", + "match" : 100, + "name" : "Organic farming", + "status" : "known", + "title" : "Organic product" + }, + { + "description" : "When you buy fair trade products, producers in developing countries are paid an higher and fairer price, which helps them improve and sustain higher social and often environmental standards.", + "description_short" : "Fair trade products help producers in developing countries.", + "grade" : "e", + "icon_url" : "http://static.openfoodfacts.localhost/images/attributes/not-fair-trade.svg", + "id" : "labels_fair_trade", + "match" : 0, + "name" : "Fair trade", + "status" : "known", + "title" : "Not a fair trade product" + } + ], + "id" : "labels", + "name" : "Labels" + } + ], "categories" : "cookies", "categories_hierarchy" : [ "en:snacks", @@ -2489,7 +2813,7 @@ "material" : { "id" : "en:wood" }, - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : { "id" : "en:recycle" }, @@ -2501,7 +2825,7 @@ "material" : { "id" : "en:glass" }, - "number_of_units" : 6, + "number_of_units" : "6", "quantity_per_unit" : "25cl", "quantity_per_unit_unit" : "cl", "quantity_per_unit_value" : 25, @@ -2516,7 +2840,7 @@ "material" : { "id" : "en:steel" }, - "number_of_units" : 3, + "number_of_units" : "3", "recycling" : { "id" : "en:recycle" }, @@ -2528,7 +2852,7 @@ "material" : { "id" : "en:plastic" }, - "number_of_units" : 1, + "number_of_units" : "1", "recycling" : { "id" : "en:discard" }, @@ -2563,7 +2887,7 @@ "quantity" : "100 g", "removed_countries_tags" : [], "rev" : 1, - "serving_quantity" : "10", + "serving_quantity" : 10, "serving_size" : "10 g", "states" : "en:to-be-completed, en:nutrition-facts-to-be-completed, en:ingredients-completed, en:expiration-date-to-be-completed, en:packaging-code-to-be-completed, en:characteristics-to-be-completed, en:origins-to-be-completed, en:categories-completed, en:brands-to-be-completed, en:packaging-to-be-completed, en:quantity-completed, en:product-name-completed, en:photos-to-be-uploaded", "states_hierarchy" : [ diff --git a/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread-fields.json b/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread-fields.json new file mode 100644 index 0000000000000..ce373c2576b1d --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread-fields.json @@ -0,0 +1,16 @@ +{ + "errors" : [], + "fields" : [ + "product_name_en", + "product_name_fr" + ], + "product" : { + "product_name_en" : "My hazelnut spread", + "product_name_fr" : "Ma pâte aux noisettes" + }, + "services" : [ + "echo" + ], + "status" : "success", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread.json b/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread.json new file mode 100644 index 0000000000000..51c1f9fc2de78 --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/echo-service-hazelnut-spread.json @@ -0,0 +1,40 @@ +{ + "errors" : [], + "fields" : [ + "all" + ], + "product" : { + "ingredients" : [ + { + "id" : "en:sugar", + "text" : "Sucre", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "16129", + "from_palm_oil" : "yes", + "id" : "en:palm-oil", + "text" : "huile de palme", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "17210", + "from_palm_oil" : "no", + "id" : "en:hazelnut-oil", + "percent" : 13, + "text" : "huile de NOISETTES", + "vegan" : "yes", + "vegetarian" : "yes" + } + ], + "product_name_en" : "My hazelnut spread", + "product_name_fr" : "Ma pâte aux noisettes" + }, + "services" : [ + "echo" + ], + "status" : "success", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-analyze-ingredients-services-hazelnut-spread.json b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-analyze-ingredients-services-hazelnut-spread.json new file mode 100644 index 0000000000000..cd338c2e12c8f --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-analyze-ingredients-services-hazelnut-spread.json @@ -0,0 +1,59 @@ +{ + "errors" : [], + "fields" : [ + "updated" + ], + "product" : { + "ingredients" : [ + { + "id" : "en:sugar", + "percent_estimate" : 58.75, + "percent_max" : 74, + "percent_min" : 43.5, + "text" : "Sucre", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "16129", + "from_palm_oil" : "yes", + "id" : "en:palm-oil", + "percent_estimate" : 27.125, + "percent_max" : 43.5, + "percent_min" : 13, + "text" : "huile de palme", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "17210", + "from_palm_oil" : "no", + "id" : "en:hazelnut-oil", + "percent" : 13, + "percent_estimate" : 14.125, + "percent_max" : 13, + "percent_min" : 13, + "text" : "huile de NOISETTES", + "vegan" : "yes", + "vegetarian" : "yes" + } + ], + "ingredients_analysis" : { + "en:palm-oil" : [ + "en:palm-oil" + ] + }, + "ingredients_analysis_tags" : [ + "en:palm-oil", + "en:vegan", + "en:vegetarian" + ], + "ingredients_percent_analysis" : 1 + }, + "services" : [ + "estimate_ingredients_percent", + "analyze_ingredients" + ], + "status" : "success", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread-specific-fields.json b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread-specific-fields.json new file mode 100644 index 0000000000000..71169a8bb2557 --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread-specific-fields.json @@ -0,0 +1,14 @@ +{ + "errors" : [], + "fields" : [ + "ingredients_percent_analysis" + ], + "product" : { + "ingredients_percent_analysis" : 1 + }, + "services" : [ + "estimate_ingredients_percent" + ], + "status" : "success", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread.json b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread.json new file mode 100644 index 0000000000000..ad845c837fe00 --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/estimate-ingredients-percent-service-hazelnut-spread.json @@ -0,0 +1,48 @@ +{ + "errors" : [], + "fields" : [ + "updated" + ], + "product" : { + "ingredients" : [ + { + "id" : "en:sugar", + "percent_estimate" : 58.75, + "percent_max" : 74, + "percent_min" : 43.5, + "text" : "Sucre", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "16129", + "from_palm_oil" : "yes", + "id" : "en:palm-oil", + "percent_estimate" : 27.125, + "percent_max" : 43.5, + "percent_min" : 13, + "text" : "huile de palme", + "vegan" : "yes", + "vegetarian" : "yes" + }, + { + "ciqual_food_code" : "17210", + "from_palm_oil" : "no", + "id" : "en:hazelnut-oil", + "percent" : 13, + "percent_estimate" : 14.125, + "percent_max" : 13, + "percent_min" : 13, + "text" : "huile de NOISETTES", + "vegan" : "yes", + "vegetarian" : "yes" + } + ], + "ingredients_percent_analysis" : 1 + }, + "services" : [ + "estimate_ingredients_percent" + ], + "status" : "success", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/service-no-body.json b/tests/integration/expected_test_results/api_v3_product_services/service-no-body.json new file mode 100644 index 0000000000000..dc6d604fd9f26 --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/service-no-body.json @@ -0,0 +1,22 @@ +{ + "errors" : [ + { + "field" : { + "id" : "body", + "value" : "" + }, + "impact" : { + "id" : "failure", + "lc_name" : "Failure", + "name" : "Failure" + }, + "message" : { + "id" : "empty_request_body", + "lc_name" : "Empty request body", + "name" : "Empty request body" + } + } + ], + "status" : "failure", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_services/unknown-service.json b/tests/integration/expected_test_results/api_v3_product_services/unknown-service.json new file mode 100644 index 0000000000000..c774103c2971f --- /dev/null +++ b/tests/integration/expected_test_results/api_v3_product_services/unknown-service.json @@ -0,0 +1,29 @@ +{ + "errors" : [ + { + "field" : { + "id" : "services", + "value" : "unknown" + }, + "impact" : { + "id" : "failure", + "lc_name" : "Failure", + "name" : "Failure" + }, + "message" : { + "id" : "unknown_service", + "lc_name" : "", + "name" : "" + } + } + ], + "fields" : [ + "updated" + ], + "product" : {}, + "services" : [ + "unknown" + ], + "status" : "failure", + "warnings" : [] +} diff --git a/tests/integration/expected_test_results/api_v3_product_write/patch-broken-json-body.json b/tests/integration/expected_test_results/api_v3_product_write/patch-broken-json-body.json index 7e5667c385cd5..31b7e1da505a2 100644 --- a/tests/integration/expected_test_results/api_v3_product_write/patch-broken-json-body.json +++ b/tests/integration/expected_test_results/api_v3_product_write/patch-broken-json-body.json @@ -2,6 +2,7 @@ "errors" : [ { "field" : { + "error" : "'null' expected, at character offset 0 (before \"not json\") at /opt/product-opener/lib/ProductOpener/API.pm line 199.\n", "id" : "body", "value" : "not json" }, diff --git a/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json index cef4d6abfc258..b3ae8cfb93741 100644 --- a/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json +++ b/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json @@ -831,7 +831,7 @@ "id" : "en:plastic", "lc_name" : "Plastic" }, - "number_of_units" : 1, + "number_of_units" : "1", "shape" : { "id" : "en:bag", "lc_name" : "Bag" diff --git a/tests/unit/ingredients_contents.t b/tests/unit/ingredients_contents.t index 67ea7fe8dbb69..dacb6006663f0 100644 --- a/tests/unit/ingredients_contents.t +++ b/tests/unit/ingredients_contents.t @@ -1,7 +1,5 @@ #!/usr/bin/perl -w -# Tests of Ingredients::compute_ingredients_percent_values() - use Modern::Perl '2017'; use utf8; diff --git a/tests/unit/ingredients_nesting.t b/tests/unit/ingredients_nesting.t index 3f1b284db8088..e2bdd11a668ac 100755 --- a/tests/unit/ingredients_nesting.t +++ b/tests/unit/ingredients_nesting.t @@ -377,7 +377,7 @@ foreach my $test_ref (@tests) { print STDERR "ingredients_text: " . $product_ref->{ingredients_text} . "\n"; - parse_ingredients_text($product_ref); + parse_ingredients_text_service($product_ref, {}); is_deeply($product_ref->{ingredients}, $expected_ingredients_ref) # using print + join instead of diag so that we don't have diff --git a/tests/unit/ingredients_parsing_todo.t b/tests/unit/ingredients_parsing_todo.t index 9fea124a56704..963d9fef4a47a 100644 --- a/tests/unit/ingredients_parsing_todo.t +++ b/tests/unit/ingredients_parsing_todo.t @@ -184,7 +184,7 @@ TODO: { print STDERR "ingredients_text: " . $product_ref->{ingredients_text} . "\n"; - parse_ingredients_text($product_ref); + parse_ingredients_text_service($product_ref, {}); is_deeply($product_ref->{ingredients}, $expected_ingredients_ref) # using print + join instead of diag so that we don't have @@ -200,7 +200,7 @@ TODO: { print STDERR "# Got:\n"; print STDERR join("\n", explain $product_ref->{ingredients}); print STDERR "# Expected:\n"; - print STDERR join("\n", explain $expected_ingredients_ref ); + print STDERR join("\n", explain $expected_ingredients_ref); }; # or do { diff --git a/tests/unit/ingredients_percent.t b/tests/unit/ingredients_percent.t index b685a55d564b2..0d5a3c23fd453 100755 --- a/tests/unit/ingredients_percent.t +++ b/tests/unit/ingredients_percent.t @@ -1,7 +1,5 @@ #!/usr/bin/perl -w -# Tests of Ingredients::compute_ingredients_percent_values() - use Modern::Perl '2017'; use utf8; @@ -247,9 +245,8 @@ foreach my $test_ref (@tests) { my $testid = $test_ref->[0]; my $product_ref = $test_ref->[1]; - parse_ingredients_text($product_ref); - if (compute_ingredients_percent_values(100, 100, $product_ref->{ingredients}) < 0) { - print STDERR "compute_ingredients_percent_values < 0, delete ingredients percent values\n"; + parse_ingredients_text_service($product_ref, {}); + if (compute_ingredients_percent_min_max_values(100, 100, $product_ref->{ingredients}) < 0) { delete_ingredients_percent_values($product_ref->{ingredients}); } diff --git a/tests/unit/ingredients_processing.t b/tests/unit/ingredients_processing.t index 740c7a5cc3b8d..82e91e5beaed8 100755 --- a/tests/unit/ingredients_processing.t +++ b/tests/unit/ingredients_processing.t @@ -2353,7 +2353,7 @@ foreach my $test_ref (@tests) { print STDERR "ingredients_text: " . $product_ref->{ingredients_text} . "\n"; - parse_ingredients_text($product_ref); + parse_ingredients_text_service($product_ref, {}); is_deeply($product_ref->{ingredients}, $expected_ingredients_ref) diff --git a/tests/unit/nutrition_estimation.t b/tests/unit/nutrition_estimation.t index 0345a8a4b5ff3..aaee829e7a812 100644 --- a/tests/unit/nutrition_estimation.t +++ b/tests/unit/nutrition_estimation.t @@ -40,11 +40,8 @@ foreach my $test_ref (@tests) { my $testid = $test_ref->{id}; my $product_ref = $test_ref->{product}; - print STDERR "ingredients_text: " . $product_ref->{ingredients_text} . " (" . $product_ref->{lc} . ")\n"; - - parse_ingredients_text($product_ref); - if (compute_ingredients_percent_values(100, 100, $product_ref->{ingredients}) < 0) { - print STDERR "compute_ingredients_percent_values < 0, delete ingredients percent values\n"; + parse_ingredients_text_service($product_ref, {}); + if (compute_ingredients_percent_min_max_values(100, 100, $product_ref->{ingredients}) < 0) { delete_ingredients_percent_values($product_ref->{ingredients}); }