From 6ed58f8dac50276b9cedf18cf63c6a309ae45eae Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 4 Oct 2024 10:53:38 +0200 Subject: [PATCH] feat: 978 - new prices features (thumbnails, stats) New file: * `price_total_stats.dart`: Total stats for Prices. Impacted files: * `api_get_product_test.dart`: unrelated minor fix * `api_prices_test.dart`: added test for the new `getStats` method; added test for thumbnails * `location.dart`: added new count fields * `location.g.dart`: generated * `open_prices_api_client.dart`: new method `getStats` * `openfoodfacts.dart`: exported new file `price_total_stats.dart` * `price_product.dart`: added new count fields * `price_product.g.dart`: generated * `price_user.dart`: added new count fields * `price_user.g.dart`: generated * `proof.dart`: added thumbnail field and getter * `proof.g.dart`: generated * `proof_type.dart`: added new "SHOP_IMPORT" value --- lib/openfoodfacts.dart | 1 + lib/src/open_prices_api_client.dart | 26 +++++++++++ lib/src/prices/location.dart | 18 ++++++-- lib/src/prices/location.g.dart | 8 +++- lib/src/prices/price_product.dart | 16 ++++++- lib/src/prices/price_product.g.dart | 8 +++- lib/src/prices/price_total_stats.dart | 62 +++++++++++++++++++++++++ lib/src/prices/price_user.dart | 18 +++++--- lib/src/prices/price_user.g.dart | 10 +++-- lib/src/prices/proof.dart | 25 +++++++++-- lib/src/prices/proof.g.dart | 3 ++ lib/src/prices/proof_type.dart | 5 ++- test/api_get_product_test.dart | 2 - test/api_prices_test.dart | 65 ++++++++++++++++++++++++++- 14 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 lib/src/prices/price_total_stats.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 04f176c1d9..6961707a9e 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -114,6 +114,7 @@ export 'src/prices/order_by.dart'; export 'src/prices/price.dart'; export 'src/prices/price_per.dart'; export 'src/prices/price_product.dart'; +export 'src/prices/price_total_stats.dart'; export 'src/prices/price_user.dart'; export 'src/prices/proof.dart'; export 'src/prices/proof_type.dart'; diff --git a/lib/src/open_prices_api_client.dart b/lib/src/open_prices_api_client.dart index f5f6693910..02cd4c4f08 100644 --- a/lib/src/open_prices_api_client.dart +++ b/lib/src/open_prices_api_client.dart @@ -21,6 +21,7 @@ import 'prices/get_users_result.dart'; import 'prices/location.dart'; import 'prices/location_osm_type.dart'; import 'prices/price_product.dart'; +import 'prices/price_total_stats.dart'; import 'prices/proof_type.dart'; import 'prices/session.dart'; import 'prices/update_price_parameters.dart'; @@ -612,4 +613,29 @@ class OpenPricesAPIClient { } return MaybeError.responseError(response); } + + /// Returns the total stats. + static Future> getStats({ + final UriProductHelper uriHelper = uriHelperFoodProd, + }) async { + final Uri uri = getUri( + path: '/api/v1/stats', + uriHelper: uriHelper, + ); + final Response response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + ); + if (response.statusCode == 200) { + try { + final dynamic decodedResponse = HttpHelper().jsonDecodeUtf8(response); + return MaybeError.value( + PriceTotalStats.fromJson(decodedResponse), + ); + } catch (e) { + // + } + } + return MaybeError.responseError(response); + } } diff --git a/lib/src/prices/location.dart b/lib/src/prices/location.dart index 4372e6c842..a2425c1e13 100644 --- a/lib/src/prices/location.dart +++ b/lib/src/prices/location.dart @@ -8,7 +8,7 @@ part 'location.g.dart'; /// Location object in the Prices API. /// -/// cf. `LocationBase` in https://prices.openfoodfacts.net/docs +/// cf. `Location` in https://prices.openfoodfacts.org/api/docs @JsonSerializable() class Location extends JsonObject { /// ID of the location in OpenStreetMap: the store where the product was bought. @@ -25,7 +25,19 @@ class Location extends JsonObject { /// Number of prices for this location. @JsonKey(name: 'price_count') - late int priceCount; + int? priceCount; + + /// Number of users for this location. + @JsonKey(name: 'user_count') + int? userCount; + + /// Number of products for this location. + @JsonKey(name: 'product_count') + int? productCount; + + /// Number of proofs for this location. + @JsonKey(name: 'proof_count') + int? proofCount; /// ID in the Prices API. @JsonKey(name: 'id') @@ -65,7 +77,7 @@ class Location extends JsonObject { @JsonKey(fromJson: JsonHelper.stringTimestampToDate) late DateTime created; - /// Date when the product was bought. + /// Latest update timestamp. @JsonKey(fromJson: JsonHelper.nullableStringTimestampToDate) DateTime? updated; diff --git a/lib/src/prices/location.g.dart b/lib/src/prices/location.g.dart index a60dbcf781..d9435ff716 100644 --- a/lib/src/prices/location.g.dart +++ b/lib/src/prices/location.g.dart @@ -9,7 +9,10 @@ part of 'location.dart'; Location _$LocationFromJson(Map json) => Location() ..osmId = (json['osm_id'] as num).toInt() ..type = $enumDecode(_$LocationOSMTypeEnumMap, json['osm_type']) - ..priceCount = (json['price_count'] as num).toInt() + ..priceCount = (json['price_count'] as num?)?.toInt() + ..userCount = (json['user_count'] as num?)?.toInt() + ..productCount = (json['product_count'] as num?)?.toInt() + ..proofCount = (json['proof_count'] as num?)?.toInt() ..locationId = (json['id'] as num).toInt() ..name = json['osm_name'] as String? ..displayName = json['osm_display_name'] as String? @@ -28,6 +31,9 @@ Map _$LocationToJson(Location instance) => { 'osm_id': instance.osmId, 'osm_type': _$LocationOSMTypeEnumMap[instance.type]!, 'price_count': instance.priceCount, + 'user_count': instance.userCount, + 'product_count': instance.productCount, + 'proof_count': instance.proofCount, 'id': instance.locationId, 'osm_name': instance.name, 'osm_display_name': instance.displayName, diff --git a/lib/src/prices/price_product.dart b/lib/src/prices/price_product.dart index 79dfe30e40..4c8530e62e 100644 --- a/lib/src/prices/price_product.dart +++ b/lib/src/prices/price_product.dart @@ -8,7 +8,7 @@ part 'price_product.g.dart'; /// Product object in the Prices API. /// -/// cf. `ProductFull` in https://prices.openfoodfacts.net/docs +/// cf. `ProductFull` in https://prices.openfoodfacts.org/api/docs @JsonSerializable() class PriceProduct extends JsonObject { /// Barcode (EAN) of the product, as a string. @@ -17,7 +17,19 @@ class PriceProduct extends JsonObject { /// Number of prices for this product. @JsonKey(name: 'price_count') - late int priceCount; + int? priceCount; + + /// Number of locations for this product. + @JsonKey(name: 'location_count') + int? locationCount; + + /// Number of users for this product. + @JsonKey(name: 'user_count') + int? userCount; + + /// Number of proofs for this product. + @JsonKey(name: 'proof_count') + int? proofCount; @JsonKey(name: 'id') late int productId; diff --git a/lib/src/prices/price_product.g.dart b/lib/src/prices/price_product.g.dart index 8ad8c5c658..c1f1f482db 100644 --- a/lib/src/prices/price_product.g.dart +++ b/lib/src/prices/price_product.g.dart @@ -8,7 +8,10 @@ part of 'price_product.dart'; PriceProduct _$PriceProductFromJson(Map json) => PriceProduct() ..code = json['code'] as String - ..priceCount = (json['price_count'] as num).toInt() + ..priceCount = (json['price_count'] as num?)?.toInt() + ..locationCount = (json['location_count'] as num?)?.toInt() + ..userCount = (json['user_count'] as num?)?.toInt() + ..proofCount = (json['proof_count'] as num?)?.toInt() ..productId = (json['id'] as num).toInt() ..source = $enumDecodeNullable(_$FlavorEnumMap, json['source']) ..name = json['product_name'] as String? @@ -34,6 +37,9 @@ Map _$PriceProductToJson(PriceProduct instance) => { 'code': instance.code, 'price_count': instance.priceCount, + 'location_count': instance.locationCount, + 'user_count': instance.userCount, + 'proof_count': instance.proofCount, 'id': instance.productId, 'source': _$FlavorEnumMap[instance.source], 'product_name': instance.name, diff --git a/lib/src/prices/price_total_stats.dart b/lib/src/prices/price_total_stats.dart new file mode 100644 index 0000000000..91fce07fc3 --- /dev/null +++ b/lib/src/prices/price_total_stats.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../interface/json_object.dart'; +import '../utils/json_helper.dart'; + +part 'price_total_stats.g.dart'; + +/// Total stats for Prices. +/// +/// cf. `TotalStats` in https://prices.openfoodfacts.org/api/docs +@JsonSerializable() +class PriceTotalStats extends JsonObject { + @JsonKey(name: 'price_count') + int? priceCount; + + @JsonKey(name: 'price_type_product_code_count') + int? priceTypeProductCodeCount; + + @JsonKey(name: 'price_type_category_tag_count') + int? priceTypeCategoryTagCount; + + @JsonKey(name: 'product_count') + int? productCount; + + @JsonKey(name: 'product_with_price_count') + int? productWithPriceCount; + + @JsonKey(name: 'location_count') + int? locationCount; + + @JsonKey(name: 'location_with_price_count') + int? locationWithPriceCount; + + @JsonKey(name: 'proof_count') + int? proofCount; + + @JsonKey(name: 'proof_with_price_count') + int? proofWithPriceCount; + + @JsonKey(name: 'proof_type_price_tag_count') + int? proofTypePriceTagCount; + + @JsonKey(name: 'proof_type_receipt_count') + int? proofTypeReceiptCount; + + @JsonKey(name: 'user_count') + int? userCount; + + @JsonKey(name: 'user_with_price_count') + int? userWithPriceCount; + + @JsonKey(fromJson: JsonHelper.nullableStringTimestampToDate) + DateTime? updated; + + PriceTotalStats(); + + factory PriceTotalStats.fromJson(Map json) => + _$PriceTotalStatsFromJson(json); + + @override + Map toJson() => _$PriceTotalStatsToJson(this); +} diff --git a/lib/src/prices/price_user.dart b/lib/src/prices/price_user.dart index d96cde5f93..4249945db5 100644 --- a/lib/src/prices/price_user.dart +++ b/lib/src/prices/price_user.dart @@ -6,7 +6,7 @@ part 'price_user.g.dart'; /// Price user object. /// -/// cf. `UserBase` in https://prices.openfoodfacts.org/api/docs +/// cf. `User` in https://prices.openfoodfacts.org/api/docs @JsonSerializable() class PriceUser extends JsonObject { @JsonKey(name: 'user_id') @@ -14,11 +14,19 @@ class PriceUser extends JsonObject { /// Number of prices for this user. @JsonKey(name: 'price_count') - late int priceCount; + int? priceCount; - /// Number of prices for this user. - @JsonKey(name: 'is_moderator') - bool? isModerator; + /// Number of locations for this user. + @JsonKey(name: 'location_count') + int? locationCount; + + /// Number of products for this user. + @JsonKey(name: 'product_count') + int? productCount; + + /// Number of proofs for this user. + @JsonKey(name: 'proof_count') + int? proofCount; PriceUser(); diff --git a/lib/src/prices/price_user.g.dart b/lib/src/prices/price_user.g.dart index 555b32fb4d..ac17dd106f 100644 --- a/lib/src/prices/price_user.g.dart +++ b/lib/src/prices/price_user.g.dart @@ -8,11 +8,15 @@ part of 'price_user.dart'; PriceUser _$PriceUserFromJson(Map json) => PriceUser() ..userId = json['user_id'] as String - ..priceCount = (json['price_count'] as num).toInt() - ..isModerator = json['is_moderator'] as bool?; + ..priceCount = (json['price_count'] as num?)?.toInt() + ..locationCount = (json['location_count'] as num?)?.toInt() + ..productCount = (json['product_count'] as num?)?.toInt() + ..proofCount = (json['proof_count'] as num?)?.toInt(); Map _$PriceUserToJson(PriceUser instance) => { 'user_id': instance.userId, 'price_count': instance.priceCount, - 'is_moderator': instance.isModerator, + 'location_count': instance.locationCount, + 'product_count': instance.productCount, + 'proof_count': instance.proofCount, }; diff --git a/lib/src/prices/proof.dart b/lib/src/prices/proof.dart index b2e102a539..cfe5d8bbdc 100644 --- a/lib/src/prices/proof.dart +++ b/lib/src/prices/proof.dart @@ -24,6 +24,10 @@ class Proof extends JsonObject { @JsonKey(name: 'file_path') String? filePath; + /// Image thumb file path. Read-only. + @JsonKey(name: 'image_thumb_path') + String? imageThumbPath; + /// Mime type. Read-only. @JsonKey() late String mimetype; @@ -89,12 +93,25 @@ class Proof extends JsonObject { @override Map toJson() => _$ProofToJson(this); - /// Returns the URL of the proof image. - Uri? getFileUrl({required final UriProductHelper uriProductHelper}) => - filePath == null + /// Returns the URL of the proof image, thumbnail or full. + Uri? getFileUrl({ + required final UriProductHelper uriProductHelper, + final bool isThumbnail = true, + }) => + _getFileUrl( + uriProductHelper: uriProductHelper, + path: isThumbnail ? imageThumbPath : filePath, + ); + + /// Returns the URL of a proof image. + static Uri? _getFileUrl({ + required final UriProductHelper uriProductHelper, + required String? path, + }) => + path == null ? null : OpenPricesAPIClient.getUri( - path: 'img/$filePath', + path: 'img/$path', uriHelper: uriProductHelper, addUserAgentParameters: false, ); diff --git a/lib/src/prices/proof.g.dart b/lib/src/prices/proof.g.dart index 614a9363b8..e037a4d9ad 100644 --- a/lib/src/prices/proof.g.dart +++ b/lib/src/prices/proof.g.dart @@ -9,6 +9,7 @@ part of 'proof.dart'; Proof _$ProofFromJson(Map json) => Proof() ..id = (json['id'] as num).toInt() ..filePath = json['file_path'] as String? + ..imageThumbPath = json['image_thumb_path'] as String? ..mimetype = json['mimetype'] as String ..type = $enumDecodeNullable(_$ProofTypeEnumMap, json['type']) ..priceCount = (json['price_count'] as num).toInt() @@ -28,6 +29,7 @@ Proof _$ProofFromJson(Map json) => Proof() Map _$ProofToJson(Proof instance) => { 'id': instance.id, 'file_path': instance.filePath, + 'image_thumb_path': instance.imageThumbPath, 'mimetype': instance.mimetype, 'type': _$ProofTypeEnumMap[instance.type], 'price_count': instance.priceCount, @@ -46,6 +48,7 @@ const _$ProofTypeEnumMap = { ProofType.priceTag: 'PRICE_TAG', ProofType.receipt: 'RECEIPT', ProofType.gdprRequest: 'GDPR_REQUEST', + ProofType.shopImport: 'SHOP_IMPORT', }; const _$LocationOSMTypeEnumMap = { diff --git a/lib/src/prices/proof_type.dart b/lib/src/prices/proof_type.dart index 061965527e..13d562d6e7 100644 --- a/lib/src/prices/proof_type.dart +++ b/lib/src/prices/proof_type.dart @@ -12,7 +12,10 @@ enum ProofType implements OffTagged { receipt(offTag: 'RECEIPT'), @JsonValue('GDPR_REQUEST') - gdprRequest(offTag: 'GDPR_REQUEST'); + gdprRequest(offTag: 'GDPR_REQUEST'), + + @JsonValue('SHOP_IMPORT') + shopImport(offTag: 'SHOP_IMPORT'); const ProofType({ required this.offTag, diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 62338862bd..a0840bafcf 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -78,8 +78,6 @@ void main() { expect(nutriments.getValue(Nutrient.proteins, perSize), isNotNull); expect(nutriments.getValue(Nutrient.salt, perSize), isNotNull); expect(nutriments.getValue(Nutrient.fat, perSize), isNotNull); - - expect(result.product!.countries, 'United States'); }); test('check alcohol data', () async { diff --git a/test/api_prices_test.dart b/test/api_prices_test.dart index 58f88c31be..d09cf0c98e 100644 --- a/test/api_prices_test.dart +++ b/test/api_prices_test.dart @@ -20,6 +20,57 @@ void main() { expect(status.isError, isFalse); expect(status.value, OpenPricesAPIClient.statusRunning); }); + + test('getStats', () async { + final MaybeError stats = + await OpenPricesAPIClient.getStats(uriHelper: uriHelper); + expect(stats.isError, isFalse); + expect(stats.value.priceCount, greaterThan(0)); + expect(stats.value.priceTypeProductCodeCount, greaterThan(0)); + expect( + stats.value.priceTypeProductCodeCount, + lessThanOrEqualTo(stats.value.priceCount!), + ); + expect(stats.value.priceTypeCategoryTagCount, greaterThan(0)); + expect( + stats.value.priceTypeCategoryTagCount, + lessThanOrEqualTo(stats.value.priceCount!), + ); + expect(stats.value.productCount, greaterThan(0)); + expect(stats.value.productWithPriceCount, greaterThan(0)); + expect( + stats.value.productWithPriceCount, + lessThanOrEqualTo(stats.value.productCount!), + ); + expect(stats.value.locationCount, greaterThan(0)); + expect(stats.value.locationWithPriceCount, greaterThan(0)); + expect( + stats.value.locationWithPriceCount, + lessThanOrEqualTo(stats.value.locationCount!), + ); + expect(stats.value.proofCount, greaterThan(0)); + expect(stats.value.proofWithPriceCount, greaterThan(0)); + expect( + stats.value.proofWithPriceCount, + lessThanOrEqualTo(stats.value.proofCount!), + ); + expect(stats.value.proofTypePriceTagCount, greaterThan(0)); + expect( + stats.value.proofTypeReceiptCount, + lessThanOrEqualTo(stats.value.proofCount!), + ); + expect(stats.value.proofTypeReceiptCount, greaterThan(0)); + expect( + stats.value.proofTypeReceiptCount, + lessThanOrEqualTo(stats.value.proofCount!), + ); + expect(stats.value.userCount, greaterThan(0)); + expect(stats.value.userWithPriceCount, greaterThan(0)); + expect( + stats.value.userWithPriceCount, + lessThanOrEqualTo(stats.value.userCount!), + ); + }); }); group('$OpenPricesAPIClient Auth', () { @@ -744,8 +795,20 @@ void main() { expect(maybeProof.value.mimetype, proof.mimetype); expect(maybeProof.value.created, proof.created); expect(maybeProof.value.filePath, proof.filePath); + expect(maybeProof.value.imageThumbPath, proof.imageThumbPath); if (proof.filePath != null) { - final Uri uri = proof.getFileUrl(uriProductHelper: uriHelper)!; + final Uri uri = proof.getFileUrl( + uriProductHelper: uriHelper, + isThumbnail: false, + )!; + final http.Response response = await http.get(uri); + expect(response.statusCode, HTTP_OK); + } + if (proof.imageThumbPath != null) { + final Uri uri = proof.getFileUrl( + uriProductHelper: uriHelper, + isThumbnail: true, + )!; final http.Response response = await http.get(uri); expect(response.statusCode, HTTP_OK); }