Skip to content

Commit

Permalink
Allow configuring Azure Search AI from UI or appsettings. (#15004)
Browse files Browse the repository at this point in the history
  • Loading branch information
MikeAlhayek authored Jan 10, 2024
1 parent 5a90a24 commit c0ed728
Show file tree
Hide file tree
Showing 33 changed files with 718 additions and 87 deletions.
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

0 comments on commit c0ed728

Please sign in to comment.