Exploration des activités

This commit is contained in:
Paul Schneider 2026-08-31 02:14:04 +01:00
commit 7b92745153
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
20 changed files with 743 additions and 61 deletions

View file

@ -1,5 +1,13 @@
# Changelog
## [1.0.8-rc10] - unstable
### Added
### Changed
### Fixed
## [1.0.8-rc9] - unstable
### Added

View file

@ -14,10 +14,10 @@ public class ActivitiesPageViewModelTests
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);
await client.GetUsersAsync("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]);
Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]);
}
[Fact]
@ -34,12 +34,20 @@ public class ActivitiesPageViewModelTests
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.True(vm.Performers[0].IsPerformerActive);
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Pas d'autre activité", vm.Performers[0].ExtraActivityLabel);
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.True(vm.Performers[0].HasPerformerProfile);
Assert.False(vm.Performers[0].IsPerformerActive);
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Autres spécialisations: 2", vm.Performers[0].ExtraActivityLabel);
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
await vm.ShowSpecializationAsync(null);
@ -86,14 +94,14 @@ public class ActivitiesPageViewModelTests
if (typeof(T) == typeof(List<ActivityPerformerDto>))
{
var performers = path.EndsWith("brush-pro/performers", StringComparison.Ordinal)
var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal)
? new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-2", UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro" }
new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 }
}
: new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-1", UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush" }
new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 }
};
return Task.FromResult((T)(object)performers);

View file

@ -86,11 +86,6 @@ private void ConfigureRootView(MainView rootView)
rootView.NavRoot.PopToRootAsync();
};
sessionStatus.LoginSucceeded += async () =>
{
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus;
}

View file

@ -29,7 +29,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityPerformerDto> Performers { get; set; } = new();
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -122,14 +122,14 @@ public partial class ActivitiesPageViewModel : ViewModelBase
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Performers = new ObservableCollection<ActivityPerformerDto>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
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>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
@ -158,7 +158,7 @@ public partial class ActivitiesPageViewModel : ViewModelBase
if (activity is null)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
return;
}
@ -197,18 +197,19 @@ public partial class ActivitiesPageViewModel : ViewModelBase
IsBusy = true;
try
{
var list = await _client.GetPerformersAsync(activity.Code);
Performers = new ObservableCollection<ActivityPerformerDto>(list ?? new());
StatusMessage = $"{activity.Name} · {Performers.Count} prestataire(s)";
var list = await _client.GetUsersAsync(activity.Code);
Performers = new ObservableCollection<ActivityUserDisplayItem>((list ?? new())
.Select(ActivityUserDisplayItem.FromDto));
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally

View file

@ -0,0 +1,39 @@
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public sealed class ActivityUserDisplayItem
{
public string PerformerId { get; init; } = string.Empty;
public bool HasPerformerProfile { get; init; }
public string PerformerBadgeLabel { get; init; } = "Profil pro";
public bool IsPerformerActive { get; init; }
public string PerformerStatusBadgeLabel { get; init; } = "Inactif";
public string PerformerStatusBadgeBackground { get; init; } = "#FDECEA";
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
public string UserName { get; init; } = string.Empty;
public string WebSite { get; init; } = string.Empty;
public int ExtraActivityCount { get; init; }
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
public static ActivityUserDisplayItem FromDto(ActivityPerformerDto dto)
{
return new ActivityUserDisplayItem
{
PerformerId = dto.PerformerId,
HasPerformerProfile = dto.HasPerformerProfile,
UserName = dto.UserName,
WebSite = dto.WebSite,
IsPerformerActive = dto.Active,
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",
PerformerStatusBadgeBackground = dto.Active ? "#E6F7EC" : "#FDECEA",
PerformerStatusBadgeBorder = dto.Active ? "#2E7D32" : "#C62828",
PerformerStatusBadgeForeground = dto.Active ? "#1B5E20" : "#8E0000",
ExtraActivityCount = dto.ExtraActivityCount,
ExtraActivityLabel = dto.ExtraActivityCount == 0
? "Pas d'autre activité"
: $"Autres spécialisations: {dto.ExtraActivityCount}"
};
}
}

View file

@ -6,6 +6,20 @@
x:DataType="vm:ActivitiesPageViewModel"
Header="Activités">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<Grid.Styles>
<Style Selector="Border.user-badge">
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="5,0" />
<Setter Property="MinHeight" Value="16" />
</Style>
<Style Selector="TextBlock.user-badge-text">
<Setter Property="FontSize" Value="9" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Grid.Styles>
<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" />
@ -22,7 +36,7 @@
<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}'}"
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
@ -43,7 +57,7 @@
<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}'}"
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
@ -52,17 +66,39 @@
</Grid>
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Prestataires" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="0" Text="Utilisateurs" 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">
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="#D9F5E5"
BorderBrush="#2E7D32"
>
<TextBlock Text="{Binding PerformerBadgeLabel}"
Classes="user-badge-text"
Foreground="#1B5E20"
/>
</Border>
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="{Binding PerformerStatusBadgeBackground}"
BorderBrush="{Binding PerformerStatusBadgeBorder}"
>
<TextBlock Text="{Binding PerformerStatusBadgeLabel}"
Classes="user-badge-text"
Foreground="{Binding PerformerStatusBadgeForeground}"
/>
</Border>
</StackPanel>
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding ExtraActivityCount, StringFormat='Autres spécialisations: {0}'}"
<TextBlock Text="{Binding ExtraActivityLabel}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>

View file

@ -6,6 +6,7 @@ namespace Yavsc.Abstract.Workflow;
public sealed class ActivityPerformerDto
{
public string PerformerId { get; set; } = string.Empty;
public bool HasPerformerProfile { get; set; }
public string UserName { get; set; } = string.Empty;
public bool Active { get; set; }
public bool AcceptNotifications { get; set; }

View file

@ -39,7 +39,7 @@ public sealed class ActivityApiClient
return _api.CallAsync<List<ActivityBrowseItemDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<List<ActivityPerformerDto>> GetPerformersAsync(
public Task<List<ActivityPerformerDto>> GetUsersAsync(
string activityCode,
CancellationToken ct = default)
{
@ -48,9 +48,14 @@ public sealed class ActivityApiClient
return _api.CallAsync<List<ActivityPerformerDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/performers"),
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/users"),
ct: ct);
}
public Task<List<ActivityPerformerDto>> GetPerformersAsync(
string activityCode,
CancellationToken ct = default)
=> GetUsersAsync(activityCode, ct);
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
}

View file

@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class ActivityApiControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public ActivityApiControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
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));
return http;
}
[Fact]
public async Task GetUsers_returns_declared_user_for_exact_activity_code()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
Assert.Equal("alice", payload[0].UserName);
Assert.True(payload[0].HasPerformerProfile);
Assert.True(payload[0].Active);
Assert.Equal("dev", payload[0].ActivityCode);
}
[Fact]
public async Task GetUsers_returns_user_even_when_performer_inactive()
{
_fixture.ResetAndSeedActivityGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
var performer = db.Performers.Single(p => p.PerformerId == "alice");
performer.Active = false;
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("alice", payload[0].PerformerId);
Assert.False(payload[0].Active);
}
[Fact]
public async Task Catalog_and_Performers_are_consistent_for_dev_activity()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
var dev = catalog!.Single(a => a.Code == "dev");
Assert.True(dev.PerformerCount > 0);
var performersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var performers = await performersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(performers);
Assert.Equal(dev.PerformerCount, performers!.Count);
Assert.Contains(performers, p => p.PerformerId == "alice");
}
[Fact]
public async Task Catalog_does_not_list_activity_without_declaration()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.DoesNotContain(catalog!, a => a.Code == "ghost");
Assert.Contains(catalog!, a => a.Code == "dev");
}
[Fact]
public async Task Catalog_lists_declared_activity_even_when_performer_inactive()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
var catalog = await catalogResponse.Content.ReadFromJsonAsync<List<ActivityBrowseItemDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(catalog);
Assert.Contains(catalog!, a => a.Code == "declared-only");
var response = await http.GetAsync("/api/v1/activity/declared-only/users", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Single(payload!);
Assert.Equal("bob", payload[0].PerformerId);
Assert.Equal("bob", payload[0].UserName);
Assert.True(payload[0].HasPerformerProfile);
Assert.False(payload[0].Active);
}
[Fact]
public async Task Performers_alias_returns_same_payload_as_users_endpoint()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient();
var usersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
var performersResponse = await http.GetAsync("/api/v1/activity/dev/performers", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
var usersPayload = await usersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
var performersPayload = await performersResponse.Content.ReadFromJsonAsync<List<ActivityPerformerDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(usersPayload);
Assert.NotNull(performersPayload);
Assert.Equal(usersPayload!.Count, performersPayload!.Count);
Assert.Equal(usersPayload[0].PerformerId, performersPayload[0].PerformerId);
Assert.Equal(usersPayload[0].UserName, performersPayload[0].UserName);
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Api.Test;
[CollectionDefinition("Yavsc Api")]
public sealed class ApiCollection
{
}

View file

@ -0,0 +1,7 @@
<Project>
<!--
Yavsc.Api.Test has no project-specific package versions. All
package versions are declared at the repository root.
-->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

View file

@ -0,0 +1,210 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Yavsc.Controllers;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test.Fixtures;
public sealed class ApiWebServerFixture : WebHostFixture
{
protected override int HttpsPort => 5104;
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
protected override WebApplication BuildApp(WebApplicationBuilder builder)
{
SqliteConnection sharedConnection;
lock (_sqliteLock)
{
if (_sharedSqliteConnection is null)
{
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlite(sharedConnection));
builder.Services.AddControllers()
.AddApplicationPart(typeof(ActivityApiController).Assembly);
builder.Services.AddAuthorization();
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.IncludeErrorDetails = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TestTokenIssuer.Issuer,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey,
NameClaimType = "sub",
RoleClaimType = Yavsc.Constants.RoleClaimType,
};
});
return builder.Build();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureCreated();
}
await Task.CompletedTask;
return app;
}
public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
public void ResetAndSeedActivityGraph()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
var user = new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.test",
EmailConfirmed = true,
FullName = "Alice",
};
db.Users.Add(user);
var location = new Location
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
};
db.Add(location);
db.SaveChanges();
var activity = new Activity
{
Code = "dev",
Name = "Dev",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.Activities.Add(activity);
db.Activities.Add(new Activity
{
Code = "ghost",
Name = "Ghost",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
var performer = new PerformerProfile
{
PerformerId = "alice",
SIREN = "123456789",
OrganizationAddressId = location.Id,
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
};
db.Performers.Add(performer);
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "dev",
Weight = 100,
});
db.Users.Add(new ApplicationUser
{
Id = "bob",
UserName = "bob",
Email = "bob@example.test",
EmailConfirmed = true,
FullName = "Bob",
});
db.Activities.Add(new Activity
{
Code = "declared-only",
Name = "Declared Only",
Hidden = false,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.Performers.Add(new PerformerProfile
{
PerformerId = "bob",
SIREN = "987654321",
OrganizationAddressId = location.Id,
AcceptNotifications = false,
AcceptPublicContact = false,
Active = false,
Rate = 0,
WebSite = "",
});
db.UserActivities.Add(new UserActivity
{
UserId = "bob",
DoesCode = "declared-only",
Weight = 10,
});
db.SaveChanges();
}
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
}
}

View file

@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Controllers;
using Yavsc.Models.Workflow;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class HomeControllerTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public HomeControllerTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Index_does_not_list_activity_without_declaration()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Yavsc.Models.ApplicationDbContext>();
var controller = new HomeController(
NullLogger<HomeController>.Instance,
localizer: null!,
context: db,
settingsOptions: Options.Create(new SiteSettings()),
env: new TestEnvironment());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("sub", "alice"),
new Claim(ClaimTypes.NameIdentifier, "alice")
}, "Bearer"))
}
};
var result = await controller.Index(id: null);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsAssignableFrom<IEnumerable<Activity>>(view.Model);
Assert.Contains(model, a => a.Code == "dev");
Assert.DoesNotContain(model, a => a.Code == "ghost");
}
private sealed class TestEnvironment : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Yavsc.Api.Test";
public IFileProvider WebRootFileProvider { get; set; } = null!;
public string WebRootPath { get; set; } = string.Empty;
public string EnvironmentName { get; set; } = "Development";
public string ContentRootPath { get; set; } = string.Empty;
public IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Api.Test</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Api\Yavsc.Api.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -53,8 +53,7 @@ namespace Yavsc.Controllers
var performerCounts = await (
from ua in _context.UserActivities.AsNoTracking()
join p in _context.Performers.AsNoTracking() on ua.UserId equals p.PerformerId
where p.Active && !string.IsNullOrWhiteSpace(ua.DoesCode) && codes.Contains(ua.DoesCode)
where !string.IsNullOrWhiteSpace(ua.DoesCode) && codes.Contains(ua.DoesCode)
group ua by ua.DoesCode into g
select new
{
@ -63,11 +62,19 @@ namespace Yavsc.Controllers
})
.ToDictionaryAsync(x => x.Code, x => x.Count, cancellationToken);
return Ok(activities.Select(a => ToBrowseItem(a, performerCounts)).ToList());
var filteredActivities = activities
.Where(a =>
(performerCounts.TryGetValue(a.Code, out var ownCount) && ownCount > 0)
|| (a.Children ?? new List<Activity>())
.Where(c => !c.Hidden)
.Any(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0))
.ToList();
return Ok(filteredActivities.Select(a => ToBrowseItem(a, performerCounts)).ToList());
}
[HttpGet("{id}/performers")]
public async Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetPerformers(
[HttpGet("{id}/users")]
public async Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetUsers(
[FromRoute] string id,
CancellationToken cancellationToken)
{
@ -84,35 +91,54 @@ namespace Yavsc.Controllers
return NotFound();
}
var performers = await (
from p in _context.Performers.AsNoTracking()
join ua in _context.UserActivities.AsNoTracking() on p.PerformerId equals ua.UserId
join u in _context.ApplicationUser.AsNoTracking() on p.PerformerId equals u.Id into users
var users = await QueryDeclaredUsersAsync(id, activity.Name, cancellationToken);
return Ok(users);
}
[HttpGet("{id}/performers")]
public Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetPerformers(
[FromRoute] string id,
CancellationToken cancellationToken)
{
// Backward-compatible alias kept for existing clients.
return GetUsers(id, cancellationToken);
}
private Task<List<ActivityPerformerDto>> QueryDeclaredUsersAsync(
string activityCode,
string activityName,
CancellationToken cancellationToken)
{
return (
from ua in _context.UserActivities.AsNoTracking()
join u in _context.ApplicationUser.AsNoTracking() on ua.UserId equals u.Id into users
from user in users.DefaultIfEmpty()
where p.Active && ua.DoesCode == id
orderby p.Rate
join p in _context.Performers.AsNoTracking() on ua.UserId equals p.PerformerId into performerProfiles
from performer in performerProfiles.DefaultIfEmpty()
where ua.DoesCode == activityCode
orderby user != null ? user.UserName : ua.UserId
select new ActivityPerformerDto
{
PerformerId = p.PerformerId,
PerformerId = ua.UserId,
HasPerformerProfile = performer != null,
UserName = user != null ? (user.UserName ?? string.Empty) : string.Empty,
Active = p.Active,
AcceptNotifications = p.AcceptNotifications,
AcceptPublicContact = p.AcceptPublicContact,
WebSite = p.WebSite ?? string.Empty,
ActivityCode = id,
ActivityName = activity.Name,
Active = performer != null && performer.Active,
AcceptNotifications = performer != null && performer.AcceptNotifications,
AcceptPublicContact = performer != null && performer.AcceptPublicContact,
WebSite = performer != null ? (performer.WebSite ?? string.Empty) : string.Empty,
ActivityCode = activityCode,
ActivityName = activityName,
SettingsClassName = _context.Activities
.Where(a => a.Code == id)
.Where(a => a.Code == activityCode)
.Select(a => a.SettingsClassName)
.FirstOrDefault() ?? string.Empty,
ExtraActivityCount = _context.UserActivities
.Where(x => x.UserId == p.PerformerId && x.DoesCode != id)
.Where(x => x.UserId == ua.UserId && x.DoesCode != activityCode)
.Count()
})
.Distinct()
.ToListAsync(cancellationToken);
return Ok(performers);
}
// GET: api/ActivityApi/5
@ -257,6 +283,7 @@ namespace Yavsc.Controllers
.ToList(),
Children = (activity.Children ?? Enumerable.Empty<Activity>())
.Where(c => !c.Hidden)
.Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)
.OrderByDescending(c => c.Rate)
.Select(c => new ActivityBrowseItemDto
{

View file

@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Server.Helpers;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace Yavsc.Controllers
{
@ -13,7 +15,7 @@ namespace Yavsc.Controllers
public SIRENExceptionsController(ApplicationDbContext context)
{
_context = context;
_context = context;
}
// GET: SIRENExceptions
@ -50,15 +52,41 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public IActionResult Create(ExceptionSIREN exceptionSIREN)
{
exceptionSIREN ??= new ExceptionSIREN();
exceptionSIREN.SIREN = NormalizeSiren(exceptionSIREN.SIREN);
if (string.IsNullOrWhiteSpace(exceptionSIREN.SIREN) || exceptionSIREN.SIREN.Length != 9 || !exceptionSIREN.SIREN.All(char.IsDigit))
{
ModelState.AddModelError(nameof(ExceptionSIREN.SIREN), "Le SIREN doit contenir exactement 9 chiffres.");
}
if (_context.ExceptionsSIREN.Any(e => e.SIREN == exceptionSIREN.SIREN))
{
ModelState.AddModelError(nameof(ExceptionSIREN.SIREN), "Ce SIREN est deja dans la liste des exceptions.");
}
if (ModelState.IsValid)
{
_context.ExceptionsSIREN.Add(exceptionSIREN);
_context.SaveChanges(User.GetUserId());
return RedirectToAction("Index");
try
{
_context.SaveChanges(User.GetUserId());
return RedirectToAction("Index");
}
catch (DbUpdateException)
{
ModelState.AddModelError(string.Empty, "Impossible d'enregistrer cette exception SIREN.");
}
}
return View(exceptionSIREN);
}
private static string NormalizeSiren(string? siren)
{
if (string.IsNullOrWhiteSpace(siren)) return string.Empty;
return new string(siren.Where(char.IsDigit).ToArray());
}
// GET: SIRENExceptions/Edit/5
public IActionResult Edit(string id)
{

View file

@ -63,9 +63,35 @@ namespace Yavsc.Controllers
.Where(a => a.ParentCode == id)
.OrderByDescending(a => a.Rate).ToList();
var candidateCodes = toShow
.Select(a => a.Code)
.Concat(toShow.SelectMany(a => (a.Children ?? new List<Yavsc.Models.Workflow.Activity>())
.Where(c => !c.Hidden)
.Select(c => c.Code)))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct()
.ToArray();
var performerCounts = _dbContext.UserActivities
.Where(ua => candidateCodes.Contains(ua.DoesCode))
.GroupBy(ua => ua.DoesCode)
.Select(g => new { Code = g.Key, Count = g.Select(x => x.UserId).Distinct().Count() })
.ToDictionary(x => x.Code, x => x.Count);
toShow = toShow
.Where(a =>
(performerCounts.TryGetValue(a.Code, out var ownCount) && ownCount > 0)
|| (a.Children ?? new List<Yavsc.Models.Workflow.Activity>())
.Where(c => !c.Hidden)
.Any(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0))
.ToList();
foreach (var a in toShow)
{
a.Children = a.Children.Where(c => !c.Hidden).ToList();
a.Children = (a.Children ?? new List<Yavsc.Models.Workflow.Activity>())
.Where(c => !c.Hidden)
.Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)
.ToList();
}
return View(toShow);
}

View file

@ -62,7 +62,7 @@
<label asp-for="Active" class="col-md-2 control-label">ActivateMyProSettings</label>
<div>
<input asp-for="Active" class="form-control" />
<input class="form-check-input" type="checkbox" asp-for="Active" class="form-control" />
<span asp-validation-for="Active" class="text-danger"></span>
</div>

View file

@ -5,21 +5,28 @@
}
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div class="form-horizontal">
<h4>Exception SIREN</h4>
<hr />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group mb-3">
<label asp-for="SIREN" class="control-label">SIREN</label>
<input asp-for="SIREN" class="form-control" maxlength="9" pattern="[0-9]{9}" inputmode="numeric" autocomplete="off" />
<span asp-validation-for="SIREN" class="text-danger"></span>
</div>
<div class="form-group">
@Html.TextBox("SIREN")
<div class="col-md-offset-1 col-md-10">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</div>
<input type="submit" value="Create" class="btn btn-primary" />
</div>
</div>
</form>
<div>
<a asp-action="Index">Back to List</a>
</div>
</div>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View file

@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Client", "src\Yav
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\PostIt.Tests\PostIt.Tests.csproj", "{021E6F5A-B81D-42A7-9918-DFE39B424FC2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -229,6 +231,18 @@ Global
{021E6F5A-B81D-42A7-9918-DFE39B424FC2}.Release|x64.Build.0 = Release|Any CPU
{021E6F5A-B81D-42A7-9918-DFE39B424FC2}.Release|x86.ActiveCfg = Release|Any CPU
{021E6F5A-B81D-42A7-9918-DFE39B424FC2}.Release|x86.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|x64.ActiveCfg = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|x64.Build.0 = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|x86.ActiveCfg = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Debug|x86.Build.0 = Debug|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|Any CPU.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -250,5 +264,6 @@ Global
{34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
EndGlobalSection
EndGlobal