diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandImportDataObject.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandImportDataObject.cs index f08ad1f4b..62c8f6105 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandImportDataObject.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandImportDataObject.cs @@ -5,7 +5,6 @@ using Grand.Business.Core.Interfaces.Storage; using Grand.Business.Core.Utilities.System; using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Domain.Seo; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Mapper; @@ -44,10 +43,12 @@ public BrandImportDataObject( _logger = logger; } - public async Task Execute(IEnumerable data) + public async Task Execute(IEnumerable data, CancellationToken cancellationToken = default) { - var result = await Import(data.ToList(), false, ImportMode.Panel); + var result = await Import(data.ToList(), false, ImportMode.Panel, cancellationToken); ImportRows.LogRejected(_logger, "brand", result); + //what was saved stays saved; the panel must not report a cancelled import as done + cancellationToken.ThrowIfCancellationRequested(); } /// Row API: an unknown Id is rejected, a row without Id is matched by SeName, else (best effort) by Name @@ -61,11 +62,13 @@ public Task Import(IReadOnlyList rows, bool dryRun, private Task Import(IReadOnlyList rows, bool dryRun, ImportMode mode, CancellationToken cancellationToken = default) { - return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, (row, dto) => ImportRow(row, dto, dryRun, mode), - mode == ImportMode.Row, _logger, cancellationToken); + return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, + (row, dto) => ImportRow(row, dto, dryRun, mode, cancellationToken), mode == ImportMode.Row, _logger, + cancellationToken); } - private async Task ImportRow(int row, BrandDto dto, bool dryRun, ImportMode mode) + private async Task ImportRow(int row, BrandDto dto, bool dryRun, ImportMode mode, + CancellationToken cancellationToken) { var errors = new List(); var warnings = new List(); @@ -109,10 +112,14 @@ private async Task ImportRow(int row, BrandDto dto, bool dryRun return new ImportRowResult(row, ImportRowStatus.Rejected, "", key, errors, warnings); } + //downloaded before anything is written, so a cancelled download leaves the row untouched + var image = await ImportPictures.Download(dto.Picture, _securityConfig.PictureImportAllowedPrivateHosts, + cancellationToken); + if (isNew) await _brandService.InsertBrand(brand); else await _brandService.UpdateBrand(brand); - await UpdateBrandData(dto, brand); + await UpdateBrandData(brand, image); return new ImportRowResult(row, isNew ? ImportRowStatus.Created : ImportRowStatus.Updated, brand.Id, key, errors, warnings); } @@ -146,7 +153,7 @@ private static string Key(BrandDto dto) return (found, found != null, error); } - private async Task UpdateBrandData(BrandDto brandDto, Brand brand) + private async Task UpdateBrandData(Brand brand, DownloadedImage image) { if (string.IsNullOrEmpty(brand.BrandLayoutId)) { @@ -159,12 +166,9 @@ private async Task UpdateBrandData(BrandDto brandDto, Brand brand) brand.BrandLayoutId = (await _brandLayoutService.GetAllBrandLayouts()).FirstOrDefault()?.Id; } - if (!string.IsNullOrEmpty(brandDto.Picture)) - { - var picture = await LoadPicture(brandDto.Picture, brand.Name, brand.PictureId); - if (picture != null) - brand.PictureId = picture.Id; - } + var picture = await ImportPictures.Save(_pictureService, image, brand.Name, brand.PictureId); + if (picture != null) + brand.PictureId = picture.Id; var seName = brand.SeName ?? brand.Name; seName = await _seNameService.ValidateSeName(brand, seName, brand.Name, true); @@ -178,45 +182,4 @@ private static bool ValidBrand(Brand brand) { return !string.IsNullOrEmpty(brand.Name); } - - /// - /// Creates or loads the image - /// - /// The URL of the image - /// The name of the object - /// Image identifier, may be null - /// The image or null if the image has not changed - private async Task LoadPicture(string pictureUrl, string name, string picId = "") - { - if (string.IsNullOrEmpty(pictureUrl)) - return null; - - //only http(s) URLs to public or explicitly allowed hosts are downloaded - a local path would let the - //import read any file on the server - var image = await DownloadUrl.DownloadImage(pictureUrl, _securityConfig.PictureImportAllowedPrivateHosts); - if (image == null) - return null; - - var mimeType = image.MimeType; - var newPictureBinary = image.Binary; - var pictureAlreadyExists = false; - if (!string.IsNullOrEmpty(picId)) - { - //compare with existing product pictures - var existingPicture = await _pictureService.GetPictureById(picId); - - var existingBinary = await _pictureService.LoadPictureBinary(existingPicture); - //picture binary after validation (like in database) - var validatedPictureBinary = _pictureService.ValidatePicture(newPictureBinary, mimeType); - if (existingBinary.SequenceEqual(validatedPictureBinary) || - existingBinary.SequenceEqual(newPictureBinary)) - pictureAlreadyExists = true; - } - - if (pictureAlreadyExists) return null; - - var newPicture = await _pictureService.InsertPicture(newPictureBinary, mimeType, - _pictureService.GetPictureSeName(name)); - return newPicture; - } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandSchemaProperty.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandSchemaProperty.cs index eac4b1a7f..90f8b9626 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandSchemaProperty.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/BrandSchemaProperty.cs @@ -14,9 +14,11 @@ public BrandSchemaProperty(IPictureService pictureService) _pictureService = pictureService; } - public virtual async Task[]> GetProperties(IEnumerable items) + public virtual async Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { - var pictures = await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures); + var pictures = + await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures, cancellationToken); var properties = new[] { new PropertyByName("Id", p => p.Id), diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/CategoryImportDataObject.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/CategoryImportDataObject.cs index 913135768..eb54d94e0 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/CategoryImportDataObject.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/CategoryImportDataObject.cs @@ -5,7 +5,6 @@ using Grand.Business.Core.Interfaces.Storage; using Grand.Business.Core.Utilities.System; using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Domain.Seo; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Mapper; @@ -44,10 +43,12 @@ public CategoryImportDataObject( _logger = logger; } - public async Task Execute(IEnumerable data) + public async Task Execute(IEnumerable data, CancellationToken cancellationToken = default) { - var result = await Import(data.ToList(), false, ImportMode.Panel); + var result = await Import(data.ToList(), false, ImportMode.Panel, cancellationToken); ImportRows.LogRejected(_logger, "category", result); + //what was saved stays saved; the panel must not report a cancelled import as done + cancellationToken.ThrowIfCancellationRequested(); } /// Row API: an unknown Id is rejected, a row without Id is matched by SeName, else (best effort) by Name @@ -61,11 +62,13 @@ public Task Import(IReadOnlyList rows, bool dryR private Task Import(IReadOnlyList rows, bool dryRun, ImportMode mode, CancellationToken cancellationToken = default) { - return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, (row, dto) => ImportRow(row, dto, dryRun, mode), - mode == ImportMode.Row, _logger, cancellationToken); + return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, + (row, dto) => ImportRow(row, dto, dryRun, mode, cancellationToken), mode == ImportMode.Row, _logger, + cancellationToken); } - private async Task ImportRow(int row, CategoryDto dto, bool dryRun, ImportMode mode) + private async Task ImportRow(int row, CategoryDto dto, bool dryRun, ImportMode mode, + CancellationToken cancellationToken) { var errors = new List(); var warnings = new List(); @@ -114,10 +117,14 @@ private async Task ImportRow(int row, CategoryDto dto, bool dry return new ImportRowResult(row, ImportRowStatus.Rejected, "", key, errors, warnings); } + //downloaded before anything is written, so a cancelled download leaves the row untouched + var image = await ImportPictures.Download(dto.Picture, _securityConfig.PictureImportAllowedPrivateHosts, + cancellationToken); + if (isNew) await _categoryService.InsertCategory(category); else await _categoryService.UpdateCategory(category); - await UpdateCategoryData(dto, category, parentMissing); + await UpdateCategoryData(dto, category, parentMissing, image); return new ImportRowResult(row, isNew ? ImportRowStatus.Created : ImportRowStatus.Updated, category.Id, key, errors, warnings); } @@ -153,7 +160,8 @@ private static string Key(CategoryDto dto) return (found, found != null, error); } - private async Task UpdateCategoryData(CategoryDto categoryDto, Category category, bool parentMissing) + private async Task UpdateCategoryData(CategoryDto categoryDto, Category category, bool parentMissing, + DownloadedImage image) { if (string.IsNullOrEmpty(category.CategoryLayoutId)) { @@ -174,12 +182,9 @@ private async Task UpdateCategoryData(CategoryDto categoryDto, Category category await _categoryService.GetCategoryById(category.ParentCategoryId) == null) category.ParentCategoryId = string.Empty; - if (!string.IsNullOrEmpty(categoryDto.Picture)) - { - var picture = await LoadPicture(categoryDto.Picture, category.Name, category.PictureId); - if (picture != null) - category.PictureId = picture.Id; - } + var picture = await ImportPictures.Save(_pictureService, image, category.Name, category.PictureId); + if (picture != null) + category.PictureId = picture.Id; var seName = category.SeName ?? category.Name; seName = await _seNameService.ValidateSeName(category, seName, category.Name, true); @@ -192,45 +197,4 @@ private static bool ValidCategory(Category category) { return !string.IsNullOrEmpty(category.Name); } - - /// - /// Creates or loads the image - /// - /// The URL of the image - /// The name of the object - /// Image identifier, may be null - /// The image or null if the image has not changed - private async Task LoadPicture(string pictureUrl, string name, string picId = "") - { - if (string.IsNullOrEmpty(pictureUrl)) - return null; - - //only http(s) URLs to public or explicitly allowed hosts are downloaded - a local path would let the - //import read any file on the server - var image = await DownloadUrl.DownloadImage(pictureUrl, _securityConfig.PictureImportAllowedPrivateHosts); - if (image == null) - return null; - - var mimeType = image.MimeType; - var newPictureBinary = image.Binary; - var pictureAlreadyExists = false; - if (!string.IsNullOrEmpty(picId)) - { - //compare with existing product pictures - var existingPicture = await _pictureService.GetPictureById(picId); - - var existingBinary = await _pictureService.LoadPictureBinary(existingPicture); - //picture binary after validation (like in database) - var validatedPictureBinary = _pictureService.ValidatePicture(newPictureBinary, mimeType); - if (existingBinary.SequenceEqual(validatedPictureBinary) || - existingBinary.SequenceEqual(newPictureBinary)) - pictureAlreadyExists = true; - } - - if (pictureAlreadyExists) return null; - - var newPicture = await _pictureService.InsertPicture(newPictureBinary, mimeType, - _pictureService.GetPictureSeName(name)); - return newPicture; - } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/CategorySchemaProperty.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/CategorySchemaProperty.cs index 85f2cb7d6..fa2f22d22 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/CategorySchemaProperty.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/CategorySchemaProperty.cs @@ -14,9 +14,11 @@ public CategorySchemaProperty(IPictureService pictureService) _pictureService = pictureService; } - public virtual async Task[]> GetProperties(IEnumerable items) + public virtual async Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { - var pictures = await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures); + var pictures = + await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures, cancellationToken); var properties = new[] { new PropertyByName("Id", p => p.Id), diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionImportDataObject.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionImportDataObject.cs index b124d0cc9..13439ab62 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionImportDataObject.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionImportDataObject.cs @@ -5,7 +5,6 @@ using Grand.Business.Core.Interfaces.Storage; using Grand.Business.Core.Utilities.System; using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Domain.Seo; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Mapper; @@ -44,10 +43,12 @@ public CollectionImportDataObject( _logger = logger; } - public async Task Execute(IEnumerable data) + public async Task Execute(IEnumerable data, CancellationToken cancellationToken = default) { - var result = await Import(data.ToList(), false, ImportMode.Panel); + var result = await Import(data.ToList(), false, ImportMode.Panel, cancellationToken); ImportRows.LogRejected(_logger, "collection", result); + //what was saved stays saved; the panel must not report a cancelled import as done + cancellationToken.ThrowIfCancellationRequested(); } /// Row API: an unknown Id is rejected, a row without Id is matched by SeName, else (best effort) by Name @@ -61,11 +62,13 @@ public Task Import(IReadOnlyList rows, bool dr private Task Import(IReadOnlyList rows, bool dryRun, ImportMode mode, CancellationToken cancellationToken = default) { - return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, (row, dto) => ImportRow(row, dto, dryRun, mode), - mode == ImportMode.Row, _logger, cancellationToken); + return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, + (row, dto) => ImportRow(row, dto, dryRun, mode, cancellationToken), mode == ImportMode.Row, _logger, + cancellationToken); } - private async Task ImportRow(int row, CollectionDto dto, bool dryRun, ImportMode mode) + private async Task ImportRow(int row, CollectionDto dto, bool dryRun, ImportMode mode, + CancellationToken cancellationToken) { var errors = new List(); var warnings = new List(); @@ -109,10 +112,14 @@ private async Task ImportRow(int row, CollectionDto dto, bool d return new ImportRowResult(row, ImportRowStatus.Rejected, "", key, errors, warnings); } + //downloaded before anything is written, so a cancelled download leaves the row untouched + var image = await ImportPictures.Download(dto.Picture, _securityConfig.PictureImportAllowedPrivateHosts, + cancellationToken); + if (isNew) await _collectionService.InsertCollection(collection); else await _collectionService.UpdateCollection(collection); - await UpdateCollectionData(dto, collection); + await UpdateCollectionData(collection, image); return new ImportRowResult(row, isNew ? ImportRowStatus.Created : ImportRowStatus.Updated, collection.Id, key, errors, warnings); } @@ -147,7 +154,7 @@ private static string Key(CollectionDto dto) return (found, found != null, error); } - private async Task UpdateCollectionData(CollectionDto collectionDto, Collection collection) + private async Task UpdateCollectionData(Collection collection, DownloadedImage image) { if (string.IsNullOrEmpty(collection.CollectionLayoutId)) { @@ -162,12 +169,9 @@ private async Task UpdateCollectionData(CollectionDto collectionDto, Collection (await _collectionLayoutService.GetAllCollectionLayouts()).FirstOrDefault()?.Id; } - if (!string.IsNullOrEmpty(collectionDto.Picture)) - { - var picture = await LoadPicture(collectionDto.Picture, collection.Name, collection.PictureId); - if (picture != null) - collection.PictureId = picture.Id; - } + var picture = await ImportPictures.Save(_pictureService, image, collection.Name, collection.PictureId); + if (picture != null) + collection.PictureId = picture.Id; var sename = collection.SeName ?? collection.Name; sename = await _seNameService.ValidateSeName(collection, sename, collection.Name, true); @@ -181,45 +185,4 @@ private static bool ValidCollection(Collection collection) { return !string.IsNullOrEmpty(collection.Name); } - - /// - /// Creates or loads the image - /// - /// The URL of the image - /// The name of the object - /// Image identifier, may be null - /// The image or null if the image has not changed - private async Task LoadPicture(string pictureUrl, string name, string picId = "") - { - if (string.IsNullOrEmpty(pictureUrl)) - return null; - - //only http(s) URLs to public or explicitly allowed hosts are downloaded - a local path would let the - //import read any file on the server - var image = await DownloadUrl.DownloadImage(pictureUrl, _securityConfig.PictureImportAllowedPrivateHosts); - if (image == null) - return null; - - var mimeType = image.MimeType; - var newPictureBinary = image.Binary; - var pictureAlreadyExists = false; - if (!string.IsNullOrEmpty(picId)) - { - //compare with existing product pictures - var existingPicture = await _pictureService.GetPictureById(picId); - - var existingBinary = await _pictureService.LoadPictureBinary(existingPicture); - //picture binary after validation (like in database) - var validatedPictureBinary = _pictureService.ValidatePicture(newPictureBinary, mimeType); - if (existingBinary.SequenceEqual(validatedPictureBinary) || - existingBinary.SequenceEqual(newPictureBinary)) - pictureAlreadyExists = true; - } - - if (pictureAlreadyExists) return null; - - var newPicture = await _pictureService.InsertPicture(newPictureBinary, mimeType, - _pictureService.GetPictureSeName(name)); - return newPicture; - } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionSchemaProperty.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionSchemaProperty.cs index 924e9bfbe..c4bd38b53 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionSchemaProperty.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/CollectionSchemaProperty.cs @@ -14,9 +14,11 @@ public CollectionSchemaProperty(IPictureService pictureService) _pictureService = pictureService; } - public virtual async Task[]> GetProperties(IEnumerable items) + public virtual async Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { - var pictures = await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures); + var pictures = + await ExportPictures.Preload(items.Select(x => x.PictureId), GetPictures, cancellationToken); var properties = new[] { new PropertyByName("Id", p => p.Id), diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/ExportPictures.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/ExportPictures.cs index 2d05f2b03..30f4e566f 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/ExportPictures.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/ExportPictures.cs @@ -6,14 +6,17 @@ internal static class ExportPictures /// /// Loads each distinct picture once, before the rows are written, keyed by picture id. An empty id is left out, so /// an entity without a picture exports an empty cell instead of the default picture's path, which an import would - /// read back as a picture to download. + /// read back as a picture to download. A cancelled token stops before the next picture. /// public static async Task> Preload(IEnumerable pictureIds, - Func> getPicture) + Func> getPicture, CancellationToken cancellationToken = default) { var pictures = new Dictionary(); foreach (var pictureId in pictureIds.Where(x => !string.IsNullOrEmpty(x)).Distinct()) + { + cancellationToken.ThrowIfCancellationRequested(); pictures[pictureId] = await getPicture(pictureId); + } return pictures; } } diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportPictures.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportPictures.cs new file mode 100644 index 000000000..be92dc415 --- /dev/null +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportPictures.cs @@ -0,0 +1,51 @@ +using Grand.Business.Core.Interfaces.Storage; +using Grand.Business.Core.Utilities.System; +using Grand.Domain.Media; + +namespace Grand.Business.Catalog.Services.ExportImport; + +/// +/// Pictures of the catalogue row imports. A row downloads its pictures before it writes anything, so a cancelled +/// token leaves the row untouched instead of half saved; storing them never takes the token. +/// +internal static class ImportPictures +{ + /// + /// Only http(s) URLs to public or explicitly allowed hosts are downloaded - a local path would let the import read + /// any file on the server + /// + /// The image, or null when there is no URL or it could not be downloaded + public static Task Download(string url, string[] allowedPrivateHosts, + CancellationToken cancellationToken) + { + return string.IsNullOrEmpty(url) + ? Task.FromResult(null) + : DownloadUrl.DownloadImage(url, allowedPrivateHosts, cancellationToken); + } + + /// Whether the stored picture has the image's content, as sent or after validation (like in database) + public static async Task IsStored(IPictureService pictureService, string pictureId, DownloadedImage image) + { + if (string.IsNullOrEmpty(pictureId)) + return false; + + var existingPicture = await pictureService.GetPictureById(pictureId); + if (existingPicture == null) + return false; + + var existingBinary = await pictureService.LoadPictureBinary(existingPicture); + return existingBinary.SequenceEqual(image.Binary) || + existingBinary.SequenceEqual(pictureService.ValidatePicture(image.Binary, image.MimeType)); + } + + /// Stores the image as the entity's picture + /// The new picture, or null when there is no image or the current picture already has its content + public static async Task Save(IPictureService pictureService, DownloadedImage image, string name, + string currentPictureId) + { + if (image == null || await IsStored(pictureService, currentPictureId, image)) + return null; + + return await pictureService.InsertPicture(image.Binary, image.MimeType, pictureService.GetPictureSeName(name)); + } +} diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportRows.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportRows.cs index f82838b9b..44cad9211 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportRows.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/ImportRows.cs @@ -18,9 +18,10 @@ internal static class ImportRows { /// /// Runs the rows one by one. A cancelled token stops the batch at a row boundary: what was saved is reported, the - /// rest is not touched. With a row that throws is logged and reported rejected - /// instead of losing the results of the rows saved before it; the panel's spreadsheet import leaves it off, so its - /// error still reaches the panel. + /// rest is not touched. A row only takes the token while it downloads its pictures, before it writes anything, so + /// the row the token interrupts is not processed either. With a row that + /// throws is logged and reported rejected instead of losing the results of the rows saved before it; the panel's + /// spreadsheet import leaves it off, so its error still reaches the panel. /// public static async Task RunBatch(IReadOnlyList rows, bool dryRun, Func id, Func key, Func> importRow, @@ -39,6 +40,10 @@ public static async Task RunBatch(IReadOnlyList r { results.Add(await importRow(i + 1, rows[i])); } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken) + { + results.Add(ImportRowResult.NotProcessed(i + 1, id(rows[i]), key(rows[i]))); + } catch (Exception ex) when (isolateFailures) { logger.LogError(ex, "Import of row {Row} ({Key}) failed", i + 1, key(rows[i])); diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductImportDataObject.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductImportDataObject.cs index 4a51a7956..a501aa0ba 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductImportDataObject.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductImportDataObject.cs @@ -42,7 +42,7 @@ public class ProductImportDataObject : IImportDataObject, IRowImport private readonly TaxSettings _taxSettings; private readonly ImportHtmlGuard _htmlGuard; private readonly ILogger _logger; - private readonly Func> _downloadImage; + private readonly Func> _downloadImage; /// Test seam; the default downloads through public ProductImportDataObject( @@ -64,7 +64,7 @@ public ProductImportDataObject( TaxSettings taxSettings, ImportHtmlGuard htmlGuard, ILogger logger, - Func> downloadImage = null) + Func> downloadImage = null) { _productService = productService; _pictureService = pictureService; @@ -87,13 +87,16 @@ public ProductImportDataObject( //only http(s) URLs to public or explicitly allowed hosts are downloaded - a local path would let the //import read any file on the server _downloadImage = downloadImage ?? - (url => DownloadUrl.DownloadImage(url, _securityConfig.PictureImportAllowedPrivateHosts)); + ((url, cancellationToken) => DownloadUrl.DownloadImage(url, + _securityConfig.PictureImportAllowedPrivateHosts, cancellationToken)); } - public async Task Execute(IEnumerable data) + public async Task Execute(IEnumerable data, CancellationToken cancellationToken = default) { - var result = await Import(data.ToList(), false, ImportMode.Panel); + var result = await Import(data.ToList(), false, ImportMode.Panel, cancellationToken); ImportRows.LogRejected(_logger, "product", result); + //what was saved stays saved; the panel must not report a cancelled import as done + cancellationToken.ThrowIfCancellationRequested(); } /// Row API: an unknown Id is rejected, a row without Id is matched by Sku @@ -121,12 +124,12 @@ private Task Import(IReadOnlyList rows, bool dryR //the cap protects the row API; the panel's spreadsheet import downloads every picture var budget = mode == ImportMode.Row ? new PictureBudget() : null; return ImportRows.RunBatch(rows, dryRun, r => r.Id, Key, - (row, dto) => ImportRow(row, dto, dryRun, mode, budget), mode == ImportMode.Row, _logger, - cancellationToken); + (row, dto) => ImportRow(row, dto, dryRun, mode, budget, cancellationToken), mode == ImportMode.Row, + _logger, cancellationToken); } private async Task ImportRow(int row, ProductDto dto, bool dryRun, ImportMode mode, - PictureBudget budget) + PictureBudget budget, CancellationToken cancellationToken) { var errors = new List(); var warnings = new List(); @@ -188,10 +191,13 @@ await _productService.GetProductBySku(dto.Sku) is { } skuOwner && skuOwner.Id != return new ImportRowResult(row, ImportRowStatus.Rejected, "", key, errors, warnings); } + //downloaded before anything is written, so a cancelled download leaves the row untouched + var images = await DownloadProductPictures(dto, budget, warnings, cancellationToken); + if (isNew) await _productService.InsertProduct(product); else await _productService.UpdateProduct(product); - await UpdateProductData(dto, product, isNew, references, budget, warnings); + await UpdateProductData(dto, product, isNew, references, images); return new ImportRowResult(row, isNew ? ImportRowStatus.Created : ImportRowStatus.Updated, product.Id, key, errors, warnings); } @@ -254,7 +260,7 @@ private static IEnumerable SplitIds(string ids) } private async Task UpdateProductData(ProductDto productDto, Product product, bool isNew, - References references, PictureBudget budget, List warnings) + References references, List images) { await UpdateProductDataLayout(product); await UpdateProductDataDeliveryDate(product); @@ -275,7 +281,7 @@ private async Task UpdateProductData(ProductDto productDto, Product product, boo await PrepareProductCollections(product, references.CollectionIds); //pictures - await PrepareProductPictures(productDto, product, isNew, budget, warnings); + await PrepareProductPictures(product, isNew, images); } private async Task UpdateProductDataLayout(Product product) @@ -365,14 +371,15 @@ private async Task PrepareProductCollections(Product product, List colle } } - private async Task PrepareProductPictures(ProductDto productDto, Product product, bool isNew, - PictureBudget budget, List warnings) + private async Task> DownloadProductPictures(ProductDto productDto, PictureBudget budget, + List warnings, CancellationToken cancellationToken) { //PictureUrls, when sent, replaces Picture1..3 IEnumerable urls = productDto.PictureUrls is { Count: > 0 } ? productDto.PictureUrls : new[] { productDto.Picture1, productDto.Picture2, productDto.Picture3 }; + var images = new List(); foreach (var pictureUrl in urls) { if (string.IsNullOrEmpty(pictureUrl)) @@ -385,33 +392,24 @@ private async Task PrepareProductPictures(ProductDto productDto, Product product } if (budget != null) budget.Downloads++; - var image = await _downloadImage(pictureUrl); + var image = await _downloadImage(pictureUrl, cancellationToken); if (image == null) - { warnings.Add($"Picture '{pictureUrl}' could not be downloaded."); - continue; - } + else + images.Add(image); + } - var pictureAlreadyExists = false; - if (!isNew) - { - //compare with existing product pictures - var existingPictures = product.ProductPictures; - foreach (var existingPicture in existingPictures) - { - var pp = await _pictureService.GetPictureById(existingPicture.PictureId); - var existingBinary = await _pictureService.LoadPictureBinary(pp); - //picture binary after validation (like in database) - var validatedPictureBinary = _pictureService.ValidatePicture(image.Binary, image.MimeType); - if (!existingBinary.SequenceEqual(validatedPictureBinary) && - !existingBinary.SequenceEqual(image.Binary)) continue; - //the same picture content - pictureAlreadyExists = true; - break; - } - } + return images; + } + + private async Task PrepareProductPictures(Product product, bool isNew, List images) + { + foreach (var image in images) + { + //the same content as one of the product's pictures + if (!isNew && await AnyStored(product.ProductPictures, image)) + continue; - if (pictureAlreadyExists) continue; var picture = await _pictureService.InsertPicture(image.Binary, image.MimeType, _pictureService.GetPictureSeName(product.Name), "", "", false, Reference.Product, product.Id); @@ -423,6 +421,14 @@ private async Task PrepareProductPictures(ProductDto productDto, Product product } } + private async Task AnyStored(IEnumerable productPictures, DownloadedImage image) + { + foreach (var productPicture in productPictures) + if (await ImportPictures.IsStored(_pictureService, productPicture.PictureId, image)) + return true; + return false; + } + private static bool ValidProduct(Product product) { return !string.IsNullOrEmpty(product.Name); diff --git a/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductSchemaProperty.cs b/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductSchemaProperty.cs index 9c1396a10..951521c32 100644 --- a/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductSchemaProperty.cs +++ b/src/Business/Grand.Business.Catalog/Services/ExportImport/ProductSchemaProperty.cs @@ -15,10 +15,11 @@ public ProductSchemaProperty(IPictureService pictureService) _pictureService = pictureService; } - public virtual async Task[]> GetProperties(IEnumerable items) + public virtual async Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { var pictures = await ExportPictures.Preload( - items.SelectMany(x => x.ProductPictures.Take(3)).Select(x => x.PictureId), GetPicture); + items.SelectMany(x => x.ProductPictures.Take(3)).Select(x => x.PictureId), GetPicture, cancellationToken); var properties = new[] { new PropertyByName("Id", p => p.Id), diff --git a/src/Business/Grand.Business.Checkout/Services/ExportImpot/OrderSchemaProperty.cs b/src/Business/Grand.Business.Checkout/Services/ExportImpot/OrderSchemaProperty.cs index 779f08722..ac57291ca 100644 --- a/src/Business/Grand.Business.Checkout/Services/ExportImpot/OrderSchemaProperty.cs +++ b/src/Business/Grand.Business.Checkout/Services/ExportImpot/OrderSchemaProperty.cs @@ -14,7 +14,8 @@ public OrderSchemaProperty(ICountryService countryService) _countryService = countryService; } - public virtual async Task[]> GetProperties(IEnumerable items) + public virtual async Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { var countries = (await _countryService.GetAllCountries(showHidden: true)) .ToDictionary(c => c.Id, c => c.Name); diff --git a/src/Business/Grand.Business.Common/Services/ExportImport/CountryImportDataObject.cs b/src/Business/Grand.Business.Common/Services/ExportImport/CountryImportDataObject.cs index 30db9a938..31fcb51e7 100644 --- a/src/Business/Grand.Business.Common/Services/ExportImport/CountryImportDataObject.cs +++ b/src/Business/Grand.Business.Common/Services/ExportImport/CountryImportDataObject.cs @@ -14,9 +14,13 @@ public CountryImportDataObject(ICountryService countryService) _countryService = countryService; } - public async Task Execute(IEnumerable data) + public async Task Execute(IEnumerable data, CancellationToken cancellationToken = default) { - foreach (var item in data) await Import(item); + foreach (var item in data) + { + cancellationToken.ThrowIfCancellationRequested(); + await Import(item); + } } private async Task Import(CountryStatesDto countryStatesDtoDto) diff --git a/src/Business/Grand.Business.Common/Services/ExportImport/CountrySchemaProperty.cs b/src/Business/Grand.Business.Common/Services/ExportImport/CountrySchemaProperty.cs index 7a34925a4..58664e857 100644 --- a/src/Business/Grand.Business.Common/Services/ExportImport/CountrySchemaProperty.cs +++ b/src/Business/Grand.Business.Common/Services/ExportImport/CountrySchemaProperty.cs @@ -6,7 +6,8 @@ namespace Grand.Business.Common.Services.ExportImport; public class CountrySchemaProperty : ISchemaProperty { - public virtual Task[]> GetProperties(IEnumerable items) + public virtual Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { var properties = new[] { new PropertyByName("Country", p => p.Country), diff --git a/src/Business/Grand.Business.Common/Services/ExportImport/ExcelImportProvider.cs b/src/Business/Grand.Business.Common/Services/ExportImport/ExcelImportProvider.cs index 6a741ebc5..2687d41fa 100644 --- a/src/Business/Grand.Business.Common/Services/ExportImport/ExcelImportProvider.cs +++ b/src/Business/Grand.Business.Common/Services/ExportImport/ExcelImportProvider.cs @@ -5,11 +5,11 @@ namespace Grand.Business.Common.Services.ExportImport; public class ExcelImportProvider : IImportDataProvider { - public async Task> Convert(Stream stream) + public async Task> Convert(Stream stream, CancellationToken cancellationToken = default) { var excel = new PowerExcelMapper { SkipBlankCells = false }; - return await excel.FetchAsync(stream); + return await excel.FetchAsync(stream, cancellationToken: cancellationToken); } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Common/Services/ExportImport/ExportManager.cs b/src/Business/Grand.Business.Common/Services/ExportImport/ExportManager.cs index 033d2e838..768284868 100644 --- a/src/Business/Grand.Business.Common/Services/ExportImport/ExportManager.cs +++ b/src/Business/Grand.Business.Common/Services/ExportImport/ExportManager.cs @@ -13,10 +13,12 @@ public ExportManager(IExportProvider exportProvider, ISchemaProperty schemaPr _schemaProperty = schemaProperty; } - public virtual async Task Export(IEnumerable entity) + public virtual async Task Export(IEnumerable entity, CancellationToken cancellationToken = default) { //the rows are enumerated twice: by the schema to preload data, then by the provider var items = entity.ToList(); - return _exportProvider.ExportToByte(await _schemaProperty.GetProperties(items), items); + var properties = await _schemaProperty.GetProperties(items, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return _exportProvider.ExportToByte(properties, items); } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Common/Services/ExportImport/ImportManager.cs b/src/Business/Grand.Business.Common/Services/ExportImport/ImportManager.cs index 3c4ac794c..c46efb9a9 100644 --- a/src/Business/Grand.Business.Common/Services/ExportImport/ImportManager.cs +++ b/src/Business/Grand.Business.Common/Services/ExportImport/ImportManager.cs @@ -13,9 +13,9 @@ public ImportManager(IImportDataProvider importDataProvider, IImportDataObject(stream); - await _importDataObject.Execute(data); + var data = await _importDataProvider.Convert(stream, cancellationToken); + await _importDataObject.Execute(data, cancellationToken); } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Common/Utilities/PowerExcelMapper.cs b/src/Business/Grand.Business.Common/Utilities/PowerExcelMapper.cs index 9f0cfe8fc..817accabc 100644 --- a/src/Business/Grand.Business.Common/Utilities/PowerExcelMapper.cs +++ b/src/Business/Grand.Business.Common/Utilities/PowerExcelMapper.cs @@ -14,17 +14,17 @@ public class PowerExcelMapper : ExcelMapper private Dictionary> Objects { get; } = new(); private IWorkbook Workbook { get; set; } - private static async Task ReadAsync(Stream stream) + private static async Task ReadAsync(Stream stream, CancellationToken cancellationToken) { var ms = new MemoryStream(); - await stream.CopyToAsync(ms); + await stream.CopyToAsync(ms, cancellationToken); return ms; } public async Task> FetchAsync(Stream stream, int sheetIndex = 0, - Func valueParser = null) + Func valueParser = null, CancellationToken cancellationToken = default) { - using var ms = await ReadAsync(stream); + using var ms = await ReadAsync(stream, cancellationToken); ms.Position = 0; return Fetch(ms, typeof(T), sheetIndex, valueParser).OfType(); } diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IExportManager.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IExportManager.cs index 8df5cc9ff..63e3100c8 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IExportManager.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IExportManager.cs @@ -6,5 +6,6 @@ public interface IExportManager /// Export list of entity to XLSX /// /// list of entity - Task Export(IEnumerable entity); + /// Stops loading the data the columns need; the workbook is not built + Task Export(IEnumerable entity, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataObject.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataObject.cs index 032366992..679d7eafb 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataObject.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataObject.cs @@ -2,5 +2,9 @@ public interface IImportDataObject { - Task Execute(IEnumerable data); + /// + /// Imports the rows of a spreadsheet. A cancelled token stops the import before the next row and the call throws + /// ; rows already saved stay saved. + /// + Task Execute(IEnumerable data, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataProvider.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataProvider.cs index 2488925ea..3fd1860f3 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataProvider.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportDataProvider.cs @@ -2,5 +2,5 @@ public interface IImportDataProvider { - Task> Convert(Stream stream); + Task> Convert(Stream stream, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportManager.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportManager.cs index 975b0c7fb..a0aa68aac 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportManager.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IImportManager.cs @@ -2,5 +2,5 @@ public interface IImportManager { - Task Import(Stream stream); + Task Import(Stream stream, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IRowImport.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IRowImport.cs index 093b36bad..8c773fc6f 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/IRowImport.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/IRowImport.cs @@ -40,7 +40,9 @@ public sealed record ImportBatchResult(bool DryRun, IReadOnlyList /// Row-level import with validation and dry run; the panel's Execute runs it with dryRun = false. A cancelled -/// token does not throw: the rows not reached yet come back rejected with . +/// token does not throw: the rows not reached yet come back rejected with +/// . A row downloads its pictures before it writes anything, so a row +/// whose download the token interrupted is one of them. /// Neither does a row that fails while saving: it comes back rejected with /// and the batch goes on /// diff --git a/src/Business/Grand.Business.Core/Interfaces/ExportImport/ISchemaProperty.cs b/src/Business/Grand.Business.Core/Interfaces/ExportImport/ISchemaProperty.cs index 9f060b2be..2d32504be 100644 --- a/src/Business/Grand.Business.Core/Interfaces/ExportImport/ISchemaProperty.cs +++ b/src/Business/Grand.Business.Core/Interfaces/ExportImport/ISchemaProperty.cs @@ -9,5 +9,10 @@ public interface ISchemaProperty /// is loaded here, so the column getters never block on a task. /// /// Rows that will be exported - Task[]> GetProperties(IEnumerable items); + /// + /// Stops loading that data where an implementation loads it in steps (pictures); the call then throws + /// . checks the token again before it + /// builds the workbook + /// + Task[]> GetProperties(IEnumerable items, CancellationToken cancellationToken = default); } diff --git a/src/Business/Grand.Business.Core/Utilities/System/DownloadUrl.cs b/src/Business/Grand.Business.Core/Utilities/System/DownloadUrl.cs index e652ea10e..446fc1779 100644 --- a/src/Business/Grand.Business.Core/Utilities/System/DownloadUrl.cs +++ b/src/Business/Grand.Business.Core/Utilities/System/DownloadUrl.cs @@ -53,15 +53,19 @@ public static class DownloadUrl /// Image URL /// Hosts that may resolve to a non-public address and use any port; /// a leading "*." matches any subdomain + /// Stops the download. Unlike the time limit it throws + /// , so the caller does not go on as if the picture were missing /// Image binary with the MIME type detected from its content, or null when the URL is not allowed, /// the download fails or the content is not a supported image - public static async Task DownloadImage(string url, string[] allowedPrivateHosts = null) + public static async Task DownloadImage(string url, string[] allowedPrivateHosts = null, + CancellationToken cancellationToken = default) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) return null; - using var cts = new CancellationTokenSource(RequestTimeout); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(RequestTimeout); try { //redirects reuse this request message, so every hop is checked against the same list @@ -83,6 +87,7 @@ public static async Task DownloadImage(string url, string[] all } catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException) { + cancellationToken.ThrowIfCancellationRequested(); return null; } } diff --git a/src/Business/Grand.Business.Customers/Services/ExportImport/AddressSchemaProperty.cs b/src/Business/Grand.Business.Customers/Services/ExportImport/AddressSchemaProperty.cs index a583847ea..3937bcd0a 100644 --- a/src/Business/Grand.Business.Customers/Services/ExportImport/AddressSchemaProperty.cs +++ b/src/Business/Grand.Business.Customers/Services/ExportImport/AddressSchemaProperty.cs @@ -14,7 +14,8 @@ public AddressSchemaProperty(ICountryService countryService) _countryService = countryService; } - public virtual async Task[]> GetProperties(IEnumerable
items) + public virtual async Task[]> GetProperties(IEnumerable
items, + CancellationToken cancellationToken = default) { var countries = (await _countryService.GetAllCountries(showHidden: true)) .ToDictionary(c => c.Id, c => c.Name); diff --git a/src/Business/Grand.Business.Customers/Services/ExportImport/CustomerSchemaProperty.cs b/src/Business/Grand.Business.Customers/Services/ExportImport/CustomerSchemaProperty.cs index 54ad44ce7..5008dfad7 100644 --- a/src/Business/Grand.Business.Customers/Services/ExportImport/CustomerSchemaProperty.cs +++ b/src/Business/Grand.Business.Customers/Services/ExportImport/CustomerSchemaProperty.cs @@ -7,7 +7,8 @@ namespace Grand.Business.Customers.Services.ExportImport; public class CustomerSchemaProperty : ISchemaProperty { - public virtual Task[]> GetProperties(IEnumerable items) + public virtual Task[]> GetProperties(IEnumerable items, + CancellationToken cancellationToken = default) { var properties = new[] { new PropertyByName("CustomerId", p => p.Id), diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/DownloadUrlTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/DownloadUrlTests.cs index 9ec385e42..100311765 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/DownloadUrlTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/DownloadUrlTests.cs @@ -119,6 +119,19 @@ public async Task DownloadImage_InternalServerNotAllowed_NeverConnects(string[] Assert.AreEqual(0, server.Connections); } + [TestMethod] + public async Task DownloadImage_CallerCancelled_ThrowsInsteadOfReturningNull() + { + using var server = new LocalImageServer(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsExactlyAsync(() => + DownloadUrl.DownloadImage($"http://127.0.0.1:{server.Port}/image.png", ["127.0.0.1"], cts.Token)); + + Assert.AreEqual(0, server.Connections); + } + private sealed class LocalImageServer : IDisposable { public static readonly byte[] Png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03]; diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/ProductImportDataObjectTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/ProductImportDataObjectTests.cs index 08740cab3..1b092edb2 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/ProductImportDataObjectTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/ProductImportDataObjectTests.cs @@ -73,6 +73,7 @@ public class ProductImportDataObjectTests private Mock> _loggerMock; private List _downloadedUrls; private Func _downloadResult; + private Action _onDownload; [TestInitialize] public void Init() @@ -120,9 +121,10 @@ public void Init() _taxSettings, new ImportHtmlGuard(new HtmlSanitizationService(_securityConfig), _securityConfig), (_loggerMock = new Mock>()).Object, - url => + (url, cancellationToken) => { _downloadedUrls.Add(url); + _onDownload?.Invoke(cancellationToken); return Task.FromResult(_downloadResult(url)); }); } @@ -642,6 +644,53 @@ public async Task Import_CancelledAfterFirstRow_RestAreRejectedAsNotProcessed() Assert.AreEqual("one", _repository.Table.Single().Name); } + [TestMethod] + public async Task Import_CancelledDuringPictureDownload_RowNotWrittenAndNotProcessed() + { + SetupLookups(); + using var cts = new CancellationTokenSource(); + //what DownloadUrl.DownloadImage does when the caller's token fires + _onDownload = token => + { + cts.Cancel(); + token.ThrowIfCancellationRequested(); + }; + + var result = await _productImportDataObject.Import(new List { + new() { Name = "one", Sku = "S1", Picture1 = "https://images.example.com/one.png" }, + new() { Name = "two", Sku = "S2" } + }, false, cts.Token); + + //the pictures are downloaded before the product is written, so the interrupted row is untouched + Assert.AreEqual(ImportRowStatus.Rejected, result.Rows[0].Status); + Assert.AreEqual(ImportRowResult.NotProcessedError, result.Rows[0].Errors.Single()); + Assert.AreEqual(ImportRowResult.NotProcessedError, result.Rows[1].Errors.Single()); + Assert.IsFalse(_repository.Table.Any()); + } + + [TestMethod] + public async Task Execute_CancelledMidBatch_RemainingRowsLoggedAsNotProcessedAndThrows() + { + SetupLookups(); + using var cts = new CancellationTokenSource(); + //the layout lookup runs while row 1 is saved + _productLayoutServiceMock.Setup(c => c.GetAllProductLayouts()) + .Callback(() => cts.Cancel()) + .Returns(Task.FromResult>(new List { new() })); + + await Assert.ThrowsExactlyAsync(() => _productImportDataObject.Execute( + new List { + new() { Name = "one", Sku = "S1" }, new() { Name = "two", Sku = "S2" }, + new() { Name = "three", Sku = "S3" } + }, cts.Token)); + + Assert.AreEqual("one", _repository.Table.Single().Name); + _loggerMock.Verify(l => l.Log(LogLevel.Warning, It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains("2 row(s)") && v.ToString()!.Contains("row 3") && + v.ToString()!.Contains(ImportRowResult.NotProcessedError)), + It.IsAny(), It.IsAny>()), Times.Once); + } + [TestMethod] public async Task Execute_RejectedRows_LogsOneWarningWithRowNumbersAndReasons() { diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/SchemaPropertyTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/SchemaPropertyTests.cs index 4aec09b7a..95110cab9 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/SchemaPropertyTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/ExportImport/SchemaPropertyTests.cs @@ -46,6 +46,23 @@ public async Task Product_PictureColumns_ReadPreloadedPictures() _pictureService.Verify(x => x.GetPictureById(It.IsAny()), Times.Exactly(2)); } + [TestMethod] + public async Task Product_CancelledDuringPicturePreload_StopsBeforeNextPicture() + { + using var cts = new CancellationTokenSource(); + _pictureService.Setup(x => x.GetPictureById(It.IsAny())) + .Callback(() => cts.Cancel()) + .ReturnsAsync((string id) => new Picture { Id = id }); + var product = new Product(); + product.ProductPictures.Add(new ProductPicture { PictureId = "p1" }); + product.ProductPictures.Add(new ProductPicture { PictureId = "p2" }); + + await Assert.ThrowsExactlyAsync(() => + new ProductSchemaProperty(_pictureService.Object).GetProperties([product], cts.Token)); + + _pictureService.Verify(x => x.GetPictureById(It.IsAny()), Times.Once); + } + [TestMethod] public async Task Product_PictureColumns_DoNotDependOnRowInstances() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBrandControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBrandControllerTests.cs index 2d65b9116..24ac187d5 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBrandControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBrandControllerTests.cs @@ -380,7 +380,7 @@ public async Task ExportXlsx_Success_ReturnsXlsxFile() .Setup(b => b.GetAllBrands("", "", 0, int.MaxValue, true)) .ReturnsAsync(new PagedList(new List { new() { Id = "b1" } }, 0, int.MaxValue)); var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); var result = await _controller.ExportXlsx(exportManagerMock.Object); @@ -397,7 +397,7 @@ public async Task ExportXlsx_StoreScoped_ScopesExportToDefaultStoreId() .Setup(b => b.GetAllBrands("", "store-1", 0, int.MaxValue, true)) .ReturnsAsync(new PagedList(new List { new() { Id = "b1" } }, 0, int.MaxValue)); var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); var result = await _controller.ExportXlsx(exportManagerMock.Object); @@ -432,7 +432,7 @@ public async Task ImportFromXlsx_NoFile_ShowsErrorAndRedirectsToList() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); } // --- ImportFromXlsx (security fix: ImportManager upserts by an attacker-supplied Id with no @@ -451,7 +451,7 @@ public async Task ImportFromXlsx_StoreScope_DeniesAndNeverCallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -468,6 +468,39 @@ public async Task ImportFromXlsx_GlobalScope_CallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Once); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task ImportFromXlsx_RequestAborted_PropagatesWithoutNotification() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + _controller.HttpContext.RequestAborted = cts.Token; + var importManagerMock = new Mock>(); + importManagerMock.Setup(m => m.Import(It.IsAny(), cts.Token)) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(100); + fileMock.Setup(f => f.OpenReadStream()).Returns(Stream.Null); + + //neither "imported" nor an error: the exception handler middleware logs the aborted request + await Assert.ThrowsExactlyAsync(() => + _controller.ImportFromXlsx(fileMock.Object, importManagerMock.Object)); + Assert.AreEqual(0, _controller.TempData.Count); + } + + [TestMethod] + public async Task ExportXlsx_CancelledWithoutAbortedRequest_ShowsErrorAndRedirectsToList() + { + _brandServiceMock + .Setup(b => b.GetAllBrands("", "", 0, int.MaxValue, true)) + .ThrowsAsync(new OperationCanceledException()); + var exportManagerMock = new Mock>(); + + var result = await _controller.ExportXlsx(exportManagerMock.Object); + + Assert.AreEqual("List", (result as RedirectToActionResult)?.ActionName); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs index 338d5bb14..e516ff43a 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs @@ -520,7 +520,7 @@ public async Task ImportFromXlsx_StoreScope_DeniesAndNeverCallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -537,6 +537,6 @@ public async Task ImportFromXlsx_GlobalScope_CallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Once); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs index 95702e9a8..6aba71c60 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs @@ -570,7 +570,7 @@ public async Task ImportFromXlsx_StoreScope_DeniesAndNeverCallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] @@ -587,6 +587,6 @@ public async Task ImportFromXlsx_GlobalScope_CallsImportManager() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(m => m.Import(It.IsAny()), Times.Once); + importManagerMock.Verify(m => m.Import(It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 83f486777..73df439b8 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2872,7 +2872,7 @@ public async Task ExportExcelAll_UsesScopeDefaultStoreId() var model = new ProductListModel(); _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); await _controller.ExportExcelAll(model, exportManagerMock.Object); @@ -2886,7 +2886,7 @@ public async Task ExportExcelAll_NoDefaultStoreId_DoesNotOverrideModelSearchStor var model = new ProductListModel { SearchStoreId = "explicit" }; _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); await _controller.ExportExcelAll(model, exportManagerMock.Object); @@ -2899,7 +2899,7 @@ public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() var model = new ProductListModel(); _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>())) + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())) .ThrowsAsync(new Exception("boom")); var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); @@ -2913,7 +2913,7 @@ public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() public async Task ExportExcelSelected_NullSelectedIds_ExportsEmptyList() { var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()))) + exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()), It.IsAny())) .ReturnsAsync([9]); var result = await _controller.ExportExcelSelected(null, exportManagerMock.Object); @@ -2937,14 +2937,16 @@ public async Task ExportExcelSelected_FiltersOutProductsScopeDenies() _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); var exportManagerMock = new Mock>(); exportManagerMock - .Setup(e => e.Export(It.Is>(p => p.Single() == owned))) + .Setup(e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny())) .ReturnsAsync([7]); var result = await _controller.ExportExcelSelected("owned,foreign", exportManagerMock.Object); var file = result as FileContentResult; Assert.IsNotNull(file); - exportManagerMock.Verify(e => e.Export(It.Is>(p => p.Single() == owned)), Times.Once); + exportManagerMock.Verify( + e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny()), + Times.Once); } [TestMethod] @@ -2959,7 +2961,7 @@ public async Task ImportExcel_EmptyFile_DoesNotImport_RedirectsToListWithError() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); _translationServiceMock.Verify(t => t.GetResource("Admin.Common.UploadFile"), Times.Once); } @@ -2977,7 +2979,7 @@ public async Task ImportExcel_StoreScope_DeniesAndNeverCallsImportManager() var redirect = Assert.IsInstanceOfType(result); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); } @@ -2993,7 +2995,7 @@ public async Task ImportExcel_VendorScope_DeniesAndNeverCallsImportManager() var redirect = Assert.IsInstanceOfType(result); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); } @@ -3007,7 +3009,7 @@ public async Task ImportExcel_NullFile_DoesNotImport_RedirectsToListWithError() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] diff --git a/src/Web/Grand.Web.Admin/Controllers/CountryController.cs b/src/Web/Grand.Web.Admin/Controllers/CountryController.cs index da9404f77..9ea0306c6 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CountryController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CountryController.cs @@ -363,7 +363,7 @@ from s in p.StateProvinces Published = s.Published }; - var bytes = await _exportManager.Export(query); + var bytes = await _exportManager.Export(query, HttpContext.RequestAborted); return File(bytes, "text/xls", "country.xlsx"); } @@ -375,7 +375,7 @@ public async Task ImportExcel(IFormFile importexcelfile) { if (importexcelfile is { Length: > 0 }) { - await _importManager.Import(importexcelfile.OpenReadStream()); + await _importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); Success(_translationService.GetResource("Admin.Configuration.Countries.ImportSuccess")); return RedirectToAction("List"); @@ -384,7 +384,7 @@ public async Task ImportExcel(IFormFile importexcelfile) Error(_translationService.GetResource("Admin.Common.UploadFile")); return RedirectToAction("List"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); diff --git a/src/Web/Grand.Web.Admin/Controllers/OrderController.cs b/src/Web/Grand.Web.Admin/Controllers/OrderController.cs index e2a4d26a2..64cffab75 100644 --- a/src/Web/Grand.Web.Admin/Controllers/OrderController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/OrderController.cs @@ -47,10 +47,10 @@ public async Task ExportExcelAll(OrderListModel model) var orders = await OrderViewModelService.PrepareOrders(model); try { - var bytes = await exportManager.Export(orders); + var bytes = await exportManager.Export(orders, HttpContext.RequestAborted); return File(bytes, "text/xls", "orders.xlsx"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); @@ -68,7 +68,7 @@ public async Task ExportExcelSelected(string selectedIds) orders.AddRange(await OrderService.GetOrdersByIds(ids)); } - var bytes = await exportManager.Export(orders); + var bytes = await exportManager.Export(orders, HttpContext.RequestAborted); return File(bytes, "text/xls", "orders.xlsx"); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs index b778d4ae3..ec77e7092 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseBrandController.cs @@ -251,10 +251,11 @@ public async Task ExportXlsx([FromServices] IExportManager { try { - var bytes = await exportManager.Export(await brandService.GetAllBrands(brandName: "", storeId: scope.DefaultStoreId ?? "", showHidden: true)); + var bytes = await exportManager.Export(await brandService.GetAllBrands(brandName: "", storeId: scope.DefaultStoreId ?? "", showHidden: true), + HttpContext.RequestAborted); return File(bytes, "text/xls", "brands.xlsx"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); @@ -281,7 +282,7 @@ public async Task ImportFromXlsx(IFormFile importexcelfile, if (importexcelfile is { Length: > 0 }) { - await importManager.Import(importexcelfile.OpenReadStream()); + await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); } else { @@ -292,7 +293,7 @@ public async Task ImportFromXlsx(IFormFile importexcelfile, Success(translationService.GetResource("Admin.Catalog.Brands.Imported")); return RedirectToAction("List"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs index 50fda8d85..a8c2da8a9 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs @@ -219,10 +219,11 @@ public async Task ExportXlsx([FromServices] IExportManager ImportFromXlsx(IFormFile importexcelfile, if (importexcelfile is { Length: > 0 }) { - await importManager.Import(importexcelfile.OpenReadStream()); + await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); } else { @@ -260,7 +261,7 @@ public async Task ImportFromXlsx(IFormFile importexcelfile, Success(translationService.GetResource("Admin.Catalog.Category.Imported")); return RedirectToAction("List"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index 41ab27700..8846ae9f2 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -252,10 +252,11 @@ public async Task ExportXlsx([FromServices] IExportManager ImportFromXlsx(IFormFile importexcelfile, if (importexcelfile is { Length: > 0 }) { - await importManager.Import(importexcelfile.OpenReadStream()); + await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); } else { @@ -293,7 +294,7 @@ public async Task ImportFromXlsx(IFormFile importexcelfile, Success(translationService.GetResource("Admin.Catalog.Collection.Imported")); return RedirectToAction("List"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs index 66159220a..cf27fb1a1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerManagementController.cs @@ -178,10 +178,10 @@ public async Task ExportExcelAll(CustomerListModel model) try { - var bytes = await exportManager.Export(customers); + var bytes = await exportManager.Export(customers, HttpContext.RequestAborted); return File(bytes, "text/xls", "customers.xlsx"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); @@ -199,7 +199,7 @@ public async Task ExportExcelSelected(string selectedIds) customers.AddRange(await CustomerService.GetCustomersByIds(ids)); } - var bytes = await exportManager.Export(customers); + var bytes = await exportManager.Export(customers, HttpContext.RequestAborted); return File(bytes, "text/xls", "customers.xlsx"); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 28c5c8b70..0f156277a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1813,10 +1813,10 @@ public async Task ExportExcelAll(ProductListModel model, var products = await productViewModelService.PrepareProducts(model); try { - var bytes = await exportManager.Export(products); + var bytes = await exportManager.Export(products, HttpContext.RequestAborted); return File(bytes, "text/xls", "products.xlsx"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); @@ -1849,7 +1849,7 @@ public async Task ExportExcelSelected(string selectedIds, if (await scope.HasAccess(product)) scoped.Add(product); - var bytes = await exportManager.Export(scoped); + var bytes = await exportManager.Export(scoped, HttpContext.RequestAborted); return File(bytes, "text/xls", "products.xlsx"); } @@ -1878,7 +1878,7 @@ public async Task ImportExcel(IFormFile importexcelfile, if (importexcelfile is { Length: > 0 }) { - await importManager.Import(importexcelfile.OpenReadStream()); + await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); } else { @@ -1889,7 +1889,7 @@ public async Task ImportExcel(IFormFile importexcelfile, Success(translationService.GetResource("Admin.Catalog.Products.Imported")); return RedirectToAction("List"); } - catch (Exception exc) + catch (Exception exc) when (!IsRequestAborted(exc)) { Error(exc); return RedirectToAction("List"); diff --git a/src/Web/Grand.Web.Common/Controllers/BaseController.cs b/src/Web/Grand.Web.Common/Controllers/BaseController.cs index a69861e44..8e7a10fbf 100644 --- a/src/Web/Grand.Web.Common/Controllers/BaseController.cs +++ b/src/Web/Grand.Web.Common/Controllers/BaseController.cs @@ -109,6 +109,16 @@ protected void Error(Exception exception, bool persistNextRequest = true, bool l Notification(NotifyType.Error, exception.Message, persistNextRequest); } + /// + /// Whether the exception is the request's own cancellation: the client went away. A catch-all that reports an + /// error leaves it out, so the exception handler middleware logs an aborted request at debug level instead of an + /// error nobody sees. + /// + protected bool IsRequestAborted(Exception exception) + { + return exception is OperationCanceledException && HttpContext.RequestAborted.IsCancellationRequested; + } + /// /// Log exception /// diff --git a/src/Web/Grand.Web/Features/Handlers/Customers/GetCustomerDataHandler.cs b/src/Web/Grand.Web/Features/Handlers/Customers/GetCustomerDataHandler.cs index a99d9f809..e79799613 100644 --- a/src/Web/Grand.Web/Features/Handlers/Customers/GetCustomerDataHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Customers/GetCustomerDataHandler.cs @@ -37,10 +37,11 @@ public async Task Handle(GetCustomerData request, CancellationToken canc var customers = new List { request.Customer }; _exportProvider - .BuilderExportToByte(await _customerSchemaProperty.GetProperties(customers), customers) - .BuilderExportToByte(await _addresSchemaProperty.GetProperties(request.Customer.Addresses), + .BuilderExportToByte(await _customerSchemaProperty.GetProperties(customers, cancellationToken), customers) + .BuilderExportToByte( + await _addresSchemaProperty.GetProperties(request.Customer.Addresses, cancellationToken), request.Customer.Addresses) - .BuilderExportToByte(await _orderSchemaProperty.GetProperties(orders), orders); + .BuilderExportToByte(await _orderSchemaProperty.GetProperties(orders, cancellationToken), orders); return _exportProvider.BuilderExportToByte(); }