Skip to content

Commit

Permalink
feat: determine packaging components in contact with food (#11238)
Browse files Browse the repository at this point in the history
Some simple rules (to start) to determine which packaging components are
in contact with food.

See
https://docs.google.com/document/d/1GFWM7TBQwBQ2UJ_WSgmk4VYkHl0W7wmdTPHprwJlsxY/edit?tab=t.0#heading=h.i1wspmuo16w4
for discussion.

Exposed as a separate service.

Currently computed and displayed only for moderators, for testing.
  • Loading branch information
stephanegigandet authored Jan 17, 2025
1 parent 30c0566 commit c5cda35
Show file tree
Hide file tree
Showing 30 changed files with 738 additions and 37 deletions.
2 changes: 2 additions & 0 deletions lib/ProductOpener/APIProductServices.pm
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ my %service_functions = (
extend_ingredients => \&ProductOpener::Ingredients::extend_ingredients_service,
estimate_ingredients_percent => \&ProductOpener::Ingredients::estimate_ingredients_percent_service,
analyze_ingredients => \&ProductOpener::Ingredients::analyze_ingredients_service,
determine_food_contact_of_packaging_components =>
\&ProductOpener::PackagingFoodContact::determine_food_contact_of_packaging_components_service,
);

sub check_product_services_api_input ($request_ref) {
Expand Down
8 changes: 8 additions & 0 deletions lib/ProductOpener/Display.pm
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ use ProductOpener::Cache qw/$max_memcached_object_size $memd generate_cache_key/
use ProductOpener::Permissions qw/has_permission/;
use ProductOpener::ProductsFeatures qw(feature_enabled);
use ProductOpener::RequestStats qw(:all);
use ProductOpener::PackagingFoodContact qw/determine_food_contact_of_packaging_components_service/;

use Encode;
use URI::Escape::XS;
Expand Down Expand Up @@ -7978,6 +7979,13 @@ JS
$product_ref->{environmental_score_data});
}

# 2025/01 - For moderators, determine which packaging components are in contact with food, so that we can display them
# This is for initial development of the feature, once finalized, we could compute and store this data in the product

if ($User{moderator}) {
ProductOpener::PackagingFoodContact::determine_food_contact_of_packaging_components_service($product_ref);
}

# Activate knowledge panels for all users

initialize_knowledge_panels_options($knowledge_panels_options_ref, $request_ref);
Expand Down
271 changes: 271 additions & 0 deletions lib/ProductOpener/PackagingFoodContact.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# This file is part of Product Opener.
#
# Product Opener
# Copyright (C) 2011-2023 Association Open Food Facts
# Contact: [email protected]
# 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 <http://www.gnu.org/licenses/>.

=encoding UTF-8
=head1 NAME
ProductOpener::PackagingFoodContact
=head1 SYNOPSIS
=head1 DESCRIPTION
=cut

package ProductOpener::PackagingFoodContact;

use ProductOpener::PerlStandards;
use Exporter qw< import >;

use Log::Any qw($log);

BEGIN {
use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS);
@EXPORT_OK = qw(
&determine_food_contact_of_packaging_components_service
&determine_food_contact_of_packaging_components
); # symbols to export on request
%EXPORT_TAGS = (all => [@EXPORT_OK]);
}

use vars @EXPORT_OK;

use ProductOpener::Config qw/:all/;
use ProductOpener::API qw/add_warning/;
use ProductOpener::Packaging qw/%packaging_taxonomies/;
use ProductOpener::Tags qw/:all/;

use Data::DeepAccess qw(deep_get deep_val);
use List::Util qw(first);

=head1 FUNCTIONS
=head2 determine_food_contact_of_packaging_components_service ( $product_ref, $updated_product_fields_ref, $errors_ref )
Determine if packaging components are in contact with the food.
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
=head4 $errors_ref
reference to an array of error messages
=cut

sub determine_food_contact_of_packaging_components_service (
$product_ref,
$updated_product_fields_ref = {},
$errors_ref = []
)
{

# Check if we have packaging data in the packagings structure
my $packagings_ref = $product_ref->{packagings};

if (not defined $packagings_ref) {
push @{$errors_ref},
{
message => {id => "missing_field"},
field => {
id => "packagings",
impact => {id => "skipped_service"},
service => {id => "determine_food_contact_of_packaging_components"}
}
};
return;
}

# indicate that the service is updating the "packagings" structure
$updated_product_fields_ref->{packagings} = 1;

determine_food_contact_of_packaging_components($packagings_ref);

return;
}

=head2 get_matching_and_non_matching_packaging_components ($packagings_ref, $conditions_ref)
Find packaging components that match specific conditions (e.g material, shape, recycling).
Conditions are matched using the taxonomy, a parent in the condition matches more specific children.
(e.g. "en:plastic" matches "en:pet" and "en:hdpe")
=head3 Parameters
=head4 $packaging_ref packaging data
=head4 $conditions_ref conditions
Hash reference with conditions like material, shape, recycling as keys, and a value or an array of values as values.
=head3 Return values
Array with:
- a reference to an array of packaging components that match the conditions,
- a reference to an array of packaging components that do not match the conditions.
=cut

sub get_matching_and_non_matching_packaging_components ($packagings_ref, $conditions_ref) {

my @matching_packagings = ();
my @non_matching_packagings = ();

foreach my $packaging_ref (@$packagings_ref) {

my $matched = 1;

foreach my $property (keys %$conditions_ref) {

if (defined $packaging_ref->{$property}) {

# Check if the component value is a child of one of the condition values
my @values
= ref $conditions_ref->{$property} eq 'ARRAY'
? @{$conditions_ref->{$property}}
: ($conditions_ref->{$property});
my $matched_value = 0;

foreach my $value (@values) {
if (is_a($packaging_taxonomies{$property}, $value, $packaging_ref->{$property})) {
$matched_value = 1;
last;
}
}
if (not $matched_value) {
$matched = 0;
last;
}
}
else {
$matched = 0;
last;
}
}

if ($matched) {
push @matching_packagings, $packaging_ref;
}
else {
push @non_matching_packagings, $packaging_ref;
}
}

return \@matching_packagings, \@non_matching_packagings;
}

sub set_food_contact_property_of_packaging_components ($packagings_ref, $food_contact) {

foreach my $packaging_ref (@$packagings_ref) {

$packaging_ref->{food_contact} = $food_contact;
}

return;
}

=head2 determine_food_contact_of_packaging_components ($packagings_ref)
Determine if packaging components are in contact with the food.
=head3 Parameters
=head4 $packagings_ref packaging data
=cut

sub determine_food_contact_of_packaging_components ($packagings_ref) {

# Cans: only the can itself is in contact with the food
my ($cans_ref, $non_cans_ref)
= get_matching_and_non_matching_packaging_components($packagings_ref, {shape => "en:can"});
if (@$cans_ref) {
set_food_contact_property_of_packaging_components($cans_ref, 1);
set_food_contact_property_of_packaging_components($non_cans_ref, 0);
return;
}

# Bottles, pots, jars: in contact with the food
my ($bottles_ref, $non_bottles_ref)
= get_matching_and_non_matching_packaging_components($packagings_ref,
{shape => ["en:bottle", "en:pot", "en:jar"]});
if (@$bottles_ref) {
set_food_contact_property_of_packaging_components($bottles_ref, 1);
set_food_contact_property_of_packaging_components($non_bottles_ref, 0);

# If there is a seal, it is in contact with the food
my ($seals_ref, $non_seals_ref)
= get_matching_and_non_matching_packaging_components($non_bottles_ref, {shape => "en:seal"});
if (@$seals_ref) {
set_food_contact_property_of_packaging_components($seals_ref, 1);
}
# Otherwise, if there is a lid or a cap, it is in contact wit the food
else {
my ($lids_ref, $non_lids_ref)
= get_matching_and_non_matching_packaging_components($non_bottles_ref, {shape => ["en:lid", "en:cap"]});
if (@$lids_ref) {
set_food_contact_property_of_packaging_components($lids_ref, 1);
}
}
return;
}

# Trays: in contact with food, and the film is in contact with the food
my ($trays_ref, $non_trays_ref)
= get_matching_and_non_matching_packaging_components($packagings_ref, {shape => "en:tray"});
if (@$trays_ref) {
set_food_contact_property_of_packaging_components($trays_ref, 1);
set_food_contact_property_of_packaging_components($non_trays_ref, 0);

my ($films_ref, $non_films_ref)
= get_matching_and_non_matching_packaging_components($non_trays_ref, {shape => "en:film"});
if (@$films_ref) {
set_food_contact_property_of_packaging_components($films_ref, 1);
}
return;
}

# Individual packaging components (dose, bag): in contact with the food
my ($individuals_ref, $non_individuals_ref)
= get_matching_and_non_matching_packaging_components($packagings_ref,
{shape => ["en:individual-dose", "en:individual-bag"]});
if (@$individuals_ref) {
set_food_contact_property_of_packaging_components($individuals_ref, 1);
set_food_contact_property_of_packaging_components($non_individuals_ref, 0);
return;
}

return;
}

1;

4 changes: 4 additions & 0 deletions po/common/common.pot
Original file line number Diff line number Diff line change
Expand Up @@ -7369,3 +7369,7 @@ msgstr "Limiting ultra-processed foods reduces the risk of noncommunicable chron
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."

msgctxt "in_contact_with_food"
msgid "In contact with food"
msgstr "In contact with food"
4 changes: 4 additions & 0 deletions po/common/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -7358,3 +7358,7 @@ msgstr "Limiting ultra-processed foods reduces the risk of noncommunicable chron
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."

msgctxt "in_contact_with_food"
msgid "In contact with food"
msgstr "In contact with food"
4 changes: 4 additions & 0 deletions po/common/fr.po
Original file line number Diff line number Diff line change
Expand Up @@ -7317,3 +7317,7 @@ msgstr "Limiter les aliments ultra-transformés réduit le risque de maladies ch
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Plusieurs études ont montré qu'une consommation plus faible d'aliments ultra-transformés est associée à un risque diminué de maladies chroniques non transmissibles, telles que l'obésité, l'hypertension et le diabète."

msgctxt "in_contact_with_food"
msgid "In contact with food"
msgstr "En contact avec l'aliment"
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
[% IF packaging.material %]
([% display_taxonomy_tag_name('packaging_materials',packaging.material) %][% IF packaging.weight_specified %][% sep %]: [% packaging.weight_specified %] g[% ELSIF packaging.weight_measured %][% sep %]: [% packaging.weight_measured %] g[% END %])
[% END %]
[% IF packaging.food_contact %]
- [% edq(lang('in_contact_with_food')) %]
[% END %]
<br>
[% END %]
[% END %]
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/api_v3_product_services.t
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,35 @@ JSON
"ingredients_text_fr": "sucre, huile de palme, eau, quelque chose d'inconnu"
}
}
JSON
},

# determine_food_contact_of_packaging_components
{
test_case => 'determine-food-contact-of-packaging-components-service',
method => 'POST',
path => '/api/v3/product_services',
body => <<JSON
{
"services":["determine_food_contact_of_packaging_components"],
"product": {
"lc": "en",
"packagings": [
{
"material": "en:plastic",
"shape": "en:tray"
},
{
"material": "en:plastic",
"shape": "en:film"
},
{
"material": "en:paper",
"shape": "en:label"
}
]
}
}
JSON
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2442,7 +2442,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
"html" : "\n \n \n 1 x \n <strong>\n Box\n \n </strong>\n \n (Wood)\n \n <br>\n \n \n \n \n \n 3 x \n <strong>\n Lid\n \n </strong>\n \n (Steel)\n \n <br>\n \n \n \n \n ",
"html" : "\n \n \n 1 x \n <strong>\n Box\n \n </strong>\n \n (Wood)\n \n \n <br>\n \n \n \n \n \n 3 x \n <strong>\n Lid\n \n </strong>\n \n (Steel)\n \n \n <br>\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
Expand All @@ -2453,7 +2453,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
"html" : "\n \n \n \n \n \n \n \n \n 1 x \n <strong>\n Film\n \n </strong>\n \n (Plastic)\n \n <br>\n \n \n ",
"html" : "\n \n \n \n \n \n \n \n \n 1 x \n <strong>\n Film\n \n </strong>\n \n (Plastic)\n \n \n <br>\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
Expand All @@ -2464,7 +2464,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
"html" : "\n \n \n \n \n 6 x \n <strong>\n Bottle\n 25cl \n </strong>\n \n (Glass)\n \n <br>\n \n \n \n \n \n \n ",
"html" : "\n \n \n \n \n 6 x \n <strong>\n Bottle\n 25cl \n </strong>\n \n (Glass)\n \n \n <br>\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
Expand Down
Loading

0 comments on commit c5cda35

Please sign in to comment.