feat(postit): migrate editable profile data and admin profile flow

This commit is contained in:
Paul Schneider 2026-10-07 12:20:26 +01:00
commit af1e572f32
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
38 changed files with 6573 additions and 19 deletions

View file

@ -12,4 +12,68 @@ public class HomePageProviderFlowTests
Assert.NotNull(vm.OpenProviderRequests); Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null)); Assert.True(vm.OpenProviderRequests.CanExecute(null));
} }
[Fact]
public void HomePage_exposes_profile_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProfile);
Assert.True(vm.OpenProfile.CanExecute(null));
}
[Fact]
public void HomePage_exposes_performer_configuration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenPerformerConfiguration);
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
}
[Fact]
public void HomePage_exposes_administration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
}
[Fact]
public void Administration_page_filters_users_by_search_text()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol" });
vm.SearchText = "ali";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void UserProfilePage_exposes_full_name_and_address_fields()
{
var vm = new UserProfilePageViewModel();
Assert.Equal("—", vm.FullName);
Assert.Equal("—", vm.Address);
Assert.NotNull(vm.RefreshCommand);
}
[Fact]
public void UserProfilePage_can_save_profile_values()
{
var vm = new UserProfilePageViewModel
{
FullName = "Alice Example",
Address = "14 rue de l’Érable"
};
Assert.NotNull(vm.SaveProfileCommand);
Assert.True(vm.SaveProfileCommand.CanExecute(null));
}
} }

View file

@ -0,0 +1,117 @@
using System.Net.Http;
using System.Text.Json;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ResourceUsageApiClientTests
{
[Fact]
public async Task GetCurrentAsync_calls_user_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetCurrentAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(12.5m, usage.ApiCalls);
Assert.Equal(3.2m, usage.CpuSeconds);
}
[Fact]
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetAdminOverviewAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(2, usage.Count);
Assert.Equal("administrator", usage[0].UserName);
}
private sealed class FakeYavscApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public string? LastMethod { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method.Method;
LastPath = path;
if (typeof(T) == typeof(ResourceUsageSummary))
{
var summary = new ResourceUsageSummary
{
UserId = "user-42",
UserName = "alice",
ApiCalls = 12.5m,
CpuSeconds = 3.2m,
BandwidthMb = 18.4m,
StorageMb = 51.2m,
EstimatedCompensation = 4.9m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
};
return Task.FromResult((T)(object)summary);
}
if (typeof(T) == typeof(List<ResourceUsageSummary>))
{
var list = new List<ResourceUsageSummary>
{
new()
{
UserId = "admin-user",
UserName = "administrator",
ApiCalls = 90m,
CpuSeconds = 14m,
BandwidthMb = 500m,
StorageMb = 2048m,
EstimatedCompensation = 32m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
},
new()
{
UserId = "user-99",
UserName = "bob",
ApiCalls = 40m,
CpuSeconds = 7m,
BandwidthMb = 120m,
StorageMb = 600m,
EstimatedCompensation = 15m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
}
};
return Task.FromResult((T)(object)list);
}
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -76,6 +76,9 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>(); services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>(); services.AddTransient<MyFilesPage>();
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>(); services.AddTransient<ResourceUsagePage>();
// ViewModels // ViewModels
@ -95,6 +98,9 @@ public static class ServiceCollectionHelpers
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>(); services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddTransient<UserProfilePageViewModel>();
services.AddTransient<PerformerConfigurationPageViewModel>();
services.AddTransient<AdministrationPageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>(); services.AddSingleton<ActivitiesPageViewModel>();

View file

@ -60,6 +60,9 @@ public class ViewLocator : IDataTemplate
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(), MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(), ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(), ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },

View file

@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class AdministrationPageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial ObservableCollection<UserInfo> Users { get; set; } = new();
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement…";
public IReadOnlyList<UserInfo> FilteredUsers =>
string.IsNullOrWhiteSpace(SearchText)
? Users.ToList()
: Users.Where(user =>
(user.UserName ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.Email ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (user.UserId ?? string.Empty).Contains(SearchText, StringComparison.OrdinalIgnoreCase))
.ToList();
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public AdministrationPageViewModel(YavscApiClient? api)
{
_api = api;
}
public AdministrationPageViewModel() : this(null) { }
public Task InitializeAsync() => RefreshAsync();
partial void OnSearchTextChanged(string value)
{
OnPropertyChanged(nameof(FilteredUsers));
}
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
StatusMessage = "Client API d’administration indisponible.";
return;
}
IsBusy = true;
try
{
var query = SearchText.Trim();
var usersEndpoint = string.IsNullOrWhiteSpace(query)
? new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "users").ToString()
: new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), $"users/search/{Uri.EscapeDataString(query)}").ToString();
var users = await _api.CallAsync<List<UserInfo>>(HttpMethod.Get, usersEndpoint).ConfigureAwait(true);
Users.Clear();
foreach (var user in users ?? [])
{
Users.Add(user);
}
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = Users.Count > 0 ? $"{Users.Count} utilisateur(s) chargé(s)." : "Aucun utilisateur trouvé.";
}
catch (Exception ex)
{
Users.Clear();
OnPropertyChanged(nameof(FilteredUsers));
StatusMessage = $"Accès administration refusé ou indisponible : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -36,6 +36,9 @@ public class HomePageViewModel : ViewModelBase
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
OpenProfile = new AsyncRelayCommand(OpenProfileAsync);
OpenPerformerConfiguration = new AsyncRelayCommand(OpenPerformerConfigurationAsync);
OpenAdministration = new AsyncRelayCommand(OpenAdministrationAsync);
OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync); OpenResourceUsage = new AsyncRelayCommand(OpenResourceUsageAsync);
OpenChat = new AsyncRelayCommand(OpenChatAsync); OpenChat = new AsyncRelayCommand(OpenChatAsync);
} }
@ -43,6 +46,9 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenChat { get; } public IAsyncRelayCommand OpenChat { get; }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenProfile { get; }
public IAsyncRelayCommand OpenPerformerConfiguration { get; }
public IAsyncRelayCommand OpenAdministration { get; }
public IAsyncRelayCommand OpenResourceUsage { get; } public IAsyncRelayCommand OpenResourceUsage { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
@ -140,6 +146,60 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
private async Task OpenProfileAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<UserProfilePageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page profil indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenPerformerConfigurationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<PerformerConfigurationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page performer indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenAdministrationAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = app.ServiceProvider?.GetRequiredService<AdministrationPageViewModel>();
if (vm is null)
{
throw new InvalidOperationException("Page administration indisponible.");
}
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
private async Task OpenResourceUsageAsync() private async Task OpenResourceUsageAsync()
{ {
var app = (App?)Application.Current; var app = (App?)Application.Current;

View file

@ -0,0 +1,125 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Identity;
namespace PostIt.ViewModels;
public partial class UserProfilePageViewModel : ViewModelBase
{
private readonly YavscApiClient? _api;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string FullName { get; set; } = "—";
[ObservableProperty]
public partial string Email { get; set; } = "—";
[ObservableProperty]
public partial string Address { get; set; } = "—";
[ObservableProperty]
public partial string Avatar { get; set; } = "—";
[ObservableProperty]
public partial string Roles { get; set; } = "—";
[ObservableProperty]
public partial string DedicatedGoogleCalendar { get; set; } = "Non configuré";
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public UserProfilePageViewModel(YavscApiClient? api)
{
_api = api;
}
public UserProfilePageViewModel() : this(null) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var me = await _api.CallAsync<Me>(HttpMethod.Get, new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString()).ConfigureAwait(true);
UserName = me?.UserName ?? "—";
FullName = me?.FullName ?? "—";
Email = me?.Email ?? "—";
Address = me?.Address ?? "—";
Avatar = string.IsNullOrWhiteSpace(me?.Avatar) ? "Aucun avatar" : me.Avatar;
Roles = me?.Roles is { Length: > 0 } ? string.Join(", ", me.Roles) : "Aucun rôle";
DedicatedGoogleCalendar = string.IsNullOrWhiteSpace(me?.DedicatedGoogleCalendar) ? "Non configuré" : me.DedicatedGoogleCalendar;
StatusMessage = "Profil chargé.";
}
catch (Exception ex)
{
UserName = "—";
FullName = "—";
Email = "—";
Address = "—";
Avatar = "—";
Roles = "—";
DedicatedGoogleCalendar = "Non configuré";
StatusMessage = $"Impossible de charger le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task SaveProfileAsync()
{
if (_api is null)
{
StatusMessage = "Client API de profil indisponible.";
return;
}
IsBusy = true;
try
{
var payload = new ProfileUpdateRequest
{
FullName = FullName,
Address = Address
};
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/me").ToString();
await _api.CallAsync(HttpMethod.Put, endpoint, payload).ConfigureAwait(true);
StatusMessage = "Profil enregistré.";
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer le profil : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitySelectionOption : ObservableObject
{
public string Code { get; }
public string Name { get; }
[ObservableProperty]
public partial bool IsSelected { get; set; }
public ActivitySelectionOption(string code, string name)
{
Code = code;
Name = name;
}
}
public partial class PerformerConfigurationPageViewModel : ViewModelBase
{
private readonly YavscApiClient _api;
private readonly ActivityApiClient _activityClient;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string UserName { get; set; } = "—";
[ObservableProperty]
public partial string ExerciseCountryCode { get; set; } = "fr";
[ObservableProperty]
public partial string SIREN { get; set; } = string.Empty;
[ObservableProperty]
public partial string Website { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Active { get; set; }
[ObservableProperty]
public partial bool AcceptNotifications { get; set; }
[ObservableProperty]
public partial bool AcceptPublicContact { get; set; }
[ObservableProperty]
public partial bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivitySelectionOption> AvailableActivities { get; set; } = new();
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public PerformerConfigurationPageViewModel(YavscApiClient api, ActivityApiClient activityClient)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_activityClient = activityClient ?? throw new ArgumentNullException(nameof(activityClient));
}
public PerformerConfigurationPageViewModel() : this(null!, null!) { }
public Task InitializeAsync() => RefreshAsync();
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var dto = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Get, endpoint).ConfigureAwait(true);
UserName = dto?.UserName ?? "—";
ExerciseCountryCode = string.IsNullOrWhiteSpace(dto?.ExerciseCountryCode) ? "fr" : dto.ExerciseCountryCode;
SIREN = dto?.SIREN ?? string.Empty;
Website = dto?.WebSite ?? string.Empty;
Active = dto?.Active ?? false;
AcceptNotifications = dto?.AcceptNotifications ?? false;
AcceptPublicContact = dto?.AcceptPublicContact ?? false;
UseGeoLocalizationToReduceDistanceWithClients = dto?.UseGeoLocalizationToReduceDistanceWithClients ?? false;
var catalog = await _activityClient.GetCatalogAsync().ConfigureAwait(true);
var options = Flatten(catalog)
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.Select(a => new ActivitySelectionOption(a.Code, a.Name)
{
IsSelected = dto?.SelectedActivityCodes.Contains(a.Code, StringComparer.OrdinalIgnoreCase) == true
})
.ToList();
AvailableActivities.Clear();
foreach (var option in options)
{
AvailableActivities.Add(option);
}
StatusMessage = "Profil performer chargé.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de charger la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task SaveAsync()
{
IsBusy = true;
try
{
var dto = new PerformerProfileSettings
{
PerformerId = string.Empty,
UserName = UserName,
ExerciseCountryCode = string.IsNullOrWhiteSpace(ExerciseCountryCode) ? "fr" : ExerciseCountryCode,
SIREN = SIREN,
WebSite = Website,
Active = Active,
AcceptNotifications = AcceptNotifications,
AcceptPublicContact = AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = UseGeoLocalizationToReduceDistanceWithClients,
SelectedActivityCodes = AvailableActivities
.Where(a => a.IsSelected)
.Select(a => a.Code)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
var endpoint = new Uri(new Uri(_api.Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/performer-profile").ToString();
var response = await _api.CallAsync<PerformerProfileSettings>(HttpMethod.Put, endpoint, dto).ConfigureAwait(true);
StatusMessage = response is not null ? "Configuration performer enregistrée." : "Configuration performer enregistrée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible d’enregistrer la configuration performer : {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static IEnumerable<ActivityInfo> Flatten(IEnumerable<ActivityInfo> activities)
{
foreach (var activity in activities)
{
yield return activity;
foreach (var child in Flatten(activity.Children ?? Enumerable.Empty<ActivityInfo>()))
{
yield return child;
}
}
}
}

View file

@ -0,0 +1,42 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.AdministrationPage"
x:DataType="vm:AdministrationPageViewModel"
Header="Administration">
<Grid RowDefinitions="Auto,Auto,Auto,*" Margin="16" RowSpacing="12">
<TextBlock Grid.Row="0"
Text="Administration"
FontSize="22"
FontWeight="SemiBold"/>
<StackPanel Grid.Row="1" Spacing="8">
<TextBox Text="{Binding SearchText, Mode=TwoWay}"
PlaceholderText="Rechercher un utilisateur..."/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap"/>
</StackPanel>
<ListBox Grid.Row="3"
ItemsSource="{Binding FilteredUsers}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class AdministrationPage : ContentPage
{
public AdministrationPage()
{
InitializeComponent();
}
}

View file

@ -44,6 +44,18 @@
Command="{Binding OpenMyFiles}" Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon profil"
Command="{Binding OpenProfile}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon activité / performer"
Command="{Binding OpenPerformerConfiguration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Administration"
Command="{Binding OpenAdministration}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mon usage / administration" <Button Content="Mon usage / administration"
Command="{Binding OpenResourceUsage}" Command="{Binding OpenResourceUsage}"
HorizontalAlignment="Center" HorizontalAlignment="Center"

View file

@ -0,0 +1,59 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.PerformerConfigurationPage"
x:DataType="vm:PerformerConfigurationPageViewModel"
Header="Mon activité / performer">
<ScrollViewer>
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Mon activité / performer"
FontSize="22"
FontWeight="SemiBold"/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
<TextBox Text="{Binding ExerciseCountryCode, Mode=TwoWay}"
Watermark="Code pays (fr, en...)"/>
<TextBox Text="{Binding SIREN, Mode=TwoWay}"
Watermark="SIREN"/>
<TextBox Text="{Binding Website, Mode=TwoWay}"
Watermark="Site web"/>
<CheckBox Content="Disponible / actif"
IsChecked="{Binding Active, Mode=TwoWay}"/>
<CheckBox Content="Accepter les notifications"
IsChecked="{Binding AcceptNotifications, Mode=TwoWay}"/>
<CheckBox Content="Accepter les demandes publiques"
IsChecked="{Binding AcceptPublicContact, Mode=TwoWay}"/>
<CheckBox Content="Utiliser la géolocalisation"
IsChecked="{Binding UseGeoLocalizationToReduceDistanceWithClients, Mode=TwoWay}"/>
<TextBlock Text="Activités" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding AvailableActivities}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox Margin="0,0,0,6"
Content="{Binding Name}"
IsChecked="{Binding IsSelected, Mode=TwoWay}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Enregistrer"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}"/>
<TextBlock Text="{Binding StatusMessage}"
Foreground="#4B5563"
TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class PerformerConfigurationPage : ContentPage
{
public PerformerConfigurationPage()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,34 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.UserProfilePage"
x:DataType="vm:UserProfilePageViewModel"
Header="Mon profil">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Mon profil"
FontSize="22"
FontWeight="SemiBold"/>
<Button Content="Actualiser"
Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}"/>
<ProgressBar IsVisible="{Binding IsBusy}"
IsIndeterminate="True"
Height="8"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="*" RowSpacing="8">
<TextBox Watermark="Nom complet" Text="{Binding FullName}"/>
<TextBox Grid.Row="1" Watermark="Adresse" Text="{Binding Address}" AcceptsReturn="True" TextWrapping="Wrap"/>
<Button Grid.Row="2" Content="Enregistrer" Command="{Binding SaveProfileCommand}" IsEnabled="{Binding !IsBusy}"/>
</Grid>
<TextBlock Text="{Binding UserName, StringFormat='Nom d’utilisateur : {0}'}"/>
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}"/>
<TextBlock Text="{Binding Roles, StringFormat='Rôles : {0}'}"/>
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier dédié : {0}'}"/>
<TextBlock Text="{Binding Avatar, StringFormat='Avatar : {0}'}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding StatusMessage}" Foreground="#4B5563" TextWrapping="Wrap"/>
</StackPanel>
</ContentPage>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PostIt.Views;
public partial class UserProfilePage : ContentPage
{
public UserProfilePage()
{
InitializeComponent();
}
}

View file

@ -30,6 +30,13 @@ namespace Yavsc
public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$"; public const string UserNameRegExp = @"^[a-zA-Z][a-zA-Z0-9._-]*$";
public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$"; public const string UserFileNamePatternRegExp = @"^([a-zA-Z0-9._-]*/)*[a-zA-Z0-9._-]+$";
public const string ResourceUsagePath = "resource-usage";
public const string ResourceUsageCurrentUserRoute = "me";
public const string ResourceUsageAdminRoute = "admin";
public const string ResourceUsageDefaultCurrency = "EUR";
public const string ResourceUsageCurrentUserDisplayName = "current-user";
public const string ResourceUsageAdminDisplayName = "administrator";
public const string SigninPath = "~/signin"; public const string SigninPath = "~/signin";
public const string LogoutPath = "~/signout"; public const string LogoutPath = "~/signout";

View file

@ -1,7 +1,9 @@
#nullable enable
namespace Yavsc.Abstract.Identity { namespace Yavsc.Abstract.Identity {
public class Me : UserInfo public class Me : UserInfo
{ {
public AuthToken Token { public AuthToken? Token {
get; get;
set; set;
} }

View file

@ -0,0 +1,9 @@
#nullable enable
namespace Yavsc.Abstract.Identity;
public class ProfileUpdateRequest
{
public string? FullName { get; set; }
public string? Address { get; set; }
}

View file

@ -1,10 +1,11 @@
#nullable enable
namespace Yavsc.Abstract.Identity namespace Yavsc.Abstract.Identity
{ {
public class UserInfo { public class UserInfo {
public UserInfo() public UserInfo()
{ {
} }
public UserInfo(string userId, string userName, string email, string avatar) public UserInfo(string userId, string userName, string email, string avatar)
@ -14,10 +15,14 @@ namespace Yavsc.Abstract.Identity
Email = email; Email = email;
Avatar = avatar; Avatar = avatar;
} }
public string UserId { get; set; }
public string UserName { get; set; } public string UserId { get; set; } = string.Empty;
public string Email { get; } public string? UserName { get; set; }
public string Avatar { get; set; } public string? FullName { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? Avatar { get; set; }
public string[] Roles { get; set; } = [];
public string? DedicatedGoogleCalendar { get; set; }
} }
} }

View file

@ -0,0 +1,22 @@
namespace Yavsc.Abstract.Resources;
/// <summary>
/// Lightweight usage snapshot for PostIt / Yavsc resource accounting.
/// The initial implementation is intentionally intentionally small and
/// serialisable so it can be exposed through the API and consumed by the
/// PostIt client without requiring a full database model yet.
/// </summary>
public sealed class ResourceUsageSummary
{
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public DateTimeOffset RecordedAtUtc { get; set; }
public decimal ApiCalls { get; set; }
public decimal CpuSeconds { get; set; }
public decimal BandwidthMb { get; set; }
public decimal StorageMb { get; set; }
public decimal EstimatedCompensation { get; set; }
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
}

View file

@ -0,0 +1,15 @@
namespace Yavsc.Abstract.Workflow;
public sealed class PerformerProfileSettings
{
public string PerformerId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string ExerciseCountryCode { get; set; } = "fr";
public string SIREN { get; set; } = string.Empty;
public string WebSite { get; set; } = string.Empty;
public bool Active { get; set; }
public bool AcceptNotifications { get; set; }
public bool AcceptPublicContact { get; set; }
public bool UseGeoLocalizationToReduceDistanceWithClients { get; set; }
public List<string> SelectedActivityCodes { get; set; } = new();
}

View file

@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Resources;
namespace Yavsc.Api.Client;
/// <summary>
/// Thin HTTP client for the PostIt resource-usage reporting flow.
/// It follows the same pattern as the other Yavsc API clients: absolute
/// paths, JSON request/response mapping, and no application logic.
/// </summary>
public sealed class ResourceUsageApiClient
{
private const string PathPrefix = Constants.ResourceUsagePath;
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public ResourceUsageApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public ResourceUsageApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
public Task<ResourceUsageSummary> GetCurrentAsync(CancellationToken ct = default)
{
return _api.CallAsync<ResourceUsageSummary>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.ResourceUsageCurrentUserRoute}"),
ct: ct);
}
public Task<List<ResourceUsageSummary>> GetAdminOverviewAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<ResourceUsageSummary>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Constants.ResourceUsageAdminRoute}"),
ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Resources;
using Yavsc.Models;
using Yavsc.Server.Helpers;
namespace Yavsc.ApiControllers.Administration;
[Produces("application/json")]
[Authorize]
[Route(Constants.APIPrefix + "/" + Constants.ResourceUsagePath)]
public sealed class ResourceUsageApiController : Controller
{
private readonly ApplicationDbContext _dbContext;
public ResourceUsageApiController(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
private static ResourceUsageSummary ToSummary(ResourceUsageRecord record)
=> new()
{
UserId = record.UserId,
UserName = record.UserName,
RecordedAtUtc = new DateTimeOffset(record.RecordedAtUtc, TimeSpan.Zero),
ApiCalls = record.ApiCalls,
CpuSeconds = record.CpuSeconds,
BandwidthMb = record.BandwidthMb,
StorageMb = record.StorageMb,
EstimatedCompensation = record.EstimatedCompensation,
Currency = record.Currency
};
[HttpGet(Constants.ResourceUsageCurrentUserRoute)]
public async Task<ActionResult<ResourceUsageSummary>> GetCurrentUserUsage()
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var userName = User.Identity?.Name ?? Constants.ResourceUsageCurrentUserDisplayName;
var record = await _dbContext.ResourceUsageRecords
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RecordedAtUtc)
.FirstOrDefaultAsync();
if (record is null)
{
var zeroUsage = new ResourceUsageRecord
{
UserId = userId,
UserName = userName,
RecordedAtUtc = DateTime.UtcNow,
ApiCalls = 0m,
CpuSeconds = 0m,
BandwidthMb = 0m,
StorageMb = 0m,
EstimatedCompensation = 0m,
Currency = Constants.ResourceUsageDefaultCurrency,
UserCreated = userId,
UserModified = userId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
_dbContext.ResourceUsageRecords.Add(zeroUsage);
await _dbContext.SaveChangesAsync(userId);
return Ok(ToSummary(zeroUsage));
}
return Ok(ToSummary(record));
}
[HttpGet(Constants.ResourceUsageAdminRoute)]
public async Task<ActionResult<List<ResourceUsageSummary>>> GetAdminOverview()
{
if (!User.IsInRole(Constants.AdminGroupName))
{
return Forbid();
}
var latestByUser = (await _dbContext.ResourceUsageRecords
.OrderByDescending(r => r.RecordedAtUtc)
.ToListAsync())
.GroupBy(r => r.UserId)
.Select(g => g.First())
.OrderByDescending(r => r.RecordedAtUtc)
.ToList();
return Ok(latestByUser.Select(ToSummary).ToList());
}
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.Razor;
@ -129,6 +130,9 @@ namespace Yavsc.ApiControllers
Temp = siteSettings.TempDir, Temp = siteSettings.TempDir,
}; };
var stopwatch = Stopwatch.StartNew();
FileInfo? renderedPdf = null;
try try
{ {
// 1. Render the LaTeX template (Estimate_tex) to a TeX source // 1. Render the LaTeX template (Estimate_tex) to a TeX source
@ -141,10 +145,28 @@ namespace Yavsc.ApiControllers
// we stream the generated PDF bytes — but driving the // we stream the generated PDF bytes — but driving the
// generation through the view keeps the template as the // generation through the view keeps the template as the
// output producer, per the original design. // output producer, per the original design.
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists) renderedPdf = model.GenerateEstimatePdf();
if (renderedPdf is FileInfo fo && fo.Exists)
{ {
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: fo.Length,
actorUserId: uid);
return File(fo.OpenRead(), "application/pdf"); return File(fo.OpenRead(), "application/pdf");
} }
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: 0,
actorUserId: uid);
string msg = "TeX render failed: " + model.GenerationErrorMessage; string msg = "TeX render failed: " + model.GenerationErrorMessage;
logger.LogError(msg); logger.LogError(msg);
return Problem(msg); return Problem(msg);
@ -152,6 +174,14 @@ namespace Yavsc.ApiControllers
} }
catch (Exception ex) catch (Exception ex)
{ {
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
User.Identity?.Name ?? uid,
cpuSeconds: (decimal)stopwatch.Elapsed.TotalSeconds,
storageBytes: renderedPdf?.Length ?? 0,
actorUserId: uid);
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId); logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
return Problem("TeX render failed: " + ex.Message); return Problem("TeX render failed: " + ex.Message);
} }

View file

@ -9,6 +9,8 @@ using Yavsc.Api.Helpers;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Workflow;
namespace Yavsc.WebApi.Controllers namespace Yavsc.WebApi.Controllers
{ {
@ -30,14 +32,16 @@ namespace Yavsc.WebApi.Controllers
readonly ApplicationDbContext _dbContext; readonly ApplicationDbContext _dbContext;
private readonly SiteSettings siteSettings; private readonly SiteSettings siteSettings;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly UserManager<ApplicationUser> _userManager;
public ApiAccountController( public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext, ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions<SiteSettings> siteSettings) IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> userManager)
{ {
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController)); _logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext; _dbContext = dbContext;
this.siteSettings = siteSettings.Value; this.siteSettings = siteSettings.Value;
_userManager = userManager;
} }
[HttpGet("me")] [HttpGet("me")]
@ -52,7 +56,8 @@ namespace Yavsc.WebApi.Controllers
Debug.Assert(userData != null, "userData is null"); Debug.Assert(userData != null, "userData is null");
var user = new Yavsc.Models.Auth.Me(userData.Id, userData.UserName, userData.Email, var user = new Yavsc.Models.Auth.Me(userData.Id, userData.UserName, userData.Email,
userData.Avatar, userData.Avatar,
userData.PostalAddress, userData.DedicatedGoogleCalendar); userData.PostalAddress, userData.DedicatedGoogleCalendar, userData.FullName);
user.Address = userData.PostalAddress?.Address ?? string.Empty;
var userRoles = _dbContext.UserRoles.Where(u => u.UserId == uid).Select(r => r.RoleId).ToArray(); var userRoles = _dbContext.UserRoles.Where(u => u.UserId == uid).Select(r => r.RoleId).ToArray();
@ -71,6 +76,190 @@ namespace Yavsc.WebApi.Controllers
.FirstAsync(u => u.Id == uid); .FirstAsync(u => u.Id == uid);
} }
[HttpPut("me")]
public async Task<IActionResult> UpdateMe([FromBody] ProfileUpdateRequest request)
{
if (request is null)
{
return BadRequest(new { error = "Payload is required." });
}
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var user = await _dbContext.Users
.Include(u => u.PostalAddress)
.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
return NotFound();
}
if (!string.IsNullOrWhiteSpace(request.FullName))
{
user.FullName = request.FullName.Trim();
}
if (!string.IsNullOrWhiteSpace(request.Address))
{
if (user.PostalAddress is null)
{
user.PostalAddress = new Yavsc.Models.Relationship.Location { Address = request.Address.Trim() };
}
else
{
user.PostalAddress.Address = request.Address.Trim();
}
}
await _dbContext.SaveChangesAsync(uid);
return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address });
}
[HttpGet("performer-profile")]
public async Task<IActionResult> GetPerformerProfile()
{
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid))
{
return Challenge();
}
var profile = await _dbContext.Performers
.AsNoTracking()
.FirstOrDefaultAsync(p => p.PerformerId == uid);
var selectedActivityCodes = await _dbContext.UserActivities
.AsNoTracking()
.Where(x => x.UserId == uid)
.Select(x => x.DoesCode)
.ToListAsync();
return Ok(new PerformerProfileSettings
{
PerformerId = uid,
UserName = User.Identity?.Name ?? string.Empty,
ExerciseCountryCode = profile?.ExerciseCountryCode ?? "fr",
SIREN = profile?.SIREN ?? string.Empty,
WebSite = profile?.WebSite ?? string.Empty,
Active = profile?.Active ?? false,
AcceptNotifications = profile?.AcceptNotifications ?? false,
AcceptPublicContact = profile?.AcceptPublicContact ?? false,
UseGeoLocalizationToReduceDistanceWithClients = profile?.UseGeoLocalizationToReduceDistanceWithClients ?? false,
SelectedActivityCodes = selectedActivityCodes
});
}
[HttpPut("performer-profile")]
public async Task<IActionResult> SavePerformerProfile([FromBody] PerformerProfileSettings model)
{
if (model is null)
{
return BadRequest(new { error = "Payload is required." });
}
var uid = User.GetUserId();
if (string.IsNullOrWhiteSpace(uid) || !string.Equals(model.PerformerId, uid, StringComparison.Ordinal))
{
return Forbid();
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == uid);
if (user is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(model.ExerciseCountryCode))
{
model.ExerciseCountryCode = "fr";
}
var profile = await _dbContext.Performers.FirstOrDefaultAsync(p => p.PerformerId == uid);
if (profile is null)
{
profile = new Yavsc.Models.Workflow.PerformerProfile
{
PerformerId = uid,
Performer = user,
ExerciseCountryCode = model.ExerciseCountryCode,
SIREN = model.SIREN ?? string.Empty,
WebSite = model.WebSite ?? string.Empty,
Active = model.Active,
AcceptNotifications = model.AcceptNotifications,
AcceptPublicContact = model.AcceptPublicContact,
UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients,
OrganizationAddress = new Yavsc.Models.Relationship.Location()
};
_dbContext.Performers.Add(profile);
}
else
{
profile.ExerciseCountryCode = model.ExerciseCountryCode;
profile.SIREN = model.SIREN ?? string.Empty;
profile.WebSite = model.WebSite ?? string.Empty;
profile.Active = model.Active;
profile.AcceptNotifications = model.AcceptNotifications;
profile.AcceptPublicContact = model.AcceptPublicContact;
profile.UseGeoLocalizationToReduceDistanceWithClients = model.UseGeoLocalizationToReduceDistanceWithClients;
}
var desiredCodes = (model.SelectedActivityCodes ?? [])
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existingLinks = await _dbContext.UserActivities
.Where(x => x.UserId == uid)
.ToListAsync();
foreach (var link in existingLinks)
{
if (!desiredCodes.Contains(link.DoesCode, StringComparer.OrdinalIgnoreCase))
{
_dbContext.UserActivities.Remove(link);
}
}
foreach (var code in desiredCodes)
{
if (existingLinks.Any(x => string.Equals(x.DoesCode, code, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var activity = await _dbContext.Activities
.FirstOrDefaultAsync(a => a.Code == code);
if (activity is not null)
{
_dbContext.UserActivities.Add(new Yavsc.Models.Workflow.UserActivity
{
UserId = uid,
DoesCode = activity.Code,
Weight = 100
});
}
}
await _dbContext.SaveChangesAsync(uid);
if (!User.IsInRole("Performer"))
{
var roleResult = await _userManager.AddToRoleAsync(user, "Performer");
if (!roleResult.Succeeded)
{
return BadRequest(new { error = "Performer role could not be assigned.", details = roleResult.Errors.Select(e => e.Description) });
}
}
return Ok(new { status = "saved", performer = model });
}
[HttpGet("myhost")] [HttpGet("myhost")]
public IActionResult MyHost () public IActionResult MyHost ()
{ {

View file

@ -54,6 +54,13 @@ internal class Program
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.RequireClaim(JwtClaimTypes.Scope, new string[] { "api" }); .RequireClaim(JwtClaimTypes.Scope, new string[] { "api" });
}); });
options.AddPolicy("AdministratorOnly", policy =>
{
policy
.RequireAuthenticatedUser()
.RequireClaim(Constants.RoleClaimType, Constants.AdminGroupName);
});
}) })
.AddYavscCors(builder.Configuration) .AddYavscCors(builder.Configuration)
.AddControllersWithViews(); .AddControllersWithViews();
@ -116,6 +123,7 @@ internal class Program
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app app
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()

View file

@ -99,7 +99,7 @@ namespace Yavsc.Blogs.Controllers
} }
[HttpPost("{*subdir}")] [HttpPost("{*subdir}")]
public IActionResult Post([ValidRemoteUserFilePath] string subdir="") public async Task<IActionResult> Post([ValidRemoteUserFilePath] string subdir="")
{ {
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState); if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
string destDir = null; string destDir = null;
@ -159,6 +159,14 @@ namespace Yavsc.Blogs.Controllers
item.Length = f.Length; item.Length = f.Length;
item.ContentType = f.ContentType; item.ContentType = f.ContentType;
} }
await ResourceUsageTracker.RecordAsync(
dbContext,
uid,
user.UserName,
storageBytes: item.Length,
actorUserId: uid);
received.Add(item); received.Add(item);
_logger.LogInformation($"Received '{item.FileName}'."); _logger.LogInformation($"Received '{item.FileName}'.");
if (item.QuotaOffense) if (item.QuotaOffense)

View file

@ -98,6 +98,7 @@ internal class Program
{ {
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app.UseMiddleware<ResourceUsageTelemetryMiddleware>();
app app
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class ResourceUsageRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ResourceUsageRecords",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
RecordedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ApiCalls = table.Column<decimal>(type: "numeric", nullable: false),
CpuSeconds = table.Column<decimal>(type: "numeric", nullable: false),
BandwidthMb = table.Column<decimal>(type: "numeric", nullable: false),
StorageMb = table.Column<decimal>(type: "numeric", nullable: false),
EstimatedCompensation = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false, defaultValue: "EUR"),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserCreated = table.Column<string>(type: "text", nullable: true),
DateModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserModified = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ResourceUsageRecords", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ResourceUsageRecords_UserId_RecordedAtUtc",
table: "ResourceUsageRecords",
columns: new[] { "UserId", "RecordedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ResourceUsageRecords");
}
}
}

View file

@ -2821,6 +2821,68 @@ namespace Yavsc.Migrations
b.ToTable("Tags"); b.ToTable("Tags");
}); });
modelBuilder.Entity("Yavsc.Models.ResourceUsageRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("ApiCalls")
.HasColumnType("numeric");
b.Property<decimal>("BandwidthMb")
.HasColumnType("numeric");
b.Property<decimal>("CpuSeconds")
.HasColumnType("numeric");
b.Property<string>("Currency")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasDefaultValue("EUR");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateModified")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("EstimatedCompensation")
.HasColumnType("numeric");
b.Property<DateTime>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("StorageMb")
.HasColumnType("numeric");
b.Property<string>("UserCreated")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserModified")
.HasColumnType("text");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("UserId", "RecordedAtUtc");
b.ToTable("ResourceUsageRecords");
});
modelBuilder.Entity("Yavsc.Models.Skill", b => modelBuilder.Entity("Yavsc.Models.Skill", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")

View file

@ -0,0 +1,54 @@
#nullable enable
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models;
namespace Yavsc.Server.Helpers;
public sealed class ResourceUsageTelemetryMiddleware
{
private readonly RequestDelegate _next;
private readonly IServiceScopeFactory _scopeFactory;
public ResourceUsageTelemetryMiddleware(
RequestDelegate next,
IServiceScopeFactory scopeFactory)
{
_next = next;
_scopeFactory = scopeFactory;
}
public async Task InvokeAsync(HttpContext context)
{
var userId = context.User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
await _next(context);
return;
}
var userName = context.User.Identity?.Name ?? userId;
var requestBytes = context.Request.ContentLength ?? 0L;
try
{
await _next(context);
}
finally
{
var responseBytes = context.Response.ContentLength ?? 0L;
var totalBytes = requestBytes + responseBytes;
var bandwidthMb = totalBytes <= 0 ? 0m : (decimal)totalBytes / (1024m * 1024m);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await ResourceUsageTracker.RecordAsync(
db,
userId,
userName,
apiCalls: 1m,
bandwidthMb: bandwidthMb,
actorUserId: userId);
}
}
}

View file

@ -0,0 +1,69 @@
#nullable enable
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
namespace Yavsc.Server.Helpers;
public static class ResourceUsageTracker
{
public static async Task RecordAsync(
ApplicationDbContext dbContext,
string userId,
string userName,
decimal cpuSeconds = 0m,
long storageBytes = 0,
decimal apiCalls = 0m,
decimal bandwidthMb = 0m,
string? actorUserId = null,
string currency = Constants.ResourceUsageDefaultCurrency)
{
if (dbContext is null || string.IsNullOrWhiteSpace(userId))
{
return;
}
var effectiveActor = string.IsNullOrWhiteSpace(actorUserId) ? userId : actorUserId;
var now = DateTime.UtcNow;
var storageMb = storageBytes <= 0 ? 0m : (decimal)storageBytes / (1024m * 1024m);
var record = await dbContext.ResourceUsageRecords
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RecordedAtUtc)
.FirstOrDefaultAsync();
if (record is null)
{
record = new ResourceUsageRecord
{
UserId = userId,
UserName = string.IsNullOrWhiteSpace(userName) ? userId : userName,
RecordedAtUtc = now,
ApiCalls = apiCalls,
CpuSeconds = cpuSeconds,
BandwidthMb = bandwidthMb,
StorageMb = storageMb,
EstimatedCompensation = 0m,
Currency = currency,
UserCreated = effectiveActor,
UserModified = effectiveActor,
DateCreated = now,
DateModified = now,
};
dbContext.ResourceUsageRecords.Add(record);
await dbContext.SaveChangesAsync(effectiveActor);
return;
}
record.UserName = string.IsNullOrWhiteSpace(userName) ? record.UserName : userName;
record.RecordedAtUtc = now;
record.ApiCalls += apiCalls;
record.CpuSeconds += cpuSeconds;
record.BandwidthMb += bandwidthMb;
record.StorageMb += storageMb;
record.Currency = string.IsNullOrWhiteSpace(currency) ? record.Currency : currency;
record.DateModified = now;
record.UserModified = effectiveActor;
await dbContext.SaveChangesAsync(effectiveActor);
}
}

View file

@ -58,6 +58,14 @@ namespace Yavsc.Models
} }
builder.UseIdentityByDefaultColumns(); builder.UseIdentityByDefaultColumns();
builder.Entity<ResourceUsageRecord>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasIndex(r => new { r.UserId, r.RecordedAtUtc });
entity.Property(r => r.Currency).HasMaxLength(8).HasDefaultValue(Constants.ResourceUsageDefaultCurrency);
entity.Property(r => r.UserName).HasMaxLength(256);
});
// Customize the ASP.NET Identity model and override the defaults if needed. // Customize the ASP.NET Identity model and override the defaults if needed.
// For example, you can rename the ASP.NET Identity table names and more. // For example, you can rename the ASP.NET Identity table names and more.
// Add your customizations after calling base.OnModelCreating(builder); // Add your customizations after calling base.OnModelCreating(builder);
@ -324,6 +332,7 @@ namespace Yavsc.Models
public DbSet<Estimate> Estimates { get; set; } public DbSet<Estimate> Estimates { get; set; }
public DbSet<Signature> Signatures { get; set; } public DbSet<Signature> Signatures { get; set; }
public DbSet<ResourceUsageRecord> ResourceUsageRecords { get; set; }
public DbSet<AccountBalance> BankStatus { get; set; } public DbSet<AccountBalance> BankStatus { get; set; }
public DbSet<BalanceImpact> BalanceImpact { get; set; } public DbSet<BalanceImpact> BalanceImpact { get; set; }

View file

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Yavsc.Models;
public sealed class ResourceUsageRecord : ITrackedEntity
{
[Key]
public long Id { get; set; }
[Required]
[MaxLength(256)]
public string UserId { get; set; } = string.Empty;
[Required]
[MaxLength(256)]
public string UserName { get; set; } = string.Empty;
[Required]
public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow;
public decimal ApiCalls { get; set; }
public decimal CpuSeconds { get; set; }
public decimal BandwidthMb { get; set; }
public decimal StorageMb { get; set; }
public decimal EstimatedCompensation { get; set; }
[Required]
[MaxLength(8)]
public string Currency { get; set; } = Constants.ResourceUsageDefaultCurrency;
public DateTime DateCreated { get; set; } = DateTime.UtcNow;
public string UserCreated { get; set; } = string.Empty;
public DateTime DateModified { get; set; } = DateTime.UtcNow;
public string UserModified { get; set; } = string.Empty;
}

View file

@ -3,7 +3,7 @@ using Yavsc.Abstract.Identity;
namespace Yavsc.Models.Auth namespace Yavsc.Models.Auth
{ {
public class Me : IApplicationUser { public class Me : IApplicationUser {
public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId) public Me(string userId, string userName, string email, string avatar, ILocation address, string gCalId, string? fullName = null)
{ {
Id = userId; Id = userId;
UserName = userName; UserName = userName;
@ -11,10 +11,14 @@ namespace Yavsc.Models.Auth
Avatar = avatar; Avatar = avatar;
PostalAddress = address; PostalAddress = address;
DedicatedGoogleCalendar = gCalId; DedicatedGoogleCalendar = gCalId;
FullName = fullName ?? string.Empty;
Address = address?.Address ?? string.Empty;
} }
public string Id { get; set; } public string Id { get; set; }
public string UserName { get; set; } public string UserName { get; set; }
public string FullName { get; set; }
public string EMail { get; set; } public string EMail { get; set; }
public string Address { get; set; }
public string[] Roles { get; set; } public string[] Roles { get; set; }
/// <summary> /// <summary>
/// Known as profile, could point to an avatar /// Known as profile, could point to an avatar

View file

@ -66,7 +66,14 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.Configure<GoogleAuthSettings>(_ => { }); builder.Services.Configure<GoogleAuthSettings>(_ => { });
builder.Services.AddTransient<IBillingService, BillingService>(); builder.Services.AddTransient<IBillingService, BillingService>();
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>(); builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
builder.Services.AddAuthorization(); builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdministratorOnly", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName);
});
});
builder.Services.AddAuthentication("Bearer") builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options => .AddJwtBearer("Bearer", options =>

View file

@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class ResourceUsageApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public ResourceUsageApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private static HttpClient NewClient(ApiWebServerFixture fixture, string subject = "alice", string scope = "api", IEnumerable<Claim>? extraClaims = null)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope, extraClaims: extraClaims));
return http;
}
[Fact]
public async Task GetCurrentUserUsage_persists_snapshot_in_database()
{
using var http = NewClient(_fixture, "alice");
var response = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<ResourceUsageSummary>(TestContext.Current.CancellationToken);
Assert.NotNull(summary);
Assert.Equal("alice", summary!.UserId);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
Assert.Contains(db.ResourceUsageRecords, r => r.UserId == "alice");
}
[Fact]
public async Task RecordAsync_persists_cpu_and_storage_usage()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
await ResourceUsageTracker.RecordAsync(
db,
"alice",
"alice",
cpuSeconds: 1.5m,
storageBytes: 2 * 1024 * 1024,
actorUserId: "alice");
var record = await db.ResourceUsageRecords
.SingleAsync(r => r.UserId == "alice");
Assert.True(record.CpuSeconds >= 1.5m);
Assert.True(record.StorageMb >= 2m / 1024m / 1024m);
}
[Fact]
public async Task RecordAsync_persists_api_and_bandwidth_usage()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
await ResourceUsageTracker.RecordAsync(
db,
"telemetry-user",
"telemetry-user",
apiCalls: 2m,
bandwidthMb: 3.5m,
actorUserId: "telemetry-user");
var record = await db.ResourceUsageRecords
.SingleAsync(r => r.UserId == "telemetry-user");
Assert.True(record.ApiCalls >= 2m);
Assert.True(record.BandwidthMb >= 3.5m);
}
[Fact]
public async Task GetAdminOverview_returns_latest_usage_snapshot_for_each_user()
{
using var http = NewClient(_fixture, "alice");
var first = await http.GetAsync("/api/v1/resource-usage/me", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using var adminHttp = NewClient(
_fixture,
"admin-user",
"api",
new[]
{
new Claim(Yavsc.Constants.RoleClaimType, Yavsc.Constants.AdminGroupName)
});
var response = await adminHttp.GetAsync("/api/v1/resource-usage/admin", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ResourceUsageSummary>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains(payload!, item => item.UserId == "alice");
}
}