An activity interface
This commit is contained in:
parent
e987f215bb
commit
6e2fec1620
30 changed files with 847 additions and 10 deletions
24
contrib/.env-sample
Normal file
24
contrib/.env-sample
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# parametres de déploiement au Makefile
|
||||||
|
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=yavsc
|
||||||
|
POSTGRES_USER=yavsc
|
||||||
|
POSTGRES_PASSWORD=<your-password-here>
|
||||||
|
|
||||||
|
HTTP_HOST=localhost
|
||||||
|
|
||||||
|
Org_PORT=83
|
||||||
|
Blogs_PORT=85
|
||||||
|
Api_PORT=87
|
||||||
|
|
||||||
|
PostIt_CLIENT_ID=postit
|
||||||
|
|
||||||
|
ASPNETCORE_Smtp__Host="mercure.pschneider.fr"
|
||||||
|
ASPNETCORE_Smtp__Port=465
|
||||||
|
ASPNETCORE_Smtp__SenderName="Paul Schneider"
|
||||||
|
ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr"
|
||||||
|
ASPNETCORE_Smtp__UserName="paul"
|
||||||
|
ASPNETCORE_Smtp__Password="<your-smtp-password-here>"
|
||||||
|
|
||||||
|
DESTDIR=/srv/www/yavsc
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
APP_PROJECT_NAMES=Org Blogs
|
APP_PROJECT_NAMES=Org Blogs Api
|
||||||
SLNDIR=..
|
SLNDIR=..
|
||||||
|
|
||||||
include $(SLNDIR)/.env
|
include $(SLNDIR)/.env
|
||||||
|
|
@ -9,9 +9,11 @@ generated/:
|
||||||
|
|
||||||
generated/yavscOrg.service:
|
generated/yavscOrg.service:
|
||||||
generated/yavscBlogs.service:
|
generated/yavscBlogs.service:
|
||||||
|
generated/yavscApi.service:
|
||||||
|
|
||||||
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||||
@cat template.service | APP_NAME="$*" \
|
@cat template.service | APP_NAME="$*" \
|
||||||
|
DESTDIR="$(DESTDIR)" \
|
||||||
HTTP_HOST="$(HTTP_HOST)" \
|
HTTP_HOST="$(HTTP_HOST)" \
|
||||||
HTTP_PORT="$*_$(HTTP_PORT)" \
|
HTTP_PORT="$*_$(HTTP_PORT)" \
|
||||||
BASEAPPDIR="$(BASEAPPDIR)" \
|
BASEAPPDIR="$(BASEAPPDIR)" \
|
||||||
|
|
@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||||
@echo Created service file: $@
|
@echo Created service file: $@
|
||||||
|
|
||||||
|
|
||||||
copy-services: copy-service-Org copy-service-Blogs
|
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
|
||||||
copy-service-Org: /etc/systemd/system/yavscOrg.service
|
copy-service-Org: /etc/systemd/system/yavscOrg.service
|
||||||
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
|
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
|
||||||
|
copy-service-Api: /etc/systemd/system/yavscApi.service
|
||||||
|
|
||||||
copy-binaries: build_publish_Org build_publish_Blogs stop-services
|
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
|
||||||
@for project in $(APP_PROJECT_NAMES); \
|
@for project in $(APP_PROJECT_NAMES); \
|
||||||
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
|
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
|
||||||
echo "$${project} -> $${LCAPI}" ; \
|
echo "$${project} -> $${LCAPI}" ; \
|
||||||
|
|
@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services
|
||||||
build_publish_%: clean_publish_dir_%
|
build_publish_%: clean_publish_dir_%
|
||||||
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
|
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
|
||||||
|
|
||||||
|
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
|
||||||
|
|
||||||
clean_publish_dir_%:
|
clean_publish_dir_%:
|
||||||
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
|
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
|
||||||
|
|
||||||
|
|
@ -84,6 +89,7 @@ stop-services:
|
||||||
|
|
||||||
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
||||||
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
||||||
|
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
||||||
|
|
||||||
showConfig:
|
showConfig:
|
||||||
@echo CONFIGURATION: $(CONFIGURATION)
|
@echo CONFIGURATION: $(CONFIGURATION)
|
||||||
|
|
@ -92,4 +98,3 @@ showConfig:
|
||||||
clean:
|
clean:
|
||||||
@rm -rf generated
|
@rm -rf generated
|
||||||
|
|
||||||
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean
|
|
||||||
|
|
|
||||||
37
contrib/tmp/yavscBlogs.service
Normal file
37
contrib/tmp/yavscBlogs.service
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
[Unit]
|
||||||
|
Description=yavsc-Blogs
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Wants=postgresql.service
|
||||||
|
After=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=5s
|
||||||
|
Type=simple
|
||||||
|
User=yavsc
|
||||||
|
Group=yavsc
|
||||||
|
WorkingDirectory=/srv/www/yavsc
|
||||||
|
ExecStart=/srv/www/yavsc/Yavsc.Blogs
|
||||||
|
Restart=always
|
||||||
|
Environment="HOME="
|
||||||
|
Environment="ANTHROPIC_API_KEY=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"
|
||||||
|
Environment="ANTHROPIC_MAX_TOKENS=255"
|
||||||
|
Environment="ASPNETCORE_Environment="
|
||||||
|
Environment="ASPNETCORE_Kestrel__Endpoints__Http=http://localhost:Blogs_"
|
||||||
|
Environment="ASPNETCORE_ConnectionStrings__YavscConnection=Server=localhost;Port=5432;Database=yavsc;Username=yavsc;Password=4T/X+fOnE;"
|
||||||
|
|
||||||
|
Environment="ASPNETCORE_Smtp__Host=\"mercure.pschneider.f\""
|
||||||
|
Environment="ASPNETCORE_Smtp__Port=465"
|
||||||
|
Environment="ASPNETCORE_Smtp__SenderName=\"Paul Schneider\""
|
||||||
|
Environment="ASPNETCORE_Smtp__SenderEmail=\"paul@pschneider.fr\""
|
||||||
|
Environment="ASPNETCORE_Smtp__UserName=\"paul\""
|
||||||
|
Environment="ASPNETCORE_Smtp__Password=\"j\0Dsn5=t\""
|
||||||
|
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
|
SyslogIdentifier=yavscBlogs
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
113
src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs
Normal file
113
src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class ActivitiesPageViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ActivityApiClient_uses_business_absolute_paths()
|
||||||
|
{
|
||||||
|
var api = new StubActivityApi();
|
||||||
|
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
|
||||||
|
|
||||||
|
await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
|
||||||
|
await client.GetPerformersAsync("brush-pro", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]);
|
||||||
|
Assert.Equal("https://business.example/api/v1/activity/brush-pro/performers", api.Paths[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
|
||||||
|
{
|
||||||
|
var api = new StubActivityApi();
|
||||||
|
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
|
||||||
|
var vm = new ActivitiesPageViewModel(client);
|
||||||
|
|
||||||
|
await vm.RefreshAsync();
|
||||||
|
|
||||||
|
Assert.Equal("brush", vm.SelectedActivity?.Code);
|
||||||
|
Assert.Single(vm.Specializations);
|
||||||
|
Assert.Equal("brush", vm.CurrentActivity?.Code);
|
||||||
|
Assert.Single(vm.Performers);
|
||||||
|
Assert.Equal("Alice", vm.Performers[0].UserName);
|
||||||
|
|
||||||
|
await vm.ShowSpecializationAsync(vm.Specializations[0]);
|
||||||
|
|
||||||
|
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
|
||||||
|
Assert.Single(vm.Performers);
|
||||||
|
Assert.Equal("Bob", vm.Performers[0].UserName);
|
||||||
|
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
await vm.ShowSpecializationAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("brush", vm.CurrentActivity?.Code);
|
||||||
|
Assert.Single(vm.Performers);
|
||||||
|
Assert.Equal("Alice", vm.Performers[0].UserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubActivityApi : IYavscApiClient
|
||||||
|
{
|
||||||
|
public HttpClient Http { get; } = new();
|
||||||
|
public List<string> Paths { get; } = new();
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Paths.Add(path);
|
||||||
|
|
||||||
|
if (typeof(T) == typeof(List<ActivityBrowseItemDto>))
|
||||||
|
{
|
||||||
|
var activities = new List<ActivityBrowseItemDto>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Code = "brush",
|
||||||
|
Name = "Brush",
|
||||||
|
Description = "Coiffure à domicile",
|
||||||
|
PerformerCount = 1,
|
||||||
|
Children = new List<ActivityBrowseItemDto>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Code = "brush-pro",
|
||||||
|
Name = "Brush Pro",
|
||||||
|
Description = "Spécialisation premium",
|
||||||
|
ParentCode = "brush",
|
||||||
|
PerformerCount = 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Task.FromResult((T)(object)activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(T) == typeof(List<ActivityPerformerDto>))
|
||||||
|
{
|
||||||
|
var performers = path.EndsWith("brush-pro/performers", StringComparison.Ordinal)
|
||||||
|
? new List<ActivityPerformerDto>
|
||||||
|
{
|
||||||
|
new() { PerformerId = "pro-2", UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro" }
|
||||||
|
}
|
||||||
|
: new List<ActivityPerformerDto>
|
||||||
|
{
|
||||||
|
new() { PerformerId = "pro-1", UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush" }
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult((T)(object)performers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(default(T)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Paths.Add(path);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,26 @@ public class SignaturePadControlTests
|
||||||
Assert.NotEqual(first.Strokes, third.Strokes);
|
Assert.NotEqual(first.Strokes, third.Strokes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PendingStroke_is_exposed_only_while_capturing()
|
||||||
|
{
|
||||||
|
var pad = new SignaturePadControl();
|
||||||
|
|
||||||
|
Assert.Empty(pad.PendingStroke);
|
||||||
|
|
||||||
|
pad.BeginCaptureForTest();
|
||||||
|
pad.AppendPointForTest(1_000, 2_000);
|
||||||
|
pad.AppendPointForTest(3_000, 4_000);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke);
|
||||||
|
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
|
||||||
|
|
||||||
|
pad.SealStrokeForTest();
|
||||||
|
|
||||||
|
Assert.Empty(pad.PendingStroke);
|
||||||
|
Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Clear_empties_buffer_and_raises_redraw()
|
public void Clear_empties_buffer_and_raises_redraw()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,17 @@ public class SignaturePadControl : TemplatedControl
|
||||||
_pendingPoints++;
|
_pendingPoints++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test hook: mark the control as actively capturing so tests
|
||||||
|
/// can exercise the live-preview path without synthetic pointer
|
||||||
|
/// events.
|
||||||
|
/// </summary>
|
||||||
|
internal void BeginCaptureForTest()
|
||||||
|
{
|
||||||
|
_capturing = true;
|
||||||
|
_pendingPoints = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test hook: seal the currently-pending stroke with a length
|
/// Test hook: seal the currently-pending stroke with a length
|
||||||
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at
|
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public static class ServiceCollectionHelpers
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
|
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
// Vues
|
// Vues
|
||||||
|
|
@ -45,6 +46,7 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton<HomePage>();
|
services.AddSingleton<HomePage>();
|
||||||
services.AddSingleton<SignaturePage>();
|
services.AddSingleton<SignaturePage>();
|
||||||
services.AddSingleton<CirclesPage>();
|
services.AddSingleton<CirclesPage>();
|
||||||
|
services.AddSingleton<ActivitiesPage>();
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
services.AddSingleton<YavscApiClient>(api);
|
services.AddSingleton<YavscApiClient>(api);
|
||||||
|
|
@ -52,10 +54,12 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(circleClient);
|
services.AddSingleton(circleClient);
|
||||||
services.AddSingleton(blogAclClient);
|
services.AddSingleton(blogAclClient);
|
||||||
services.AddSingleton(userSearchClient);
|
services.AddSingleton(userSearchClient);
|
||||||
|
services.AddSingleton(activityClient);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
services.AddSingleton<SignaturePageViewModel>();
|
services.AddSingleton<SignaturePageViewModel>();
|
||||||
services.AddSingleton<CirclesPageViewModel>();
|
services.AddSingleton<CirclesPageViewModel>();
|
||||||
|
services.AddSingleton<ActivitiesPageViewModel>();
|
||||||
|
|
||||||
// Dialogs (modal-light pages): the ViewLocator resolves
|
// Dialogs (modal-light pages): the ViewLocator resolves
|
||||||
// them when a caller pushes a PostAclDialogViewModel or
|
// them when a caller pushes a PostAclDialogViewModel or
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ public class ViewLocator : IDataTemplate
|
||||||
MainViewModel => services.GetRequiredService<MainPage>(),
|
MainViewModel => services.GetRequiredService<MainPage>(),
|
||||||
Settings => services.GetRequiredService<SettingsPage>(),
|
Settings => services.GetRequiredService<SettingsPage>(),
|
||||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||||
|
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
||||||
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
|
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
|
||||||
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
|
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
|
||||||
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
|
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),
|
||||||
|
|
|
||||||
219
src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs
Normal file
219
src/PostIt/PostIt/ViewModels/ActivitiesPageViewModel.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
public partial class ActivitiesPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ActivityApiClient _client;
|
||||||
|
private bool _syncingSelection;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<ActivityBrowseItemDto> Activities { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ActivityBrowseItemDto? SelectedActivity { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<ActivityBrowseItemDto> Specializations { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<ActivityPerformerDto> Performers { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
|
||||||
|
|
||||||
|
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
|
||||||
|
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
|
||||||
|
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
|
||||||
|
|
||||||
|
public override bool CanNavigateNext
|
||||||
|
{
|
||||||
|
get => false;
|
||||||
|
protected set { _ = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanNavigatePrevious
|
||||||
|
{
|
||||||
|
get => true;
|
||||||
|
protected set { _ = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivitiesPageViewModel(ActivityApiClient client)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
|
||||||
|
{
|
||||||
|
if (_syncingSelection) return;
|
||||||
|
_ = ShowActivitySafeAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value)
|
||||||
|
{
|
||||||
|
if (_syncingSelection) return;
|
||||||
|
_ = ShowSpecializationSafeAsync(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ShowActivityAsync(value);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ShowSpecializationAsync(value);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _client.GetCatalogAsync();
|
||||||
|
Activities = new ObservableCollection<ActivityBrowseItemDto>(list ?? new());
|
||||||
|
|
||||||
|
var first = Activities.FirstOrDefault();
|
||||||
|
await ShowActivityAsync(first);
|
||||||
|
if (first is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Aucune activité disponible.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
Activities = new ObservableCollection<ActivityBrowseItemDto>();
|
||||||
|
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>();
|
||||||
|
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Activities = new ObservableCollection<ActivityBrowseItemDto>();
|
||||||
|
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>();
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowActivityAsync(ActivityBrowseItemDto? activity)
|
||||||
|
{
|
||||||
|
_syncingSelection = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SelectedActivity = activity;
|
||||||
|
SelectedSpecialization = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncingSelection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentActivity));
|
||||||
|
OnPropertyChanged(nameof(SelectedActivityLabel));
|
||||||
|
OnPropertyChanged(nameof(CurrentActivityLabel));
|
||||||
|
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
|
||||||
|
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadPerformersAsync(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization)
|
||||||
|
{
|
||||||
|
_syncingSelection = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SelectedSpecialization = specialization;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_syncingSelection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CurrentActivity));
|
||||||
|
OnPropertyChanged(nameof(CurrentActivityLabel));
|
||||||
|
|
||||||
|
if (specialization is null)
|
||||||
|
{
|
||||||
|
if (SelectedActivity is not null)
|
||||||
|
{
|
||||||
|
await LoadPerformersAsync(SelectedActivity);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadPerformersAsync(specialization);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPerformersAsync(ActivityBrowseItemDto activity)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _client.GetPerformersAsync(activity.Code);
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>(list ?? new());
|
||||||
|
StatusMessage = $"{activity.Name} · {Performers.Count} prestataire(s)";
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>();
|
||||||
|
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Performers = new ObservableCollection<ActivityPerformerDto>();
|
||||||
|
StatusMessage = $"Erreur: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Helpers;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -24,8 +29,25 @@ public class HomePageViewModel : ViewModelBase
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
SessionStatus = sessionStatus;
|
SessionStatus = sessionStatus;
|
||||||
|
|
||||||
|
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
|
||||||
|
|
||||||
}
|
}
|
||||||
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
|
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync);
|
||||||
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
|
|
||||||
|
private async Task OpenActivitiesAsync()
|
||||||
|
{
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
|
||||||
|
if (app is null || vm is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Activities page is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await vm.RefreshAsync();
|
||||||
|
await app.PushPageAsync(vm);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Avalonia designer constructor. Builds a self-contained VM
|
/// Avalonia designer constructor. Builds a self-contained VM
|
||||||
/// with a freshly-constructed Settings so the XAML preview can
|
/// with a freshly-constructed Settings so the XAML preview can
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
|
||||||
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string SearchText { get; set; } = string.Empty;
|
public partial string SearchText { get; set; } = string.Empty;
|
||||||
|
|
@ -154,7 +154,10 @@ public partial class Settings : ViewModelBase
|
||||||
{
|
{
|
||||||
"openid", // OIDC: required for the id_token
|
"openid", // OIDC: required for the id_token
|
||||||
"profile", // OIDC: standard profile claims
|
"profile", // OIDC: standard profile claims
|
||||||
"offline_access" // OIDC: required to receive a refresh_token
|
"offline_access", // OIDC: required to receive a refresh_token
|
||||||
|
"blogs",
|
||||||
|
"api"
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -297,8 +300,15 @@ public partial class Settings : ViewModelBase
|
||||||
// → our overridden dispatcher-safe marshaller below.
|
// → our overridden dispatcher-safe marshaller below.
|
||||||
else lock (_mutationGate)
|
else lock (_mutationGate)
|
||||||
{
|
{
|
||||||
|
var legacyApiUrl = TryReadLegacyApiUrl(json);
|
||||||
this.Authentication = settings.Authentication;
|
this.Authentication = settings.Authentication;
|
||||||
this.DarkMode = settings.DarkMode;
|
this.DarkMode = settings.DarkMode;
|
||||||
|
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
||||||
|
? settings.BlogsApiUrl
|
||||||
|
: legacyApiUrl ?? this.BlogsApiUrl;
|
||||||
|
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
|
||||||
|
? settings.BusinessApiUrl
|
||||||
|
: this.BusinessApiUrl;
|
||||||
this.SearchText = settings.SearchText ?? string.Empty;
|
this.SearchText = settings.SearchText ?? string.Empty;
|
||||||
if (!(settings.Authentication is null))
|
if (!(settings.Authentication is null))
|
||||||
{
|
{
|
||||||
|
|
@ -343,6 +353,26 @@ public partial class Settings : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? TryReadLegacyApiUrl(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
|
||||||
|
&& apiUrl.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return apiUrl.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore legacy payload parse errors: normal deserialization
|
||||||
|
// already reports actionable diagnostics to the caller.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void UseDefaultSettings()
|
private void UseDefaultSettings()
|
||||||
{
|
{
|
||||||
this.Authentication = new AuthenticationSettings
|
this.Authentication = new AuthenticationSettings
|
||||||
|
|
@ -353,6 +383,8 @@ public partial class Settings : ViewModelBase
|
||||||
Scopes = AuthenticationSettings.DefaultScopes
|
Scopes = AuthenticationSettings.DefaultScopes
|
||||||
};
|
};
|
||||||
this.DarkMode = false;
|
this.DarkMode = false;
|
||||||
|
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
|
||||||
|
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
|
||||||
this.SearchText = string.Empty;
|
this.SearchText = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
79
src/PostIt/PostIt/Views/ActivitiesPage.axaml
Normal file
79
src/PostIt/PostIt/Views/ActivitiesPage.axaml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:wf="using:Yavsc.Abstract.Workflow"
|
||||||
|
x:Class="PostIt.Views.ActivitiesPage"
|
||||||
|
x:DataType="vm:ActivitiesPageViewModel"
|
||||||
|
Header="Activités">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
|
||||||
|
<TextBlock Text="Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="Auto,*">
|
||||||
|
<TextBlock Grid.Row="0" Text="Activités" FontWeight="Bold" Margin="0,0,0,8" />
|
||||||
|
<ListBox Grid.Row="1"
|
||||||
|
ItemsSource="{Binding Activities}"
|
||||||
|
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
|
||||||
|
<StackPanel Spacing="2" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
|
||||||
|
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
|
||||||
|
FontSize="11" Opacity="0.6" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,*">
|
||||||
|
<TextBlock Grid.Row="0" Text="Spécialisations" FontWeight="Bold" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="{Binding SelectedActivityLabel, StringFormat='Pour : {0}'}"
|
||||||
|
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
|
||||||
|
<ListBox Grid.Row="2"
|
||||||
|
ItemsSource="{Binding Specializations}"
|
||||||
|
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
|
||||||
|
<StackPanel Spacing="2" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
|
||||||
|
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
|
||||||
|
FontSize="11" Opacity="0.6" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*">
|
||||||
|
<TextBlock Grid.Row="0" Text="Prestataires" FontWeight="Bold" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
|
||||||
|
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
|
||||||
|
<ListBox Grid.Row="2" ItemsSource="{Binding Performers}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="wf:ActivityPerformerDto">
|
||||||
|
<StackPanel Spacing="2" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
|
||||||
|
<TextBlock Text="{Binding ExtraActivityCount, StringFormat='Autres spécialisations: {0}'}"
|
||||||
|
FontSize="11" Opacity="0.6" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" VerticalAlignment="Center" />
|
||||||
|
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
17
src/PostIt/PostIt/Views/ActivitiesPage.axaml.cs
Normal file
17
src/PostIt/PostIt/Views/ActivitiesPage.axaml.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class ActivitiesPage : ContentPage
|
||||||
|
{
|
||||||
|
public ActivitiesPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,5 +18,9 @@
|
||||||
Command="{Binding OpenBlogs}"
|
Command="{Binding OpenBlogs}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Parcourir les activités"
|
||||||
|
Command="{Binding OpenActivities}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,16 @@ public partial class SignaturePage : ContentPage
|
||||||
var h = PadFrame.Bounds.Height;
|
var h = PadFrame.Bounds.Height;
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
var pending = _control.PendingStroke;
|
||||||
var strokes = _control.Strokes;
|
var strokes = _control.Strokes;
|
||||||
|
int sealedCount = strokes.Count - pending.Count;
|
||||||
|
if (sealedCount < 0)
|
||||||
|
{
|
||||||
|
sealedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < strokes.Count)
|
while (i < sealedCount)
|
||||||
{
|
{
|
||||||
int k = strokes[i];
|
int k = strokes[i];
|
||||||
if (k <= 0) break;
|
if (k <= 0) break;
|
||||||
|
|
@ -88,7 +95,6 @@ public partial class SignaturePage : ContentPage
|
||||||
InkLayer.Children.Add(poly);
|
InkLayer.Children.Add(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending = _control.PendingStroke;
|
|
||||||
if (pending.Count > 0)
|
if (pending.Count > 0)
|
||||||
{
|
{
|
||||||
var poly = new Polyline
|
var poly = new Polyline
|
||||||
|
|
|
||||||
17
src/Yavsc.Abstract/Workflow/ActivityBrowseItemDto.cs
Normal file
17
src/Yavsc.Abstract/Workflow/ActivityBrowseItemDto.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activity node returned by the browsing API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ActivityBrowseItemDto
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ParentCode { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Photo { get; set; } = string.Empty;
|
||||||
|
public int Rate { get; set; }
|
||||||
|
public int PerformerCount { get; set; }
|
||||||
|
public List<CommandFormSummaryDto> Forms { get; set; } = new();
|
||||||
|
public List<ActivityBrowseItemDto> Children { get; set; } = new();
|
||||||
|
}
|
||||||
18
src/Yavsc.Abstract/Workflow/ActivityPerformerDto.cs
Normal file
18
src/Yavsc.Abstract/Workflow/ActivityPerformerDto.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight performer description returned for one activity.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ActivityPerformerDto
|
||||||
|
{
|
||||||
|
public string PerformerId { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public bool Active { get; set; }
|
||||||
|
public bool AcceptNotifications { get; set; }
|
||||||
|
public bool AcceptPublicContact { get; set; }
|
||||||
|
public string WebSite { get; set; } = string.Empty;
|
||||||
|
public string ActivityCode { get; set; } = string.Empty;
|
||||||
|
public string ActivityName { get; set; } = string.Empty;
|
||||||
|
public string SettingsClassName { get; set; } = string.Empty;
|
||||||
|
public int ExtraActivityCount { get; set; }
|
||||||
|
}
|
||||||
11
src/Yavsc.Abstract/Workflow/CommandFormSummaryDto.cs
Normal file
11
src/Yavsc.Abstract/Workflow/CommandFormSummaryDto.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight command-form description exposed to API clients.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CommandFormSummaryDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string ActionName { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
56
src/Yavsc.Api.Client/ActivityApiClient.cs
Normal file
56
src/Yavsc.Api.Client/ActivityApiClient.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for browsing business activities and their performers.
|
||||||
|
/// Uses absolute URLs so it can coexist with other Yavsc clients that
|
||||||
|
/// target a different API host on the same shared transport.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ActivityApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = "activity";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Uri _baseAddress;
|
||||||
|
|
||||||
|
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
if (string.IsNullOrEmpty(businessBaseAddress))
|
||||||
|
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
|
||||||
|
|
||||||
|
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<ActivityBrowseItemDto>> GetCatalogAsync(
|
||||||
|
string? parentCode = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var path = string.IsNullOrWhiteSpace(parentCode)
|
||||||
|
? $"{PathPrefix}/catalog"
|
||||||
|
: $"{PathPrefix}/catalog?parentCode={Uri.EscapeDataString(parentCode)}";
|
||||||
|
|
||||||
|
return _api.CallAsync<List<ActivityBrowseItemDto>>(HttpMethod.Get, Absolute(path), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<ActivityPerformerDto>> GetPerformersAsync(
|
||||||
|
string activityCode,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(activityCode))
|
||||||
|
throw new ArgumentException("Activity code is required.", nameof(activityCode));
|
||||||
|
|
||||||
|
return _api.CallAsync<List<ActivityPerformerDto>>(
|
||||||
|
HttpMethod.Get,
|
||||||
|
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/performers"),
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Workflow;
|
using Yavsc.Models.Workflow;
|
||||||
|
|
@ -8,6 +9,7 @@ using Yavsc.Models.Workflow;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/activity")]
|
[Route(Constants.APIPrefix + "/activity")]
|
||||||
public class ActivityApiController : Controller
|
public class ActivityApiController : Controller
|
||||||
|
|
@ -26,6 +28,86 @@ namespace Yavsc.Controllers
|
||||||
return _context.Activities.Include(a=>a.Forms).Where( a => !a.Hidden );
|
return _context.Activities.Include(a=>a.Forms).Where( a => !a.Hidden );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("catalog")]
|
||||||
|
public async Task<ActionResult<IEnumerable<ActivityBrowseItemDto>>> GetCatalog(
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
[FromQuery] string parentCode = null)
|
||||||
|
{
|
||||||
|
var activities = await _context.Activities
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Forms)
|
||||||
|
.Include(a => a.Children)
|
||||||
|
.ThenInclude(c => c.Forms)
|
||||||
|
.Where(a => !a.Hidden && a.ParentCode == parentCode)
|
||||||
|
.OrderByDescending(a => a.Rate)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var codes = activities
|
||||||
|
.Select(a => a.Code)
|
||||||
|
.Concat(activities.SelectMany(a => a.Children.Where(c => !c.Hidden).Select(c => c.Code)))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var performerCounts = await _context.Performers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Active)
|
||||||
|
.SelectMany(
|
||||||
|
p => p.Activity
|
||||||
|
.Where(a => codes.Contains(a.DoesCode))
|
||||||
|
.Select(a => new { a.DoesCode, p.PerformerId }))
|
||||||
|
.GroupBy(x => x.DoesCode)
|
||||||
|
.Select(g => new { Code = g.Key, Count = g.Select(x => x.PerformerId).Distinct().Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Code, x => x.Count, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(activities.Select(a => ToBrowseItem(a, performerCounts)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/performers")]
|
||||||
|
public async Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetPerformers(
|
||||||
|
[FromRoute] string id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return BadRequest("Activity code is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await _context.Activities
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(a => a.Code == id, cancellationToken);
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var performers = await _context.Performers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(p => p.Performer)
|
||||||
|
.Include(p => p.Activity)
|
||||||
|
.ThenInclude(a => a.Does)
|
||||||
|
.Where(p => p.Active && p.Activity.Any(a => a.DoesCode == id))
|
||||||
|
.OrderBy(p => p.Rate)
|
||||||
|
.Select(p => new ActivityPerformerDto
|
||||||
|
{
|
||||||
|
PerformerId = p.PerformerId,
|
||||||
|
UserName = p.Performer.UserName,
|
||||||
|
Active = p.Active,
|
||||||
|
AcceptNotifications = p.AcceptNotifications,
|
||||||
|
AcceptPublicContact = p.AcceptPublicContact,
|
||||||
|
WebSite = p.WebSite,
|
||||||
|
ActivityCode = id,
|
||||||
|
ActivityName = activity.Name,
|
||||||
|
SettingsClassName = p.Activity
|
||||||
|
.Where(a => a.DoesCode == id)
|
||||||
|
.Select(a => a.Does.SettingsClassName)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
ExtraActivityCount = p.Activity.Count(a => a.DoesCode != id)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(performers);
|
||||||
|
}
|
||||||
|
|
||||||
// GET: api/ActivityApi/5
|
// GET: api/ActivityApi/5
|
||||||
[HttpGet("{id}", Name = "GetActivity")]
|
[HttpGet("{id}", Name = "GetActivity")]
|
||||||
public async Task<IActionResult> GetActivity([FromRoute] string id)
|
public async Task<IActionResult> GetActivity([FromRoute] string id)
|
||||||
|
|
@ -144,5 +226,51 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
return _context.Activities.Count(e => e.Code == id) > 0;
|
return _context.Activities.Count(e => e.Code == id) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ActivityBrowseItemDto ToBrowseItem(
|
||||||
|
Activity activity,
|
||||||
|
IReadOnlyDictionary<string, int> performerCounts)
|
||||||
|
{
|
||||||
|
return new ActivityBrowseItemDto
|
||||||
|
{
|
||||||
|
Code = activity.Code,
|
||||||
|
Name = activity.Name,
|
||||||
|
ParentCode = activity.ParentCode,
|
||||||
|
Description = activity.Description,
|
||||||
|
Photo = activity.Photo,
|
||||||
|
Rate = activity.Rate,
|
||||||
|
PerformerCount = performerCounts.TryGetValue(activity.Code, out var count) ? count : 0,
|
||||||
|
Forms = activity.Forms
|
||||||
|
.Select(f => new CommandFormSummaryDto
|
||||||
|
{
|
||||||
|
Id = f.Id,
|
||||||
|
ActionName = f.ActionName,
|
||||||
|
Title = f.Title,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Children = activity.Children
|
||||||
|
.Where(c => !c.Hidden)
|
||||||
|
.OrderByDescending(c => c.Rate)
|
||||||
|
.Select(c => new ActivityBrowseItemDto
|
||||||
|
{
|
||||||
|
Code = c.Code,
|
||||||
|
Name = c.Name,
|
||||||
|
ParentCode = c.ParentCode,
|
||||||
|
Description = c.Description,
|
||||||
|
Photo = c.Photo,
|
||||||
|
Rate = c.Rate,
|
||||||
|
PerformerCount = performerCounts.TryGetValue(c.Code, out var childCount) ? childCount : 0,
|
||||||
|
Forms = c.Forms
|
||||||
|
.Select(f => new CommandFormSummaryDto
|
||||||
|
{
|
||||||
|
Id = f.Id,
|
||||||
|
ActionName = f.ActionName,
|
||||||
|
Title = f.Title,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ namespace Yavsc.ApiControllers
|
||||||
using Yavsc.ViewModels.Auth;
|
using Yavsc.ViewModels.Auth;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[Route(Constants.APIPrefix + "/bill"), Authorize]
|
[Route(Constants.APIPrefix + "/bill"), Authorize]
|
||||||
public class BillingController : Controller
|
public class BillingController : Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ namespace Yavsc.Controllers
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
|
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
|
||||||
public class BookQueryApiController : Controller
|
public class BookQueryApiController : Controller
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/estimate"), Authorize]
|
[Route(Constants.APIPrefix + "/estimate"), Authorize]
|
||||||
public class EstimateApiController : Controller
|
public class EstimateApiController : Controller
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
|
@ -7,6 +8,7 @@ using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
|
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
|
||||||
public class EstimateTemplatesApiController : Controller
|
public class EstimateTemplatesApiController : Controller
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
|
|
@ -6,6 +7,7 @@ using Yavsc.ViewModels.FrontOffice;
|
||||||
|
|
||||||
namespace Yavsc.ApiControllers
|
namespace Yavsc.ApiControllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Route(Constants.APIPrefix + "/front")]
|
[Route(Constants.APIPrefix + "/front")]
|
||||||
public class FrontOfficeApiController : Controller
|
public class FrontOfficeApiController : Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
@ -6,6 +7,7 @@ using Yavsc.Models;
|
||||||
|
|
||||||
namespace Yavsc.ApiControllers
|
namespace Yavsc.ApiControllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Route(Constants.APIPrefix + "/payment")]
|
[Route(Constants.APIPrefix + "/payment")]
|
||||||
public class PaymentApiController : Controller
|
public class PaymentApiController : Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ namespace Yavsc.Controllers
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/performers")]
|
[Route(Constants.APIPrefix + "/performers")]
|
||||||
public class PerformersApiController : Controller
|
public class PerformersApiController : Controller
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
[Route(Constants.APIPrefix + "/ProductApi")]
|
[Route(Constants.APIPrefix + "/ProductApi")]
|
||||||
public class ProductApiController : Controller
|
public class ProductApiController : Controller
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public static class Constants
|
||||||
// 'offline_access' is handled by IdentityServer8 itself and never
|
// 'offline_access' is handled by IdentityServer8 itself and never
|
||||||
// needs an explicit ApiScope row.
|
// needs an explicit ApiScope row.
|
||||||
public static readonly string[] BuildInApiScopes = {
|
public static readonly string[] BuildInApiScopes = {
|
||||||
"admin", "moderation", "performer", "client" };
|
"admin", "moderation", "performer", "client", "api" };
|
||||||
|
|
||||||
// One ApiResource per application scope. Each scope is exposed by
|
// One ApiResource per application scope. Each scope is exposed by
|
||||||
// exactly one resource, named after the scope ("admin" -> "admin"
|
// exactly one resource, named after the scope ("admin" -> "admin"
|
||||||
|
|
@ -29,6 +29,7 @@ public static class Constants
|
||||||
new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" },
|
new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" },
|
||||||
new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" },
|
new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" },
|
||||||
new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" },
|
new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" },
|
||||||
|
new ApiResourceScopeSpecification { ScopeName = "api", Description = "Core API access", ResourceName = "api", ResourceDisplayName = "Yavsc Core API" },
|
||||||
new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" }
|
new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,7 @@ public static class HostingExtensions
|
||||||
// silent refresh path to work; without it IdentityServer
|
// silent refresh path to work; without it IdentityServer
|
||||||
// refuses to issue a refresh_token.
|
// refuses to issue a refresh_token.
|
||||||
"blogs",
|
"blogs",
|
||||||
|
"api",
|
||||||
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
|
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
|
||||||
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
|
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
|
||||||
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
|
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue