< Summary

Information
Class: OzonOrdersWeb.Areas.Admin.Controllers.DataScopesController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Admin/Controllers/DataScopesController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 212
Coverable lines: 212
Total lines: 357
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 94
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Index()100%210%
Create()100%210%
Create()0%2040%
Edit()0%620%
Edit()0%2040%
Delete()0%620%
Templates()100%210%
FromTemplate()0%620%
FromTemplate()0%1640400%
BuildRangeExpression(...)0%420200%
LoadTemplateItemsAsync()0%620%
BuildTemplateDescription(...)0%7280%
BuildInExpression(...)0%2040%
SuggestCode(...)0%620%
TestExpression(...)100%210%
ReplaceTokens(...)100%210%
ValidateExpression(...)0%620%
Apply(...)100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Admin/Controllers/DataScopesController.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Mvc;
 2using Microsoft.EntityFrameworkCore;
 3using OzonDomains.Models.Authorization;
 4using OzonOrdersWeb.Areas.Admin.Models;
 5using OzonOrdersWeb.Areas.Admin.Services;
 6using OzonRepositories.Context;
 7using Services.ApiServices._1CApi;
 8using Services.Authorization.Services;
 9using System.Globalization;
 10using System.Linq.Dynamic.Core;
 11using Services.ApiServices;
 12
 13namespace OzonOrdersWeb.Areas.Admin.Controllers;
 14
 15/// <summary>
 16/// CRUD наборов данных (RLS, row-level security) с валидацией Dynamic LINQ выражений.
 17/// </summary>
 18public class DataScopesController : AdminAreaControllerBase
 19{
 20    private readonly ApplicationDbContext _db;
 21    private readonly IPermissionService _permissions;
 22    private readonly DataScopeFilterSummaryService _filterSummary;
 23    private readonly OneCDataManager _oneCDataManager;
 24
 025    public DataScopesController(
 026        ApplicationDbContext db,
 027        IPermissionService permissions,
 028        DataScopeFilterSummaryService filterSummary,
 029        OneCDataManager oneCDataManager)
 30    {
 031        _db = db;
 032        _permissions = permissions;
 033        _filterSummary = filterSummary;
 034        _oneCDataManager = oneCDataManager;
 035    }
 36
 37    public async Task<IActionResult> Index(CancellationToken ct)
 38    {
 039        ViewData["Title"] = "Наборы данных";
 040        ViewData["Breadcrumb"] = new List<(string, string?)> { ("Админка", Url.Action("Index", "Home")), ("Наборы данных
 041        var list = await _db.DataScopes
 042            .OrderBy(s => s.EntityName).ThenBy(s => s.Code)
 043            .Select(s => new DataScopeListItemVm
 044            {
 045                Id = s.Id, Code = s.Code, DisplayName = s.DisplayName,
 046                EntityName = s.EntityName, FilterExpression = s.FilterExpression, IsActive = s.IsActive,
 047            })
 048            .ToListAsync(ct);
 049        await _filterSummary.PopulateFilterSummariesAsync(list, ct);
 050        return View(list);
 051    }
 52
 53    [HttpGet]
 54    public IActionResult Create()
 55    {
 056        ViewData["Title"] = "Новый набор данных";
 057        return View("Edit", new DataScopeEditVm { IsActive = true });
 58    }
 59
 60    [HttpPost]
 61    [ValidateAntiForgeryToken]
 62    public async Task<IActionResult> Create(DataScopeEditVm model, CancellationToken ct)
 63    {
 064        if (await _db.DataScopes.AnyAsync(s => s.Code == model.Code, ct))
 065            ModelState.AddModelError(nameof(model.Code), "Набор с таким кодом уже существует");
 066        ValidateExpression(model.FilterExpression);
 067        if (!ModelState.IsValid) return View("Edit", model);
 68
 069        _db.DataScopes.Add(Apply(new DataScope(), model));
 070        await _db.SaveChangesAsync(ct);
 071        _permissions.InvalidateAll();
 072        TempData["SuccessMessage"] = "Набор данных создан";
 073        return RedirectToAction(nameof(Index));
 074    }
 75
 76    [HttpGet]
 77    public async Task<IActionResult> Edit(int id, CancellationToken ct)
 78    {
 079        var s = await _db.DataScopes.FirstOrDefaultAsync(x => x.Id == id, ct);
 080        if (s == null) return NotFound();
 081        ViewData["Title"] = $"Набор: {s.DisplayName}";
 082        return View(new DataScopeEditVm
 083        {
 084            Id = s.Id, Code = s.Code, DisplayName = s.DisplayName, EntityName = s.EntityName,
 085            FilterExpression = s.FilterExpression, Description = s.Description, IsActive = s.IsActive,
 086        });
 087    }
 88
 89    [HttpPost]
 90    [ValidateAntiForgeryToken]
 91    public async Task<IActionResult> Edit(int id, DataScopeEditVm model, CancellationToken ct)
 92    {
 093        var s = await _db.DataScopes.FirstOrDefaultAsync(x => x.Id == id, ct);
 094        if (s == null) return NotFound();
 095        ValidateExpression(model.FilterExpression);
 096        if (!ModelState.IsValid) return View(model);
 097        Apply(s, model);
 098        s.UpdatedAt = DateTime.UtcNow;
 099        await _db.SaveChangesAsync(ct);
 0100        _permissions.InvalidateAll();
 0101        TempData["SuccessMessage"] = "Сохранено";
 0102        return RedirectToAction(nameof(Index));
 0103    }
 104
 105    [HttpPost]
 106    [ValidateAntiForgeryToken]
 107    public async Task<IActionResult> Delete(int id, CancellationToken ct)
 108    {
 0109        var s = await _db.DataScopes.FirstOrDefaultAsync(x => x.Id == id, ct);
 0110        if (s == null) return NotFound();
 0111        _db.DataScopes.Remove(s);
 0112        await _db.SaveChangesAsync(ct);
 0113        _permissions.InvalidateAll();
 0114        TempData["SuccessMessage"] = "Удалено";
 0115        return RedirectToAction(nameof(Index));
 0116    }
 117
 118    // ===== Создание DataScope из готовых шаблонов =====
 119
 120    [HttpGet]
 121    public IActionResult Templates()
 122    {
 0123        ViewData["Title"] = "Новый набор из шаблона";
 0124        ViewData["Breadcrumb"] = new List<(string, string?)>
 0125        {
 0126            ("Админка", Url.Action("Index", "Home")),
 0127            ("Наборы данных", Url.Action(nameof(Index))),
 0128            ("Из шаблона", null)
 0129        };
 0130        return View(DataScopeTemplateRegistry.All);
 131    }
 132
 133    [HttpGet]
 134    public async Task<IActionResult> FromTemplate(string key, CancellationToken ct)
 135    {
 0136        var tpl = DataScopeTemplateRegistry.GetByKey(key);
 0137        if (tpl is null) return NotFound();
 138
 0139        var items = await LoadTemplateItemsAsync(tpl, ct);
 0140        ViewData["Title"] = tpl.DisplayName;
 0141        ViewData["Breadcrumb"] = new List<(string, string?)>
 0142        {
 0143            ("Админка", Url.Action("Index", "Home")),
 0144            ("Наборы данных", Url.Action(nameof(Index))),
 0145            ("Из шаблона", Url.Action(nameof(Templates))),
 0146            (tpl.DisplayName, null)
 0147        };
 148
 0149        var vm = new DataScopeFromTemplateVm
 0150        {
 0151            TemplateKey = tpl.Key,
 0152            EntityName = tpl.EntityName,
 0153            FkField = tpl.FkField,
 0154            TemplateDisplayName = tpl.DisplayName,
 0155            TemplateIcon = tpl.Icon,
 0156            TemplateDescription = tpl.Description,
 0157            Items = items,
 0158            IsRangeTemplate = tpl.Kind == DataScopeTemplateKind.Range,
 0159            RangeIsDecimal = tpl.RangeIsDecimal,
 0160            Code = SuggestCode(tpl.Key),
 0161            DisplayName = tpl.DisplayName,
 0162            IsActive = true,
 0163        };
 0164        return View(vm);
 0165    }
 166
 167    [HttpPost]
 168    [ValidateAntiForgeryToken]
 169    public async Task<IActionResult> FromTemplate(DataScopeFromTemplateVm model, CancellationToken ct)
 170    {
 0171        var tpl = DataScopeTemplateRegistry.GetByKey(model.TemplateKey);
 0172        if (tpl is null) return NotFound();
 173
 0174        model.Items = await LoadTemplateItemsAsync(tpl, ct);
 0175        model.EntityName = tpl.EntityName;
 0176        model.FkField = tpl.FkField;
 0177        model.TemplateDisplayName = tpl.DisplayName;
 0178        model.TemplateIcon = tpl.Icon;
 0179        model.TemplateDescription = tpl.Description;
 0180        model.IsRangeTemplate = tpl.Kind == DataScopeTemplateKind.Range;
 0181        model.RangeIsDecimal = tpl.RangeIsDecimal;
 182
 0183        if (string.IsNullOrWhiteSpace(model.Code))
 0184            ModelState.AddModelError(nameof(model.Code), "Укажите код набора");
 0185        if (string.IsNullOrWhiteSpace(model.DisplayName))
 0186            ModelState.AddModelError(nameof(model.DisplayName), "Укажите название набора");
 0187        if (await _db.DataScopes.AnyAsync(s => s.Code == model.Code, ct))
 0188            ModelState.AddModelError(nameof(model.Code), "Набор с таким кодом уже существует");
 189
 190        string expression;
 0191        if (tpl.Kind == DataScopeTemplateKind.Range)
 192        {
 0193            if (!model.MinValue.HasValue && !model.MaxValue.HasValue)
 194            {
 0195                ModelState.AddModelError(nameof(model.MinValue), "Укажите минимум и/или максимум");
 196            }
 0197            else if (model.MinValue.HasValue && model.MaxValue.HasValue && model.MinValue.Value > model.MaxValue.Value)
 198            {
 0199                ModelState.AddModelError(nameof(model.MaxValue), "Минимум не может быть больше максимума");
 200            }
 0201            if (!tpl.RangeIsDecimal)
 202            {
 0203                if (model.MinValue.HasValue && model.MinValue.Value != Math.Truncate(model.MinValue.Value))
 0204                    ModelState.AddModelError(nameof(model.MinValue), "Для количества укажите целое число");
 0205                if (model.MaxValue.HasValue && model.MaxValue.Value != Math.Truncate(model.MaxValue.Value))
 0206                    ModelState.AddModelError(nameof(model.MaxValue), "Для количества укажите целое число");
 207            }
 0208            if (!ModelState.IsValid) return View(model);
 0209            expression = BuildRangeExpression(tpl.FkField, tpl.RangeIsDecimal, model.MinValue, model.MaxValue);
 210        }
 211        else
 212        {
 0213            if (model.SelectedIds is null || model.SelectedIds.Length == 0)
 0214                ModelState.AddModelError(nameof(model.SelectedIds), "Выберите хотя бы одно значение");
 0215            if (!ModelState.IsValid) return View(model);
 0216            expression = BuildInExpression(tpl.FkField, model.SelectedIds!);
 217        }
 218
 0219        _db.DataScopes.Add(new DataScope
 0220        {
 0221            Code = model.Code.Trim(),
 0222            DisplayName = model.DisplayName.Trim(),
 0223            EntityName = tpl.EntityName,
 0224            FilterExpression = expression,
 0225            Description = string.IsNullOrWhiteSpace(model.Description)
 0226                ? BuildTemplateDescription(tpl, model)
 0227                : model.Description,
 0228            IsActive = model.IsActive,
 0229        });
 0230        await _db.SaveChangesAsync(ct);
 0231        _permissions.InvalidateAll();
 0232        TempData["SuccessMessage"] = "Набор данных создан из шаблона";
 0233        return RedirectToAction(nameof(Index));
 0234    }
 235
 236    private static string BuildRangeExpression(string field, bool isDecimal, decimal? min, decimal? max)
 237    {
 0238        if (!min.HasValue && !max.HasValue) return "false";
 0239        if (min.HasValue && max.HasValue && min.Value > max.Value) return "false";
 240
 0241        var inv = CultureInfo.InvariantCulture;
 0242        var parts = new List<string> { $"{field} != null" };
 0243        if (isDecimal)
 244        {
 0245            if (min.HasValue) parts.Add($"{field} >= {min.Value.ToString(inv)}");
 0246            if (max.HasValue) parts.Add($"{field} <= {max.Value.ToString(inv)}");
 247        }
 248        else
 249        {
 0250            if (min.HasValue) parts.Add($"{field} >= {(long)Math.Truncate(min.Value)}");
 0251            if (max.HasValue) parts.Add($"{field} <= {(long)Math.Truncate(max.Value)}");
 252        }
 253
 0254        return "(" + string.Join(" && ", parts) + ")";
 255    }
 256
 257    private async Task<List<DataScopeLookupItem>> LoadTemplateItemsAsync(DataScopeTemplate tpl, CancellationToken ct)
 258    {
 0259        if (string.Equals(tpl.Key, "bitrix-warehouse", StringComparison.OrdinalIgnoreCase))
 260        {
 0261            var bitrixWarehouses = await _oneCDataManager.GetWarehouseListAsync();
 0262            return bitrixWarehouses
 0263                .Select(x => x.Name?.Trim())
 0264                .Where(x => !string.IsNullOrWhiteSpace(x))
 0265                .Distinct(StringComparer.OrdinalIgnoreCase)
 0266                .OrderBy(x => x)
 0267                .Select(x => new DataScopeLookupItem(x!, x!))
 0268                .ToList();
 269        }
 270
 0271        return await tpl.LoadItems(_db, ct);
 0272    }
 273
 274    private static string BuildTemplateDescription(DataScopeTemplate tpl, DataScopeFromTemplateVm model)
 275    {
 0276        if (tpl.Kind == DataScopeTemplateKind.Range)
 277        {
 0278            var inv = CultureInfo.InvariantCulture;
 0279            var bits = new List<string>();
 0280            if (model.MinValue.HasValue) bits.Add($"от {model.MinValue.Value.ToString(inv)}");
 0281            if (model.MaxValue.HasValue) bits.Add($"до {model.MaxValue.Value.ToString(inv)}");
 0282            return $"Шаблон: {tpl.DisplayName}. {tpl.FkField}: {string.Join(", ", bits)}.";
 283        }
 284
 0285        return $"Шаблон: {tpl.DisplayName}. Значений: {model.SelectedIds?.Length ?? 0}.";
 286    }
 287
 288    private static string BuildInExpression(string fkField, IEnumerable<string> ids)
 289    {
 0290        var sanitized = ids
 0291            .Where(s => !string.IsNullOrWhiteSpace(s))
 0292            .Select(s => s.Trim())
 0293            .Distinct()
 0294            .ToList();
 295
 0296        if (sanitized.Count == 0) return "false";
 297
 0298        var allNumeric = sanitized.All(s => long.TryParse(s, out _));
 0299        var parts = sanitized.Select(v => allNumeric
 0300                ? $"{fkField} == {v}"
 0301                : $"{fkField} == \"{v.Replace("\"", "\\\"")}\"");
 0302        return "(" + string.Join(" || ", parts) + ")";
 303    }
 304
 305    private string SuggestCode(string templateKey)
 306    {
 0307        var baseCode = templateKey.ToLowerInvariant();
 0308        var code = baseCode;
 0309        var i = 2;
 0310        while (_db.DataScopes.Any(s => s.Code == code))
 0311            code = $"{baseCode}-{i++}";
 0312        return code;
 313    }
 314
 315    /// <summary>AJAX-тестирование Dynamic LINQ выражения (синтаксическая проверка без выполнения).</summary>
 316    [HttpPost]
 317    [ValidateAntiForgeryToken]
 318    public IActionResult TestExpression([FromForm] string expression)
 319    {
 320        try
 321        {
 0322            var data = new List<object>().AsQueryable();
 0323            var userId = "test-user";
 0324            var userName = "test";
 0325            var tokens = ReplaceTokens(expression, userId, userName);
 326            _ = tokens;
 0327            return Json(new { ok = true, message = "Выражение синтаксически корректно. Параметры: @userId, @userName." }
 328        }
 0329        catch (Exception ex)
 330        {
 0331            return Json(new { ok = false, message = ex.Message });
 332        }
 0333    }
 334
 335    private static string ReplaceTokens(string expr, string userId, string userName)
 336    {
 0337        return expr
 0338            .Replace("@userId", $"\"{userId}\"")
 0339            .Replace("@userName", $"\"{userName}\"");
 340    }
 341
 342    private void ValidateExpression(string expr)
 343    {
 0344        if (string.IsNullOrWhiteSpace(expr)) { ModelState.AddModelError(nameof(DataScopeEditVm.FilterExpression), "Выраж
 0345    }
 346
 347    private static DataScope Apply(DataScope target, DataScopeEditVm vm)
 348    {
 0349        target.Code = vm.Code.Trim();
 0350        target.DisplayName = vm.DisplayName.Trim();
 0351        target.EntityName = vm.EntityName.Trim();
 0352        target.FilterExpression = vm.FilterExpression.Trim();
 0353        target.Description = vm.Description;
 0354        target.IsActive = vm.IsActive;
 0355        return target;
 356    }
 357}