Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring Azure Search AI from UI or appsettings. #15004

Merged
merged 15 commits into from
Jan 10, 2024
Merged
9 changes: 6 additions & 3 deletions src/OrchardCore.Cms.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,12 @@
//},
// Provides Azure AI Search Connection
//"OrchardCore_AzureAISearch": {
// "Endpoint": "",
// "IndexesPrefix": "",
// "Credential": {
// "Endpoint":"",
// "IndexesPrefix":"",
// "AuthenticationType":"Default",
// "IdentityClientId":null,
// "DisableUIConfiguration":false,
// "Credential":{
// "Key": ""
// }
//},
Expand Down
24 changes: 23 additions & 1 deletion src/OrchardCore.Modules/OrchardCore.Search.AzureAI/AdminMenu.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using OrchardCore.Navigation;
using OrchardCore.Search.AzureAI.Drivers;
using OrchardCore.Search.AzureAI.Models;

namespace OrchardCore.Search.AzureAI;

public class AdminMenu(IStringLocalizer<AdminMenu> stringLocalizer) : INavigationProvider
public class AdminMenu(
IStringLocalizer<AdminMenu> stringLocalizer,
IOptions<AzureAISearchDefaultOptions> azureAISearchSettings) : INavigationProvider
{
protected readonly IStringLocalizer S = stringLocalizer;
private readonly AzureAISearchDefaultOptions _azureAISearchSettings = azureAISearchSettings.Value;

public Task BuildNavigationAsync(string name, NavigationBuilder builder)
{
Expand All @@ -25,9 +31,25 @@ public Task BuildNavigationAsync(string name, NavigationBuilder builder)
.Action("Index", "Admin", new { area = "OrchardCore.Search.AzureAI" })
.Permission(AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)
.LocalNav()
)
)
);

if (!_azureAISearchSettings.DisableUIConfiguration)
{
builder
.Add(S["Configuration"], configuration => configuration
.Add(S["Settings"], settings => settings
.Add(S["Azure AI Search"], S["Azure AI Search"].PrefixPosition(), azureAISearch => azureAISearch
.AddClass("azure-ai-search")
.Id("azureaisearch")
.Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = AzureAISearchDefaultSettingsDisplayDriver.GroupId })
.Permission(AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes)
.LocalNav()
)
)
);
}

return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
using OrchardCore.BackgroundJobs;
using OrchardCore.ContentManagement;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Extensions;
using OrchardCore.DisplayManagement.Notify;
using OrchardCore.Indexing;
using OrchardCore.Navigation;
Expand Down Expand Up @@ -246,12 +245,19 @@ public async Task<IActionResult> CreatePost(AzureAISettingsViewModel model)
}

settings.IndexMappings = await _azureAIIndexDocumentManager.GetMappingsAsync(settings.IndexedContentTypes);
await _indexManager.CreateAsync(settings);
await _indexSettingsService.UpdateAsync(settings);
await AsyncContentItemsAsync(settings.IndexName);
await _notifier.SuccessAsync(H["Index <em>{0}</em> created successfully.", model.IndexName]);

return RedirectToAction(nameof(Index));
if (await _indexManager.CreateAsync(settings))
{
await _indexSettingsService.UpdateAsync(settings);
await AsyncContentItemsAsync(settings.IndexName);
await _notifier.SuccessAsync(H["Index <em>{0}</em> created successfully.", model.IndexName]);

return RedirectToAction(nameof(Index));
}
else
{
await _notifier.ErrorAsync(H["An error occurred while creating the index."]);
}
}
catch (Exception e)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using OrchardCore.DisplayManagement.Entities;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Environment.Shell;
using OrchardCore.Modules;
using OrchardCore.Mvc.ModelBinding;
using OrchardCore.Search.AzureAI.Models;
using OrchardCore.Search.AzureAI.Services;
using OrchardCore.Search.AzureAI.ViewModels;
using OrchardCore.Settings;

namespace OrchardCore.Search.AzureAI.Drivers;

public class AzureAISearchDefaultSettingsDisplayDriver : SectionDisplayDriver<ISite, AzureAISearchDefaultSettings>
{
public const string GroupId = "azureAISearch";

private static readonly char[] _separator = [',', ' '];

private readonly AzureAISearchIndexSettingsService _indexSettingsService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAuthorizationService _authorizationService;
private readonly ShellSettings _shellSettings;
private readonly IShellHost _shellHost;
private readonly AzureAISearchDefaultOptions _searchOptions;
private readonly IDataProtectionProvider _dataProtectionProvider;

protected readonly IStringLocalizer S;

public AzureAISearchDefaultSettingsDisplayDriver(
AzureAISearchIndexSettingsService indexSettingsService,
IHttpContextAccessor httpContextAccessor,
IAuthorizationService authorizationService,
IOptions<AzureAISearchDefaultOptions> searchOptions,
ShellSettings shellSettings,
IShellHost shellHost,
IDataProtectionProvider dataProtectionProvider,
IStringLocalizer<AzureAISearchDefaultSettingsDisplayDriver> stringLocalizer
)
{
_indexSettingsService = indexSettingsService;
_httpContextAccessor = httpContextAccessor;
_authorizationService = authorizationService;
_shellSettings = shellSettings;
_shellHost = shellHost;
_searchOptions = searchOptions.Value;
_dataProtectionProvider = dataProtectionProvider;
S = stringLocalizer;
}

public override IDisplayResult Edit(AzureAISearchDefaultSettings settings)
{
if (_searchOptions.DisableUIConfiguration)
{
return null;
}

return Initialize<AzureAISearchDefaultSettingsViewModel>("AzureAISearchDefaultSettings_Edit", model =>
{
model.AuthenticationTypes = new[]
{
new SelectListItem(S["Default"], nameof(AzureAIAuthenticationType.Default)),
new SelectListItem(S["Managed Identity"], nameof(AzureAIAuthenticationType.ManagedIdentity)),
new SelectListItem(S["API Key"], nameof(AzureAIAuthenticationType.ApiKey)),
};
model.ConfigurationsAreOptional = _searchOptions.IsFileConfigurationExists();
model.AuthenticationType = settings.AuthenticationType;
model.UseCustomConfiguration = settings.UseCustomConfiguration;
model.Endpoint = settings.Endpoint;
model.IdentityClientId = settings.IdentityClientId;
model.ApiKeyExists = !string.IsNullOrEmpty(settings.ApiKey);
}).Location("Content")
.RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes))
.OnGroup(GroupId);
}

public override async Task<IDisplayResult> UpdateAsync(AzureAISearchDefaultSettings settings, BuildEditorContext context)
{
if (!GroupId.EqualsOrdinalIgnoreCase(context.GroupId) || _searchOptions.DisableUIConfiguration)
{
return null;
}

if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, AzureAISearchIndexPermissionHelper.ManageAzureAISearchIndexes))
{
return null;
}

var model = new AzureAISearchDefaultSettingsViewModel();

if (await context.Updater.TryUpdateModelAsync(model, Prefix))
{
if (!_searchOptions.IsFileConfigurationExists())
{
model.UseCustomConfiguration = true;
}

var useCustomConfigurationChanged = settings.UseCustomConfiguration != model.UseCustomConfiguration;

if (model.UseCustomConfiguration)
{
settings.AuthenticationType = model.AuthenticationType.Value;
settings.Endpoint = model.Endpoint;
settings.IdentityClientId = model.IdentityClientId?.Trim();

if (string.IsNullOrWhiteSpace(model.Endpoint))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.Endpoint), S["Endpoint is required."]);
}
else if (!Uri.TryCreate(model.Endpoint, UriKind.Absolute, out var _))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.Endpoint), S["Endpoint must be a valid url."]);
}

if (model.AuthenticationType == AzureAIAuthenticationType.ApiKey)
{
var hasNewKey = !string.IsNullOrWhiteSpace(model.ApiKey);

if (!hasNewKey && string.IsNullOrEmpty(settings.ApiKey))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.ApiKey), S["API Key is required when using API Key authentication type."]);
}
else if (hasNewKey)
{
var protector = _dataProtectionProvider.CreateProtector(AzureAISearchDefaultOptionsConfigurations.ProtectorName);

settings.ApiKey = protector.Protect(model.ApiKey);
}
}
}

settings.UseCustomConfiguration = model.UseCustomConfiguration;

if (context.Updater.ModelState.IsValid &&
(_searchOptions.Credential?.Key != model.ApiKey
|| _searchOptions.Endpoint != settings.Endpoint
|| _searchOptions.AuthenticationType != settings.AuthenticationType
|| _searchOptions.IdentityClientId != settings.IdentityClientId
|| useCustomConfigurationChanged))
{
await _shellHost.ReleaseShellContextAsync(_shellSettings);
}
}

return Edit(settings);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,5 @@ public override IDisplayResult Display(AzureAISearchSettingsDeploymentStep step)
);

public override IDisplayResult Edit(AzureAISearchSettingsDeploymentStep step)
{
return View("AzureAISearchSettingsDeploymentStep_Fields_Edit", step).Location("Content");
}
=> View("AzureAISearchSettingsDeploymentStep_Fields_Edit", step).Location("Content");
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using OrchardCore.DisplayManagement.Entities;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Environment.Shell;
using OrchardCore.Modules;
using OrchardCore.Mvc.ModelBinding;
using OrchardCore.Search.AzureAI.Models;
Expand All @@ -24,19 +25,24 @@ public class AzureAISearchSettingsDisplayDriver : SectionDisplayDriver<ISite, Az
private readonly AzureAISearchIndexSettingsService _indexSettingsService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAuthorizationService _authorizationService;

private readonly IShellHost _shellHost;
private readonly ShellSettings _shellSettings;
protected readonly IStringLocalizer S;

public AzureAISearchSettingsDisplayDriver(
AzureAISearchIndexSettingsService indexSettingsService,
IHttpContextAccessor httpContextAccessor,
IAuthorizationService authorizationService,
IShellHost shellHost,
ShellSettings shellSettings,
IStringLocalizer<AzureAISearchSettingsDisplayDriver> stringLocalizer
)
{
_indexSettingsService = indexSettingsService;
_httpContextAccessor = httpContextAccessor;
_authorizationService = authorizationService;
_shellHost = shellHost;
_shellSettings = shellSettings;
S = stringLocalizer;
}

Expand Down Expand Up @@ -67,26 +73,37 @@ public override async Task<IDisplayResult> UpdateAsync(AzureAISearchSettings sec

var model = new AzureAISearchSettingsViewModel();

await context.Updater.TryUpdateModelAsync(model, Prefix);

if (string.IsNullOrEmpty(model.SearchIndex))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Search Index is required."]);
}
else
if (await context.Updater.TryUpdateModelAsync(model, Prefix))
{
var indexes = await _indexSettingsService.GetSettingsAsync();
if (string.IsNullOrEmpty(model.SearchIndex))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Search Index is required."]);
}
else
{
var indexes = await _indexSettingsService.GetSettingsAsync();

if (!indexes.Any(index => index.IndexName == model.SearchIndex))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Invalid Search Index value."]);
}
}

if (!indexes.Any(index => index.IndexName == model.SearchIndex))
var fields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries);

if (section.SearchIndex != model.SearchIndex || !AreTheSame(section.DefaultSearchFields, fields))
{
context.Updater.ModelState.AddModelError(Prefix, nameof(model.SearchIndex), S["Invalid Search Index value."]);
section.SearchIndex = model.SearchIndex;
section.DefaultSearchFields = fields;

if (context.Updater.ModelState.IsValid)
{
await _shellHost.ReleaseShellContextAsync(_shellSettings);
}
}
}

section.SearchIndex = model.SearchIndex;
section.DefaultSearchFields = model.SearchFields?.Split(_separator, StringSplitOptions.RemoveEmptyEntries);

return await EditAsync(section, context);
return Edit(section);
}

protected override void BuildPrefix(ISite model, string htmlFieldPrefix)
Expand All @@ -98,4 +115,21 @@ protected override void BuildPrefix(ISite model, string htmlFieldPrefix)
Prefix = htmlFieldPrefix + "." + Prefix;
}
}

private static bool AreTheSame(string[] a, string[] b)
{
if (a == null && b == null)
{
return false;
}

if ((a is null && b is not null) || (a is not null && b is null))
{
return true;
}

var combine = a.Intersect(b).ToList();

return combine.Count == a.Length && combine.Count == b.Length;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\OrchardCore\OrchardCore.ContentManagement.Abstractions\OrchardCore.ContentManagement.Abstractions.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.ContentManagement\OrchardCore.ContentManagement.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.ContentTypes.Abstractions\OrchardCore.ContentTypes.Abstractions.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Admin.Abstractions\OrchardCore.Admin.Abstractions.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.DisplayManagement\OrchardCore.DisplayManagement.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Navigation.Core\OrchardCore.Navigation.Core.csproj" />
<ProjectReference Include="..\..\OrchardCore\OrchardCore.Module.Targets\OrchardCore.Module.Targets.csproj" />
Expand Down
Loading