Compare commits
36 changed files with 23 additions and 7391 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -44,8 +44,5 @@ DataDir/
|
|||
*.log
|
||||
|
||||
# gitleaks scan output (local secret-scan report, not for the repo)
|
||||
/gitleaks-report.json
|
||||
/test-report.html
|
||||
/test-report.xml
|
||||
|
||||
gitleaks-report.json
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ public class EstimateEditionPageViewModelTests
|
|||
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
||||
{
|
||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
|
||||
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -144,8 +143,10 @@ public class EstimateEditionPageViewModelTests
|
|||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
|
||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
||||
|
||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
||||
Assert.Equal(42, payload.CommandId);
|
||||
Assert.Equal("cli-1", payload.ClientId);
|
||||
Assert.Equal("Brush", payload.CommandType);
|
||||
|
|
@ -215,8 +216,7 @@ public class EstimateEditionPageViewModelTests
|
|||
|
||||
await vm.SendCommand.ExecuteAsync(null);
|
||||
|
||||
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
||||
Assert.Equal(2, payload.Bill.Count);
|
||||
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
||||
Assert.True(vm.HasSent);
|
||||
|
|
@ -230,20 +230,11 @@ public class EstimateEditionPageViewModelTests
|
|||
public object? LastBody { get; private set; }
|
||||
public Exception? Failure { get; init; }
|
||||
|
||||
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
|
||||
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||
|
||||
private void Record(HttpMethod method, string path, object? body)
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
Calls.Add((method, path, body));
|
||||
}
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
|
||||
if (Failure is not null)
|
||||
{
|
||||
|
|
@ -262,7 +253,9 @@ public class EstimateEditionPageViewModelTests
|
|||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, body);
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastBody = body;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
|
@ -272,12 +265,6 @@ public class EstimateEditionPageViewModelTests
|
|||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||
=> CallAsync(method, path, (object?)null, ct);
|
||||
|
||||
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||
{
|
||||
Record(method, path, null);
|
||||
return Task.FromResult(Array.Empty<byte>());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ public static class ServiceCollectionHelpers
|
|||
|
||||
var api = new YavscApiClient(settings, tokenStore);
|
||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
|
|
@ -66,7 +65,6 @@ public static class ServiceCollectionHelpers
|
|||
services.AddTransient<EstimateEditionPage>();
|
||||
services.AddTransient<EstimateListPage>();
|
||||
services.AddTransient<EstimateValidationPage>();
|
||||
services.AddTransient<MyFilesPage>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton(settings);
|
||||
|
|
@ -79,7 +77,6 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(billingClient);
|
||||
services.AddSingleton(estimateClient);
|
||||
services.AddSingleton(frontClient);
|
||||
services.AddSingleton(userFilesClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddSingleton<HomePageViewModel>();
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ public class ViewLocator : IDataTemplate
|
|||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
||||
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||
null => new TextBlock { Text = "No view for <null>" },
|
||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -132,18 +132,14 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
|||
{
|
||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
Activity,
|
||||
Performer,
|
||||
Form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
fsClient,
|
||||
details,
|
||||
IsReadOnly,
|
||||
canAttach: !IsReadOnly);
|
||||
await vm.InitializeAsync().ConfigureAwait(true);
|
||||
IsReadOnly);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
|
|
@ -16,17 +15,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
{
|
||||
private readonly BillingApiClient _billingClient;
|
||||
private readonly FrontOfficeApiClient _frontClient;
|
||||
private readonly UserFilesApiClient _fsClient;
|
||||
private readonly BillingQueryDetailsDto _details;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai lorsque l'utilisateur courant est le client de la demande
|
||||
/// et peut y attacher/détacher des fichiers de son espace perso.
|
||||
/// Le fournisseur consulte et télécharge les pièces mais n'attache
|
||||
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
|
||||
/// </summary>
|
||||
public bool CanAttach { get; }
|
||||
|
||||
public ActivityInfo Activity { get; }
|
||||
public ActivityUserDisplayItem Performer { get; }
|
||||
public CommandFormSummary Form { get; }
|
||||
|
|
@ -62,10 +52,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
||||
|
||||
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||
|
||||
public override bool CanNavigateNext
|
||||
{
|
||||
get => false;
|
||||
|
|
@ -84,27 +70,20 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
CommandFormSummary form,
|
||||
BillingApiClient billingClient,
|
||||
FrontOfficeApiClient frontClient,
|
||||
UserFilesApiClient fsClient,
|
||||
BillingQueryDetailsDto details,
|
||||
bool isReadOnly,
|
||||
bool canAttach)
|
||||
bool isReadOnly)
|
||||
{
|
||||
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
||||
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||
IsReadOnly = isReadOnly;
|
||||
CanAttach = canAttach;
|
||||
|
||||
this.SetInfoStatus("Details de commande charges.");
|
||||
}
|
||||
|
||||
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenEditorAsync()
|
||||
{
|
||||
|
|
@ -155,110 +134,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
|||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Charge les pièces jointes de la demande.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadQueryAttachmentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await _frontClient
|
||||
.GetQueryAttachmentsAsync(BillingCode, Id)
|
||||
.ConfigureAwait(true);
|
||||
Attachments.Clear();
|
||||
if (list is not null)
|
||||
foreach (var a in list) Attachments.Add(a);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non bloquant : les pièces jointes sont un complément.
|
||||
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||
/// est rattaché à la demande par référence via
|
||||
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
|
||||
/// (<see cref="CanAttach"/>).
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task AddQueryAttachmentAsync()
|
||||
{
|
||||
if (!CanAttach) return;
|
||||
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var billingCode = BillingCode;
|
||||
var queryId = Id;
|
||||
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
|
||||
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||
}
|
||||
});
|
||||
await picker.InitializeAsync().ConfigureAwait(true);
|
||||
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null || !CanAttach) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
|
||||
Attachments.Remove(attachment);
|
||||
this.SetInfoStatus("Pièce jointe détachée de la demande.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Download the estimate linked to this command as a LaTeX source
|
||||
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ namespace PostIt.ViewModels;
|
|||
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly EstimateApiClient _estimateClient;
|
||||
private readonly UserFilesApiClient _fsClient;
|
||||
private readonly BillingQuerySummaryDto _query;
|
||||
|
||||
public long QueryId => _query.Id;
|
||||
|
|
@ -69,14 +68,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
|||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||
|
||||
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
|
||||
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
|
||||
public partial long? EstimateId { get; set; }
|
||||
|
||||
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||
|
||||
public decimal Total => Lines.Sum(line => line.LineTotal);
|
||||
|
||||
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
|
||||
|
|
@ -95,14 +86,10 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
|||
protected set { _ = value; }
|
||||
}
|
||||
|
||||
public EstimateEditionPageViewModel(
|
||||
BillingQuerySummaryDto query,
|
||||
EstimateApiClient estimateClient,
|
||||
UserFilesApiClient fsClient)
|
||||
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
|
||||
{
|
||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||
|
||||
EstimateDescription = query.Description ?? string.Empty;
|
||||
Lines.CollectionChanged += OnLinesCollectionChanged;
|
||||
|
|
@ -111,9 +98,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
|||
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
|
||||
}
|
||||
|
||||
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
|
||||
|
||||
[RelayCommand]
|
||||
private void AddLine()
|
||||
{
|
||||
|
|
@ -157,11 +141,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
|||
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
|
||||
|
||||
HasSent = true;
|
||||
EstimateId = created.Id;
|
||||
OnPropertyChanged(nameof(SendLabel));
|
||||
this.SetInfoStatus(
|
||||
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
|
||||
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
|
|
@ -189,106 +171,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
|||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charge les pièces jointes rattachées au devis courant
|
||||
/// (<c>GET estimate/{id}/attachments</c>).
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task LoadAttachmentsAsync()
|
||||
{
|
||||
if (EstimateId is null || _estimateClient is null) return;
|
||||
try
|
||||
{
|
||||
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
|
||||
Attachments.Clear();
|
||||
if (list is not null)
|
||||
foreach (var a in list) Attachments.Add(a);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||
/// est rattaché au devis par référence via
|
||||
/// <c>POST estimate/{id}/attachments</c>.
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
|
||||
private async Task AddAttachmentAsync()
|
||||
{
|
||||
if (EstimateId is null) return;
|
||||
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var estimateId = EstimateId.Value;
|
||||
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
|
||||
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||
}
|
||||
});
|
||||
await picker.InitializeAsync().ConfigureAwait(true);
|
||||
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DetachFileAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null || EstimateId is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
|
||||
Attachments.Remove(attachment);
|
||||
this.SetInfoStatus("Pièce jointe détachée du devis.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
|
||||
{
|
||||
if (attachment is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal EstimateDto BuildPayload()
|
||||
{
|
||||
return new EstimateDto
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
|||
.ConfigureAwait(true);
|
||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||
|
||||
var vm = new BillingQueryDetailsPageViewModel(
|
||||
activity,
|
||||
|
|
@ -163,12 +162,9 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
|||
form,
|
||||
_billingClient,
|
||||
frontClient,
|
||||
fsClient,
|
||||
details,
|
||||
isReadOnly: false,
|
||||
canAttach: false);
|
||||
isReadOnly: false);
|
||||
|
||||
await vm.InitializeAsync().ConfigureAwait(true);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -247,13 +243,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
|||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||
if (fsClient is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||
}
|
||||
|
||||
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
|
||||
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
|
||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,14 +35,12 @@ public class HomePageViewModel : ViewModelBase
|
|||
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
||||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||
}
|
||||
public IAsyncRelayCommand OpenBlogs { get; }
|
||||
public IAsyncRelayCommand OpenActivities { get; }
|
||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||
|
||||
private async Task OpenActivitiesAsync()
|
||||
{
|
||||
|
|
@ -103,29 +101,6 @@ public class HomePageViewModel : ViewModelBase
|
|||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
||||
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
||||
/// </summary>
|
||||
private async Task OpenMyFilesAsync()
|
||||
{
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null)
|
||||
{
|
||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
}
|
||||
|
||||
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
|
||||
if (fsClient is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||
}
|
||||
|
||||
var vm = new MyFilesViewModel(fsClient);
|
||||
await vm.InitializeAsync();
|
||||
await app.PushPageAsync(vm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia designer constructor. Builds a self-contained VM
|
||||
/// with a freshly-constructed Settings so the XAML preview can
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PostIt.Helpers;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion de l'espace de stockage personnel (« My Files ») :
|
||||
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
|
||||
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
|
||||
/// (fournisseur) ou à une demande (client) par référence : lorsque
|
||||
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
|
||||
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
|
||||
/// puis revient à la page précédente.
|
||||
/// </summary>
|
||||
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
|
||||
{
|
||||
private readonly UserFilesApiClient _fs;
|
||||
private readonly Action<long>? _onFilePicked;
|
||||
|
||||
/// <summary>
|
||||
/// Sous-répertoire courant, relatif à la racine de l'espace perso
|
||||
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial string CurrentPath { get; set; } = string.Empty;
|
||||
|
||||
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
|
||||
? "Mes fichiers"
|
||||
: $"Mes fichiers / {CurrentPath}";
|
||||
|
||||
public bool IsPickerMode => _onFilePicked is not null;
|
||||
|
||||
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusMessage { get; set; } = "Prêt.";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||
|
||||
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||
|
||||
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
|
||||
{
|
||||
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
|
||||
_onFilePicked = onFilePicked;
|
||||
}
|
||||
|
||||
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||
public MyFilesViewModel() : this(null!) { }
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
|
||||
Files.Clear();
|
||||
SubDirectories.Clear();
|
||||
if (dir?.Files is not null)
|
||||
foreach (var f in dir.Files) Files.Add(f);
|
||||
if (dir?.SubDirectories is not null)
|
||||
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
|
||||
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenDirAsync(DirEntryDto? dir)
|
||||
{
|
||||
if (dir is null) return;
|
||||
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
|
||||
OnPropertyChanged(nameof(PathLabel));
|
||||
OnPropertyChanged(nameof(CanGoUp));
|
||||
await RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UpAsync()
|
||||
{
|
||||
if (!CanGoUp) return;
|
||||
CurrentPath = ParentPath(CurrentPath);
|
||||
OnPropertyChanged(nameof(PathLabel));
|
||||
OnPropertyChanged(nameof(CanGoUp));
|
||||
await RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
|
||||
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
|
||||
{
|
||||
if (uploads is null || uploads.Count == 0) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
|
||||
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
|
||||
await RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteFileAsync(FileEntryDto? file)
|
||||
{
|
||||
if (file is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
|
||||
this.SetInfoStatus($"Fichier supprimé.");
|
||||
await RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadFileAsync(FileEntryDto? file)
|
||||
{
|
||||
if (file is null || file.Id is null) return;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
|
||||
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
|
||||
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PickFileAsync(FileEntryDto? file)
|
||||
{
|
||||
if (file is null || file.Id is null)
|
||||
{
|
||||
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
|
||||
return;
|
||||
}
|
||||
|
||||
var callback = _onFilePicked;
|
||||
if (callback is null)
|
||||
{
|
||||
// Mode gestion : le téléchargement est l'action par défaut.
|
||||
await DownloadFileAsync(file).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(file.Id.Value);
|
||||
await GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task BackAsync()
|
||||
{
|
||||
await GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static async Task GoBackAsync()
|
||||
{
|
||||
var app = (App?)Application.Current;
|
||||
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
|
||||
await app.GoBackAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static string CombinePath(string current, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(current)) return name;
|
||||
return $"{current}/{name}";
|
||||
}
|
||||
|
||||
private static string ParentPath(string current)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
|
||||
var idx = current.LastIndexOf('/');
|
||||
return idx < 0 ? string.Empty : current[..idx];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:client="using:Yavsc.Api.Client"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Views.BillingQueryDetailsPage"
|
||||
x:DataType="vm:BillingQueryDetailsPageViewModel"
|
||||
|
|
@ -81,53 +80,6 @@
|
|||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Pièces jointes (par référence à l'espace perso du client) -->
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="8"
|
||||
Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="Pièces jointes"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Ajouter depuis My Files"
|
||||
Command="{Binding AddQueryAttachmentCommand}"
|
||||
IsVisible="{Binding CanAttach}" />
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="client:AttachmentDto">
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#11000000"
|
||||
CornerRadius="6"
|
||||
Padding="8"
|
||||
Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Télécharger"
|
||||
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Grid.Column="3"
|
||||
Content="Détacher"
|
||||
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Expander Header="Metadonnees techniques"
|
||||
IsExpanded="False">
|
||||
<Border BorderThickness="1"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:client="using:Yavsc.Api.Client"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Views.EstimateEditionPage"
|
||||
x:DataType="vm:EstimateEditionPageViewModel"
|
||||
|
|
@ -134,52 +133,6 @@
|
|||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="8"
|
||||
Padding="10"
|
||||
IsVisible="{Binding HasSent}">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="Pièces jointes"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Ajouter depuis My Files"
|
||||
Command="{Binding AddAttachmentCommand}" />
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="client:AttachmentDto">
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#11000000"
|
||||
CornerRadius="6"
|
||||
Padding="8"
|
||||
Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Path}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Télécharger"
|
||||
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Grid.Column="3"
|
||||
Content="Détacher"
|
||||
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,5 @@
|
|||
Command="{Binding OpenProviderEstimates}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
<Button Content="Mes fichiers"
|
||||
Command="{Binding OpenMyFiles}"
|
||||
HorizontalAlignment="Center"
|
||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||
</StackPanel>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PostIt.ViewModels"
|
||||
xmlns:client="using:Yavsc.Api.Client"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Views.MyFilesPage"
|
||||
x:DataType="vm:MyFilesViewModel"
|
||||
Header="Mes fichiers">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||
<StackPanel Grid.Row="0" Spacing="3">
|
||||
<TextBlock Text="{Binding PathLabel}"
|
||||
FontSize="20"
|
||||
FontWeight="Bold" />
|
||||
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
|
||||
Opacity="0.7"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
|
||||
<Button Grid.Column="0"
|
||||
Content="Retour"
|
||||
Command="{Binding BackCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
Content="Remonter"
|
||||
Command="{Binding UpCommand}"
|
||||
IsEnabled="{Binding CanGoUp}" />
|
||||
<Button Grid.Column="4"
|
||||
Content="Téléverser…"
|
||||
Click="Upload_Click"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<StackPanel Spacing="10">
|
||||
<ItemsControl ItemsSource="{Binding SubDirectories}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="client:DirEntryDto">
|
||||
<Button Content="📁 {Binding Name}"
|
||||
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,0,0,4" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="client:FileEntryDto">
|
||||
<Border BorderThickness="1"
|
||||
BorderBrush="#22000000"
|
||||
CornerRadius="6"
|
||||
Padding="8"
|
||||
Margin="0,0,0,6">
|
||||
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding Name}"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="NoWrap" />
|
||||
<Button Grid.Column="1"
|
||||
Content="Joindre"
|
||||
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
|
||||
<Button Grid.Column="3"
|
||||
Content="Télécharger"
|
||||
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Grid.Column="5"
|
||||
Content="Supprimer"
|
||||
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
|
||||
<postitControls:StatusBar Grid.Column="0"
|
||||
DataContext="{Binding ActionStatus}" />
|
||||
<ProgressBar Grid.Column="3"
|
||||
Width="120"
|
||||
IsIndeterminate="True"
|
||||
IsVisible="{Binding IsBusy}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Interactivity;
|
||||
using PostIt.ViewModels;
|
||||
|
||||
namespace PostIt.Views;
|
||||
|
||||
public partial class MyFilesPage : ContentPage
|
||||
{
|
||||
public MyFilesPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void Upload_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null || DataContext is not MyFilesViewModel vm)
|
||||
return;
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
|
||||
AllowMultiple = true,
|
||||
});
|
||||
|
||||
if (files.Count == 0)
|
||||
return;
|
||||
|
||||
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
|
||||
foreach (var file in files)
|
||||
{
|
||||
await using var stream = await file.OpenReadAsync();
|
||||
using var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory);
|
||||
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||
}
|
||||
|
||||
await vm.UploadAsync(uploads);
|
||||
}
|
||||
|
||||
private static string GetMimeType(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -8,17 +8,7 @@
|
|||
|
||||
public DateTime CreationTime { get; set; }
|
||||
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
|
||||
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
|
||||
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
|
||||
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
|
||||
/// encore backfillé). C'est cette valeur que le client lourd
|
||||
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
|
||||
/// </summary>
|
||||
public long? Id { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Représentation d'un répertoire de l'espace de stockage personnel,
|
||||
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
|
||||
/// des helpers serveur : c'est un pur DTO de désérialisation.
|
||||
/// </summary>
|
||||
public sealed class UserDirectoryDto
|
||||
{
|
||||
public string? UserName { get; set; }
|
||||
public string? SubPath { get; set; }
|
||||
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
|
||||
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
|
||||
}
|
||||
|
||||
public sealed class FileEntryDto
|
||||
{
|
||||
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
|
||||
public long? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DirEntryDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
|
||||
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
|
||||
/// </summary>
|
||||
public sealed class AttachmentDto
|
||||
{
|
||||
public long FileId { get; set; }
|
||||
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
|
||||
public string? Path { get; set; }
|
||||
public long Size { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public string? OwnerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
|
||||
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
|
||||
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
|
||||
/// </summary>
|
||||
public sealed class FileReceivedDto
|
||||
{
|
||||
public long? FileId { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public long Length { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public bool QuotaOffense { get; set; }
|
||||
public bool Overridden { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
|
||||
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
|
||||
/// </summary>
|
||||
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);
|
||||
|
|
@ -113,41 +113,6 @@ public sealed class EstimateApiClient
|
|||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
|
||||
/// rattachées au devis <paramref name="id"/> par référence
|
||||
/// (<c>GET estimate/{id}/attachments</c>).
|
||||
/// </summary>
|
||||
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attache un fichier de l'espace perso du fournisseur au devis
|
||||
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
|
||||
/// Réservé au fournisseur côté serveur.
|
||||
/// </summary>
|
||||
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
|
||||
body: new { fileId }, ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détache un fichier du devis <paramref name="id"/>
|
||||
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
|
||||
/// </summary>
|
||||
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
|
||||
}
|
||||
|
||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||
|
||||
private Uri ResolveBusinessBaseAddress()
|
||||
|
|
|
|||
|
|
@ -122,48 +122,6 @@ public sealed class FrontOfficeApiClient
|
|||
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste les pièces jointes (fichiers de l'espace perso du client)
|
||||
/// rattachées à la demande <paramref name="queryId"/> par référence
|
||||
/// (<c>GET front/query/{queryId}/attachments</c>).
|
||||
/// </summary>
|
||||
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(billingCode))
|
||||
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attache un fichier de l'espace perso du client à sa demande
|
||||
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
|
||||
/// </summary>
|
||||
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(billingCode))
|
||||
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détache un fichier de la demande
|
||||
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
|
||||
/// </summary>
|
||||
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(billingCode))
|
||||
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
|
||||
}
|
||||
|
||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||
|
||||
private Uri ResolveBusinessBaseAddress()
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Yavsc.Api.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client HTTP de l'espace de stockage personnel
|
||||
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
|
||||
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
|
||||
/// Bearer, refresh silencieux sur 401) est délégué à
|
||||
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
|
||||
/// DTO↔chemin.
|
||||
///
|
||||
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
|
||||
/// segment de version déjà porté par l'URL de base
|
||||
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
|
||||
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
|
||||
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class UserFilesApiClient
|
||||
{
|
||||
private const string PathPrefix = "fs";
|
||||
|
||||
private readonly IYavscApiClient _api;
|
||||
private readonly Uri _baseAddress;
|
||||
|
||||
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
if (string.IsNullOrEmpty(blogsBaseAddress))
|
||||
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
||||
|
||||
_baseAddress = new Uri(blogsBaseAddress);
|
||||
_api.Http.BaseAddress = _baseAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
|
||||
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
|
||||
/// </summary>
|
||||
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Téléverse des fichiers dans le sous-répertoire
|
||||
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
|
||||
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
|
||||
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
|
||||
/// </summary>
|
||||
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
throw new ArgumentException("At least one file is required.", nameof(files));
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(subdir)
|
||||
? PathPrefix
|
||||
: $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||
|
||||
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supprime un fichier ou répertoire de l'espace perso
|
||||
/// (<c>DELETE fs/{path}</c>).
|
||||
/// </summary>
|
||||
public Task DeleteAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path is required.", nameof(path));
|
||||
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Télécharge une pièce jointe référencée par son identifiant
|
||||
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
|
||||
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
|
||||
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
|
||||
/// devis/demande auquel le fichier est rattaché).
|
||||
/// </summary>
|
||||
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
|
||||
{
|
||||
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
|
||||
/// une part <c>file</c> par fichier (type MIME retombant sur
|
||||
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
|
||||
/// tests backend qui assertent la forme filaire.
|
||||
/// </summary>
|
||||
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileContent = new ByteArrayContent(file.Content);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||
content.Add(fileContent, "file", file.FileName);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the estimate attachment endpoints
|
||||
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
|
||||
/// attach a file from the provider's personal storage to an estimate
|
||||
/// **by reference** (<see cref="EstimateAttachedFile"/>).
|
||||
///
|
||||
/// <para>Coverage mirrors the authorization contract documented on the
|
||||
/// controller: only the provider (<c>estimate.OwnerId</c>) may attach or
|
||||
/// detach, the file must belong to the caller, and listing is open to
|
||||
/// both parties (provider/client) plus admin. A third party is refused.
|
||||
/// The download of the bytes themselves is served by the Blogs host and
|
||||
/// exercised in <c>Yavsc.Blogs.Tests</c>; these tests cover only the
|
||||
/// metadata/relationship endpoints that live on the API host.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private readonly ApiWebServerFixture _fixture;
|
||||
|
||||
public EstimateAttachmentsApiTests(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the graph (seeding alice the provider and bob the client),
|
||||
/// create an estimate owned by alice for bob, and seed two personal
|
||||
/// <see cref="UploadedFile"/> rows: one owned by alice (the provider's
|
||||
/// own file) and one owned by bob (a file that does NOT belong to the
|
||||
/// provider, used to assert the ownership guard on attach).
|
||||
/// </summary>
|
||||
private (long estimateId, long aliceFileId, long bobFileId) Seed()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var estimate = new Estimate
|
||||
{
|
||||
ClientId = "bob",
|
||||
OwnerId = "alice",
|
||||
CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis prestation",
|
||||
Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(),
|
||||
AttachedGraphics = new List<string>(),
|
||||
Bill = new List<CommandLine>
|
||||
{
|
||||
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||
},
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
db.SaveChanges();
|
||||
|
||||
var aliceFile = new UploadedFile
|
||||
{
|
||||
OwnerId = "alice",
|
||||
Path = "provider-doc.pdf",
|
||||
ContentType = "application/pdf",
|
||||
Length = 2048,
|
||||
};
|
||||
var bobFile = new UploadedFile
|
||||
{
|
||||
OwnerId = "bob",
|
||||
Path = "client-note.txt",
|
||||
ContentType = "text/plain",
|
||||
Length = 64,
|
||||
};
|
||||
db.UploadedFiles.AddRange(aliceFile, bobFile);
|
||||
db.SaveChanges();
|
||||
|
||||
return (estimate.Id, aliceFile.Id, bobFile.Id);
|
||||
}
|
||||
|
||||
private int CountAttachments(long estimateId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_attaching_own_file_creates_the_link()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
Assert.Equal(1, CountAttachments(estimateId));
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
|
||||
Assert.Equal(aliceFileId, link.FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attaching_the_same_file_twice_is_idempotent()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("alice");
|
||||
await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
var second = await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
Assert.Equal(1, CountAttachments(estimateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_provider_cannot_attach_to_an_estimate()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
// bob is the client of the estimate, not the provider.
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(estimateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_cannot_attach_a_file_they_do_not_own()
|
||||
{
|
||||
var (estimateId, _, bobFileId) = Seed();
|
||||
|
||||
// alice is the provider, but the file belongs to bob.
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(estimateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_detaches_a_file()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("alice");
|
||||
await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(1, CountAttachments(estimateId));
|
||||
|
||||
var response = await http.DeleteAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(estimateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_provider_cannot_detach()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("alice");
|
||||
await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var bob = NewClient("bob");
|
||||
var response = await bob.DeleteAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(1, CountAttachments(estimateId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_lists_attachments()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("alice");
|
||||
await http.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
|
||||
Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
|
||||
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_can_list_attachments_as_a_party()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var alice = NewClient("alice");
|
||||
await alice.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// bob is the client of the estimate: a party, may list.
|
||||
using var bob = NewClient("bob");
|
||||
var response = await bob.GetAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Third_party_cannot_list_attachments()
|
||||
{
|
||||
var (estimateId, aliceFileId, _) = Seed();
|
||||
|
||||
using var alice = NewClient("alice");
|
||||
await alice.PostAsJsonAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// carol is neither the provider nor the client.
|
||||
using var carol = NewClient("carol");
|
||||
var response = await carol.GetAsync(
|
||||
$"/api/v1/estimate/{estimateId}/attachments",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Listing_an_unknown_estimate_returns_404()
|
||||
{
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
using var http = NewClient("alice");
|
||||
|
||||
var response = await http.GetAsync(
|
||||
"/api/v1/estimate/999999/attachments",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the query (demande) attachment endpoints
|
||||
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
|
||||
/// which attach a file from the client's personal storage to their
|
||||
/// <c>NominativeServiceCommand</c> **by reference**
|
||||
/// (<see cref="QueryAttachedFile"/>).
|
||||
///
|
||||
/// <para>Coverage mirrors the controller contract: only the client
|
||||
/// (the demande's <c>ClientId</c>) may attach or detach, the file must
|
||||
/// belong to the caller, and listing is open to both parties
|
||||
/// (client/provider) plus admin. A third party is refused.</para>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private readonly ApiWebServerFixture _fixture;
|
||||
|
||||
public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "bob", 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed an Rdv query from bob (client) to alice (provider) and two
|
||||
/// personal <see cref="UploadedFile"/> rows: one owned by bob (the
|
||||
/// client's own file) and one owned by alice (a file that does NOT
|
||||
/// belong to the client, used to assert the ownership guard).
|
||||
/// </summary>
|
||||
private (long queryId, long bobFileId, long aliceFileId) Seed()
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
_fixture.ResetAndSeedActivityGraph();
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
|
||||
|
||||
var query = new RdvQuery
|
||||
{
|
||||
ActivityCode = "dev",
|
||||
ClientId = "bob",
|
||||
PerformerId = "alice",
|
||||
Consent = true,
|
||||
UserCreated = "bob",
|
||||
UserModified = "bob",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow,
|
||||
EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = location,
|
||||
Reason = "Demande de devis",
|
||||
Status = QueryStatus.InProgress,
|
||||
Description = "Demande en attente",
|
||||
};
|
||||
db.RdvQueries.Add(query);
|
||||
db.SaveChanges();
|
||||
|
||||
var bobFile = new UploadedFile
|
||||
{
|
||||
OwnerId = "bob",
|
||||
Path = "demande-photo.jpg",
|
||||
ContentType = "image/jpeg",
|
||||
Length = 4096,
|
||||
};
|
||||
var aliceFile = new UploadedFile
|
||||
{
|
||||
OwnerId = "alice",
|
||||
Path = "provider-spec.pdf",
|
||||
ContentType = "application/pdf",
|
||||
Length = 1024,
|
||||
};
|
||||
db.UploadedFiles.AddRange(bobFile, aliceFile);
|
||||
db.SaveChanges();
|
||||
|
||||
return (query.Id, bobFile.Id, aliceFile.Id);
|
||||
}
|
||||
|
||||
private int CountAttachments(long queryId)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
|
||||
}
|
||||
|
||||
private string AttachUrl(long queryId) =>
|
||||
$"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
|
||||
|
||||
private string DetachUrl(long queryId, long fileId) =>
|
||||
$"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
|
||||
|
||||
[Fact]
|
||||
public async Task Client_attaching_own_file_creates_the_link()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
Assert.Equal(1, CountAttachments(queryId));
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
|
||||
Assert.Equal(bobFileId, link.FileId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_cannot_attach_to_a_query()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
// alice is the provider of the query; only the client attaches.
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(queryId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_cannot_attach_a_file_they_do_not_own()
|
||||
{
|
||||
var (queryId, _, aliceFileId) = Seed();
|
||||
|
||||
// bob is the client, but the file belongs to alice.
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = aliceFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(queryId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_detaches_a_file()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
using var http = NewClient("bob");
|
||||
await http.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(1, CountAttachments(queryId));
|
||||
|
||||
var response = await http.DeleteAsync(
|
||||
DetachUrl(queryId, bobFileId),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(0, CountAttachments(queryId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_cannot_detach()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
using var bob = NewClient("bob");
|
||||
await bob.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var alice = NewClient("alice");
|
||||
var response = await alice.DeleteAsync(
|
||||
DetachUrl(queryId, bobFileId),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(1, CountAttachments(queryId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provider_lists_attachments_as_a_party()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
using var bob = NewClient("bob");
|
||||
await bob.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// alice is the provider: a party, may list.
|
||||
using var alice = NewClient("alice");
|
||||
var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
|
||||
Assert.Equal("bob", item.GetProperty("ownerId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Third_party_cannot_list_attachments()
|
||||
{
|
||||
var (queryId, bobFileId, _) = Seed();
|
||||
|
||||
using var bob = NewClient("bob");
|
||||
await bob.PostAsJsonAsync(
|
||||
AttachUrl(queryId),
|
||||
new { fileId = bobFileId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var carol = NewClient("carol");
|
||||
var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore;
|
|||
using Newtonsoft.Json;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
|
||||
namespace Yavsc.Controllers
|
||||
|
|
@ -238,96 +237,6 @@ namespace Yavsc.Controllers
|
|||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// GET: api/v1/estimate/{id}/attachments
|
||||
//
|
||||
// Liste les fichiers de l'espace perso du fournisseur rattachés au
|
||||
// devis, par référence (UploadedFile). Lecture ouverte aux deux
|
||||
// parties (fournisseur/client) du devis et aux admin, comme
|
||||
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
|
||||
// les pièces (le téléchargement des octets reste servi par le host
|
||||
// Blogs, qui autorise via cette même table de jointure).
|
||||
[HttpGet("{id}/attachments")]
|
||||
public IActionResult GetAttachments(long id)
|
||||
{
|
||||
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||
if (estimate == null) return NotFound();
|
||||
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var attached = _context.EstimateAttachedFiles
|
||||
.Where(a => a.EstimateId == id)
|
||||
.Select(a => new
|
||||
{
|
||||
fileId = a.FileId,
|
||||
path = a.File.Path,
|
||||
size = a.File.Length,
|
||||
contentType = a.File.ContentType,
|
||||
ownerId = a.File.OwnerId
|
||||
})
|
||||
.ToList();
|
||||
return Ok(attached);
|
||||
}
|
||||
|
||||
// POST: api/v1/estimate/{id}/attachments { fileId }
|
||||
//
|
||||
// Attache un fichier de l'espace perso du fournisseur au devis, par
|
||||
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
|
||||
// seul le fournisseur constitue le devis et y joint ses documents
|
||||
// (dont les documents à faire signer par le client, per README).
|
||||
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
|
||||
[HttpPost("{id}/attachments")]
|
||||
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
|
||||
{
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||
if (estimate == null) return NotFound();
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
if (body == null || body.FileId <= 0)
|
||||
return BadRequest(new { error = "fileId required" });
|
||||
|
||||
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
|
||||
if (file == null) return BadRequest(new { error = "file not found" });
|
||||
if (file.OwnerId != uid)
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var existing = _context.EstimateAttachedFiles
|
||||
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
|
||||
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
|
||||
|
||||
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||
{
|
||||
EstimateId = id,
|
||||
FileId = body.FileId
|
||||
});
|
||||
_context.SaveChanges(User.GetUserId());
|
||||
return Ok(new { estimateId = id, fileId = body.FileId });
|
||||
}
|
||||
|
||||
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
|
||||
//
|
||||
// Détache un fichier du devis. Réservé au fournisseur.
|
||||
[HttpDelete("{id}/attachments/{fileId}")]
|
||||
public IActionResult DetachFile(long id, long fileId)
|
||||
{
|
||||
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||
if (estimate == null) return NotFound();
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var link = _context.EstimateAttachedFiles
|
||||
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
|
||||
if (link == null) return NotFound();
|
||||
_context.EstimateAttachedFiles.Remove(link);
|
||||
_context.SaveChanges(User.GetUserId());
|
||||
return Ok(new { estimateId = id, fileId });
|
||||
}
|
||||
|
||||
private bool EstimateExists(long id)
|
||||
{
|
||||
return _context.Estimates.Count(e => e.Id == id) > 0;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ using Yavsc.Abstract.Workflow;
|
|||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.ViewModels.FrontOffice;
|
||||
|
|
@ -367,95 +366,6 @@ namespace Yavsc.ApiControllers
|
|||
|
||||
private static bool HasSignature(QueryAcceptanceRequest? body)
|
||||
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
||||
|
||||
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
|
||||
//
|
||||
// Liste les fichiers de l'espace perso du client rattachés à la
|
||||
// demande, par référence (UploadedFile). Lecture ouverte aux deux
|
||||
// parties (client/fournisseur) de la demande et aux admin ; le
|
||||
// téléchargement des octets est servi par le host Blogs.
|
||||
[HttpGet("query/{queryId}/attachments")]
|
||||
public async Task<IActionResult> GetQueryAttachments(
|
||||
string billingCode, long queryId, CancellationToken token)
|
||||
{
|
||||
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||
if (error is not null) return error;
|
||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
var attached = await dbContext.QueryAttachedFiles
|
||||
.Where(a => a.CommandId == queryId)
|
||||
.Select(a => new
|
||||
{
|
||||
fileId = a.FileId,
|
||||
path = a.File.Path,
|
||||
size = a.File.Length,
|
||||
contentType = a.File.ContentType,
|
||||
ownerId = a.File.OwnerId
|
||||
})
|
||||
.ToListAsync(token);
|
||||
return Ok(attached);
|
||||
}
|
||||
|
||||
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
|
||||
//
|
||||
// Attache un fichier de l'espace perso du client à sa demande, par
|
||||
// référence. Réservé au client (rôle Client) : c'est lui qui
|
||||
// documente sa demande. Le fichier doit lui appartenir.
|
||||
[HttpPost("query/{queryId}/attachments")]
|
||||
public async Task<IActionResult> AttachQueryFile(
|
||||
string billingCode, long queryId, [FromBody] AttachFileRequest? body, CancellationToken token)
|
||||
{
|
||||
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||
if (error is not null) return error;
|
||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
if (body == null || body.FileId <= 0)
|
||||
return BadRequest(new { error = "fileId required" });
|
||||
|
||||
var uid = User.GetUserId();
|
||||
var file = await dbContext.UploadedFiles
|
||||
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
|
||||
if (file == null) return BadRequest(new { error = "file not found" });
|
||||
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var existing = await dbContext.QueryAttachedFiles
|
||||
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
|
||||
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
|
||||
|
||||
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
|
||||
{
|
||||
CommandId = queryId,
|
||||
FileId = body.FileId
|
||||
});
|
||||
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||
return Ok(new { queryId, fileId = body.FileId });
|
||||
}
|
||||
|
||||
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
|
||||
//
|
||||
// Détache un fichier de la demande. Réservé au client.
|
||||
[HttpDelete("query/{queryId}/attachments/{fileId}")]
|
||||
public async Task<IActionResult> DetachQueryFile(
|
||||
string billingCode, long queryId, long fileId, CancellationToken token)
|
||||
{
|
||||
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||
if (error is not null) return error;
|
||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||
|
||||
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
|
||||
var link = await dbContext.QueryAttachedFiles
|
||||
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
|
||||
if (link == null) return NotFound();
|
||||
dbContext.QueryAttachedFiles.Remove(link);
|
||||
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||
return Ok(new { queryId, fileId });
|
||||
}
|
||||
}
|
||||
|
||||
internal enum ActorRole { None, Provider, Client, Admin }
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Blogs.Tests.Fixtures;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Relationship;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the personal-storage file endpoints
|
||||
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
|
||||
/// Blogs host.
|
||||
///
|
||||
/// <para>Two concerns:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description><b>Upload records metadata</b>: a multipart
|
||||
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
|
||||
/// owned by the caller and returns its <c>fileId</c>, so the client
|
||||
/// can later attach that file to an estimate/query by
|
||||
/// reference.</description></item>
|
||||
/// <item><description><b>Download authorization</b>:
|
||||
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
|
||||
/// owner's personal space. The owner may always read; a party
|
||||
/// (client or provider) of an estimate/query the file is attached
|
||||
/// to may read; a third party is refused. The bytes live on the
|
||||
/// Blogs host's disk — this is the cross-container reason the
|
||||
/// download endpoint lives here and not on the API host.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Collection("Yavsc Blogs")]
|
||||
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
||||
public FileSystemApiTests(BlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "alice")
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
|
||||
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
|
||||
|
||||
/// <summary>Write a physical file into the owner's personal space,
|
||||
/// so <c>GetFile</c> can stream it back. Creates the directory if
|
||||
/// needed.</summary>
|
||||
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
|
||||
{
|
||||
var path = PhysicalFile(fixture, owner, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, content);
|
||||
}
|
||||
|
||||
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var file = new UploadedFile
|
||||
{
|
||||
OwnerId = ownerId,
|
||||
Path = path,
|
||||
ContentType = contentType,
|
||||
Length = length,
|
||||
};
|
||||
db.UploadedFiles.Add(file);
|
||||
db.SaveChanges();
|
||||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
|
||||
// once usage >= DiskQuota, and the controller skips the metadata
|
||||
// row when QuotaOffense is set. A fresh user has quota 0.
|
||||
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
|
||||
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(payload);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
form.Add(fileContent, "file", "note.txt");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var item = Assert.Single(doc.RootElement.EnumerateArray());
|
||||
var fileId = item.GetProperty("fileId").GetInt64();
|
||||
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
|
||||
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
|
||||
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
|
||||
Assert.Equal("note.txt", row.Path);
|
||||
Assert.Equal("text/plain", row.ContentType);
|
||||
Assert.Equal(payload.Length, row.Length);
|
||||
|
||||
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
|
||||
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
|
||||
"Expected the uploaded file on disk under the owner's personal root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_owner_can_download_their_own_file()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
|
||||
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
|
||||
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(bytes, downloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_refuses_a_third_party()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("carol");
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
|
||||
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
|
||||
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
|
||||
|
||||
// carol is neither the owner nor a party to any estimate/query
|
||||
// this file is attached to.
|
||||
using var http = NewClient("carol");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the graph needed to prove a party (the client of an
|
||||
/// estimate the file is attached to) can download a file they do
|
||||
/// not own: alice owns the file and is the provider; bob is the
|
||||
/// client; the file is attached to the estimate by reference.
|
||||
/// </summary>
|
||||
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
_fixture.SeedUser("bob");
|
||||
|
||||
fileName = "party-spec.pdf";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
|
||||
// to PerformerProfile.PerformerId, so the provider profile must
|
||||
// exist before the estimate can be inserted.
|
||||
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
|
||||
db.Locations.Add(location);
|
||||
db.SaveChanges();
|
||||
|
||||
db.Performers.Add(new PerformerProfile
|
||||
{
|
||||
PerformerId = "alice",
|
||||
SIREN = "12345678901234",
|
||||
OrganizationAddressId = location.Id,
|
||||
ExerciseCountryCode = "fr",
|
||||
AcceptNotifications = true,
|
||||
AcceptPublicContact = true,
|
||||
Active = true,
|
||||
Rate = 5,
|
||||
WebSite = "https://alice.dev",
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
var estimate = new Estimate
|
||||
{
|
||||
OwnerId = "alice",
|
||||
ClientId = "bob",
|
||||
CommandType = "Rdv",
|
||||
Title = "Devis party test",
|
||||
Description = "Devis",
|
||||
AttachedFiles = new List<string>(),
|
||||
AttachedGraphics = new List<string>(),
|
||||
Bill = new List<CommandLine>
|
||||
{
|
||||
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
|
||||
},
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
db.SaveChanges();
|
||||
estimateId = estimate.Id;
|
||||
|
||||
var file = new UploadedFile
|
||||
{
|
||||
OwnerId = "alice",
|
||||
Path = fileName,
|
||||
ContentType = "application/pdf",
|
||||
Length = bytes.Length,
|
||||
};
|
||||
db.UploadedFiles.Add(file);
|
||||
db.SaveChanges();
|
||||
|
||||
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||
{
|
||||
FileId = file.Id,
|
||||
EstimateId = estimate.Id,
|
||||
});
|
||||
db.SaveChanges();
|
||||
|
||||
// The bytes must exist on disk for GetFile to stream them.
|
||||
WritePhysicalFile(_fixture, "alice", fileName, bytes);
|
||||
return file.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
|
||||
{
|
||||
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||
|
||||
// bob is the client of the estimate the file is attached to:
|
||||
// a party, authorized to read even though alice owns the file.
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK,
|
||||
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
|
||||
{
|
||||
var fileId = SeedPartyDownloadGraph(out _, out _);
|
||||
|
||||
// alice is the owner too, so this also exercises the owner path;
|
||||
// the provider of an attached estimate is authorized regardless.
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFile_unknown_id_returns_404()
|
||||
{
|
||||
_fixture.ResetDatabase();
|
||||
_fixture.SeedUser("alice");
|
||||
|
||||
using var http = NewClient("alice");
|
||||
var response = await http.GetAsync(
|
||||
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,14 +8,10 @@ namespace Yavsc.Blogs.Controllers
|
|||
{
|
||||
using Yavsc.Attributes.Validation;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Exceptions;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Abstract.Helpers;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
using Yavsc.ViewModels.UserFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
[Authorize,Route(APIPrefix + "/fs")]
|
||||
|
|
@ -38,55 +34,21 @@ namespace Yavsc.Blogs.Controllers
|
|||
this.siteSettings = siteSettings.Value;
|
||||
}
|
||||
|
||||
// GET: api/v1/fs
|
||||
[HttpGet()]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return GetDir(null);
|
||||
}
|
||||
|
||||
// GET: api/v1/fs/{subdir}
|
||||
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
|
||||
// en enrichissant chaque entrée de l'identifiant de métadonnées
|
||||
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
|
||||
[HttpGet("{*subdir}")]
|
||||
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
|
||||
{
|
||||
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
|
||||
var uid = User.GetUserId();
|
||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
|
||||
EnrichWithFileIds(files, uid, subdir);
|
||||
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
|
||||
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
|
||||
// retournée par la liste, pour que le client lourd puisse attacher
|
||||
// le fichier à un devis/demande par référence.
|
||||
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
|
||||
{
|
||||
if (dir?.Files == null || dir.Files.Length == 0) return;
|
||||
var relPaths = dir.Files
|
||||
.Select(f => RelPath(subdir, f.Name))
|
||||
.ToHashSet();
|
||||
var byPath = dbContext.UploadedFiles
|
||||
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
|
||||
.ToDictionary(u => u.Path, u => u.Id);
|
||||
foreach (var f in dir.Files)
|
||||
{
|
||||
var rel = RelPath(subdir, f.Name);
|
||||
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Chemin relatif au racine de l'espace perso de l'utilisateur,
|
||||
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
|
||||
static string RelPath(string subdir, string fileName)
|
||||
{
|
||||
// Normalize with '/' for cross-platform consistency in DB.
|
||||
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
|
||||
return $"{subdir.TrimStart('/')}/{fileName}";
|
||||
}
|
||||
|
||||
[HttpPost("{*subdir}")]
|
||||
public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
|
||||
{
|
||||
|
|
@ -116,38 +78,7 @@ namespace Yavsc.Blogs.Controllers
|
|||
foreach (var f in Request.Form.Files)
|
||||
{
|
||||
var item = user.ReceiveUserFile(destDir, f);
|
||||
if (!item.QuotaOffense)
|
||||
{
|
||||
// Persiste une ligne de métadonnées UploadedFile,
|
||||
// propriétaire = l'utilisateur courant, chemin relatif
|
||||
// à son espace perso. Upsert sur (OwnerId, Path) pour
|
||||
// le re-téléversement (même fichier écrasé sur disque).
|
||||
var rel = RelPath(subdir, item.FileName);
|
||||
var existing = dbContext.UploadedFiles
|
||||
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
|
||||
if (existing == null)
|
||||
{
|
||||
var uploaded = new UploadedFile
|
||||
{
|
||||
OwnerId = uid,
|
||||
Path = rel,
|
||||
ContentType = f.ContentType,
|
||||
Length = f.Length
|
||||
};
|
||||
dbContext.UploadedFiles.Add(uploaded);
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
item.FileId = uploaded.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ContentType = f.ContentType;
|
||||
existing.Length = f.Length;
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
item.FileId = existing.Id;
|
||||
}
|
||||
item.Length = f.Length;
|
||||
item.ContentType = f.ContentType;
|
||||
}
|
||||
dbContext.SaveChanges(User.GetUserId());
|
||||
received.Add(item);
|
||||
_logger.LogInformation($"Received '{item.FileName}'.");
|
||||
if (item.QuotaOffense)
|
||||
|
|
@ -157,62 +88,6 @@ namespace Yavsc.Blogs.Controllers
|
|||
return Ok(received);
|
||||
}
|
||||
|
||||
// GET: api/v1/fs/file/{fileId}
|
||||
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
|
||||
// Autorise : le propriétaire du fichier, ou toute partie (client /
|
||||
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
|
||||
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
|
||||
// sont lus depuis l'espace perso du propriétaire, sur le disque de
|
||||
// ce host Blogs (le host API ne possède pas ces octets).
|
||||
[HttpGet("file/{fileId:long}")]
|
||||
public IActionResult GetFile(long fileId)
|
||||
{
|
||||
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
|
||||
if (file == null) return NotFound(new { error = "file not found" });
|
||||
|
||||
var uid = User.GetUserId();
|
||||
if (!CanReadFile(file, uid))
|
||||
return Forbid();
|
||||
|
||||
var ownerUserName = dbContext.Users
|
||||
.Where(u => u.Id == file.OwnerId)
|
||||
.Select(u => u.UserName)
|
||||
.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(ownerUserName))
|
||||
return NotFound(new { error = "owner not found" });
|
||||
|
||||
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
|
||||
var fi = new FileInfo(physicalPath);
|
||||
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
|
||||
? "application/octet-stream" : file.ContentType;
|
||||
var stream = fi.OpenRead();
|
||||
return File(stream, contentType, fi.Name);
|
||||
}
|
||||
|
||||
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
|
||||
// que le fichier soit rattaché à un devis ou une demande dont
|
||||
// l'appelant est partie (client ou fournisseur). Les admin sont
|
||||
// autorisés comme pour le reste du workflow.
|
||||
bool CanReadFile(UploadedFile file, string uid)
|
||||
{
|
||||
if (User.IsInRole(Constants.AdminGroupName)) return true;
|
||||
if (file.OwnerId == uid) return true;
|
||||
|
||||
if (dbContext.EstimateAttachedFiles.Any(a =>
|
||||
a.FileId == file.Id &&
|
||||
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
|
||||
return true;
|
||||
|
||||
if (dbContext.QueryAttachedFiles.Any(a =>
|
||||
a.FileId == file.Id &&
|
||||
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Route("/api/fsc/addquota/{uname}/{len}")]
|
||||
[Authorize("AdministratorOnly")]
|
||||
public IActionResult AddQuota(string uname, int len)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models;
|
||||
|
|
@ -20,7 +19,7 @@ namespace Yavsc.Controllers
|
|||
|
||||
public CommentsController(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet("{id:long}", Name = "GetComment")]
|
||||
|
|
@ -83,17 +82,8 @@ namespace Yavsc.Controllers
|
|||
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
|
||||
}
|
||||
|
||||
// --- Actions MVC scaffoldées (vues CRUD) ---------------------
|
||||
// Ces actions ne font pas partie de la surface d'API documentée
|
||||
// par Swagger. Elles sont exclues de l'ApiExplorer : sinon, comme
|
||||
// le contrôleur porte [Route("~/api/v1/blogcomments")], leurs
|
||||
// [HttpPost] sans template propre atterrissent sur la même route
|
||||
// "POST api/v1/blogcomments" que l'action Post ci-dessus, et
|
||||
// Swashbuckle lève une SwaggerGeneratorException (conflicting
|
||||
// method/path) au démarrage de /swagger.
|
||||
// GET: Comments
|
||||
[HttpGet]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var applicationDbContext = _context.Comment.Include(c => c.Post);
|
||||
|
|
@ -101,7 +91,6 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
|
||||
// GET: Comments/Details/5
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Details(long? id)
|
||||
{
|
||||
if (id == null)
|
||||
|
|
@ -120,7 +109,6 @@ namespace Yavsc.Controllers
|
|||
|
||||
// GET: Comments/Create (MVC form endpoint)
|
||||
[HttpGet("form")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public IActionResult Create()
|
||||
{
|
||||
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
|
||||
|
|
@ -130,7 +118,6 @@ namespace Yavsc.Controllers
|
|||
// POST: Comments/Create (MVC form endpoint)
|
||||
[HttpPost("form")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Create(Comment comment)
|
||||
{
|
||||
comment.UserCreated = User.GetUserId();
|
||||
|
|
@ -139,7 +126,7 @@ namespace Yavsc.Controllers
|
|||
// remove the stale binding error so a valid authenticated POST
|
||||
// does not fall into the invalid branch.
|
||||
ModelState.Remove(nameof(Comment.AuthorId));
|
||||
|
||||
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
_context.Comment.Add(comment);
|
||||
|
|
@ -151,7 +138,6 @@ namespace Yavsc.Controllers
|
|||
}
|
||||
|
||||
// GET: Comments/Edit/5
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Edit(long? id)
|
||||
{
|
||||
if (id == null)
|
||||
|
|
@ -171,7 +157,6 @@ namespace Yavsc.Controllers
|
|||
// POST: Comments/Edit/5
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Edit(Comment comment)
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
|
|
@ -186,7 +171,6 @@ namespace Yavsc.Controllers
|
|||
|
||||
// GET: Comments/Delete/5
|
||||
[ActionName("Delete")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Delete(long? id)
|
||||
{
|
||||
if (id == null)
|
||||
|
|
@ -206,7 +190,6 @@ namespace Yavsc.Controllers
|
|||
// POST: Comments/Delete/5
|
||||
[HttpPost, ActionName("Delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> DeleteConfirmed(long id)
|
||||
{
|
||||
Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,148 +0,0 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Yavsc.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UserFileAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Path",
|
||||
table: "UploadedFiles",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ContentType",
|
||||
table: "UploadedFiles",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OwnerId",
|
||||
table: "UploadedFiles",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EstimateAttachedFiles",
|
||||
columns: table => new
|
||||
{
|
||||
FileId = table.Column<long>(type: "bigint", nullable: false),
|
||||
EstimateId = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EstimateAttachedFiles", x => new { x.FileId, x.EstimateId });
|
||||
table.ForeignKey(
|
||||
name: "FK_EstimateAttachedFiles_Estimates_EstimateId",
|
||||
column: x => x.EstimateId,
|
||||
principalTable: "Estimates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_EstimateAttachedFiles_UploadedFiles_FileId",
|
||||
column: x => x.FileId,
|
||||
principalTable: "UploadedFiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QueryAttachedFiles",
|
||||
columns: table => new
|
||||
{
|
||||
FileId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CommandId = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QueryAttachedFiles", x => new { x.FileId, x.CommandId });
|
||||
table.ForeignKey(
|
||||
name: "FK_QueryAttachedFiles_NominativeServiceCommands_CommandId",
|
||||
column: x => x.CommandId,
|
||||
principalTable: "NominativeServiceCommands",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_QueryAttachedFiles_UploadedFiles_FileId",
|
||||
column: x => x.FileId,
|
||||
principalTable: "UploadedFiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UploadedFiles_OwnerId",
|
||||
table: "UploadedFiles",
|
||||
column: "OwnerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EstimateAttachedFiles_EstimateId",
|
||||
table: "EstimateAttachedFiles",
|
||||
column: "EstimateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QueryAttachedFiles_CommandId",
|
||||
table: "QueryAttachedFiles",
|
||||
column: "CommandId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
|
||||
table: "UploadedFiles",
|
||||
column: "OwnerId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
|
||||
table: "UploadedFiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EstimateAttachedFiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "QueryAttachedFiles");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_UploadedFiles_OwnerId",
|
||||
table: "UploadedFiles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnerId",
|
||||
table: "UploadedFiles");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Path",
|
||||
table: "UploadedFiles",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "ContentType",
|
||||
table: "UploadedFiles",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1394,21 +1394,6 @@ namespace Yavsc.Migrations
|
|||
b.ToTable("Estimates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
|
||||
{
|
||||
b.Property<long>("FileId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("EstimateId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("FileId", "EstimateId");
|
||||
|
||||
b.HasIndex("EstimateId");
|
||||
|
||||
b.ToTable("EstimateAttachedFiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
|
@ -1517,21 +1502,6 @@ namespace Yavsc.Migrations
|
|||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
|
||||
{
|
||||
b.Property<long>("FileId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CommandId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("FileId", "CommandId");
|
||||
|
||||
b.HasIndex("CommandId");
|
||||
|
||||
b.ToTable("QueryAttachedFiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
|
@ -1704,23 +1674,16 @@ namespace Yavsc.Migrations
|
|||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Length")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("OwnerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("UploadedFiles");
|
||||
});
|
||||
|
||||
|
|
@ -3864,25 +3827,6 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("Query");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
|
||||
.WithMany()
|
||||
.HasForeignKey("EstimateId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Estimate");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
|
||||
|
|
@ -3916,25 +3860,6 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("Regularization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
|
||||
.WithMany()
|
||||
.HasForeignKey("CommandId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
|
||||
.WithMany()
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Command");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
|
||||
|
|
@ -4027,15 +3952,6 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("Post");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.ApplicationUser", "Owner")
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
||||
|
|
|
|||
|
|
@ -509,9 +509,5 @@ namespace Yavsc.Models
|
|||
public DbSet<ModerationLog> ModerationLogs { get; set; }
|
||||
|
||||
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
|
||||
|
||||
public DbSet<EstimateAttachedFile> EstimateAttachedFiles { get; set; }
|
||||
|
||||
public DbSet<QueryAttachedFile> QueryAttachedFiles { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
namespace Yavsc.Models.Billing
|
||||
{
|
||||
/// <summary>
|
||||
/// Corps de requête pour attacher un fichier de l'espace perso à un
|
||||
/// devis (fournisseur) ou à une demande (client), par référence à son
|
||||
/// identifiant <see cref="Yavsc.Models.Blog.UploadedFile"/>.
|
||||
/// </summary>
|
||||
public class AttachFileRequest
|
||||
{
|
||||
/// <summary>Identifiant du fichier référencé.</summary>
|
||||
public long FileId { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models.Blog;
|
||||
|
||||
namespace Yavsc.Models.Billing
|
||||
{
|
||||
/// <summary>
|
||||
/// Pièce jointe d'un devis, attachée **par référence** à un fichier
|
||||
/// de l'espace de stockage personnel du fournisseur
|
||||
/// (<see cref="UploadedFile"/>). Le fichier reste dans l'espace perso
|
||||
/// du fournisseur (pas de duplication) ; seul le lien est persisté.
|
||||
/// Le téléchargement est servi par le host Blogs, qui autorise
|
||||
/// l'appelant (fournisseur ou client du devis) via cette table.
|
||||
/// </summary>
|
||||
[PrimaryKey(nameof(FileId), nameof(EstimateId))]
|
||||
public class EstimateAttachedFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifiant du fichier référencé (partie de la clé composite).
|
||||
/// </summary>
|
||||
public long FileId { get; set; }
|
||||
|
||||
[ForeignKey("FileId")]
|
||||
public virtual UploadedFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant du devis auquel le fichier est rattaché
|
||||
/// (partie de la clé composite).
|
||||
/// </summary>
|
||||
public long EstimateId { get; set; }
|
||||
|
||||
[ForeignKey("EstimateId")]
|
||||
public virtual Estimate Estimate { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Models.Blog;
|
||||
|
||||
namespace Yavsc.Models.Billing
|
||||
{
|
||||
/// <summary>
|
||||
/// Pièce jointe d'une demande (NominativeServiceCommand), attachée
|
||||
/// **par référence** à un fichier de l'espace de stockage personnel
|
||||
/// du client (<see cref="UploadedFile"/>). Le fichier reste dans
|
||||
/// l'espace perso du client (pas de duplication) ; seul le lien est
|
||||
/// persisté. Le téléchargement est servi par le host Blogs, qui
|
||||
/// autorise l'appelant (client ou fournisseur de la demande) via
|
||||
/// cette table.
|
||||
/// </summary>
|
||||
[PrimaryKey(nameof(FileId), nameof(CommandId))]
|
||||
public class QueryAttachedFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifiant du fichier référencé (partie de la clé composite).
|
||||
/// </summary>
|
||||
public long FileId { get; set; }
|
||||
|
||||
[ForeignKey("FileId")]
|
||||
public virtual UploadedFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de la demande à laquelle le fichier est rattaché
|
||||
/// (partie de la clé composite).
|
||||
/// </summary>
|
||||
public long CommandId { get; set; }
|
||||
|
||||
[ForeignKey("CommandId")]
|
||||
public virtual NominativeServiceCommand Command { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
#nullable enable annotations
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -34,19 +33,6 @@ namespace Yavsc.Models.Blog
|
|||
/// </summary>
|
||||
/// <value></value>
|
||||
public string ContentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Propriétaire du fichier dans l'espace de stockage personnel.
|
||||
/// Renseigné pour les fichiers téléversés via <c>api/v1/fs</c>
|
||||
/// (référencés par <see cref="EstimateAttachedFile"/> /
|
||||
/// <see cref="QueryAttachedFile"/>). Reste <c>null</c> pour les
|
||||
/// pièces jointes de blog historiques (<see cref="BlogAttachedFile"/>),
|
||||
/// qui ne sont pas adressées par propriétaire.
|
||||
/// </summary>
|
||||
public string? OwnerId { get; set; }
|
||||
|
||||
[ForeignKey("OwnerId")]
|
||||
public virtual ApplicationUser? Owner { get; set; }
|
||||
}
|
||||
|
||||
[PrimaryKey(nameof(FileId), nameof(PostId))]
|
||||
|
|
|
|||
|
|
@ -43,29 +43,11 @@ namespace Yavsc.Server.Models.FileSystem
|
|||
}
|
||||
|
||||
public string DestDir { get; set; }
|
||||
|
||||
|
||||
public string FileName { get; set; }
|
||||
public bool Overridden { get; set; }
|
||||
|
||||
public bool QuotaOffense { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant de la ligne <see cref="Yavsc.Models.Blog.UploadedFile"/>
|
||||
/// créée à l'enregistrement du fichier dans l'espace perso
|
||||
/// (<c>api/v1/fs</c>). Nul si aucune métadonnée n'a été persistée.
|
||||
/// </summary>
|
||||
public long? FileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Taille du fichier reçu (octets), pour le reflet côté métadonnées.
|
||||
/// </summary>
|
||||
public long Length { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type MIME du fichier reçu.
|
||||
/// </summary>
|
||||
public string ContentType { get; set; }
|
||||
|
||||
public string FullName { get => Path.Combine(DestDir, FileName); }
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue