Compare commits
140 changed files with 1977 additions and 13987 deletions
|
|
@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=db
|
POSTGRES_DB=db
|
||||||
POSTGRES_USER=pguser
|
POSTGRES_USER=pguser
|
||||||
POSTGRES_PASSWORD=<your pg password>
|
POSTGRES_PASSWORD=pgpassword
|
||||||
|
|
||||||
ANTHROPIC_API_KEY=none
|
ANTHROPIC_API_KEY=none
|
||||||
ANTHROPIC_MAX_TOKENS=0
|
ANTHROPIC_MAX_TOKENS=0
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
cd /src
|
cd /src
|
||||||
git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||||
cd _src
|
cd _src
|
||||||
if [ -n "${GITHUB_REF:-}" ]; then
|
if [ -n "${GITHUB_REF:-}" ]; then
|
||||||
git fetch origin "$GITHUB_REF"
|
git fetch origin "$GITHUB_REF"
|
||||||
|
|
@ -53,6 +53,5 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Lancement des tests..."
|
echo "🚀 Lancement des tests..."
|
||||||
cd /src/_src && dotnet test \
|
cd /src/_src && dotnet test \
|
||||||
--report-xunit \
|
--verbosity normal \
|
||||||
--report-xunit-html \
|
--filter="Category!=Platform-Android"
|
||||||
--report-xunit-html-filename "test-results.html"
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ jobs:
|
||||||
cd /src
|
cd /src
|
||||||
|
|
||||||
if [[ ! -d _src/.git ]]; then
|
if [[ ! -d _src/.git ]]; then
|
||||||
git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
|
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd _src
|
cd _src
|
||||||
|
|
|
||||||
20
.vscode/tasks.json
vendored
20
.vscode/tasks.json
vendored
|
|
@ -1,13 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
|
"problemMatcher": [
|
||||||
|
{
|
||||||
|
"owner": "dotnet",
|
||||||
|
"fileLocation": ["relative", "${workspaceFolder}"],
|
||||||
|
"source": "dotnet",
|
||||||
|
"pattern": {
|
||||||
|
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
|
||||||
|
"file": 1,
|
||||||
|
"line": 2,
|
||||||
|
"column": 3,
|
||||||
|
"severity": 4,
|
||||||
|
"code": 5,
|
||||||
|
"message": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "run-debug-android",
|
"label": "run-debug-android",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"problemMatcher": ["$msCompile"],
|
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
|
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
|
||||||
"env": {
|
"env": {
|
||||||
|
|
@ -29,7 +43,6 @@
|
||||||
"label": "build",
|
"label": "build",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
"problemMatcher": ["$msCompile"],
|
|
||||||
"args": ["build", "/property:GenerateFullPaths=true"],
|
"args": ["build", "/property:GenerateFullPaths=true"],
|
||||||
"group": "build",
|
"group": "build",
|
||||||
"isBuildCommand": true,
|
"isBuildCommand": true,
|
||||||
|
|
@ -96,7 +109,6 @@
|
||||||
"isTestCommand": false,
|
"isTestCommand": false,
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"problemMatcher": ["$msCompile"],
|
|
||||||
"args": ["build", "/property:GenerateFullPaths=true"],
|
"args": ["build", "/property:GenerateFullPaths=true"],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "src/PostIt/PostIt.Desktop"
|
"cwd": "src/PostIt/PostIt.Desktop"
|
||||||
|
|
|
||||||
78
CHANGELOG.md
78
CHANGELOG.md
|
|
@ -1,81 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [1.0.8-rc16] - unstable
|
|
||||||
|
|
||||||
Flux de signature et de génération de devis : un devis peut désormais être
|
|
||||||
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
|
|
||||||
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
|
|
||||||
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
|
|
||||||
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
|
|
||||||
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
|
|
||||||
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
|
|
||||||
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
|
|
||||||
`ClientValidationDate` selon le côté signataire.
|
|
||||||
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
|
|
||||||
pour distinguer l'acceptation de chaque partie.
|
|
||||||
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
|
|
||||||
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
|
|
||||||
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
|
|
||||||
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
|
|
||||||
câble `AddControllersWithViews` + `SiteSettings`).
|
|
||||||
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
|
|
||||||
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
|
|
||||||
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
|
|
||||||
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
|
|
||||||
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
|
|
||||||
dans PostIt sur la page de validation du devis et le détail de commande,
|
|
||||||
avec un helper `StorageProvider` d'enregistrement de fichier.
|
|
||||||
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
|
|
||||||
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
|
|
||||||
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
|
|
||||||
fournisseur » / « Devis en attente de signature client ».
|
|
||||||
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
|
|
||||||
* [CLI] Option `-c configuration-file.json` au lancement.
|
|
||||||
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
|
|
||||||
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
|
|
||||||
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
|
|
||||||
nettoyés) ; skip propre si `lualatex` est absent.
|
|
||||||
* [README] Dépendances système au runtime pour la génération PDF :
|
|
||||||
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
|
|
||||||
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
|
|
||||||
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
|
|
||||||
« en attente ».
|
|
||||||
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
|
|
||||||
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
|
|
||||||
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
|
|
||||||
afin que seul le `.pdf` persiste.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
|
|
||||||
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
|
|
||||||
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
|
|
||||||
d'entrée — stdin est alors traité comme un fichier régulier et le document
|
|
||||||
multi-lignes compile. C'était la cause racine de l'échec en production, pas
|
|
||||||
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
|
|
||||||
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
|
|
||||||
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
|
|
||||||
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
|
|
||||||
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
|
|
||||||
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
|
|
||||||
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
|
|
||||||
unique).
|
|
||||||
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
|
|
||||||
`null.cshtml` ; corrigé en `Layout = null` (null C#).
|
|
||||||
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
|
|
||||||
`lualatex`.
|
|
||||||
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
|
|
||||||
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
|
|
||||||
d'erreur, et le `.log` est conservé en cas d'échec.
|
|
||||||
|
|
||||||
## [1.0.8-rc15] - unstable
|
## [1.0.8-rc15] - unstable
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -445,8 +369,6 @@ the ACL now comes along with the article,
|
||||||
the same user-visible switch without a schema change.
|
the same user-visible switch without a schema change.
|
||||||
|
|
||||||
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
|
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
|
||||||
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
|
|
||||||
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
|
|
||||||
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
|
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
|
||||||
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
|
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
|
||||||
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
|
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6
|
||||||
|
|
|
||||||
|
|
@ -4,71 +4,53 @@
|
||||||
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
|
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
||||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
|
||||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
|
||||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
|
||||||
|
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
|
||||||
|
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
|
||||||
|
|
||||||
|
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
|
|
||||||
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
|
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
|
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||||
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
|
||||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||||
|
|
||||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
|
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
|
||||||
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
|
||||||
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
<PackageVersion Include="YamlDotNet" Version="18.1.0" />
|
||||||
|
|
||||||
|
|
||||||
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
|
|
||||||
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
|
|
||||||
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
|
|
||||||
<PackageVersion Include="MailKit" Version="4.18.0" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
|
|
||||||
|
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
|
|
||||||
|
|
||||||
<PackageVersion Include="MimeKit" Version="4.18.1" />
|
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
|
||||||
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
|
|
||||||
<PackageVersion Include="pazof.rules" Version="1.1.3" />
|
|
||||||
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -13,10 +13,6 @@ all:
|
||||||
clean:
|
clean:
|
||||||
dotnet clean -c $(CONFIG)
|
dotnet clean -c $(CONFIG)
|
||||||
|
|
||||||
brutal_clean:
|
|
||||||
@find -name "obj" -exec rm -rf {} \;
|
|
||||||
@find -name "bin" -exec rm -rf {} \;
|
|
||||||
|
|
||||||
src/Yavsc/bin/output/wwwroot:
|
src/Yavsc/bin/output/wwwroot:
|
||||||
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
|
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
|
||||||
|
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -61,40 +61,9 @@ make test
|
||||||
|
|
||||||
### les services et l'API
|
### les services et l'API
|
||||||
|
|
||||||
#### Dépendances système au runtime
|
|
||||||
|
|
||||||
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
|
|
||||||
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
|
|
||||||
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
|
|
||||||
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
|
|
||||||
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
|
|
||||||
systemd `yavscApi`.
|
|
||||||
|
|
||||||
Sous Debian/Ubuntu :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
|
|
||||||
```
|
|
||||||
|
|
||||||
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
|
|
||||||
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
|
|
||||||
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
|
|
||||||
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
|
|
||||||
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
|
|
||||||
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
|
|
||||||
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
|
|
||||||
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
|
|
||||||
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
|
|
||||||
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
|
|
||||||
(`Emergency stop`, aucun PDF).
|
|
||||||
|
|
||||||
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
|
|
||||||
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
|
|
||||||
de compiler le document hors-ligne.
|
|
||||||
|
|
||||||
### La Prod
|
### La Prod
|
||||||
|
|
||||||
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
|
`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`.
|
||||||
|
|
||||||
puis, pour une première installation
|
puis, pour une première installation
|
||||||
`make install_service`.
|
`make install_service`.
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,23 @@
|
||||||
DESTDIR=
|
|
||||||
DOTNET_FRAMEWORK=net10.0
|
|
||||||
APP_PROJECT_NAMES=Org Blogs Api
|
APP_PROJECT_NAMES=Org Blogs Api
|
||||||
SLNDIR=..
|
SLNDIR=..
|
||||||
APP_INSTALL_ENV=preprod
|
|
||||||
APP_INSTALL_PREFIX=preprod-
|
|
||||||
|
|
||||||
include .$(APP_INSTALL_ENV).env
|
include $(SLNDIR)/.env
|
||||||
|
include .env
|
||||||
|
|
||||||
reinstall: copy-binaries
|
generated/:
|
||||||
@for project in $(APP_PROJECT_NAMES); do \
|
@mkdir -p $@
|
||||||
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
|
||||||
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
|
|
||||||
@echo "💯 backend started"
|
|
||||||
|
|
||||||
reinstall-prod:
|
generated/yavscOrg.service:
|
||||||
make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
|
generated/yavscBlogs.service:
|
||||||
|
generated/yavscApi.service:
|
||||||
|
|
||||||
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
|
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||||
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
|
|
||||||
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
|
|
||||||
|
|
||||||
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
|
||||||
@cat template.service | APP_NAME="$*" \
|
@cat template.service | APP_NAME="$*" \
|
||||||
DESTDIR="$(DESTDIR)" \
|
DESTDIR="$(DESTDIR)" \
|
||||||
HTTP_HOST="$(HTTP_HOST)" \
|
HTTP_HOST="$(HTTP_HOST)" \
|
||||||
HTTP_PORT="$*_$(HTTP_PORT)" \
|
HTTP_PORT="$*_$(HTTP_PORT)" \
|
||||||
APPDIR="$(APPDIR)" \
|
BASEAPPDIR="$(BASEAPPDIR)" \
|
||||||
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
|
APP_SYSLOG_ID="yavsc$*" \
|
||||||
POSTGRES_HOST="$(POSTGRES_HOST)" \
|
POSTGRES_HOST="$(POSTGRES_HOST)" \
|
||||||
POSTGRES_PORT="$(POSTGRES_PORT)" \
|
POSTGRES_PORT="$(POSTGRES_PORT)" \
|
||||||
POSTGRES_DB="$(POSTGRES_DB)" \
|
POSTGRES_DB="$(POSTGRES_DB)" \
|
||||||
|
|
@ -43,13 +34,11 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
|
||||||
envsubst > $@
|
envsubst > $@
|
||||||
@echo Created service file: $@
|
@echo Created service file: $@
|
||||||
|
|
||||||
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
|
|
||||||
|
|
||||||
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
|
||||||
sudo systemctl daemon-reload
|
copy-service-Org: /etc/systemd/system/yavscOrg.service
|
||||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
|
||||||
|
copy-service-Api: /etc/systemd/system/yavscApi.service
|
||||||
install-services: install-service-Org install-service-Blogs install-service-Api
|
|
||||||
|
|
||||||
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
|
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
|
||||||
@for project in $(APP_PROJECT_NAMES); \
|
@for project in $(APP_PROJECT_NAMES); \
|
||||||
|
|
@ -63,36 +52,39 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
|
||||||
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
|
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
|
||||||
sync ; \
|
sync ; \
|
||||||
ls $${BINDIR}/appsettings* 2>&1 ; \
|
ls $${BINDIR}/appsettings* 2>&1 ; \
|
||||||
sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
|
sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \
|
||||||
done
|
done
|
||||||
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
|
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
|
||||||
@sync
|
|
||||||
|
|
||||||
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
|
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
|
||||||
sudo cp $^ $@
|
sudo cp $^ $@
|
||||||
sudo chown root:root $@
|
sudo chown root:root $@
|
||||||
|
|
||||||
build_publish_%: clean_publish_dir_%
|
build_publish_%: clean_publish_dir_%
|
||||||
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
|
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
|
||||||
|
|
||||||
|
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
|
||||||
|
|
||||||
clean_publish_dir_%:
|
clean_publish_dir_%:
|
||||||
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
|
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
|
||||||
|
|
||||||
install: copy-binaries copy-services
|
install: build_publish copy-binaries copy-services
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
@for project in $(APP_PROJECT_NAMES); \
|
@for project in $(APP_PROJECT_NAMES); \
|
||||||
do \
|
do \
|
||||||
echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
sudo systemctl enable yavsc$${project} ; \
|
||||||
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
sudo systemctl start yavsc$${project} ; \
|
||||||
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
|
||||||
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
|
||||||
done
|
done
|
||||||
|
|
||||||
|
reinstall: copy-binaries
|
||||||
|
@sync
|
||||||
|
@for project in $(APP_PROJECT_NAMES); do \
|
||||||
|
sudo systemctl start yavsc$${project} ; done
|
||||||
|
|
||||||
stop-services:
|
stop-services:
|
||||||
@for project in $(APP_PROJECT_NAMES); \
|
@for project in $(APP_PROJECT_NAMES); \
|
||||||
do \
|
do \
|
||||||
echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
sudo systemctl stop yavsc$${project} ; \
|
||||||
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
|
|
||||||
done
|
done
|
||||||
|
|
||||||
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
|
||||||
|
|
@ -101,7 +93,7 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
|
||||||
|
|
||||||
showConfig:
|
showConfig:
|
||||||
@echo CONFIGURATION: $(CONFIGURATION)
|
@echo CONFIGURATION: $(CONFIGURATION)
|
||||||
@echo APPDIR: $(APPDIR)
|
@echo BASEAPPDIR: $(BASEAPPDIR)
|
||||||
|
|
||||||
showApiLogs:
|
showApiLogs:
|
||||||
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
|
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
|
||||||
|
|
@ -115,5 +107,3 @@ showBlogsLogs:
|
||||||
clean:
|
clean:
|
||||||
@rm -rf generated
|
@rm -rf generated
|
||||||
|
|
||||||
generated/:
|
|
||||||
@mkdir -p $@
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ RestartSec=5s
|
||||||
Type=simple
|
Type=simple
|
||||||
User=yavsc
|
User=yavsc
|
||||||
Group=yavsc
|
Group=yavsc
|
||||||
WorkingDirectory=$APPDIR
|
WorkingDirectory=$DESTDIR
|
||||||
ExecStart=$APPDIR/Yavsc.$APP_NAME
|
ExecStart=$DESTDIR/Yavsc.$APP_NAME
|
||||||
Restart=always
|
Restart=always
|
||||||
Environment="HOME=$APPDIR"
|
Environment="HOME=$DESTDIR"
|
||||||
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
|
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
|
||||||
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
|
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
|
||||||
Environment="ASPNETCORE_Environment=$ENV"
|
Environment="ASPNETCORE_Environment=$ENV"
|
||||||
|
|
@ -29,6 +29,8 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
|
||||||
|
|
||||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
SyslogIdentifier=$APP_SYSLOG_ID
|
SyslogIdentifier=$APP_SYSLOG_ID
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
54
contrib/yavsc-org
Normal file
54
contrib/yavsc-org
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = yavsc.pschneider.fr) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
server_name yavsc.pschneider.fr;
|
||||||
|
location / {
|
||||||
|
root /srv/www/yavsc/wwwroot;
|
||||||
|
dav_methods PUT;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:3003;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection keep-alive;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name yavsc.pschneider.fr;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
|
||||||
|
# Configure the SignalR Endpoint
|
||||||
|
location / {
|
||||||
|
# App server url
|
||||||
|
proxy_pass http://localhost:3003;
|
||||||
|
|
||||||
|
proxy_cache off;
|
||||||
|
# WebSockets were implemented after http/1.0
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# Configuration for ServerSentEvents
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
|
||||||
|
proxy_read_timeout 100s;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"test": {
|
|
||||||
"runner": "Microsoft.Testing.Platform"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,31 +4,28 @@
|
||||||
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
|
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
|
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||||
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
|
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
|
||||||
<PackageVersion Include="Material.Avalonia" Version="3.20.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
|
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
|
|
||||||
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AndroidResource Include="Icon.png">
|
<AndroidResource Include="Icon.png">
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Browser" />
|
<PackageReference Include="Avalonia.Browser" />
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ sealed class Program
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
CLIOptionHandler.HandleArgs(args);
|
|
||||||
|
|
||||||
// Short-circuit 2nd-instance launches (OS handing us the
|
// Short-circuit 2nd-instance launches (OS handing us the
|
||||||
// postit://callback URL) BEFORE Avalonia spins up a window.
|
// postit://callback URL) BEFORE Avalonia spins up a window.
|
||||||
// If we let Avalonia initialise, the new MainWindow flashes
|
// If we let Avalonia initialise, the new MainWindow flashes
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,12 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
||||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
|
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
|
||||||
Assert.Equal("Devis en attente de signature client", vm.Title);
|
Assert.Equal("Mes devis à valider", vm.Title);
|
||||||
Assert.Equal(2, vm.Estimates.Count);
|
Assert.Equal(2, vm.Estimates.Count);
|
||||||
// Most recent first
|
// Most recent first
|
||||||
Assert.Equal(2, vm.Estimates[0].Id);
|
Assert.Equal(2, vm.Estimates[0].Id);
|
||||||
|
|
@ -29,13 +28,12 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
||||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
|
||||||
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
|
Assert.Equal("Mes devis en attente", vm.Title);
|
||||||
Assert.Equal(2, vm.Estimates.Count);
|
Assert.Equal(2, vm.Estimates.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,8 +42,7 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi();
|
var api = new StubEstimateListApi();
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
||||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
@ -66,8 +63,7 @@ public class EstimateListPageViewModelTests
|
||||||
{
|
{
|
||||||
var api = new StubEstimateListApi { Empty = true };
|
var api = new StubEstimateListApi { Empty = true };
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
||||||
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
|
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net.Http;
|
|
||||||
using PostIt.Controls;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
|
|
||||||
/// signature pads (provider + client) into the right
|
|
||||||
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
|
|
||||||
/// is not tested here; the VM is driven through the same
|
|
||||||
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
|
|
||||||
/// and the API client through a recording fake
|
|
||||||
/// <see cref="IYavscApiClient"/>. The test host's
|
|
||||||
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
|
|
||||||
/// run to completion without navigation side effects.
|
|
||||||
/// </summary>
|
|
||||||
public class EstimateValidationPageViewModelTests
|
|
||||||
{
|
|
||||||
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
|
|
||||||
{
|
|
||||||
Id = 7,
|
|
||||||
CommandId = commandId,
|
|
||||||
CommandType = "Rdv",
|
|
||||||
Title = "Devis plomberie",
|
|
||||||
Description = "Réparation",
|
|
||||||
ClientId = "bob",
|
|
||||||
OwnerId = "alice",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static void DrawOneStroke(SignaturePadControl pad)
|
|
||||||
{
|
|
||||||
pad.AppendPointForTest(100, 200);
|
|
||||||
pad.AppendPointForTest(300, 400);
|
|
||||||
pad.SealStrokeForTest();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Validate_calls_accept_with_strokes_and_command_id()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
var vm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
var proPad = new SignaturePadControl();
|
|
||||||
var clientPad = new SignaturePadControl();
|
|
||||||
vm.Attach(proPad, clientPad);
|
|
||||||
DrawOneStroke(proPad); // provider signs on their writable pad
|
|
||||||
|
|
||||||
await vm.ValidateAsync();
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
|
||||||
Assert.Contains("front/query/accept", api.LastPath);
|
|
||||||
Assert.Contains("billingCode=Rdv", api.LastPath);
|
|
||||||
Assert.Contains("queryId=42", api.LastPath);
|
|
||||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
|
||||||
Assert.NotNull(body.Strokes);
|
|
||||||
Assert.NotEmpty(body.Strokes);
|
|
||||||
Assert.Equal(10_000, body.CoordinateMax);
|
|
||||||
// The provider perspective declares the Pro side, so the
|
|
||||||
// server stores the signature under the provider's author
|
|
||||||
// regardless of role inference.
|
|
||||||
Assert.Equal(SignatureType.Pro, body.SignatureType);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Validate_from_client_perspective_declares_client_side()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
var vm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
|
||||||
|
|
||||||
var cliPad = new SignaturePadControl();
|
|
||||||
vm.Attach(new SignaturePadControl(), cliPad);
|
|
||||||
DrawOneStroke(cliPad); // client signs on their writable pad
|
|
||||||
|
|
||||||
await vm.ValidateAsync();
|
|
||||||
|
|
||||||
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
|
|
||||||
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_calls_reject_endpoint_with_no_body()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
var vm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
|
||||||
|
|
||||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
|
|
||||||
|
|
||||||
await vm.RejectAsync();
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
|
||||||
Assert.Contains("front/query/reject", api.LastPath);
|
|
||||||
Assert.Contains("queryId=42", api.LastPath);
|
|
||||||
Assert.Null(api.LastBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Validate_without_signature_warns_and_does_not_call()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
var vm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
|
|
||||||
|
|
||||||
await vm.ValidateAsync(); // no throw: returns before GoBack
|
|
||||||
|
|
||||||
Assert.Null(api.LastMethod);
|
|
||||||
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_command_gates_on_signature_and_command()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
var vm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
var proPad = new SignaturePadControl();
|
|
||||||
vm.Attach(proPad, new SignaturePadControl());
|
|
||||||
|
|
||||||
// No CommandId ⇒ cannot validate even after signing.
|
|
||||||
DrawOneStroke(proPad);
|
|
||||||
Assert.False(vm.ValidateCommand.CanExecute(null));
|
|
||||||
|
|
||||||
var vm2 = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
|
||||||
var proPad2 = new SignaturePadControl();
|
|
||||||
vm2.Attach(proPad2, new SignaturePadControl());
|
|
||||||
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
|
|
||||||
DrawOneStroke(proPad2);
|
|
||||||
Assert.True(vm2.ValidateCommand.CanExecute(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RoleLabel_reflects_perspective()
|
|
||||||
{
|
|
||||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
|
||||||
var pro = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
|
||||||
var cli = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
|
||||||
|
|
||||||
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
|
|
||||||
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Only_the_author_pad_is_writable()
|
|
||||||
{
|
|
||||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
var proVm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Provider, front);
|
|
||||||
var proPad = new SignaturePadControl();
|
|
||||||
var cliPad = new SignaturePadControl();
|
|
||||||
proVm.Attach(proPad, cliPad);
|
|
||||||
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
|
|
||||||
Assert.True(cliPad.IsReadOnly);
|
|
||||||
|
|
||||||
var cliVm = new EstimateValidationPageViewModel(
|
|
||||||
SampleEstimate(), EstimateListPerspective.Client, front);
|
|
||||||
var proPad2 = new SignaturePadControl();
|
|
||||||
var cliPad2 = new SignaturePadControl();
|
|
||||||
cliVm.Attach(proPad2, cliPad2);
|
|
||||||
Assert.True(proPad2.IsReadOnly);
|
|
||||||
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_existing_signatures_renders_both_sides_in_their_pads()
|
|
||||||
{
|
|
||||||
var proStrokes = new[] { 1, 1000, 1000 };
|
|
||||||
var cliStrokes = new[] { 1, 9000, 9000 };
|
|
||||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
|
||||||
var estimate = SampleEstimate();
|
|
||||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
|
|
||||||
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
|
|
||||||
|
|
||||||
// Client perspective: both pads show the stored signatures, but
|
|
||||||
// only the client pad is writable.
|
|
||||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
|
|
||||||
var proPad = new SignaturePadControl();
|
|
||||||
var cliPad = new SignaturePadControl();
|
|
||||||
vm.Attach(proPad, cliPad);
|
|
||||||
|
|
||||||
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
|
|
||||||
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
|
|
||||||
Assert.True(vm.HasProSignature);
|
|
||||||
Assert.True(vm.HasClientSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Load_existing_pro_signature_renders_in_pro_pad_only()
|
|
||||||
{
|
|
||||||
var strokes = new[] { 1, 5000, 5000 };
|
|
||||||
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
|
|
||||||
var estimate = SampleEstimate();
|
|
||||||
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
|
|
||||||
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
|
|
||||||
|
|
||||||
var proPad = new SignaturePadControl();
|
|
||||||
var cliPad = new SignaturePadControl();
|
|
||||||
vm.Attach(proPad, cliPad);
|
|
||||||
|
|
||||||
Assert.Equal(strokes, proPad.Snapshot().Strokes);
|
|
||||||
Assert.True(cliPad.Snapshot().IsEmpty);
|
|
||||||
Assert.True(vm.HasProSignature);
|
|
||||||
Assert.False(vm.HasClientSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class RecordingApi : IYavscApiClient
|
|
||||||
{
|
|
||||||
public HttpClient Http { get; } = new();
|
|
||||||
public HttpMethod? LastMethod { get; private set; }
|
|
||||||
public string? LastPath { get; private set; }
|
|
||||||
public object? LastBody { get; private set; }
|
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
LastMethod = method;
|
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
return Task.FromResult(default(T)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
LastMethod = method;
|
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
|
||||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
|
||||||
|
|
||||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
|
||||||
=> CallAsync(method, path, (object?)null, ct);
|
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
using System.Net.Http;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
|
|
||||||
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
|
|
||||||
/// the PostIt signature payload, using a recording fake
|
|
||||||
/// <see cref="IYavscApiClient"/> (no network).
|
|
||||||
/// </summary>
|
|
||||||
public class FrontOfficeApiClientTests
|
|
||||||
{
|
|
||||||
private static QueryAcceptanceRequestDto SampleBody() => new()
|
|
||||||
{
|
|
||||||
Strokes = new[] { 2, 100, 200, 300, 400 },
|
|
||||||
CoordinateMax = 10_000,
|
|
||||||
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
var body = SampleBody();
|
|
||||||
await client.AcceptQueryAsync("Rdv", 42, body);
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
|
||||||
Assert.Equal(
|
|
||||||
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
|
|
||||||
api.LastPath);
|
|
||||||
Assert.Same(body, api.LastBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_posts_to_reject_endpoint_with_no_body()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
await client.RejectQueryAsync("Rdv", 42);
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
|
||||||
Assert.Equal(
|
|
||||||
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
|
|
||||||
api.LastPath);
|
|
||||||
Assert.Null(api.LastBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_returns_deserialized_response()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi
|
|
||||||
{
|
|
||||||
Response = new QueryAcceptanceResponseDto
|
|
||||||
{
|
|
||||||
QueryId = 42,
|
|
||||||
Status = "Accepted",
|
|
||||||
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
|
|
||||||
|
|
||||||
Assert.Equal(42, response.QueryId);
|
|
||||||
Assert.Equal("Accepted", response.Status);
|
|
||||||
Assert.NotNull(response.Signature);
|
|
||||||
Assert.Equal("Pro", response.Signature!.Type);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_rejects_invalid_arguments()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
|
|
||||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
|
|
||||||
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
|
|
||||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
await client.GetEstimatePdfAsync(42);
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
|
||||||
Assert.Equal(
|
|
||||||
"https://business.example/api/v1/front/query/42/estimate.pdf",
|
|
||||||
api.LastPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
|
|
||||||
{
|
|
||||||
var api = new RecordingApi();
|
|
||||||
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
|
||||||
|
|
||||||
await client.GetEstimateTexAsync(42);
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Get, api.LastMethod);
|
|
||||||
Assert.Equal(
|
|
||||||
"https://business.example/api/v1/front/query/42/estimate.tex",
|
|
||||||
api.LastPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class RecordingApi : IYavscApiClient
|
|
||||||
{
|
|
||||||
public HttpClient Http { get; } = new();
|
|
||||||
public HttpMethod? LastMethod { get; private set; }
|
|
||||||
public string? LastPath { get; private set; }
|
|
||||||
public object? LastBody { get; private set; }
|
|
||||||
public QueryAcceptanceResponseDto? Response { get; set; }
|
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
LastMethod = method;
|
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
|
|
||||||
var response = Response ?? new QueryAcceptanceResponseDto();
|
|
||||||
return Task.FromResult((T)(object)response);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
LastMethod = method;
|
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
|
||||||
=> CallAsync<T>(method, path, (object?)null, ct);
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
LastMethod = method;
|
|
||||||
LastPath = path;
|
|
||||||
LastBody = null;
|
|
||||||
return Task.FromResult(Array.Empty<byte>());
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,13 +8,14 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="Xamarin.UITest" />
|
<PackageReference Include="Xamarin.UITest" />
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Avalonia.Headless" />
|
<PackageReference Include="Avalonia.Headless" />
|
||||||
<PackageReference Include="Avalonia.Headless.XUnit" />
|
<PackageReference Include="Avalonia.Headless.XUnit" />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,5 @@
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ namespace PostIt;
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private int _bootStarted;
|
private int _bootStarted;
|
||||||
public string? CliConfigFileSpecification { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DI container the platform entry points hand to ViewModels so
|
/// DI container the platform entry points hand to ViewModels so
|
||||||
|
|
@ -204,9 +203,4 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
await View!.NavRoot.PopAsync();
|
await View!.NavRoot.PopAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void UseConfigFileWhenLoading(string configFile)
|
|
||||||
{
|
|
||||||
this.CliConfigFileSpecification = configFile;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Mono.Options;
|
|
||||||
using PostIt;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
public static class CLIOptionHandler
|
|
||||||
{
|
|
||||||
public static void HandleArgs(string[] args)
|
|
||||||
{
|
|
||||||
bool showHelp = false;
|
|
||||||
string configFile = null;
|
|
||||||
|
|
||||||
var options = new OptionSet {
|
|
||||||
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
|
|
||||||
{ "h|help", "Show this message and exit", v => showHelp = true }
|
|
||||||
};
|
|
||||||
|
|
||||||
List<string> extra = options.Parse(args);
|
|
||||||
|
|
||||||
if (showHelp)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Usage: app [OPTIONS]+");
|
|
||||||
options.WriteOptionDescriptions(Console.Out);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (configFile != null)
|
|
||||||
{
|
|
||||||
var app = App.Current as App;
|
|
||||||
app.UseConfigFileWhenLoading(configFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -46,21 +46,6 @@ public class SignaturePadControl : TemplatedControl
|
||||||
set => SetValue(CaptureAreaProperty, value);
|
set => SetValue(CaptureAreaProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When true the control ignores pointer input — it only displays
|
|
||||||
/// strokes loaded into its buffer (e.g. the other party's
|
|
||||||
/// signature, which must be shown but not editable). <see cref="Load"/>
|
|
||||||
/// and <see cref="Clear"/> still work.
|
|
||||||
/// </summary>
|
|
||||||
public static readonly StyledProperty<bool> IsReadOnlyProperty =
|
|
||||||
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
|
|
||||||
|
|
||||||
public bool IsReadOnly
|
|
||||||
{
|
|
||||||
get => GetValue(IsReadOnlyProperty);
|
|
||||||
set => SetValue(IsReadOnlyProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Captured strokes in wire form. Exposed as a read-only view
|
/// Captured strokes in wire form. Exposed as a read-only view
|
||||||
/// over the internal buffer. The buffer only mutates on the UI
|
/// over the internal buffer. The buffer only mutates on the UI
|
||||||
|
|
@ -124,7 +109,6 @@ public class SignaturePadControl : TemplatedControl
|
||||||
|
|
||||||
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (IsReadOnly) return;
|
|
||||||
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
|
||||||
e.Pointer.Capture(CaptureArea);
|
e.Pointer.Capture(CaptureArea);
|
||||||
_capturing = true;
|
_capturing = true;
|
||||||
|
|
@ -197,38 +181,6 @@ public class SignaturePadControl : TemplatedControl
|
||||||
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replace the buffer with the given wire-format strokes — e.g. a
|
|
||||||
/// signature loaded back from the server — and raise
|
|
||||||
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
|
|
||||||
/// Loaded strokes are all sealed (there is no pending stroke).
|
|
||||||
/// Malformed input (a non-positive length prefix, or a stroke that
|
|
||||||
/// runs past the end of the array) is truncated at the offending
|
|
||||||
/// point so the buffer never holds an inconsistent state.
|
|
||||||
/// </summary>
|
|
||||||
public void Load(int[] strokes)
|
|
||||||
{
|
|
||||||
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
|
|
||||||
|
|
||||||
_strokes.Clear();
|
|
||||||
_pendingPoints = 0;
|
|
||||||
_capturing = false;
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
while (i < strokes.Length)
|
|
||||||
{
|
|
||||||
int k = strokes[i];
|
|
||||||
if (k <= 0) break;
|
|
||||||
if (i + 1 + 2 * k > strokes.Length) break;
|
|
||||||
_strokes.Add(k);
|
|
||||||
for (int p = 0; p < 2 * k; p++)
|
|
||||||
_strokes.Add(strokes[i + 1 + p]);
|
|
||||||
i += 1 + 2 * k;
|
|
||||||
}
|
|
||||||
|
|
||||||
RedrawRequested?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defensive copy of the current buffer wrapped in a
|
/// Defensive copy of the current buffer wrapped in a
|
||||||
/// <see cref="SignaturePadData"/>. Cheap; call only when the
|
/// <see cref="SignaturePadData"/>. Cheap; call only when the
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:controls="using:PostIt.Controls"
|
|
||||||
x:Class="PostIt.Controls.SignaturePadView"
|
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
VerticalAlignment="Stretch">
|
|
||||||
<!--
|
|
||||||
Capture surface. The SignaturePadControl is render-agnostic,
|
|
||||||
so we host it inside a Border that is the capture area, and
|
|
||||||
overlay a Canvas for the visual feedback. The code-behind
|
|
||||||
repaints the canvas on every RedrawRequested. The Border size
|
|
||||||
is driven by the CaptureWidth / CaptureHeight styled properties.
|
|
||||||
-->
|
|
||||||
<Border x:Name="PadFrame"
|
|
||||||
BorderBrush="Black"
|
|
||||||
BorderThickness="1"
|
|
||||||
Background="White"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
ClipToBounds="True">
|
|
||||||
<Grid>
|
|
||||||
<controls:SignaturePadControl x:Name="InnerPad" />
|
|
||||||
<Canvas x:Name="InkLayer"
|
|
||||||
Background="Transparent"
|
|
||||||
IsHitTestVisible="False" />
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</UserControl>
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Controls.Shapes;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using PostIt.Models;
|
|
||||||
|
|
||||||
namespace PostIt.Controls;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reusable signature-capture surface: wraps the render-agnostic
|
|
||||||
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
|
|
||||||
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
|
|
||||||
/// the code-behind repaints from the control's stroke buffer. Owns
|
|
||||||
/// the ink-rendering logic so every page that captures a signature
|
|
||||||
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
|
|
||||||
/// ...) shares one implementation.
|
|
||||||
///
|
|
||||||
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
|
|
||||||
/// (DIPs, defaults 600×200) to size the surface. View models attach
|
|
||||||
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
|
|
||||||
/// read snapshots, drive <c>Clear</c>, and subscribe to
|
|
||||||
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
|
|
||||||
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
|
|
||||||
/// contract the headless tests use, since a <see cref="UserControl"/>
|
|
||||||
/// needs its XAML loaded to be drivable.
|
|
||||||
/// </summary>
|
|
||||||
public partial class SignaturePadView : UserControl
|
|
||||||
{
|
|
||||||
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
|
|
||||||
private const double StrokeThickness = 2.0;
|
|
||||||
private const double CoordinateMax = 10_000.0;
|
|
||||||
|
|
||||||
// Last size the ink layer was painted at. LayoutUpdated repaints
|
|
||||||
// only when this changes, so adding/removing polyline children
|
|
||||||
// (which invalidates layout) does not loop.
|
|
||||||
private Size _lastPaintedSize;
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> CaptureWidthProperty =
|
|
||||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<double> CaptureHeightProperty =
|
|
||||||
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
|
|
||||||
|
|
||||||
public double CaptureWidth
|
|
||||||
{
|
|
||||||
get => GetValue(CaptureWidthProperty);
|
|
||||||
set => SetValue(CaptureWidthProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double CaptureHeight
|
|
||||||
{
|
|
||||||
get => GetValue(CaptureHeightProperty);
|
|
||||||
set => SetValue(CaptureHeightProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The underlying capture control. View models attach to this to
|
|
||||||
/// read <see cref="SignaturePadControl.Snapshot"/> / call
|
|
||||||
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
|
|
||||||
/// events; this view repaints the ink layer from the same buffer.
|
|
||||||
/// </summary>
|
|
||||||
public SignaturePadControl SignaturePad => InnerPad;
|
|
||||||
|
|
||||||
public SignaturePadView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
// Wire the capture area: the Pad is the control, the
|
|
||||||
// surrounding Border (PadFrame) is the hit-test region. For an
|
|
||||||
// inline control with no template, capture wiring happens on
|
|
||||||
// first measure, which is guaranteed before the user can
|
|
||||||
// interact, so attaching here is safe.
|
|
||||||
InnerPad.CaptureArea = PadFrame;
|
|
||||||
ApplyFrameSize();
|
|
||||||
|
|
||||||
InnerPad.RedrawRequested += OnPadRedrawRequested;
|
|
||||||
// Repaint once layout completes so an ink buffer loaded before
|
|
||||||
// the first arrange (e.g. an existing signature on reopen)
|
|
||||||
// still renders without waiting for a pointer event. The
|
|
||||||
// handler repaints only on a size change to avoid a layout
|
|
||||||
// loop (child changes invalidate layout → LayoutUpdated).
|
|
||||||
LayoutUpdated += OnLayoutUpdated;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLayoutUpdated(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
var size = EffectivePadSize();
|
|
||||||
if (size != _lastPaintedSize)
|
|
||||||
Repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
||||||
{
|
|
||||||
base.OnPropertyChanged(change);
|
|
||||||
if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty)
|
|
||||||
{
|
|
||||||
ApplyFrameSize();
|
|
||||||
Repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyFrameSize()
|
|
||||||
{
|
|
||||||
if (PadFrame is null) return;
|
|
||||||
PadFrame.Width = Math.Max(1, CaptureWidth);
|
|
||||||
PadFrame.Height = Math.Max(1, CaptureHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint();
|
|
||||||
|
|
||||||
/// <summary>Defensive copy of the current buffer.</summary>
|
|
||||||
public SignaturePadData Snapshot() => InnerPad.Snapshot();
|
|
||||||
|
|
||||||
/// <summary>Forget every captured stroke.</summary>
|
|
||||||
public void Clear() => InnerPad.Clear();
|
|
||||||
|
|
||||||
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
|
|
||||||
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The effective ink surface size: the arranged bounds when
|
|
||||||
/// available, otherwise the explicitly sized
|
|
||||||
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
|
|
||||||
/// latter covers a repaint triggered before the first layout pass
|
|
||||||
/// (e.g. loading an existing signature on reopen), when Bounds is
|
|
||||||
/// still empty.
|
|
||||||
/// </summary>
|
|
||||||
private Size EffectivePadSize()
|
|
||||||
{
|
|
||||||
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
|
|
||||||
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
|
|
||||||
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
|
|
||||||
return default;
|
|
||||||
return new Size(w, h);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Repaint()
|
|
||||||
{
|
|
||||||
if (InkLayer is null) return;
|
|
||||||
|
|
||||||
var size = EffectivePadSize();
|
|
||||||
if (size == default) return;
|
|
||||||
_lastPaintedSize = size;
|
|
||||||
|
|
||||||
InkLayer.Children.Clear();
|
|
||||||
var w = size.Width;
|
|
||||||
var h = size.Height;
|
|
||||||
|
|
||||||
var pending = InnerPad.PendingStroke;
|
|
||||||
var strokes = InnerPad.Strokes;
|
|
||||||
int sealedCount = strokes.Count - pending.Count;
|
|
||||||
if (sealedCount < 0)
|
|
||||||
{
|
|
||||||
sealedCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
while (i < sealedCount)
|
|
||||||
{
|
|
||||||
int k = strokes[i];
|
|
||||||
if (k <= 0) break;
|
|
||||||
i++; // skip the length prefix
|
|
||||||
|
|
||||||
var poly = new Polyline
|
|
||||||
{
|
|
||||||
Stroke = StrokeBrush,
|
|
||||||
StrokeThickness = StrokeThickness,
|
|
||||||
StrokeLineCap = PenLineCap.Round,
|
|
||||||
StrokeJoin = PenLineJoin.Round,
|
|
||||||
};
|
|
||||||
var pts = new List<Point>(k);
|
|
||||||
for (int p = 0; p < k; p++)
|
|
||||||
{
|
|
||||||
int nx = strokes[i++];
|
|
||||||
int ny = strokes[i++];
|
|
||||||
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
|
||||||
}
|
|
||||||
poly.Points = pts;
|
|
||||||
InkLayer.Children.Add(poly);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pending.Count > 0)
|
|
||||||
{
|
|
||||||
var poly = new Polyline
|
|
||||||
{
|
|
||||||
Stroke = StrokeBrush,
|
|
||||||
StrokeThickness = StrokeThickness,
|
|
||||||
StrokeLineCap = PenLineCap.Round,
|
|
||||||
StrokeJoin = PenLineJoin.Round,
|
|
||||||
};
|
|
||||||
|
|
||||||
var pts = new List<Point>(pending.Count / 2);
|
|
||||||
for (int p = 0; p < pending.Count; p += 2)
|
|
||||||
{
|
|
||||||
int nx = pending[p];
|
|
||||||
int ny = pending[p + 1];
|
|
||||||
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
|
||||||
}
|
|
||||||
|
|
||||||
poly.Points = pts;
|
|
||||||
InkLayer.Children.Add(poly);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
|
|
||||||
namespace PostIt.Helpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves a downloaded byte payload to a file chosen by the user through
|
|
||||||
/// the platform save picker. This is the first file-save (as opposed to
|
|
||||||
/// file-open) helper in PostIt; the open picker is already used for blog
|
|
||||||
/// attachment and avatar uploads
|
|
||||||
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
|
|
||||||
///
|
|
||||||
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
|
|
||||||
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
|
|
||||||
/// both the desktop and single-view lifetimes.</para>
|
|
||||||
/// </summary>
|
|
||||||
public static class FileSaveHelpers
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Prompt the user to choose a destination and write
|
|
||||||
/// <paramref name="content"/> to it. Returns false when the user
|
|
||||||
/// cancels the picker; throws on IO/platform errors so the caller
|
|
||||||
/// can surface them.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task<bool> SaveAsync(
|
|
||||||
string suggestedName,
|
|
||||||
string defaultExtension,
|
|
||||||
byte[] content,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var view = (Application.Current as App)?.View;
|
|
||||||
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
|
|
||||||
if (topLevel is null)
|
|
||||||
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
|
|
||||||
|
|
||||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
|
||||||
{
|
|
||||||
Title = "Enregistrer sous",
|
|
||||||
DefaultExtension = defaultExtension,
|
|
||||||
SuggestedFileName = suggestedName,
|
|
||||||
FileTypeChoices = new[]
|
|
||||||
{
|
|
||||||
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
|
|
||||||
{
|
|
||||||
Patterns = new[] { $"*.{defaultExtension}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).ConfigureAwait(true);
|
|
||||||
|
|
||||||
if (file is null)
|
|
||||||
return false; // user cancelled
|
|
||||||
|
|
||||||
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
|
|
||||||
await stream.WriteAsync(content, ct).ConfigureAwait(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -31,7 +31,6 @@ public static class ServiceCollectionHelpers
|
||||||
() => settings.Authentication?.Authority);
|
() => settings.Authentication?.Authority);
|
||||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||||
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
|
||||||
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
|
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||||
|
|
||||||
|
|
@ -64,7 +63,6 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddTransient<ProviderOngoingRequestsPage>();
|
services.AddTransient<ProviderOngoingRequestsPage>();
|
||||||
services.AddTransient<EstimateEditionPage>();
|
services.AddTransient<EstimateEditionPage>();
|
||||||
services.AddTransient<EstimateListPage>();
|
services.AddTransient<EstimateListPage>();
|
||||||
services.AddTransient<EstimateValidationPage>();
|
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -76,7 +74,6 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(activityClient);
|
services.AddSingleton(activityClient);
|
||||||
services.AddSingleton(billingClient);
|
services.AddSingleton(billingClient);
|
||||||
services.AddSingleton(estimateClient);
|
services.AddSingleton(estimateClient);
|
||||||
services.AddSingleton(frontClient);
|
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
<PackageReference Include="Avalonia.AvaloniaEdit" />
|
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport">
|
||||||
|
|
@ -36,7 +35,6 @@
|
||||||
<PackageReference Include="IdentityModel.OidcClient" />
|
<PackageReference Include="IdentityModel.OidcClient" />
|
||||||
<PackageReference Include="MarkdownViewer.Core" />
|
<PackageReference Include="MarkdownViewer.Core" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Mono.Options" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -478,41 +478,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
return "Avatar mis à jour.";
|
return "Avatar mis à jour.";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
|
|
||||||
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
|
|
||||||
/// which JSON-decodes the response, this returns the raw response
|
|
||||||
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
|
|
||||||
/// contract as <see cref="SetAvatarAsync"/>.
|
|
||||||
/// </summary>
|
|
||||||
public virtual async Task<byte[]> DownloadAsync(
|
|
||||||
HttpMethod method,
|
|
||||||
string path,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (_tokens is null)
|
|
||||||
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
|
|
||||||
|
|
||||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var sendOnce = async () =>
|
|
||||||
{
|
|
||||||
using var request = new HttpRequestMessage(method, path);
|
|
||||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await sendOnce().ConfigureAwait(false);
|
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
response.Dispose();
|
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
|
||||||
response = await sendOnce().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
|
||||||
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
_store.Clear();
|
_store.Clear();
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ public class ViewLocator : IDataTemplate
|
||||||
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
|
||||||
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
|
||||||
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
|
||||||
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
|
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using Yavsc;
|
using Yavsc;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
|
|
@ -131,13 +130,11 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
|
||||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
Activity,
|
Activity,
|
||||||
Performer,
|
Performer,
|
||||||
Form,
|
Form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
frontClient,
|
|
||||||
details,
|
details,
|
||||||
IsReadOnly);
|
IsReadOnly);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
|
|
@ -174,9 +171,5 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsOngoingStatus(QueryStatus status)
|
private static bool IsOngoingStatus(QueryStatus status)
|
||||||
=> status is QueryStatus.Inserted
|
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
|
||||||
or QueryStatus.Accepted
|
|
||||||
or QueryStatus.ProAccepted
|
|
||||||
or QueryStatus.ClientAccepted
|
|
||||||
or QueryStatus.InProgress;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ namespace PostIt.ViewModels;
|
||||||
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly BillingApiClient _billingClient;
|
private readonly BillingApiClient _billingClient;
|
||||||
private readonly FrontOfficeApiClient _frontClient;
|
|
||||||
private readonly BillingQueryDetailsDto _details;
|
private readonly BillingQueryDetailsDto _details;
|
||||||
|
|
||||||
public ActivityInfo Activity { get; }
|
public ActivityInfo Activity { get; }
|
||||||
|
|
@ -69,7 +68,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
ActivityUserDisplayItem performer,
|
ActivityUserDisplayItem performer,
|
||||||
CommandFormSummary form,
|
CommandFormSummary form,
|
||||||
BillingApiClient billingClient,
|
BillingApiClient billingClient,
|
||||||
FrontOfficeApiClient frontClient,
|
|
||||||
BillingQueryDetailsDto details,
|
BillingQueryDetailsDto details,
|
||||||
bool isReadOnly)
|
bool isReadOnly)
|
||||||
{
|
{
|
||||||
|
|
@ -77,7 +75,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
|
||||||
Form = form ?? throw new ArgumentNullException(nameof(form));
|
Form = form ?? throw new ArgumentNullException(nameof(form));
|
||||||
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
|
||||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
|
||||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||||
IsReadOnly = isReadOnly;
|
IsReadOnly = isReadOnly;
|
||||||
|
|
||||||
|
|
@ -134,53 +131,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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
|
|
||||||
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
public async Task DownloadTexAsync()
|
|
||||||
{
|
|
||||||
await DownloadAsync(
|
|
||||||
() => _frontClient.GetEstimateTexAsync(_details.Id),
|
|
||||||
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download the estimate linked to this command as a compiled PDF
|
|
||||||
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
|
|
||||||
/// <c>texi2pdf</c> on the server host.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
public async Task DownloadPdfAsync()
|
|
||||||
{
|
|
||||||
await DownloadAsync(
|
|
||||||
() => _frontClient.GetEstimatePdfAsync(_details.Id),
|
|
||||||
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = await fetch().ConfigureAwait(true);
|
|
||||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
|
||||||
this.SetInfoStatus(saved
|
|
||||||
? $"Fichier .{ext} enregistré."
|
|
||||||
: "Téléchargement annulé.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
private static string EmptyAsPlaceholder(string? value, string placeholder)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
=> string.IsNullOrWhiteSpace(value) ? placeholder : value;
|
||||||
|
|
||||||
|
|
@ -216,8 +166,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.Accepted => "#E6F7EC",
|
QueryStatus.Accepted => "#E6F7EC",
|
||||||
QueryStatus.ProAccepted => "#E6F7EC",
|
|
||||||
QueryStatus.ClientAccepted => "#E1F5FE",
|
|
||||||
QueryStatus.InProgress => "#FFF4D6",
|
QueryStatus.InProgress => "#FFF4D6",
|
||||||
QueryStatus.Rejected => "#FDECEA",
|
QueryStatus.Rejected => "#FDECEA",
|
||||||
QueryStatus.Failed => "#ECEFF1",
|
QueryStatus.Failed => "#ECEFF1",
|
||||||
|
|
@ -229,8 +177,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.Accepted => "#2E7D32",
|
QueryStatus.Accepted => "#2E7D32",
|
||||||
QueryStatus.ProAccepted => "#2E7D32",
|
|
||||||
QueryStatus.ClientAccepted => "#1565C0",
|
|
||||||
QueryStatus.InProgress => "#B26A00",
|
QueryStatus.InProgress => "#B26A00",
|
||||||
QueryStatus.Rejected => "#C62828",
|
QueryStatus.Rejected => "#C62828",
|
||||||
QueryStatus.Failed => "#607D8B",
|
QueryStatus.Failed => "#607D8B",
|
||||||
|
|
@ -242,8 +188,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.Accepted => "#1B5E20",
|
QueryStatus.Accepted => "#1B5E20",
|
||||||
QueryStatus.ProAccepted => "#1B5E20",
|
|
||||||
QueryStatus.ClientAccepted => "#0D47A1",
|
|
||||||
QueryStatus.InProgress => "#7A4A00",
|
QueryStatus.InProgress => "#7A4A00",
|
||||||
QueryStatus.Rejected => "#8E0000",
|
QueryStatus.Rejected => "#8E0000",
|
||||||
QueryStatus.Failed => "#37474F",
|
QueryStatus.Failed => "#37474F",
|
||||||
|
|
@ -255,8 +199,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.Accepted => "OK",
|
QueryStatus.Accepted => "OK",
|
||||||
QueryStatus.ProAccepted => "P",
|
|
||||||
QueryStatus.ClientAccepted => "C",
|
|
||||||
QueryStatus.InProgress => "~",
|
QueryStatus.InProgress => "~",
|
||||||
QueryStatus.Rejected => "!",
|
QueryStatus.Rejected => "!",
|
||||||
QueryStatus.Failed => "X",
|
QueryStatus.Failed => "X",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
|
|
@ -19,10 +18,10 @@ namespace PostIt.ViewModels;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum EstimateListPerspective
|
public enum EstimateListPerspective
|
||||||
{
|
{
|
||||||
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
|
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
|
||||||
Client,
|
Client,
|
||||||
|
|
||||||
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
|
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
|
||||||
Provider,
|
Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,15 +32,14 @@ public enum EstimateListPerspective
|
||||||
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly EstimateApiClient _estimateClient;
|
private readonly EstimateApiClient _estimateClient;
|
||||||
private readonly FrontOfficeApiClient _frontClient;
|
|
||||||
private readonly EstimateListPerspective _perspective;
|
private readonly EstimateListPerspective _perspective;
|
||||||
private List<EstimateDto> _allEstimates = new();
|
private List<EstimateDto> _allEstimates = new();
|
||||||
|
|
||||||
public EstimateListPerspective Perspective => _perspective;
|
public EstimateListPerspective Perspective => _perspective;
|
||||||
|
|
||||||
public string Title => _perspective == EstimateListPerspective.Client
|
public string Title => _perspective == EstimateListPerspective.Client
|
||||||
? "Devis en attente de signature client"
|
? "Mes devis à valider"
|
||||||
: "Devis en attente de signature fournisseur";
|
: "Mes devis en attente";
|
||||||
|
|
||||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||||
? "Point de vue client"
|
? "Point de vue client"
|
||||||
|
|
@ -85,14 +83,10 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public EstimateListPageViewModel(
|
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
|
||||||
EstimateApiClient estimateClient,
|
|
||||||
EstimateListPerspective perspective,
|
|
||||||
FrontOfficeApiClient frontClient)
|
|
||||||
{
|
{
|
||||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||||
_perspective = perspective;
|
_perspective = perspective;
|
||||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task InitializeAsync() => RefreshAsync();
|
public Task InitializeAsync() => RefreshAsync();
|
||||||
|
|
@ -159,25 +153,4 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
private static bool ContainsInsensitive(string? value, string query)
|
private static bool ContainsInsensitive(string? value, string query)
|
||||||
=> !string.IsNullOrWhiteSpace(value)
|
=> !string.IsNullOrWhiteSpace(value)
|
||||||
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
&& value.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Open the estimate validation (accept/reject with signature)
|
|
||||||
/// page for a specific estimate. The perspective the list was
|
|
||||||
/// opened in is forwarded as the actor-role UI hint; the server
|
|
||||||
/// independently enforces the real authorization.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task OpenEstimateAsync(EstimateDto? estimate)
|
|
||||||
{
|
|
||||||
if (estimate is null) return;
|
|
||||||
|
|
||||||
var app = (App?)Application.Current;
|
|
||||||
if (app is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient);
|
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using PostIt.Controls;
|
|
||||||
using PostIt.Helpers;
|
|
||||||
using PostIt.Models;
|
|
||||||
using Yavsc.Api.Client;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validation (accept / reject) of a single estimate, with optional
|
|
||||||
/// signatures. Backs the estimate validation page opened from
|
|
||||||
/// <see cref="EstimateListPageViewModel"/> for either perspective.
|
|
||||||
///
|
|
||||||
/// <para>Two signatures are shown, each in its own pad: the provider's
|
|
||||||
/// (Pro) and the client's (Client). Both are rendered from the estimate
|
|
||||||
/// payload when present, so reopening a partly-signed estimate shows
|
|
||||||
/// what was already captured. Only the pad matching the current
|
|
||||||
/// <see cref="EstimateListPerspective"/> is writable — the server
|
|
||||||
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
|
|
||||||
/// same rule (it resolves the caller's role from the authenticated user
|
|
||||||
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
|
|
||||||
/// the other pad read-only. The billing code sent to the server is the
|
|
||||||
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
|
|
||||||
/// its <see cref="EstimateDto.CommandId"/>.</para>
|
|
||||||
/// </summary>
|
|
||||||
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
|
|
||||||
{
|
|
||||||
private readonly EstimateDto _estimate;
|
|
||||||
private readonly EstimateListPerspective _perspective;
|
|
||||||
private readonly FrontOfficeApiClient _frontClient;
|
|
||||||
private SignaturePadControl? _proPad;
|
|
||||||
private SignaturePadControl? _clientPad;
|
|
||||||
|
|
||||||
public string Title => _estimate.Title;
|
|
||||||
public string Description => _estimate.Description;
|
|
||||||
public long EstimateId => _estimate.Id;
|
|
||||||
public string CommandType => _estimate.CommandType;
|
|
||||||
public IReadOnlyList<EstimateLineDto> Bill => _estimate.Bill;
|
|
||||||
public DateTime ProviderValidationDate => _estimate.ProviderValidationDate;
|
|
||||||
public DateTime ClientValidationDate => _estimate.ClientValidationDate;
|
|
||||||
|
|
||||||
public string RoleLabel => _perspective == EstimateListPerspective.Provider
|
|
||||||
? "Vous validez en tant que prestataire"
|
|
||||||
: "Vous validez en tant que client";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the current user may sign the provider's signature
|
|
||||||
/// (i.e. they opened the list as the provider). The other party's
|
|
||||||
/// pad is read-only.
|
|
||||||
/// </summary>
|
|
||||||
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the current user may sign the client's signature
|
|
||||||
/// (i.e. they opened the list as the client).
|
|
||||||
/// </summary>
|
|
||||||
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Human-readable summary of who has already validated this
|
|
||||||
/// estimate, shown above the signature pads.
|
|
||||||
/// </summary>
|
|
||||||
public string ValidationStateText
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var parts = new List<string>();
|
|
||||||
if (ProviderValidationDate != default)
|
|
||||||
parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}");
|
|
||||||
if (ClientValidationDate != default)
|
|
||||||
parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}");
|
|
||||||
return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the estimate has a linked query to accept/reject.
|
|
||||||
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
|
|
||||||
/// templates and cannot be validated through the front-office
|
|
||||||
/// endpoint.
|
|
||||||
/// </summary>
|
|
||||||
public bool HasCommand => _estimate.CommandId is not null;
|
|
||||||
|
|
||||||
[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.");
|
|
||||||
|
|
||||||
/// <summary>Stroke/point counts for the writable pad.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial int StrokeCount { get; set; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial int PointCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>True when the writable pad holds a signature.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool HasSignature { get; set; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool HasProSignature { get; set; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool HasClientSignature { get; set; }
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial string ProSignatureHint { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial string ClientSignatureHint { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public override bool CanNavigateNext
|
|
||||||
{
|
|
||||||
get => false;
|
|
||||||
protected set { _ = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanNavigatePrevious
|
|
||||||
{
|
|
||||||
get => true;
|
|
||||||
protected set { _ = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public EstimateValidationPageViewModel(
|
|
||||||
EstimateDto estimate,
|
|
||||||
EstimateListPerspective perspective,
|
|
||||||
FrontOfficeApiClient frontClient)
|
|
||||||
{
|
|
||||||
_estimate = estimate ?? throw new ArgumentNullException(nameof(estimate));
|
|
||||||
_perspective = perspective;
|
|
||||||
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bind the page's two signature pads (the inner controls of the
|
|
||||||
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
|
|
||||||
/// the client's pad. Called from the view's code-behind once the
|
|
||||||
/// view is in the visual tree. Marks the non-author pad read-only,
|
|
||||||
/// then renders any signatures already carried on the estimate
|
|
||||||
/// payload.
|
|
||||||
/// </summary>
|
|
||||||
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
|
|
||||||
{
|
|
||||||
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
|
|
||||||
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
|
|
||||||
Detach();
|
|
||||||
_proPad = proPad;
|
|
||||||
_clientPad = clientPad;
|
|
||||||
_proPad.IsReadOnly = !CanWritePro;
|
|
||||||
_clientPad.IsReadOnly = !CanWriteClient;
|
|
||||||
_proPad.RedrawRequested += OnPadRedraw;
|
|
||||||
_clientPad.RedrawRequested += OnPadRedraw;
|
|
||||||
LoadExistingSignatures();
|
|
||||||
RefreshFromPad();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Detach()
|
|
||||||
{
|
|
||||||
if (_proPad is not null)
|
|
||||||
_proPad.RedrawRequested -= OnPadRedraw;
|
|
||||||
if (_clientPad is not null)
|
|
||||||
_clientPad.RedrawRequested -= OnPadRedraw;
|
|
||||||
_proPad = null;
|
|
||||||
_clientPad = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
|
|
||||||
|
|
||||||
/// <summary>The pad the current user may sign in.</summary>
|
|
||||||
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Render the signatures already stored for this estimate back into
|
|
||||||
/// their pads. The strokes are carried on the estimate payload as
|
|
||||||
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
|
|
||||||
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
|
|
||||||
/// its own side directly, in the same wire format PostIt captured.
|
|
||||||
/// No network round-trip.
|
|
||||||
/// </summary>
|
|
||||||
public void LoadExistingSignatures()
|
|
||||||
{
|
|
||||||
LoadSide(_proPad, _estimate.SignaturePro);
|
|
||||||
LoadSide(_clientPad, _estimate.SignatureClient);
|
|
||||||
RefreshFromPad();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
|
|
||||||
{
|
|
||||||
if (pad is null || sig is null) return;
|
|
||||||
if (sig.Strokes is { Length: > 0 } strokes)
|
|
||||||
pad.Load(strokes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshFromPad()
|
|
||||||
{
|
|
||||||
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
|
|
||||||
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
|
|
||||||
|
|
||||||
ProSignatureHint = CanWritePro
|
|
||||||
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
|
|
||||||
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
|
|
||||||
ClientSignatureHint = CanWriteClient
|
|
||||||
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
|
|
||||||
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
|
|
||||||
|
|
||||||
var pad = WritablePad;
|
|
||||||
if (pad is null) return;
|
|
||||||
var snap = pad.Snapshot();
|
|
||||||
StrokeCount = snap.StrokeCount;
|
|
||||||
PointCount = snap.PointCount;
|
|
||||||
HasSignature = !snap.IsEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnIsBusyChanged(bool value)
|
|
||||||
{
|
|
||||||
ValidateCommand.NotifyCanExecuteChanged();
|
|
||||||
RejectCommand.NotifyCanExecuteChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnHasSignatureChanged(bool value)
|
|
||||||
=> ValidateCommand.NotifyCanExecuteChanged();
|
|
||||||
|
|
||||||
private bool CanValidate => !IsBusy && HasSignature && HasCommand;
|
|
||||||
|
|
||||||
private bool CanReject => !IsBusy && HasCommand;
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanValidate))]
|
|
||||||
public async Task ValidateAsync()
|
|
||||||
{
|
|
||||||
if (_estimate.CommandId is not long queryId)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var pad = WritablePad;
|
|
||||||
if (pad is null || pad.Snapshot().IsEmpty)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Veuillez signer avant de valider.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var snap = pad.Snapshot();
|
|
||||||
IsBusy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var body = new QueryAcceptanceRequestDto
|
|
||||||
{
|
|
||||||
Strokes = snap.Strokes,
|
|
||||||
CoordinateMax = SignaturePadData.CoordinateMax,
|
|
||||||
CapturedAtUtc = DateTime.UtcNow,
|
|
||||||
// Declare which side the caller is signing as, so the
|
|
||||||
// server stores the signature under the right author
|
|
||||||
// (Pro/Client) even when one user is both parties.
|
|
||||||
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
|
|
||||||
};
|
|
||||||
|
|
||||||
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
this.SetInfoStatus("Devis validé.");
|
|
||||||
await GoBackAsync().ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus($"Validation refusée: {ex.Message}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Erreur: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReject))]
|
|
||||||
public async Task RejectAsync()
|
|
||||||
{
|
|
||||||
if (_estimate.CommandId is not long queryId)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IsBusy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
this.SetInfoStatus("Devis refusé.");
|
|
||||||
await GoBackAsync().ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Erreur: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
public void ClearSignature()
|
|
||||||
{
|
|
||||||
WritablePad?.Clear();
|
|
||||||
RefreshFromPad();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download the estimate as a LaTeX source document. The server
|
|
||||||
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
|
|
||||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
|
|
||||||
/// user picks a destination via the platform save picker.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
public async Task DownloadTexAsync()
|
|
||||||
{
|
|
||||||
await DownloadAsync(
|
|
||||||
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
|
|
||||||
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download the estimate as a compiled PDF
|
|
||||||
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
|
|
||||||
/// Requires <c>texi2pdf</c> on the server host; a failure is
|
|
||||||
/// surfaced as a status message.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
public async Task DownloadPdfAsync()
|
|
||||||
{
|
|
||||||
await DownloadAsync(
|
|
||||||
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
|
|
||||||
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
|
|
||||||
{
|
|
||||||
if (_estimate.CommandId is not long)
|
|
||||||
{
|
|
||||||
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IsBusy = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bytes = await fetch().ConfigureAwait(true);
|
|
||||||
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
|
|
||||||
this.SetInfoStatus(saved
|
|
||||||
? $"Fichier .{ext} enregistré."
|
|
||||||
: "Téléchargement annulé.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using Yavsc;
|
using Yavsc;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
|
|
@ -154,14 +153,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
|
||||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
|
||||||
|
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
activity,
|
activity,
|
||||||
performer,
|
performer,
|
||||||
form,
|
form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
frontClient,
|
|
||||||
details,
|
details,
|
||||||
isReadOnly: false);
|
isReadOnly: false);
|
||||||
|
|
||||||
|
|
@ -347,13 +344,11 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.InProgress => 0,
|
QueryStatus.InProgress => 0,
|
||||||
QueryStatus.ClientAccepted => 1,
|
QueryStatus.Accepted => 1,
|
||||||
QueryStatus.ProAccepted => 2,
|
QueryStatus.Inserted => 2,
|
||||||
QueryStatus.Accepted => 2,
|
QueryStatus.Success => 3,
|
||||||
QueryStatus.Inserted => 3,
|
QueryStatus.Rejected => 4,
|
||||||
QueryStatus.Success => 4,
|
QueryStatus.Failed => 5,
|
||||||
QueryStatus.Rejected => 5,
|
|
||||||
QueryStatus.Failed => 6,
|
|
||||||
_ => 99,
|
_ => 99,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ using Yavsc.Blogspot;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Abstract.Files;
|
using Yavsc.Abstract.Files;
|
||||||
using PostIt.Helpers;
|
using PostIt.Helpers;
|
||||||
using AvaloniaEdit.Document;
|
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -37,19 +36,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|
|
||||||
/// <summary>Editor buffer for the post body. Same pattern as
|
/// <summary>Editor buffer for the post body. Same pattern as
|
||||||
/// <see cref="DraftTitle"/>.</summary>
|
/// <see cref="DraftTitle"/>.</summary>
|
||||||
|
|
||||||
public string DraftArticle
|
|
||||||
{
|
|
||||||
get => DraftArticleDocument.Text;
|
|
||||||
set {
|
|
||||||
OnPropertyChanging(nameof(DraftArticle));
|
|
||||||
DraftArticleDocument = new TextDocument(value);
|
|
||||||
this.OnPropertyChanged(nameof(DraftArticle));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial TextDocument DraftArticleDocument {get; set;}
|
public partial string DraftArticle { get; set; }
|
||||||
|
|
||||||
/// <summary>Editor buffer for the post's publication state.
|
/// <summary>Editor buffer for the post's publication state.
|
||||||
/// Reflects the server-side <c>IsPublished</c> flag (the
|
/// Reflects the server-side <c>IsPublished</c> flag (the
|
||||||
|
|
@ -393,7 +381,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
WindowTitle = "PostIt";
|
WindowTitle = "PostIt";
|
||||||
DraftTitle = string.Empty;
|
DraftTitle = string.Empty;
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
|
|
||||||
DraftIsPublished = false;
|
DraftIsPublished = false;
|
||||||
IsLoaded = false;
|
IsLoaded = false;
|
||||||
// Production path: DI injects the canonical Settings singleton
|
// Production path: DI injects the canonical Settings singleton
|
||||||
|
|
@ -477,6 +464,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
// enable as soon as the user has typed a non-whitespace
|
// enable as soon as the user has typed a non-whitespace
|
||||||
// title, regardless of whether a post is selected.
|
// title, regardless of whether a post is selected.
|
||||||
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
|
||||||
private async Task RefreshPostsAsync()
|
private async Task RefreshPostsAsync()
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,7 @@ public class HomePageViewModel : ViewModelBase
|
||||||
throw new InvalidOperationException("Client devis indisponible.");
|
throw new InvalidOperationException("Client devis indisponible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
var vm = new EstimateListPageViewModel(estimateClient, perspective);
|
||||||
if (frontClient is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Client front-office indisponible.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
await app.PushPageAsync(vm);
|
await app.PushPageAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -259,13 +259,25 @@ public partial class Settings : ViewModelBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
|
||||||
var app = App.Current as App;
|
&& !string.IsNullOrWhiteSpace(envJson))
|
||||||
if (app!=null)
|
|
||||||
if (app.CliConfigFileSpecification!=null)
|
|
||||||
{
|
{
|
||||||
SettingsFileFullName = app.CliConfigFileSpecification;
|
Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
|
||||||
|
FileInfo configByEnvFileInfo = new FileInfo(envJson);
|
||||||
|
if (!configByEnvFileInfo.Exists)
|
||||||
|
{
|
||||||
|
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
|
||||||
|
}
|
||||||
|
string json = File.ReadAllText(configByEnvFileInfo.FullName);
|
||||||
|
ApplyJson(json, "POSTIT_SETTINGS_JSON");
|
||||||
|
SettingsFileFullName = configByEnvFileInfo.FullName;
|
||||||
|
Loaded = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
string configDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"PostIt"
|
||||||
|
);
|
||||||
|
|
||||||
if (SettingsFileFullName is not null)
|
if (SettingsFileFullName is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -275,18 +287,10 @@ public partial class Settings : ViewModelBase
|
||||||
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
|
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
|
||||||
&& !string.IsNullOrWhiteSpace(envPath))
|
&& !string.IsNullOrWhiteSpace(envPath))
|
||||||
{
|
{
|
||||||
FileInfo configByEnvFileInfo = new FileInfo(envPath);
|
SettingsFileFullName = envPath;
|
||||||
if (!configByEnvFileInfo.Exists)
|
|
||||||
{
|
|
||||||
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
|
|
||||||
}
|
|
||||||
SettingsFileFullName = configByEnvFileInfo.FullName;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
string configDir = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"PostIt");
|
|
||||||
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
|
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,7 +536,6 @@ public partial class Settings : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
|
||||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,10 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind the inner <see cref="SignaturePadControl"/> of the page's
|
/// Bind a freshly-constructed (or re-templated) control to this
|
||||||
/// <see cref="SignaturePadView"/> to this VM. Called from the
|
/// VM. Called from the view's code-behind once the control has
|
||||||
/// page's code-behind once the view is in the visual tree (so the
|
/// been added to the visual tree and its template applied (so
|
||||||
/// control's <see cref="SignaturePadControl.CaptureArea"/> is
|
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
|
||||||
/// wired). Attaching to the control directly (rather than the
|
|
||||||
/// view) keeps the same contract the headless tests use.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Attach(SignaturePadControl control)
|
public void Attach(SignaturePadControl control)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
|
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*">
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
Content="Retour"
|
Content="Retour"
|
||||||
Command="{Binding BackCommand}" />
|
Command="{Binding BackCommand}" />
|
||||||
|
|
@ -22,12 +22,6 @@
|
||||||
Content="Ouvrir en edition"
|
Content="Ouvrir en edition"
|
||||||
Command="{Binding OpenEditorCommand}"
|
Command="{Binding OpenEditorCommand}"
|
||||||
IsVisible="{Binding CanEdit}" />
|
IsVisible="{Binding CanEdit}" />
|
||||||
<Button Grid.Column="4"
|
|
||||||
Content="Télécharger PDF"
|
|
||||||
Command="{Binding DownloadPdfCommand}" />
|
|
||||||
<Button Grid.Column="6"
|
|
||||||
Content="Télécharger TeX"
|
|
||||||
Command="{Binding DownloadTexCommand}" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
<ScrollViewer Grid.Row="2" Margin="0,12,0,12">
|
||||||
|
|
|
||||||
|
|
@ -29,40 +29,34 @@
|
||||||
CornerRadius="8"
|
CornerRadius="8"
|
||||||
Padding="10"
|
Padding="10"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8">
|
||||||
<StackPanel Spacing="8">
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
<TextBlock Grid.Row="0"
|
||||||
<TextBlock Grid.Row="0"
|
Grid.Column="0"
|
||||||
Grid.Column="0"
|
Text="{Binding Title}"
|
||||||
Text="{Binding Title}"
|
FontWeight="Bold"
|
||||||
FontWeight="Bold"
|
TextWrapping="Wrap" />
|
||||||
TextWrapping="Wrap" />
|
<TextBlock Grid.Row="0"
|
||||||
<TextBlock Grid.Row="0"
|
Grid.Column="1"
|
||||||
Grid.Column="1"
|
Text="{Binding Id}"
|
||||||
Text="{Binding Id}"
|
FontSize="11"
|
||||||
FontSize="11"
|
Opacity="0.75"
|
||||||
Opacity="0.75"
|
HorizontalAlignment="Right" />
|
||||||
HorizontalAlignment="Right" />
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="0,6,0,0"
|
Margin="0,6,0,0"
|
||||||
Text="{Binding Description}"
|
Text="{Binding Description}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.75"
|
Opacity="0.75"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="0,6,0,0"
|
Margin="0,6,0,0"
|
||||||
Text="{Binding Bill.Count}"
|
Text="{Binding Bill.Count}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Opacity="0.65"
|
Opacity="0.65"
|
||||||
HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Button Content="Ouvrir"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
|
|
||||||
CommandParameter="{Binding}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -15,19 +14,4 @@ public partial class EstimateListPage : ContentPage
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch the list when returning to it (e.g. after validating an
|
|
||||||
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
|
|
||||||
// fires on the page popped back to with NavigationType.Pop; the list
|
|
||||||
// VM is retained on the nav stack while away, so this refresh picks
|
|
||||||
// up the server-side filter change that removed the signed estimate.
|
|
||||||
protected override void OnNavigatedTo(NavigatedToEventArgs e)
|
|
||||||
{
|
|
||||||
base.OnNavigatedTo(e);
|
|
||||||
if (e.NavigationType == NavigationType.Pop
|
|
||||||
&& DataContext is EstimateListPageViewModel vm)
|
|
||||||
{
|
|
||||||
_ = vm.RefreshAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
|
||||||
xmlns:controls="using:PostIt.Controls"
|
|
||||||
xmlns:dto="using:Yavsc.Api.Client"
|
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
|
||||||
x:Class="PostIt.Views.EstimateValidationPage"
|
|
||||||
x:DataType="vm:EstimateValidationPageViewModel"
|
|
||||||
Header="Validation du devis">
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
|
|
||||||
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,8">
|
|
||||||
<TextBlock Text="{Binding Title}"
|
|
||||||
FontSize="20"
|
|
||||||
FontWeight="Bold"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
<TextBlock Text="{Binding RoleLabel}" Opacity="0.75" />
|
|
||||||
<TextBlock Text="{Binding ValidationStateText}"
|
|
||||||
FontSize="11"
|
|
||||||
Opacity="0.75"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
|
||||||
<StackPanel Spacing="10">
|
|
||||||
<Border BorderThickness="1"
|
|
||||||
BorderBrush="#22000000"
|
|
||||||
CornerRadius="8"
|
|
||||||
Padding="10">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<Grid ColumnDefinitions="Auto,12,*">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
Text="N°"
|
|
||||||
FontWeight="SemiBold" />
|
|
||||||
<TextBlock Grid.Column="2"
|
|
||||||
Text="{Binding EstimateId}" />
|
|
||||||
</Grid>
|
|
||||||
<Grid ColumnDefinitions="Auto,12,*">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
Text="Type"
|
|
||||||
FontWeight="SemiBold" />
|
|
||||||
<TextBlock Grid.Column="2"
|
|
||||||
Text="{Binding CommandType}" />
|
|
||||||
</Grid>
|
|
||||||
<TextBlock Text="{Binding Description}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Opacity="0.85" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border BorderThickness="1"
|
|
||||||
BorderBrush="#22000000"
|
|
||||||
CornerRadius="8"
|
|
||||||
Padding="10">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Text="Lignes du devis" FontWeight="SemiBold" />
|
|
||||||
<ItemsControl ItemsSource="{Binding Bill}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="dto:EstimateLineDto">
|
|
||||||
<Grid ColumnDefinitions="*,8,60,8,120" Margin="0,0,0,4">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
Text="{Binding Name}"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
<TextBlock Grid.Column="2"
|
|
||||||
Text="{Binding Count}"
|
|
||||||
HorizontalAlignment="Right" />
|
|
||||||
<StackPanel Grid.Column="4"
|
|
||||||
Orientation="Horizontal"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Spacing="4">
|
|
||||||
<TextBlock Text="{Binding UnitaryCost, StringFormat='{}{0:N2}'}" />
|
|
||||||
<TextBlock Text="{Binding Currency}" Opacity="0.7" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border BorderThickness="1"
|
|
||||||
BorderBrush="#22000000"
|
|
||||||
CornerRadius="8"
|
|
||||||
Padding="10">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
|
|
||||||
<TextBlock Text="{Binding ProSignatureHint}"
|
|
||||||
FontSize="11"
|
|
||||||
Opacity="0.7" />
|
|
||||||
<controls:SignaturePadView x:Name="ProPad"
|
|
||||||
CaptureWidth="560"
|
|
||||||
CaptureHeight="160" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border BorderThickness="1"
|
|
||||||
BorderBrush="#22000000"
|
|
||||||
CornerRadius="8"
|
|
||||||
Padding="10">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
|
|
||||||
<TextBlock Text="{Binding ClientSignatureHint}"
|
|
||||||
FontSize="11"
|
|
||||||
Opacity="0.7" />
|
|
||||||
<controls:SignaturePadView x:Name="ClientPad"
|
|
||||||
CaptureWidth="560"
|
|
||||||
CaptureHeight="160" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<TextBlock FontSize="11"
|
|
||||||
Foreground="DarkSlateGray">
|
|
||||||
<Run Text="Votre signature : traits " />
|
|
||||||
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
|
|
||||||
<Run Text=" · points " />
|
|
||||||
<Run Text="{Binding PointCount, Mode=OneWay}" />
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="0,12,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
|
|
||||||
<Button Content="Effacer"
|
|
||||||
Command="{Binding ClearSignatureCommand}" />
|
|
||||||
<Button Content="Valider"
|
|
||||||
Command="{Binding ValidateCommand}" />
|
|
||||||
<Button Content="Refuser"
|
|
||||||
Command="{Binding RejectCommand}" />
|
|
||||||
<Button Content="Télécharger PDF"
|
|
||||||
Command="{Binding DownloadPdfCommand}" />
|
|
||||||
<Button Content="Télécharger TeX"
|
|
||||||
Command="{Binding DownloadTexCommand}" />
|
|
||||||
<Button Content="Retour"
|
|
||||||
Command="{Binding BackCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="2"
|
|
||||||
Orientation="Horizontal"
|
|
||||||
Spacing="8"
|
|
||||||
VerticalAlignment="Center">
|
|
||||||
<ProgressBar Width="120"
|
|
||||||
IsIndeterminate="True"
|
|
||||||
IsVisible="{Binding IsBusy}" />
|
|
||||||
<postitControls:StatusBar DataContext="{Binding ActionStatus}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</ContentPage>
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
using PostIt.Controls;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
|
||||||
|
|
||||||
public partial class EstimateValidationPage : ContentPage
|
|
||||||
{
|
|
||||||
private EstimateValidationPageViewModel? _vm;
|
|
||||||
|
|
||||||
public EstimateValidationPage()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
DataContextChanged += (_, _) => RebindViewModel(DataContext as EstimateValidationPageViewModel);
|
|
||||||
Unloaded += (_, _) => _vm?.Detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebindViewModel(EstimateValidationPageViewModel? vm)
|
|
||||||
{
|
|
||||||
if (_vm is not null)
|
|
||||||
{
|
|
||||||
_vm.Detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
_vm = vm;
|
|
||||||
|
|
||||||
if (_vm is null || ProPad is null || ClientPad is null) return;
|
|
||||||
|
|
||||||
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
|
||||||
xmlns:models="using:Yavsc.Blogspot"
|
xmlns:models="using:Yavsc.Blogspot"
|
||||||
xmlns:views="using:PostIt.Views.Blogs"
|
xmlns:views="using:PostIt.Views.Blogs"
|
||||||
|
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||||
|
|
@ -68,7 +68,6 @@
|
||||||
</CommandBarButton.Icon>
|
</CommandBarButton.Icon>
|
||||||
</CommandBarButton>
|
</CommandBarButton>
|
||||||
<CommandBarToggleButton Label="Publié"
|
<CommandBarToggleButton Label="Publié"
|
||||||
Command="{Binding TogglePublishCommand}"
|
|
||||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
|
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
|
||||||
<CommandBarToggleButton.Icon>
|
<CommandBarToggleButton.Icon>
|
||||||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||||
|
|
@ -132,14 +131,11 @@
|
||||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
||||||
<TextBox x:Name="DraftTitleTextBox"
|
<TextBox x:Name="DraftTitleTextBox"
|
||||||
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
||||||
|
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
|
||||||
<AvaloniaEdit:TextEditor
|
Text="{Binding DraftArticle, Mode=TwoWay}"
|
||||||
Grid.Row="2" VerticalAlignment="Stretch"
|
MinHeight="320"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
|
VerticalAlignment="Stretch" />
|
||||||
ShowLineNumbers="True"
|
|
||||||
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
|
|
||||||
|
|
||||||
|
|
||||||
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
|
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@
|
||||||
Command="{Binding OpenProviderRequests}"
|
Command="{Binding OpenProviderRequests}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Devis en attente de signature client"
|
<Button Content="Mes devis à valider"
|
||||||
Command="{Binding OpenClientEstimates}"
|
Command="{Binding OpenClientEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Devis en attente de signature fournisseur"
|
<Button Content="Mes devis en attente"
|
||||||
Command="{Binding OpenProviderEstimates}"
|
Command="{Binding OpenProviderEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,29 @@
|
||||||
FontWeight="Bold" />
|
FontWeight="Bold" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Capture surface. SignaturePadView owns the SignaturePadControl,
|
Capture surface. The SignaturePadControl is render-
|
||||||
the capture-area Border, the ink overlay canvas, and the
|
agnostic, so we host it inside a fixed-size Border
|
||||||
repaint logic; the page just sizes it from the VM and reads
|
that is the PART_CaptureArea, and overlay a Canvas
|
||||||
the captured data through the view's API.
|
for the visual feedback. The view's code-behind
|
||||||
|
repaints the canvas on every RedrawRequested.
|
||||||
-->
|
-->
|
||||||
<controls:SignaturePadView Grid.Row="1"
|
<Border Grid.Row="1"
|
||||||
x:Name="Pad"
|
x:Name="PadFrame"
|
||||||
CaptureWidth="{Binding Width}"
|
Width="{Binding Width}"
|
||||||
CaptureHeight="{Binding Height}" />
|
Height="{Binding Height}"
|
||||||
|
BorderBrush="Black"
|
||||||
|
BorderThickness="1"
|
||||||
|
Background="White"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid>
|
||||||
|
<controls:SignaturePadControl x:Name="Pad" />
|
||||||
|
<Canvas x:Name="InkLayer"
|
||||||
|
Background="Transparent"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Row="2"
|
<StackPanel Grid.Row="2"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Shapes;
|
||||||
|
using Avalonia.Media;
|
||||||
using PostIt.Controls;
|
using PostIt.Controls;
|
||||||
using PostIt.ViewModels;
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
|
@ -6,15 +11,26 @@ namespace PostIt.Views;
|
||||||
|
|
||||||
public partial class SignaturePage : ContentPage
|
public partial class SignaturePage : ContentPage
|
||||||
{
|
{
|
||||||
|
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
|
||||||
|
private const double StrokeThickness = 2.0;
|
||||||
|
private const double CoordinateMax = 10_000.0;
|
||||||
|
|
||||||
private SignaturePageViewModel? _vm;
|
private SignaturePageViewModel? _vm;
|
||||||
|
private SignaturePadControl? _control;
|
||||||
|
|
||||||
public SignaturePage()
|
public SignaturePage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
// SignaturePadView owns the capture area wiring and the ink
|
// Wire the capture area: the Pad itself is the control, the
|
||||||
// repaint; the page only forwards the view to the VM so it can
|
// surrounding Border (PadFrame) is the hit-test region. We
|
||||||
// read snapshots / subscribe to stroke events.
|
// set CaptureArea once the control's template has been
|
||||||
|
// applied — for an inline control with no template, that
|
||||||
|
// happens on first measure, which is guaranteed before
|
||||||
|
// the user can interact, so attaching here is safe.
|
||||||
|
_control = Pad;
|
||||||
|
_control.CaptureArea = PadFrame;
|
||||||
|
|
||||||
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
|
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,12 +39,82 @@ public partial class SignaturePage : ContentPage
|
||||||
if (_vm is not null)
|
if (_vm is not null)
|
||||||
{
|
{
|
||||||
_vm.Detach();
|
_vm.Detach();
|
||||||
|
_control!.RedrawRequested -= OnRedrawRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
_vm = vm;
|
_vm = vm;
|
||||||
|
|
||||||
if (_vm is null || Pad is null) return;
|
if (_vm is null || _control is null) return;
|
||||||
|
|
||||||
_vm.Attach(Pad.SignaturePad);
|
_vm.Attach(_control);
|
||||||
|
_control.RedrawRequested += OnRedrawRequested;
|
||||||
|
Repaint();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
|
||||||
|
|
||||||
|
private void Repaint()
|
||||||
|
{
|
||||||
|
if (_control is null || InkLayer is null) return;
|
||||||
|
|
||||||
|
InkLayer.Children.Clear();
|
||||||
|
var w = PadFrame.Bounds.Width;
|
||||||
|
var h = PadFrame.Bounds.Height;
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
var pending = _control.PendingStroke;
|
||||||
|
var strokes = _control.Strokes;
|
||||||
|
int sealedCount = strokes.Count - pending.Count;
|
||||||
|
if (sealedCount < 0)
|
||||||
|
{
|
||||||
|
sealedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
while (i < sealedCount)
|
||||||
|
{
|
||||||
|
int k = strokes[i];
|
||||||
|
if (k <= 0) break;
|
||||||
|
i++; // skip the length prefix
|
||||||
|
|
||||||
|
var poly = new Polyline
|
||||||
|
{
|
||||||
|
Stroke = StrokeBrush,
|
||||||
|
StrokeThickness = StrokeThickness,
|
||||||
|
StrokeLineCap = PenLineCap.Round,
|
||||||
|
StrokeJoin = PenLineJoin.Round,
|
||||||
|
};
|
||||||
|
var pts = new List<Point>(k);
|
||||||
|
for (int p = 0; p < k; p++)
|
||||||
|
{
|
||||||
|
int nx = strokes[i++];
|
||||||
|
int ny = strokes[i++];
|
||||||
|
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
||||||
|
}
|
||||||
|
poly.Points = pts;
|
||||||
|
InkLayer.Children.Add(poly);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.Count > 0)
|
||||||
|
{
|
||||||
|
var poly = new Polyline
|
||||||
|
{
|
||||||
|
Stroke = StrokeBrush,
|
||||||
|
StrokeThickness = StrokeThickness,
|
||||||
|
StrokeLineCap = PenLineCap.Round,
|
||||||
|
StrokeJoin = PenLineJoin.Round,
|
||||||
|
};
|
||||||
|
|
||||||
|
var pts = new List<Point>(pending.Count / 2);
|
||||||
|
for (int p = 0; p < pending.Count; p += 2)
|
||||||
|
{
|
||||||
|
int nx = pending[p];
|
||||||
|
int ny = pending[p + 1];
|
||||||
|
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
|
||||||
|
}
|
||||||
|
|
||||||
|
poly.Points = pts;
|
||||||
|
InkLayer.Children.Add(poly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,15 @@ namespace Yavsc
|
||||||
/// should be associated to any
|
/// should be associated to any
|
||||||
/// client user query to a provider user or
|
/// client user query to a provider user or
|
||||||
/// other external entity.
|
/// other external entity.
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Acceptance is split by party: <see cref="ProAccepted"/> when the
|
|
||||||
/// provider accepts the client's request (and produces/submits the
|
|
||||||
/// estimate), <see cref="ClientAccepted"/> when the client accepts
|
|
||||||
/// the provider's estimate. <see cref="Accepted"/> is the legacy
|
|
||||||
/// single-value kept for rows written before the split; new writes
|
|
||||||
/// use the party-specific values. Its integer value (2) is stable
|
|
||||||
/// so existing rows still deserialize.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum QueryStatus: int
|
public enum QueryStatus: int
|
||||||
{
|
{
|
||||||
Inserted = 0,
|
Inserted,
|
||||||
Rejected = 1,
|
Rejected,
|
||||||
Accepted = 2,
|
Accepted,
|
||||||
InProgress = 3,
|
InProgress,
|
||||||
// final states
|
// final states
|
||||||
Failed = 4,
|
Failed,
|
||||||
Success = 5,
|
Success
|
||||||
// party-specific acceptance (see summary)
|
|
||||||
ProAccepted = 6,
|
|
||||||
ClientAccepted = 7,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -39,39 +39,6 @@ public sealed class EstimateDto
|
||||||
public string CommandType { get; set; } = string.Empty;
|
public string CommandType { get; set; } = string.Empty;
|
||||||
public DateTime ProviderValidationDate { get; set; }
|
public DateTime ProviderValidationDate { get; set; }
|
||||||
public DateTime ClientValidationDate { get; set; }
|
public DateTime ClientValidationDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The provider's signature on this estimate, or null when the
|
|
||||||
/// provider has not signed. Carries the PostIt wire-format
|
|
||||||
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
|
|
||||||
/// repaint a previously drawn signature on reopen without a second
|
|
||||||
/// round-trip.
|
|
||||||
/// </summary>
|
|
||||||
public EstimateSignatureDto? SignaturePro { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The client's signature on this estimate, or null when the
|
|
||||||
/// client has not signed.
|
|
||||||
/// </summary>
|
|
||||||
public EstimateSignatureDto? SignatureClient { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One stored signature on an estimate, mirroring the JSON shape of
|
|
||||||
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
|
|
||||||
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
|
|
||||||
/// was captured and are loaded back into the pad unchanged. The side
|
|
||||||
/// (Pro/Client) is implicit in which property of
|
|
||||||
/// <see cref="EstimateDto"/> carries this DTO, so no <c>Type</c>
|
|
||||||
/// field is needed.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EstimateSignatureDto
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
public long EstimateId { get; set; }
|
|
||||||
public int CoordinateMax { get; set; }
|
|
||||||
public DateTime CapturedAtUtc { get; set; }
|
|
||||||
public int[]? Strokes { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
using System;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Body of <c>POST api/v1/front/query/accept</c> and
|
|
||||||
/// <c>.../query/reject</c>, mirroring the server-side
|
|
||||||
/// <c>Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest</c>.
|
|
||||||
/// <see cref="Strokes"/> is optional: when non-empty on accept it is
|
|
||||||
/// the PostIt wire-format signature captured with the acceptance; null
|
|
||||||
/// or empty means no signature (the only valid value on reject).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class QueryAcceptanceRequestDto
|
|
||||||
{
|
|
||||||
public int[]? Strokes { get; set; }
|
|
||||||
|
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
|
||||||
|
|
||||||
public DateTime? CapturedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which signature is being submitted:
|
|
||||||
/// <see cref="SignatureType.Pro"/> (the provider) or
|
|
||||||
/// <see cref="SignatureType.Client"/>. The PostIt client sets this
|
|
||||||
/// from the perspective the page was opened in (provider signs Pro,
|
|
||||||
/// client signs Client), so the server stores the signature under
|
|
||||||
/// the right author even when one user is both parties. Null lets
|
|
||||||
/// the server infer the role from the caller's identity.
|
|
||||||
/// </summary>
|
|
||||||
public SignatureType? SignatureType { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response of <c>POST .../query/accept</c> / <c>.../query/reject</c>
|
|
||||||
/// (<c>Ok(new { queryId, status, signature = ... })</c>). The
|
|
||||||
/// <see cref="Signature"/> ref is non-null only when a signature was
|
|
||||||
/// staged on accept.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class QueryAcceptanceResponseDto
|
|
||||||
{
|
|
||||||
public long QueryId { get; set; }
|
|
||||||
|
|
||||||
public string? Status { get; set; }
|
|
||||||
|
|
||||||
public QuerySignatureRefDto? Signature { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class QuerySignatureRefDto
|
|
||||||
{
|
|
||||||
public long Id { get; set; }
|
|
||||||
|
|
||||||
public long EstimateId { get; set; }
|
|
||||||
|
|
||||||
public string? Type { get; set; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Client;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP client for the front-office query routes
|
|
||||||
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
|
|
||||||
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
|
|
||||||
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
|
|
||||||
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
|
|
||||||
/// to <see cref="IYavscApiClient"/>.
|
|
||||||
///
|
|
||||||
/// The server resolves the caller's role (provider vs client vs admin)
|
|
||||||
/// from the authenticated user compared to the query's
|
|
||||||
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
|
|
||||||
/// role. <paramref name="billingCode"/> is the estimate's
|
|
||||||
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
|
|
||||||
/// is its <see cref="EstimateDto.CommandId"/>.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FrontOfficeApiClient
|
|
||||||
{
|
|
||||||
private const string PathPrefix = "front";
|
|
||||||
|
|
||||||
private readonly IYavscApiClient _api;
|
|
||||||
private readonly Func<string> _businessBaseAddress;
|
|
||||||
|
|
||||||
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
|
|
||||||
: this(api, () => businessBaseAddress)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
|
|
||||||
{
|
|
||||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
|
||||||
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
|
|
||||||
_ = ResolveBusinessBaseAddress();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Accept (validate) the query <paramref name="queryId"/> under
|
|
||||||
/// billing code <paramref name="billingCode"/>. When
|
|
||||||
/// <paramref name="body"/> carries a non-empty signature, the
|
|
||||||
/// server stages it on the query's linked estimate and stamps the
|
|
||||||
/// matching validation date.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<QueryAcceptanceResponseDto> AcceptQueryAsync(
|
|
||||||
string billingCode,
|
|
||||||
long queryId,
|
|
||||||
QueryAcceptanceRequestDto body,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(billingCode))
|
|
||||||
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
|
||||||
if (queryId <= 0)
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
||||||
if (body is null)
|
|
||||||
throw new ArgumentNullException(nameof(body));
|
|
||||||
|
|
||||||
var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
|
|
||||||
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
|
|
||||||
HttpMethod.Post,
|
|
||||||
Absolute(path),
|
|
||||||
body: body,
|
|
||||||
ct: ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reject the query <paramref name="queryId"/> under billing code
|
|
||||||
/// <paramref name="billingCode"/>. A signature is not meaningful on
|
|
||||||
/// a rejection; the server refuses one with 400, so this sends no
|
|
||||||
/// body.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<QueryAcceptanceResponseDto> RejectQueryAsync(
|
|
||||||
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/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
|
|
||||||
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
|
|
||||||
HttpMethod.Post,
|
|
||||||
Absolute(path),
|
|
||||||
body: null,
|
|
||||||
ct: ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download the estimate linked to query <paramref name="queryId"/>
|
|
||||||
/// as a LaTeX (<c>.tex</c>) source document
|
|
||||||
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
|
|
||||||
/// the raw TeX bytes (UTF-8 text).
|
|
||||||
/// </summary>
|
|
||||||
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
||||||
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
|
|
||||||
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download the estimate linked to query <paramref name="queryId"/>
|
|
||||||
/// as a compiled PDF document
|
|
||||||
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
|
|
||||||
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
|
|
||||||
/// a non-2xx response throws <see cref="HttpRequestException"/>.
|
|
||||||
/// </summary>
|
|
||||||
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
|
||||||
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
|
|
||||||
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
|
||||||
{
|
|
||||||
var raw = _businessBaseAddress();
|
|
||||||
if (string.IsNullOrWhiteSpace(raw))
|
|
||||||
throw new InvalidOperationException("Business base address is required.");
|
|
||||||
|
|
||||||
return new Uri(raw, UriKind.Absolute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -73,23 +73,4 @@ public interface IYavscApiClient : IAsyncDisposable
|
||||||
string path,
|
string path,
|
||||||
Func<HttpContent> contentFactory,
|
Func<HttpContent> contentFactory,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Download a binary resource (PDF, TeX, image, …). Unlike
|
|
||||||
/// <see cref="CallAsync{T}(HttpMethod,string,object?,CancellationToken)"/>,
|
|
||||||
/// which JSON-decodes the response, this returns the raw response
|
|
||||||
/// bytes. The same Bearer-token attach + 401-refresh-retry-once
|
|
||||||
/// contract applies.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A default interface implementation is provided so that test
|
|
||||||
/// fakes that do not exercise binary download still compile without
|
|
||||||
/// declaring the member; the real <see cref="YavscApiClient"/>
|
|
||||||
/// overrides it with the authenticated transport.
|
|
||||||
/// </remarks>
|
|
||||||
Task<byte[]> DownloadAsync(
|
|
||||||
HttpMethod method,
|
|
||||||
string path,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(Array.Empty<byte>());
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
<Library>true</Library>
|
<Library>true</Library>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||||
|
|
|
||||||
7
src/Yavsc.Api.Test/Directory.Packages.props
Normal file
7
src/Yavsc.Api.Test/Directory.Packages.props
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<Project>
|
||||||
|
<!--
|
||||||
|
Yavsc.Api.Test has no project-specific package versions. All
|
||||||
|
package versions are declared at the repository root.
|
||||||
|
-->
|
||||||
|
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||||
|
</Project>
|
||||||
|
|
@ -209,12 +209,9 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds an estimate owned by alice (the provider) for the given
|
/// Seeds an estimate owned by alice (the provider) for the given
|
||||||
/// client, optionally already validated by the client. By default the
|
/// client, optionally already validated by the client.
|
||||||
/// estimate is also provider-validated (i.e. the provider has submitted
|
|
||||||
/// it); pass <paramref name="providerValidated"/> = false to seed a
|
|
||||||
/// freshly inserted request the provider hasn't worked on yet.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private long SeedEstimate(string clientId, bool clientValidated, bool providerValidated = true)
|
private long SeedEstimate(string clientId, bool clientValidated)
|
||||||
{
|
{
|
||||||
using var scope = _fixture.Services.CreateScope();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
@ -232,7 +229,6 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
{
|
{
|
||||||
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
|
||||||
},
|
},
|
||||||
ProviderValidationDate = providerValidated ? DateTime.UtcNow : default,
|
|
||||||
ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
|
ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
|
||||||
};
|
};
|
||||||
db.Estimates.Add(estimate);
|
db.Estimates.Add(estimate);
|
||||||
|
|
@ -264,34 +260,11 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetOngoingEstimatesAsClient_excludes_inserted_requests_not_yet_provider_validated()
|
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
|
||||||
{
|
{
|
||||||
_fixture.ResetAndSeedActivityGraph();
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
var submittedId = SeedEstimate(clientId: "bob", clientValidated: false); // provider submitted: visible
|
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
|
||||||
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false); // inserted, not worked on: excluded
|
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
|
||||||
|
|
||||||
using var http = NewClient("bob");
|
|
||||||
|
|
||||||
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
|
||||||
var item = Assert.Single(items);
|
|
||||||
Assert.Equal(submittedId, item.GetProperty("id").GetInt64());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
|
|
||||||
{
|
|
||||||
_fixture.ResetAndSeedActivityGraph();
|
|
||||||
// Not yet provider-signed: visible in the provider's "awaiting
|
|
||||||
// provider signature" list.
|
|
||||||
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
|
|
||||||
// Provider already signed: must leave the list immediately.
|
|
||||||
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
|
|
||||||
|
|
||||||
using var http = NewClient("alice");
|
using var http = NewClient("alice");
|
||||||
|
|
||||||
|
|
@ -303,7 +276,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
var items = doc.RootElement.EnumerateArray().ToArray();
|
var items = doc.RootElement.EnumerateArray().ToArray();
|
||||||
var item = Assert.Single(items);
|
var item = Assert.Single(items);
|
||||||
Assert.Equal(pendingId, item.GetProperty("id").GetInt64());
|
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
|
||||||
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
|
||||||
Assert.Single(item.GetProperty("bill").EnumerateArray());
|
Assert.Single(item.GetProperty("bill").EnumerateArray());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Reflection;
|
|
||||||
using Yavsc.Helpers;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.ViewModels.Gen;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
|
||||||
|
|
||||||
// Unit test of the estimate PDF generation pipeline (no HTTP fixture
|
|
||||||
// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing
|
|
||||||
// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf
|
|
||||||
// — the lualatex stdin compile that writes <name>.pdf into DestDir. The
|
|
||||||
// TeX source mirrors what the Estimate_tex template emits for a devis
|
|
||||||
// signed by both parties: two side-by-side minipages, each with a
|
|
||||||
// tikzpicture bounded by \useasboundingbox and the strokes drawn by
|
|
||||||
// SignatureToTikz.
|
|
||||||
//
|
|
||||||
// The PDF test requires the system `lualatex` (and the TeX Live packages
|
|
||||||
// listed in the README runtime-deps section) on the host running the
|
|
||||||
// tests. On hosts without lualatex it skips cleanly rather than failing.
|
|
||||||
public sealed class EstimatePdfGenerationTests
|
|
||||||
{
|
|
||||||
// A provider signature: a downstroke, a cross, and a tap (dot).
|
|
||||||
// PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in
|
|
||||||
// [0, 10000], y-down.
|
|
||||||
private static readonly int[] ProStrokes =
|
|
||||||
{
|
|
||||||
4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900,
|
|
||||||
3, 2000, 5000, 3500, 4500, 3500, 5100,
|
|
||||||
1, 4000, 3000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// A client signature: a wavy stroke and a shorter one.
|
|
||||||
private static readonly int[] CliStrokes =
|
|
||||||
{
|
|
||||||
5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500,
|
|
||||||
2, 3000, 6000, 4200, 5500,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SignatureToTikz_RendersBothSignaturesAsVectorDraws()
|
|
||||||
{
|
|
||||||
var pro = MakeSig(SignatureType.Pro, ProStrokes);
|
|
||||||
var cli = MakeSig(SignatureType.Client, CliStrokes);
|
|
||||||
|
|
||||||
var proTex = pro.SignatureToTikz(5, 2.5).ToString();
|
|
||||||
var cliTex = cli.SignatureToTikz(5, 2.5).ToString();
|
|
||||||
|
|
||||||
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex);
|
|
||||||
Assert.Contains("\\fill", proTex); // the single-point tap → dot
|
|
||||||
Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped
|
|
||||||
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex);
|
|
||||||
Assert.Contains("--", cliTex);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SignatureToTikz_EmptySignatureIsEmpty()
|
|
||||||
{
|
|
||||||
var empty = MakeSig(SignatureType.Pro, Array.Empty<int>());
|
|
||||||
Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir()
|
|
||||||
{
|
|
||||||
if (!IsLualatexAvailable())
|
|
||||||
throw Xunit.Sdk.SkipException.ForSkip(
|
|
||||||
"lualatex is not installed on this host; skipping PDF generation test. " +
|
|
||||||
"Install the TeX Live runtime deps documented in the README.");
|
|
||||||
|
|
||||||
var destDir = Path.Combine(Path.GetTempPath(),
|
|
||||||
"yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N"));
|
|
||||||
Directory.CreateDirectory(destDir);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pro = MakeSig(SignatureType.Pro, ProStrokes);
|
|
||||||
var cli = MakeSig(SignatureType.Client, CliStrokes);
|
|
||||||
var baseName = "estimate-test-both-signed";
|
|
||||||
|
|
||||||
var model = new PdfGenerationViewModel
|
|
||||||
{
|
|
||||||
TeXSource = BuildEstimateTeX(pro, cli),
|
|
||||||
BaseFileName = baseName,
|
|
||||||
DestDir = destDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
var fo = model.GenerateEstimatePdf();
|
|
||||||
|
|
||||||
// The PDF must exist, at the expected path, and be non-empty.
|
|
||||||
var expectedPath = Path.Combine(destDir, baseName + ".pdf");
|
|
||||||
Assert.True(fo is not null && fo.Exists,
|
|
||||||
"GenerateEstimatePdf returned a non-existent file. Error: "
|
|
||||||
+ model.GenerationErrorMessage?.Value);
|
|
||||||
Assert.Equal(expectedPath, fo.FullName);
|
|
||||||
Assert.True(fo.Length > 0, "generated PDF is empty");
|
|
||||||
|
|
||||||
// Only the PDF should persist in DestDir — aux/log artifacts
|
|
||||||
// are cleaned up by GenerateEstimatePdf on success.
|
|
||||||
var leftovers = Directory.GetFiles(destDir)
|
|
||||||
.Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal))
|
|
||||||
.ToArray();
|
|
||||||
Assert.Empty(leftovers);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (Directory.Exists(destDir))
|
|
||||||
Directory.Delete(destDir, recursive: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Signature MakeSig(SignatureType type, int[] strokes)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Type = type,
|
|
||||||
CoordinateMax = 10_000,
|
|
||||||
Strokes = strokes,
|
|
||||||
CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Builds the LaTeX source the Estimate_tex template would emit for a
|
|
||||||
// devis signed by both parties: same preamble (incl. tikz + bera) and
|
|
||||||
// the two-minipage signature foot, with the strokes drawn by
|
|
||||||
// SignatureToTikz.
|
|
||||||
private static string BuildEstimateTeX(Signature pro, Signature cli)
|
|
||||||
{
|
|
||||||
var fr = CultureInfo.CreateSpecificCulture("fr-FR");
|
|
||||||
var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr);
|
|
||||||
var proTikz = pro.SignatureToTikz(5, 2.5).ToString();
|
|
||||||
var cliTikz = cli.SignatureToTikz(5, 2.5).ToString();
|
|
||||||
|
|
||||||
return $@"\documentclass[french,11pt]{{article}}
|
|
||||||
\usepackage{{eurosym}}
|
|
||||||
\usepackage{{babel}}
|
|
||||||
\usepackage[T1]{{fontenc}}
|
|
||||||
\usepackage[utf8]{{inputenc}}
|
|
||||||
\usepackage[a4paper]{{geometry}}
|
|
||||||
\usepackage{{units}}
|
|
||||||
\usepackage{{bera}}
|
|
||||||
\usepackage{{graphicx}}
|
|
||||||
\usepackage{{fancyhdr}}
|
|
||||||
\usepackage{{fp}}
|
|
||||||
\usepackage{{tikz}}
|
|
||||||
\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}}
|
|
||||||
\setlength{{\parindent}}{{0pt}}
|
|
||||||
\begin{{document}}
|
|
||||||
Devis n°1 --- test signe par les deux parties
|
|
||||||
|
|
||||||
\vspace{{2em}}
|
|
||||||
\noindent
|
|
||||||
\begin{{minipage}}[t]{{0.45\textwidth}}
|
|
||||||
\textbf{{Signature du fournisseur}}\\
|
|
||||||
\small Valide le {date}\\
|
|
||||||
\begin{{tikzpicture}}
|
|
||||||
\useasboundingbox (0,0) rectangle (5,2.5);
|
|
||||||
{proTikz}
|
|
||||||
\end{{tikzpicture}}
|
|
||||||
\end{{minipage}}\hfill
|
|
||||||
\begin{{minipage}}[t]{{0.45\textwidth}}
|
|
||||||
\textbf{{Signature du client}}\\
|
|
||||||
\small Valide le {date}\\
|
|
||||||
\begin{{tikzpicture}}
|
|
||||||
\useasboundingbox (0,0) rectangle (5,2.5);
|
|
||||||
{cliTikz}
|
|
||||||
\end{{tikzpicture}}
|
|
||||||
\end{{minipage}}
|
|
||||||
\end{{document}}
|
|
||||||
";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsLualatexAvailable()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var p = Process.Start(new ProcessStartInfo("lualatex", "--version")
|
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
});
|
|
||||||
if (p is null) return false;
|
|
||||||
return p.WaitForExit(5000) && p.ExitCode == 0;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Api.Test.Fixtures;
|
using Yavsc.Api.Test.Fixtures;
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.Models.Workflow;
|
|
||||||
using Yavsc.Tests.Shared;
|
using Yavsc.Tests.Shared;
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
namespace Yavsc.Api.Test;
|
||||||
|
|
@ -39,95 +34,21 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal valid PostIt wire-format signature: one stroke of one
|
|
||||||
// point (k=1, then 2*k coords). See PostIt.Models.SignaturePadData.
|
|
||||||
private static readonly int[] SampleStrokes = { 1, 5000, 5000 };
|
|
||||||
|
|
||||||
private static HttpContent NoSignature() => JsonBody(new { });
|
|
||||||
|
|
||||||
private static HttpContent WithSignature(SignatureType? type = null) =>
|
|
||||||
type is null
|
|
||||||
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
|
|
||||||
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
|
|
||||||
|
|
||||||
private static JsonContent JsonBody(object value) =>
|
|
||||||
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
|
|
||||||
/// true, an estimate linked to it via CommandId. The provider is
|
|
||||||
/// <paramref name="performer"/> (default alice) and the client is
|
|
||||||
/// <paramref name="client"/> (default bob) — pass the same value
|
|
||||||
/// for both to model a user who is both parties. Returns the query
|
|
||||||
/// id and (when seeded) the estimate id.
|
|
||||||
/// </summary>
|
|
||||||
private (long queryId, long estimateId) SeedQuery(
|
|
||||||
bool withEstimate,
|
|
||||||
string performer = "alice",
|
|
||||||
string client = "bob")
|
|
||||||
{
|
|
||||||
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 = client,
|
|
||||||
PerformerId = performer,
|
|
||||||
Consent = true,
|
|
||||||
UserCreated = performer,
|
|
||||||
UserModified = performer,
|
|
||||||
DateCreated = DateTime.UtcNow,
|
|
||||||
DateModified = DateTime.UtcNow,
|
|
||||||
EventDate = DateTime.UtcNow.AddDays(1),
|
|
||||||
Location = location,
|
|
||||||
Reason = "Initial rendez-vous",
|
|
||||||
Status = Yavsc.QueryStatus.Inserted,
|
|
||||||
};
|
|
||||||
db.RdvQueries.Add(query);
|
|
||||||
db.SaveChanges();
|
|
||||||
|
|
||||||
long estimateId = 0;
|
|
||||||
if (withEstimate)
|
|
||||||
{
|
|
||||||
var estimate = new Estimate
|
|
||||||
{
|
|
||||||
CommandId = query.Id,
|
|
||||||
ClientId = client,
|
|
||||||
OwnerId = performer,
|
|
||||||
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();
|
|
||||||
estimateId = estimate.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (query.Id, estimateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Front_accept_query_updates_status_without_server_error()
|
public async Task Front_accept_query_updates_status_without_server_error()
|
||||||
{
|
{
|
||||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
WorkflowHelpers.ConfigureBillingService();
|
||||||
|
_fixture.ResetAndSeedRdvQueryGraph();
|
||||||
|
|
||||||
|
long queryId;
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
queryId = db.RdvQueries.Select(q => q.Id).Single();
|
||||||
|
}
|
||||||
|
|
||||||
using var http = NewClient();
|
using var http = NewClient();
|
||||||
var response = await http.PostAsync(
|
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
NoSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
||||||
|
|
@ -136,207 +57,6 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
||||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
|
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
|
||||||
|
|
||||||
// Default caller is "alice" (the provider) → ProAccepted.
|
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
||||||
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
[Fact]
|
|
||||||
public async Task Provider_accept_with_signature_sets_ProValidationDate_and_Pro_signature()
|
|
||||||
{
|
|
||||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
||||||
|
|
||||||
using var assertScope = _fixture.Services.CreateScope();
|
|
||||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
|
|
||||||
Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
|
||||||
|
|
||||||
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
|
||||||
Assert.True(estimate.ProviderValidationDate != default);
|
|
||||||
Assert.True(estimate.ClientValidationDate == default);
|
|
||||||
|
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
|
||||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
|
||||||
Assert.Equal("alice", signature.SignerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Client_accept_with_signature_sets_ClientValidationDate_and_Client_signature()
|
|
||||||
{
|
|
||||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "bob");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
||||||
|
|
||||||
using var assertScope = _fixture.Services.CreateScope();
|
|
||||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
|
|
||||||
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
|
||||||
|
|
||||||
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
|
||||||
Assert.True(estimate.ClientValidationDate != default);
|
|
||||||
Assert.True(estimate.ProviderValidationDate == default);
|
|
||||||
|
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
|
||||||
Assert.Equal(SignatureType.Client, signature.Type);
|
|
||||||
Assert.Equal("bob", signature.SignerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_sets_status_Rejected()
|
|
||||||
{
|
|
||||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
|
||||||
NoSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
||||||
|
|
||||||
using var assertScope = _fixture.Services.CreateScope();
|
|
||||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
Assert.Equal(QueryStatus.Rejected, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Non_party_actor_is_forbidden()
|
|
||||||
{
|
|
||||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "carol");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
NoSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_with_signature_but_no_estimate_returns_400()
|
|
||||||
{
|
|
||||||
var (queryId, _) = SeedQuery(withEstimate: false);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reject_with_signature_returns_400()
|
|
||||||
{
|
|
||||||
var (queryId, _) = SeedQuery(withEstimate: true);
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Estimate_payload_carries_existing_signature_strokes()
|
|
||||||
{
|
|
||||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
|
||||||
|
|
||||||
// Provider accepts with a signature.
|
|
||||||
using (var pro = NewClient(subject: "alice"))
|
|
||||||
{
|
|
||||||
var r = await pro.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(r.IsSuccessStatusCode, await r.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The estimate read endpoint carries the signature strokes on
|
|
||||||
// the estimate payload, so PostIt can repaint them on reopen.
|
|
||||||
using var reader = NewClient(subject: "alice");
|
|
||||||
var response = await reader.GetAsync(
|
|
||||||
$"/api/v1/estimate/{estimateId}",
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Signatures ride on the estimate payload as two dedicated
|
|
||||||
// slots, not a list: the provider signed here, so signaturePro
|
|
||||||
// is populated and signatureClient is absent/null.
|
|
||||||
var proSig = body.GetProperty("signaturePro");
|
|
||||||
Assert.Equal(JsonValueKind.Object, proSig.ValueKind);
|
|
||||||
Assert.Equal(JsonValueKind.Array, proSig.GetProperty("strokes").ValueKind);
|
|
||||||
Assert.True(body.TryGetProperty("signatureClient", out var cli)
|
|
||||||
&& cli.ValueKind == JsonValueKind.Null,
|
|
||||||
"signatureClient should be null when only the provider has signed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// When one user is both the provider and the client of a query, the
|
|
||||||
// server cannot infer which side they are signing as from identity
|
|
||||||
// alone (it would always pick the provider). The caller declares the
|
|
||||||
// side (signatureType=1 here = Client); the server honors it because
|
|
||||||
// the caller is that party, and stores a Client signature.
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_declared_client_side_stores_client_signature_when_user_is_both_parties()
|
|
||||||
{
|
|
||||||
var (queryId, estimateId) = SeedQuery(withEstimate: true, performer: "alice", client: "alice");
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice");
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(SignatureType.Client), // signing as the client
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
|
|
||||||
|
|
||||||
using var assertScope = _fixture.Services.CreateScope();
|
|
||||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
|
|
||||||
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
|
|
||||||
|
|
||||||
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
|
|
||||||
Assert.True(estimate.ClientValidationDate != default);
|
|
||||||
Assert.True(estimate.ProviderValidationDate == default);
|
|
||||||
|
|
||||||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
|
||||||
Assert.Equal(SignatureType.Client, signature.Type);
|
|
||||||
Assert.Equal("alice", signature.SignerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A provider may not sign as the client (and vice versa): declaring
|
|
||||||
// the other party's side is refused even though the caller is a
|
|
||||||
// party to the query. Write is authorized only for the signature's
|
|
||||||
// author.
|
|
||||||
[Fact]
|
|
||||||
public async Task Accept_declaring_other_party_side_is_forbidden()
|
|
||||||
{
|
|
||||||
var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob
|
|
||||||
|
|
||||||
using var http = NewClient(subject: "alice"); // alice is the provider
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
|
||||||
WithSignature(SignatureType.Client), // alice tries to sign as the client
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" />
|
||||||
<PackageReference Include="xunit.v3.common" />
|
<PackageReference Include="xunit.v3.common" />
|
||||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,6 @@ namespace Yavsc.ApiControllers
|
||||||
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
||||||
.Where(q => q.Status == QueryStatus.Inserted
|
.Where(q => q.Status == QueryStatus.Inserted
|
||||||
|| q.Status == QueryStatus.Accepted
|
|| q.Status == QueryStatus.Accepted
|
||||||
|| q.Status == QueryStatus.ProAccepted
|
|
||||||
|| q.Status == QueryStatus.ClientAccepted
|
|
||||||
|| q.Status == QueryStatus.InProgress)
|
|| q.Status == QueryStatus.InProgress)
|
||||||
.Cast<NominativeServiceCommand>()
|
.Cast<NominativeServiceCommand>()
|
||||||
.ToList()
|
.ToList()
|
||||||
|
|
@ -171,8 +169,6 @@ namespace Yavsc.ApiControllers
|
||||||
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
||||||
.Where(q => q.Status == QueryStatus.Inserted
|
.Where(q => q.Status == QueryStatus.Inserted
|
||||||
|| q.Status == QueryStatus.Accepted
|
|| q.Status == QueryStatus.Accepted
|
||||||
|| q.Status == QueryStatus.ProAccepted
|
|
||||||
|| q.Status == QueryStatus.ClientAccepted
|
|
||||||
|| q.Status == QueryStatus.InProgress)
|
|| q.Status == QueryStatus.InProgress)
|
||||||
.Cast<NominativeServiceCommand>()
|
.Cast<NominativeServiceCommand>()
|
||||||
.ToList()
|
.ToList()
|
||||||
|
|
@ -185,8 +181,6 @@ namespace Yavsc.ApiControllers
|
||||||
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
|
||||||
.Where(q => q.Status == QueryStatus.Inserted
|
.Where(q => q.Status == QueryStatus.Inserted
|
||||||
|| q.Status == QueryStatus.Accepted
|
|| q.Status == QueryStatus.Accepted
|
||||||
|| q.Status == QueryStatus.ProAccepted
|
|
||||||
|| q.Status == QueryStatus.ClientAccepted
|
|
||||||
|| q.Status == QueryStatus.InProgress)
|
|| q.Status == QueryStatus.InProgress)
|
||||||
.Cast<NominativeServiceCommand>()
|
.Cast<NominativeServiceCommand>()
|
||||||
.ToList()
|
.ToList()
|
||||||
|
|
@ -335,9 +329,9 @@ namespace Yavsc.ApiControllers
|
||||||
return new ChallengeResult();
|
return new ChallengeResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
|
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
||||||
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
|
||||||
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
|
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
|
||||||
return File(fi.OpenRead(), "application/x-pdf", filename);
|
return File(fi.OpenRead(), "application/x-pdf", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,12 +401,79 @@ namespace Yavsc.ApiControllers
|
||||||
Strokes = body.Strokes,
|
Strokes = body.Strokes,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The Signature row is staged by the shared persister; we
|
// Disk write first: a disk failure shouldn't leave
|
||||||
// SaveChanges here so the signature write is one
|
// a Signature row pointing at a file that doesn't
|
||||||
// transaction. The Strokes column is the source of truth —
|
// exist. The file helper throws on filesystem
|
||||||
// there is no on-disk copy to roll back on failure.
|
// problems and propagates here.
|
||||||
var signature = await EstimateSignaturePersister.StageAsync(
|
FileReceivedInfo fi;
|
||||||
dbContext, id, type, userId, payload, token);
|
try
|
||||||
|
{
|
||||||
|
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
|
||||||
|
siteSettings, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
|
||||||
|
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-add: the (EstimateId, Type) pair is
|
||||||
|
// unique, so a second POST for the same side of the
|
||||||
|
// estimate replaces the previous signature. EF
|
||||||
|
// translates this into a single UPDATE when the
|
||||||
|
// row exists and an INSERT otherwise; the unique
|
||||||
|
// index in ApplicationDbContext is the
|
||||||
|
// database-level guarantee that the contract
|
||||||
|
// holds if two requests race.
|
||||||
|
var signature = await dbContext.Signatures
|
||||||
|
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
|
||||||
|
|
||||||
|
if (signature is null)
|
||||||
|
{
|
||||||
|
signature = new Signature
|
||||||
|
{
|
||||||
|
EstimateId = id,
|
||||||
|
SignerId = userId,
|
||||||
|
Type = type,
|
||||||
|
};
|
||||||
|
dbContext.Signatures.Add(signature);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Roll the signer's quota back by the size of
|
||||||
|
// the file we're about to orphan: the old
|
||||||
|
// FilePath is no longer referenced once we
|
||||||
|
// overwrite FilePath below.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var orphan = new FileInfo(signature.FilePath);
|
||||||
|
if (orphan.Exists)
|
||||||
|
{
|
||||||
|
var signerForOrphan = await dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId, token);
|
||||||
|
if (signerForOrphan is not null)
|
||||||
|
signerForOrphan.DiskUsage =
|
||||||
|
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best effort — the file is being replaced anyway */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
signature.SignerId = userId;
|
||||||
|
signature.CoordinateMax = payload.CoordinateMax;
|
||||||
|
signature.Strokes = payload.Strokes;
|
||||||
|
signature.CapturedAtUtc = payload.CapturedAtUtc;
|
||||||
|
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
|
||||||
|
|
||||||
|
// Bump the signer's quota. The Signature row's
|
||||||
|
// SignerId is the IdentityUser.Id (a string), so we
|
||||||
|
// look up by Id and not by username.
|
||||||
|
var signer = await dbContext.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId, token);
|
||||||
|
if (signer is not null)
|
||||||
|
{
|
||||||
|
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -421,6 +482,10 @@ namespace Yavsc.ApiControllers
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
logger.LogError(ex, "estimate {Id}: signature db write failed", id);
|
||||||
|
// Best-effort rollback: remove the file we wrote
|
||||||
|
// so disk and db don't disagree.
|
||||||
|
try { System.IO.File.Delete(signature.FilePath); }
|
||||||
|
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ namespace Yavsc.Controllers
|
||||||
else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ;
|
else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ;
|
||||||
// or just do nothing
|
// or just do nothing
|
||||||
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId));
|
return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId));
|
||||||
}
|
}
|
||||||
// GET: api/Estimate/5
|
// GET: api/Estimate/5
|
||||||
[HttpGet("{id}", Name = "GetEstimate")]
|
[HttpGet("{id}", Name = "GetEstimate")]
|
||||||
|
|
@ -51,7 +51,7 @@ namespace Yavsc.Controllers
|
||||||
return BadRequest(ModelState);
|
return BadRequest(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id);
|
Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id);
|
||||||
|
|
||||||
if (estimate == null)
|
if (estimate == null)
|
||||||
{
|
{
|
||||||
|
|
@ -201,31 +201,23 @@ namespace Yavsc.Controllers
|
||||||
// GET: api/estimate/asclient
|
// GET: api/estimate/asclient
|
||||||
// Ongoing estimates (not yet validated by the client) where the
|
// Ongoing estimates (not yet validated by the client) where the
|
||||||
// current user is the client who initiated the request.
|
// current user is the client who initiated the request.
|
||||||
// An estimate only becomes visible to the client once the provider
|
|
||||||
// has actually established and submitted it (ProviderValidationDate);
|
|
||||||
// a freshly inserted request the provider hasn't worked on yet is
|
|
||||||
// not something the client can validate, so it's excluded.
|
|
||||||
[HttpGet("asclient")]
|
[HttpGet("asclient")]
|
||||||
public IActionResult GetOngoingEstimatesAsClient()
|
public IActionResult GetOngoingEstimatesAsClient()
|
||||||
{
|
{
|
||||||
var uid = User.GetUserId();
|
var uid = User.GetUserId();
|
||||||
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
return Ok(_context.Estimates.Include(e => e.Bill)
|
||||||
.Where(e => e.ClientId == uid
|
.Where(e => e.ClientId == uid && e.ClientValidationDate == default));
|
||||||
&& e.ProviderValidationDate != default
|
|
||||||
&& e.ClientValidationDate == default));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/estimate/asprovider
|
// GET: api/estimate/asprovider
|
||||||
// Estimates awaiting the provider's signature, established by the
|
// Ongoing estimates (not yet validated by the client) established
|
||||||
// current user as the provider. Once the provider validates
|
// by the current user as the provider.
|
||||||
// (ProviderValidationDate stamped), the estimate leaves this list
|
|
||||||
// and appears in the client's "awaiting client signature" list.
|
|
||||||
[HttpGet("asprovider")]
|
[HttpGet("asprovider")]
|
||||||
public IActionResult GetOngoingEstimatesAsProvider()
|
public IActionResult GetOngoingEstimatesAsProvider()
|
||||||
{
|
{
|
||||||
var uid = User.GetUserId();
|
var uid = User.GetUserId();
|
||||||
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
return Ok(_context.Estimates.Include(e => e.Bill)
|
||||||
.Where(e => e.OwnerId == uid && e.ProviderValidationDate == default));
|
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Razor;
|
|
||||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Yavsc.Abstract.Workflow;
|
|
||||||
using Yavsc.Helpers;
|
using Yavsc.Helpers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
using Yavsc.ViewModels.FrontOffice;
|
using Yavsc.ViewModels.FrontOffice;
|
||||||
using Yavsc.ViewModels.Gen;
|
|
||||||
|
|
||||||
namespace Yavsc.ApiControllers
|
namespace Yavsc.ApiControllers
|
||||||
{
|
{
|
||||||
|
|
@ -20,38 +13,13 @@ namespace Yavsc.ApiControllers
|
||||||
public class FrontOfficeApiController : Controller
|
public class FrontOfficeApiController : Controller
|
||||||
{
|
{
|
||||||
ApplicationDbContext dbContext;
|
ApplicationDbContext dbContext;
|
||||||
private readonly IBillingService billing;
|
|
||||||
private readonly ILogger logger;
|
|
||||||
private readonly SiteSettings siteSettings;
|
|
||||||
private readonly IRazorViewEngine viewEngine;
|
|
||||||
|
|
||||||
// The view-rendering dependencies (siteSettings, viewEngine) are
|
private IBillingService billing;
|
||||||
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
|
||||||
// the Api host where Program.cs wires AddControllersWithViews +
|
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null)
|
||||||
// SiteSettings. The accept/reject endpoints (and the Api integration
|
|
||||||
// test host, which composes with AddControllers alone) never touch
|
|
||||||
// them, so they must not be required for controller activation.
|
|
||||||
//
|
|
||||||
// .NET 10 does not register a single IViewEngine service (the view
|
|
||||||
// engine is registered as IRazorViewEngine and surfaced through
|
|
||||||
// MvcViewOptions.ViewEngines). IRazorViewEngine (RazorViewEngine)
|
|
||||||
// implements IViewEngine, so we inject that and hand it to
|
|
||||||
// TeXHelpers.RenderViewToString as an IViewEngine. RenderViewToString
|
|
||||||
// builds its ActionContext from the controller's own ControllerContext,
|
|
||||||
// so no IActionContextAccessor is needed (and that type is deprecated
|
|
||||||
// in .NET 10).
|
|
||||||
public FrontOfficeApiController(
|
|
||||||
ApplicationDbContext context,
|
|
||||||
ILoggerFactory loggerFactory,
|
|
||||||
IOptions<SiteSettings> siteSettings = null,
|
|
||||||
IRazorViewEngine viewEngine = null,
|
|
||||||
IBillingService billing = null)
|
|
||||||
{
|
{
|
||||||
dbContext = context;
|
dbContext = context;
|
||||||
this.billing = billing ?? new BillingService(context);
|
this.billing = billing ?? new BillingService(context);
|
||||||
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
|
|
||||||
this.siteSettings = siteSettings?.Value;
|
|
||||||
this.viewEngine = viewEngine;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("profiles/{actCode}")]
|
[HttpGet("profiles/{actCode}")]
|
||||||
|
|
@ -60,313 +28,29 @@ namespace Yavsc.ApiControllers
|
||||||
return await dbContext.ListPerformersAsync(billing, actCode);
|
return await dbContext.ListPerformersAsync(billing, actCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/front/query/{queryId}/estimate.tex
|
|
||||||
//
|
|
||||||
// Renders the estimate linked to the query (Estimate.CommandId ==
|
|
||||||
// queryId) as a LaTeX source document. The view is a generation
|
|
||||||
// template (Layout = "null"), not a web page: it is returned with
|
|
||||||
// a text/x-tex content type so the caller can save it as a .tex
|
|
||||||
// file. Either party (client or provider) or an admin may read it.
|
|
||||||
[HttpGet("query/{queryId}/estimate.tex")]
|
|
||||||
public async Task<IActionResult> EstimateTex(long queryId, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (viewEngine is null || siteSettings is null)
|
|
||||||
return Problem("TeX rendering is not configured on this host.",
|
|
||||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
||||||
|
|
||||||
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
|
||||||
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
|
||||||
|
|
||||||
var uid = User.GetUserId();
|
|
||||||
if (!User.IsInRole(Constants.AdminGroupName)
|
|
||||||
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
PrepareEstimateViewBag(estimate);
|
|
||||||
Response.ContentType = "text/x-tex";
|
|
||||||
return View("Estimate_tex", estimate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: api/front/query/{queryId}/estimate.pdf
|
|
||||||
//
|
|
||||||
// Renders the same LaTeX template to a string, compiles it to PDF
|
|
||||||
// via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams
|
|
||||||
// the generated PDF bytes back. Requires /usr/bin/texi2pdf on the
|
|
||||||
// server host; on hosts without it the endpoint returns a problem.
|
|
||||||
[HttpGet("query/{queryId}/estimate.pdf")]
|
|
||||||
public async Task<IActionResult> EstimatePdf(long queryId, CancellationToken token)
|
|
||||||
{
|
|
||||||
if (viewEngine is null || siteSettings is null)
|
|
||||||
{
|
|
||||||
string msg = "PDF rendering is not configured on this host.";
|
|
||||||
logger.LogError(msg);
|
|
||||||
return Problem(msg,
|
|
||||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
|
||||||
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
|
||||||
|
|
||||||
var uid = User.GetUserId();
|
|
||||||
if (!User.IsInRole(Constants.AdminGroupName)
|
|
||||||
&& uid != estimate.ClientId && uid != estimate.OwnerId)
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
PrepareEstimateViewBag(estimate);
|
|
||||||
|
|
||||||
var baseFileName = $"estimate-{estimate.Id}";
|
|
||||||
// Resolve the bills dir to an absolute path once: appsettings
|
|
||||||
// configures it as a relative path ("bills"), and both the
|
|
||||||
// generation (GenerateEstimatePdf) and the read-back below must
|
|
||||||
// agree on the same location regardless of the host's cwd.
|
|
||||||
var billsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
|
||||||
var model = new PdfGenerationViewModel
|
|
||||||
{
|
|
||||||
BaseFileName = baseFileName,
|
|
||||||
DestDir = billsDir,
|
|
||||||
Temp = siteSettings.TempDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
|
|
||||||
// string, using the estimate as the view model.
|
|
||||||
model.TeXSource = this.RenderViewToString(viewEngine, "Estimate_tex", estimate);
|
|
||||||
// 2. Render the generation view (Estimate_pdf). Its @{} block
|
|
||||||
// calls SiteSettings.GenerateEstimatePdf(Model), which
|
|
||||||
// compiles the TeX to PDF on disk as a side effect of the
|
|
||||||
// render. The HTML the view emits is not what we return —
|
|
||||||
// we stream the generated PDF bytes — but driving the
|
|
||||||
// generation through the view keeps the template as the
|
|
||||||
// output producer, per the original design.
|
|
||||||
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
|
|
||||||
{
|
|
||||||
return File(fo.OpenRead(), "application/pdf");
|
|
||||||
}
|
|
||||||
string msg = "TeX render failed: " + model.GenerationErrorMessage;
|
|
||||||
logger.LogError(msg);
|
|
||||||
return Problem(msg);
|
|
||||||
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
|
|
||||||
return Problem("TeX render failed: " + ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the estimate linked to a query with the navigation the
|
|
||||||
// Estimate_tex template reads (client + performer profile + bill
|
|
||||||
// + the captured signatures, drawn as tikz at the foot of the devis).
|
|
||||||
private Task<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token)
|
|
||||||
=> dbContext.Estimates
|
|
||||||
.Where(e => e.CommandId == queryId)
|
|
||||||
.OrderByDescending(e => e.Id)
|
|
||||||
.Include(e => e.Bill)
|
|
||||||
.Include(e => e.Signatures)
|
|
||||||
.Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress)
|
|
||||||
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress)
|
|
||||||
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer)
|
|
||||||
.FirstOrDefaultAsync(token);
|
|
||||||
|
|
||||||
// Sets the ViewBag keys the Estimate_tex template reads. A devis
|
|
||||||
// is never a bill (AsBill=false) and is never acquitted.
|
|
||||||
private void PrepareEstimateViewBag(Estimate estimate)
|
|
||||||
{
|
|
||||||
ViewBag.AsBill = false;
|
|
||||||
ViewBag.Acquitted = false;
|
|
||||||
ViewBag.BillsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
|
|
||||||
ViewBag.AvatarsDir = new System.IO.DirectoryInfo(siteSettings.Avatars).FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: api/front/query/accept
|
|
||||||
//
|
|
||||||
// Either party of the query may accept: the provider
|
|
||||||
// (PerformerId) accepts the client's request, or the client
|
|
||||||
// (ClientId) accepts the provider's estimate. An optional
|
|
||||||
// signature (the PostIt wire format) may be sent along; it is
|
|
||||||
// persisted on the estimate linked to the query
|
|
||||||
// (Estimate.CommandId == queryId), and the matching validation
|
|
||||||
// date is stamped — ProviderValidationDate when the provider
|
|
||||||
// accepts, ClientValidationDate when the client accepts.
|
|
||||||
[HttpPost("query/accept")]
|
|
||||||
public async Task<IActionResult> AcceptQuery(
|
|
||||||
string billingCode,
|
|
||||||
long queryId,
|
|
||||||
[FromBody] QueryAcceptanceRequest? 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" });
|
|
||||||
|
|
||||||
Signature? signature = null;
|
|
||||||
// actingRole is the role the caller is signing / accepting
|
|
||||||
// as. It defaults to the role inferred from the caller's
|
|
||||||
// identity, but when a signature is submitted the caller
|
|
||||||
// declares the side (Pro/Client) and the server honors it
|
|
||||||
// only if the caller is that party. This disambiguates a
|
|
||||||
// user who is both parties (where inference would always
|
|
||||||
// pick the provider) and makes "write only for its author"
|
|
||||||
// explicit.
|
|
||||||
ActorRole actingRole = role;
|
|
||||||
if (HasSignature(body))
|
|
||||||
{
|
|
||||||
// Admins bypass authorization but are neither party of
|
|
||||||
// the estimate, so they cannot sign.
|
|
||||||
if (role == ActorRole.Admin)
|
|
||||||
return BadRequest(new { Error = "admin cannot sign on behalf of a party" });
|
|
||||||
|
|
||||||
var estimate = await dbContext.Estimates
|
|
||||||
.Where(e => e.CommandId == queryId)
|
|
||||||
.OrderByDescending(e => e.Id)
|
|
||||||
.FirstOrDefaultAsync(token);
|
|
||||||
if (estimate is null)
|
|
||||||
return BadRequest(new { Error = "no estimate linked to this query" });
|
|
||||||
|
|
||||||
var uid = User.GetUserId();
|
|
||||||
bool isProvider = query.PerformerId == uid;
|
|
||||||
bool isClient = query.ClientId == uid;
|
|
||||||
|
|
||||||
// The caller declares which side they sign as. Null
|
|
||||||
// falls back to the role inferred from identity.
|
|
||||||
var declared = body!.SignatureType;
|
|
||||||
if (declared.HasValue)
|
|
||||||
{
|
|
||||||
if (declared.Value == SignatureType.Pro && !isProvider)
|
|
||||||
return Forbid();
|
|
||||||
if (declared.Value == SignatureType.Client && !isClient)
|
|
||||||
return Forbid();
|
|
||||||
actingRole = declared.Value == SignatureType.Pro
|
|
||||||
? ActorRole.Provider
|
|
||||||
: ActorRole.Client;
|
|
||||||
}
|
|
||||||
|
|
||||||
var type = actingRole == ActorRole.Provider
|
|
||||||
? SignatureType.Pro
|
|
||||||
: SignatureType.Client;
|
|
||||||
|
|
||||||
var payload = new SignaturePadPayload
|
|
||||||
{
|
|
||||||
CoordinateMax = body.CoordinateMax,
|
|
||||||
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
|
|
||||||
Strokes = body.Strokes!,
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
signature = await EstimateSignaturePersister.StageAsync(
|
|
||||||
dbContext, estimate.Id, type, uid, payload, token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "query {QueryId}: signature stage failed", queryId);
|
|
||||||
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actingRole == ActorRole.Provider)
|
|
||||||
estimate.ProviderValidationDate = DateTime.UtcNow;
|
|
||||||
else
|
|
||||||
estimate.ClientValidationDate = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The party-specific status records who accepted: the
|
|
||||||
// provider (ProAccepted) or the client (ClientAccepted).
|
|
||||||
// An admin accepting (neither party) falls back to the
|
|
||||||
// legacy generic Accepted.
|
|
||||||
query.Status = actingRole switch
|
|
||||||
{
|
|
||||||
ActorRole.Provider => QueryStatus.ProAccepted,
|
|
||||||
ActorRole.Client => QueryStatus.ClientAccepted,
|
|
||||||
_ => QueryStatus.Accepted,
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "query {QueryId}: accept db write failed", queryId);
|
|
||||||
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
queryId,
|
|
||||||
status = query.Status.ToString(),
|
|
||||||
signature = signature is null
|
|
||||||
? null
|
|
||||||
: new
|
|
||||||
{
|
|
||||||
id = signature.Id,
|
|
||||||
estimateId = signature.EstimateId,
|
|
||||||
type = signature.Type.ToString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST: api/front/query/reject
|
|
||||||
//
|
|
||||||
// Either party may reject. A signature is not meaningful on a
|
|
||||||
// rejection, so one is refused with 400.
|
|
||||||
[HttpPost("query/reject")]
|
[HttpPost("query/reject")]
|
||||||
public async Task<IActionResult> RejectQuery(
|
public IActionResult RejectQuery(string billingCode, long queryId)
|
||||||
string billingCode,
|
|
||||||
long queryId,
|
|
||||||
[FromBody] QueryAcceptanceRequest? body,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
if (HasSignature(body))
|
if (billingCode == null) return BadRequest("billingCode");
|
||||||
return BadRequest(new { Error = "signature not allowed on reject" });
|
if (queryId == 0) return BadRequest("queryId");
|
||||||
|
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
||||||
|
if (billing == null) return BadRequest();
|
||||||
|
|
||||||
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
billing.Status = QueryStatus.Rejected;
|
||||||
if (error is not null) return error;
|
dbContext.SaveChanges(User.GetUserId());
|
||||||
if (query is null) return BadRequest(new { Error = "query not found" });
|
return Ok();
|
||||||
|
|
||||||
query.Status = QueryStatus.Rejected;
|
|
||||||
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
queryId,
|
|
||||||
status = query.Status.ToString(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads the query and checks that the caller is a party to it
|
[HttpPost("query/accept")]
|
||||||
// (provider or client) or an admin. Returns the role the caller
|
public IActionResult AcceptQuery(string billingCode, long queryId)
|
||||||
// is acting in; Admin is only returned when the caller is
|
|
||||||
// neither party but is an admin.
|
|
||||||
private async Task<(IQuery? Query, ActorRole Role, IActionResult? Error)>
|
|
||||||
ResolveAndAuthorizeAsync(string billingCode, long queryId, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(billingCode))
|
if (billingCode == null) return BadRequest("billingCode");
|
||||||
return (null, ActorRole.None, BadRequest("billingCode"));
|
if (queryId == 0) return BadRequest("queryId");
|
||||||
if (queryId == 0)
|
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
||||||
return (null, ActorRole.None, BadRequest("queryId"));
|
if (billing == null) return BadRequest();
|
||||||
|
billing.Status = QueryStatus.Accepted;
|
||||||
var query = await billing.GetBillAsync(billingCode, queryId);
|
dbContext.SaveChanges(User.GetUserId());
|
||||||
if (query is null)
|
return Ok();
|
||||||
return (null, ActorRole.None, BadRequest());
|
|
||||||
|
|
||||||
var uid = User.GetUserId();
|
|
||||||
bool isProvider = query.PerformerId == uid;
|
|
||||||
bool isClient = query.ClientId == uid;
|
|
||||||
|
|
||||||
if (isProvider) return (query, ActorRole.Provider, null);
|
|
||||||
if (isClient) return (query, ActorRole.Client, null);
|
|
||||||
|
|
||||||
if (User.IsInRole(Constants.AdminGroupName))
|
|
||||||
return (query, ActorRole.Admin, null);
|
|
||||||
|
|
||||||
return (null, ActorRole.None, Forbid());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasSignature(QueryAcceptanceRequest? body)
|
|
||||||
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum ActorRole { None, Provider, Client, Admin }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.AspNetCore.Html;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
||||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
|
||||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
|
|
||||||
namespace Yavsc.Helpers
|
|
||||||
{
|
|
||||||
using Microsoft.AspNetCore.Mvc.Razor;
|
|
||||||
using ViewModels.Gen;
|
|
||||||
public class TeXString : HtmlString
|
|
||||||
{
|
|
||||||
|
|
||||||
public TeXString(TeXString teXString): base(teXString.ToString())
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public TeXString(string str) : base(str)
|
|
||||||
{
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TeXString operator+ (TeXString a, TeXString b) {
|
|
||||||
return new TeXString(a.ToString()+b.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
public class Replacement
|
|
||||||
{
|
|
||||||
string target;
|
|
||||||
string replacement;
|
|
||||||
public Replacement(string target, string replacement)
|
|
||||||
{
|
|
||||||
this.target = target;
|
|
||||||
this.replacement = replacement;
|
|
||||||
}
|
|
||||||
public string Execute(string source)
|
|
||||||
{
|
|
||||||
return source?.Replace(target, replacement) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static class TeXHelpers
|
|
||||||
{
|
|
||||||
public static readonly Replacement[] SpecialCharsDefaultRendering =
|
|
||||||
{
|
|
||||||
new Replacement("<","\\textless"),
|
|
||||||
new Replacement(">","\\textgreater"),
|
|
||||||
new Replacement("©","\\copyright"),
|
|
||||||
new Replacement("®","\\textregistered"),
|
|
||||||
new Replacement("\\","\\textbackslash"),
|
|
||||||
new Replacement("™","\\texttrademark"),
|
|
||||||
new Replacement("¶","\\P"),
|
|
||||||
new Replacement("|","\\textbar"),
|
|
||||||
new Replacement("%","\\%"),
|
|
||||||
new Replacement("{","\\{"),
|
|
||||||
new Replacement("}","\\}"),
|
|
||||||
new Replacement("_","\\_"),
|
|
||||||
new Replacement("#","\\#"),
|
|
||||||
new Replacement("$","\\$"),
|
|
||||||
new Replacement("_","\\_"),
|
|
||||||
new Replacement("¿","\\textquestiondown"),
|
|
||||||
new Replacement("§","\\S"),
|
|
||||||
new Replacement("£","\\pounds"),
|
|
||||||
new Replacement("&","\\&"),
|
|
||||||
new Replacement("¡","\\textexclamdown"),
|
|
||||||
new Replacement("†","\\dag"),
|
|
||||||
new Replacement("–","\\textendash"),
|
|
||||||
new Replacement("°","\\textdegree")
|
|
||||||
};
|
|
||||||
|
|
||||||
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
|
|
||||||
{
|
|
||||||
if (source==null) return new TeXString(defaultValue);
|
|
||||||
string result=source;
|
|
||||||
foreach (var r in SpecialCharsDefaultRendering)
|
|
||||||
{
|
|
||||||
result = r.Execute(result);
|
|
||||||
}
|
|
||||||
return new TeXString(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
|
|
||||||
{
|
|
||||||
if (source==null) return new TeXString(defaultValue);
|
|
||||||
string result=source;
|
|
||||||
foreach (var r in SpecialCharsDefaultRendering)
|
|
||||||
{
|
|
||||||
result = r.Execute(result);
|
|
||||||
}
|
|
||||||
result = result.Replace("\n","\\tabularnewline ");
|
|
||||||
return new TeXString(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public static string NewLinesWith(this string target, string separator)
|
|
||||||
{
|
|
||||||
var items = target.Split(new char[] { '\n' }).Where(
|
|
||||||
s => !string.IsNullOrWhiteSpace(s));
|
|
||||||
|
|
||||||
return string.Join(separator, items);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
|
|
||||||
{
|
|
||||||
if (source == null) return new TeXString(defaultValue);
|
|
||||||
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
|
|
||||||
var alines = source.Split(',');
|
|
||||||
var texlines = alines.Select(l=>l.ToTeX().ToString());
|
|
||||||
return new TeXString(string.Join(lineSeparator,texlines));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders a captured signature as TikZ <c>\draw</c>/<c>\fill</c>
|
|
||||||
/// paths, suitable for embedding in a <c>tikzpicture</c> at the
|
|
||||||
/// foot of a devis. The signature is the PostIt wire format — a
|
|
||||||
/// length-prefixed sequence of strokes, each stroke being
|
|
||||||
/// <c>[k, x0, y0, x1, y1, …]</c> with coordinates normalised to
|
|
||||||
/// <c>[0, CoordinateMax]</c> and y growing downward (screen
|
|
||||||
/// orientation). We denormalise to a <paramref name="widthCm"/> ×
|
|
||||||
/// <paramref name="heightCm"/> canvas and flip y so the signature
|
|
||||||
/// appears upright on paper. A single-point stroke (a pen-down
|
|
||||||
/// tap) is rendered as a filled dot, mirroring the round-capped
|
|
||||||
/// <c>Polyline</c> the Avalonia pad draws for it.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sig">The persisted signature. Null/empty yields
|
|
||||||
/// <see cref="HtmlString.Empty"/>, so the caller can gate the
|
|
||||||
/// <c>tikzpicture</c> on a non-empty result.</param>
|
|
||||||
public static HtmlString SignatureToTikz(
|
|
||||||
this Signature sig, double widthCm, double heightCm)
|
|
||||||
{
|
|
||||||
if (sig is null || sig.Strokes is null || sig.Strokes.Length == 0)
|
|
||||||
return HtmlString.Empty;
|
|
||||||
|
|
||||||
double max = sig.CoordinateMax > 0 ? sig.CoordinateMax : 10_000.0;
|
|
||||||
var strokes = sig.Strokes;
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
|
|
||||||
int i = 0;
|
|
||||||
while (i < strokes.Length)
|
|
||||||
{
|
|
||||||
int k = strokes[i];
|
|
||||||
if (k <= 0) break;
|
|
||||||
if (i + 1 + 2 * k > strokes.Length) break;
|
|
||||||
i++; // skip the length prefix
|
|
||||||
|
|
||||||
// A single-point stroke: a tap. Draw a round dot so it
|
|
||||||
// shows instead of a zero-length, invisible \draw.
|
|
||||||
if (k == 1)
|
|
||||||
{
|
|
||||||
double px = strokes[i] / max * widthCm;
|
|
||||||
double py = heightCm - strokes[i + 1] / max * heightCm;
|
|
||||||
sb.Append(CultureInfo.InvariantCulture,
|
|
||||||
$"\\fill ({px:F3},{py:F3}) circle (0.8pt); ");
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("\\draw[line cap=round, line join=round, line width=1.5pt] ");
|
|
||||||
for (int p = 0; p < k; p++)
|
|
||||||
{
|
|
||||||
double px = strokes[i + 2 * p] / max * widthCm;
|
|
||||||
double py = heightCm - strokes[i + 2 * p + 1] / max * heightCm;
|
|
||||||
if (p == 0) sb.Append(CultureInfo.InvariantCulture,
|
|
||||||
$"({px:F3},{py:F3})");
|
|
||||||
else sb.Append(CultureInfo.InvariantCulture,
|
|
||||||
$" -- ({px:F3},{py:F3})");
|
|
||||||
}
|
|
||||||
sb.Append("; ");
|
|
||||||
i += 2 * k;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HtmlString(sb.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model)
|
|
||||||
{
|
|
||||||
string errorMsg = null;
|
|
||||||
// Resolve to an absolute path: SiteSettings.Bills may be a
|
|
||||||
// relative path (e.g. "bills"), and lualatex resolves
|
|
||||||
// -output-directory against its working directory — passing a
|
|
||||||
// relative path to both would nest them (<cwd>/bills/bills) and
|
|
||||||
// the log/pdf write would fail.
|
|
||||||
var billdir = new DirectoryInfo(Model.DestDir).FullName;
|
|
||||||
string name = Model.BaseFileName;
|
|
||||||
FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf"));
|
|
||||||
System.IO.Directory.CreateDirectory(billdir);
|
|
||||||
|
|
||||||
// Compile the LaTeX source with the system lualatex, feeding it
|
|
||||||
// the TeX on standard input (no intermediate .tex file) and
|
|
||||||
// directing the resulting <name>.pdf straight into the bills
|
|
||||||
// directory. lualatex is a file-based engine — it cannot emit
|
|
||||||
// the PDF on stdout — so the pdf lands at its final destination
|
|
||||||
// and we only clean the transient aux/log artifacts beside it.
|
|
||||||
//
|
|
||||||
// We pass "/dev/stdin" as the input filename so lualatex treats
|
|
||||||
// stdin as a regular input *file* rather than the terminal: with
|
|
||||||
// no filename argument it reads stdin in terminal mode, which
|
|
||||||
// emergency-stops after the first line of a multi-line document.
|
|
||||||
// /dev/stdin makes the full multi-line TeX compile cleanly. (The
|
|
||||||
// API host is Linux — the systemd service and lualatex dep are
|
|
||||||
// Linux-only too.)
|
|
||||||
//
|
|
||||||
// We capture stdout/stderr so a LaTeX error (missing .sty,
|
|
||||||
// missing \includegraphics target, …) is surfaced in
|
|
||||||
// GenerationErrorMessage instead of being buried in the .log
|
|
||||||
// we then delete.
|
|
||||||
int exitCode;
|
|
||||||
string stdout = null, stderr = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (Process p = new Process())
|
|
||||||
{
|
|
||||||
p.StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
WorkingDirectory = billdir,
|
|
||||||
FileName = "lualatex",
|
|
||||||
Arguments = $"-interaction=nonstopmode -halt-on-error"
|
|
||||||
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\""
|
|
||||||
+ " /dev/stdin",
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
};
|
|
||||||
p.Start();
|
|
||||||
// Write the TeX source, then drain stdout/stderr. Read
|
|
||||||
// them after WaitForExit — lualatex's output is small
|
|
||||||
// (a few KB), well under the pipe buffer, so no deadlock.
|
|
||||||
using (var stdin = p.StandardInput)
|
|
||||||
{
|
|
||||||
stdin.Write(Model.TeXSource);
|
|
||||||
}
|
|
||||||
p.WaitForExit();
|
|
||||||
exitCode = p.ExitCode;
|
|
||||||
stdout = p.StandardOutput.ReadToEnd();
|
|
||||||
stderr = p.StandardError.ReadToEnd();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
errorMsg = "lualatex invocation failed: " + ex.Message;
|
|
||||||
exitCode = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exitCode != 0 && errorMsg == null)
|
|
||||||
{
|
|
||||||
// Surface the engine's own diagnostics (stderr carries the
|
|
||||||
// "! Emergency stop" / "! LaTeX Error: …" lines; stdout has
|
|
||||||
// the transcript banner) so the caller can see why it failed
|
|
||||||
// without having to find the (soon-deleted) .log file.
|
|
||||||
var diag = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
|
|
||||||
errorMsg = $"Pdf generation failed with exit code {exitCode}: {diag}";
|
|
||||||
}
|
|
||||||
else if (!fo.Exists && errorMsg == null)
|
|
||||||
errorMsg = "Pdf generation produced no output";
|
|
||||||
|
|
||||||
// Remove the LaTeX build artifacts left beside the pdf so only
|
|
||||||
// <name>.pdf persists in the bills directory. Keep the .log on
|
|
||||||
// failure so it can be inspected.
|
|
||||||
foreach (var ext in new[] { ".aux", ".log", ".out", ".fls",
|
|
||||||
".fdb_latexmk", ".synctex.gz", ".toc" })
|
|
||||||
{
|
|
||||||
if (ext == ".log" && !fo.Exists) continue;
|
|
||||||
var fileName = name + ext;
|
|
||||||
var f = new FileInfo(System.IO.Path.Combine(billdir, fileName));
|
|
||||||
if (f.Exists) { try { f.Delete(); } catch { } }
|
|
||||||
}
|
|
||||||
|
|
||||||
Model.Generated = fo.Exists;
|
|
||||||
Model.GenerationErrorMessage = new HtmlString(errorMsg);
|
|
||||||
return fo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string RenderViewToString(
|
|
||||||
this Controller controller, IRazorViewEngine engine,
|
|
||||||
string viewName, object model, bool isMainPage = true)
|
|
||||||
{
|
|
||||||
if (engine == null)
|
|
||||||
throw new InvalidOperationException("no engine");
|
|
||||||
|
|
||||||
// Build an ActionContext from the controller's own context —
|
|
||||||
// .NET 10 deprecated IActionContextAccessor, and the controller
|
|
||||||
// already holds the live request context on its ControllerContext.
|
|
||||||
var controllerContext = controller.ControllerContext;
|
|
||||||
var actionContext = new ActionContext(
|
|
||||||
controllerContext.HttpContext,
|
|
||||||
controllerContext.RouteData,
|
|
||||||
controllerContext.ActionDescriptor);
|
|
||||||
|
|
||||||
ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage);
|
|
||||||
if (!viewResult.Success)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"View '{viewName}' not found. Searched locations: "
|
|
||||||
+ string.Join(", ", viewResult.SearchedLocations));
|
|
||||||
|
|
||||||
// Hand the model to the view through ViewData so the template's
|
|
||||||
// @model directive resolves it (the previous implementation never
|
|
||||||
// set ViewData.Model, leaving Model null inside the template).
|
|
||||||
controller.ViewData.Model = model;
|
|
||||||
|
|
||||||
using (var sw = new StringWriter())
|
|
||||||
{
|
|
||||||
var viewContext = new ViewContext(
|
|
||||||
actionContext,
|
|
||||||
viewResult.View,
|
|
||||||
controller.ViewData,
|
|
||||||
controller.TempData,
|
|
||||||
sw,
|
|
||||||
new HtmlHelperOptions());
|
|
||||||
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
|
||||||
return sw.GetStringBuilder().ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -55,11 +55,7 @@ internal class Program
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.AddYavscCors(builder.Configuration)
|
.AddYavscCors(builder.Configuration)
|
||||||
.AddControllersWithViews();
|
.AddControllers();
|
||||||
|
|
||||||
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
|
|
||||||
// the document-generation flow and BillingController.
|
|
||||||
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
|
|
||||||
|
|
||||||
// accepts any access token issued by identity server
|
// accepts any access token issued by identity server
|
||||||
services.AddAuthentication("Bearer")
|
services.AddAuthentication("Bearer")
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
|
|
||||||
namespace Yavsc.ViewModels.FrontOffice
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Body of <c>POST /api/v1/front/query/accept</c> and
|
|
||||||
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
|
|
||||||
/// when present (non-empty) on accept, it is the PostIt wire-format
|
|
||||||
/// signature captured with the acceptance. All fields are optional
|
|
||||||
/// so a caller may post <c>{}</c> to accept/reject without a
|
|
||||||
/// signature.
|
|
||||||
/// </summary>
|
|
||||||
public class QueryAcceptanceRequest
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Wire-format strokes. See
|
|
||||||
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
|
|
||||||
/// no signature.
|
|
||||||
/// </summary>
|
|
||||||
public int[]? Strokes { get; set; }
|
|
||||||
|
|
||||||
public int CoordinateMax { get; set; } = 10_000;
|
|
||||||
|
|
||||||
public DateTime? CapturedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Which signature is being submitted:
|
|
||||||
/// <see cref="SignatureType.Pro"/> (the provider) or
|
|
||||||
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
|
|
||||||
/// is present this declares the side the caller is signing as;
|
|
||||||
/// the server honors it only if the caller is that party
|
|
||||||
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
|
|
||||||
/// for Client). This disambiguates the case where one user is
|
|
||||||
/// both parties, and makes "write only for its author"
|
|
||||||
/// explicit. When null, the server falls back to the role
|
|
||||||
/// inferred from the caller's identity.
|
|
||||||
/// </summary>
|
|
||||||
public SignatureType? SignatureType { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
@using Yavsc
|
|
||||||
@using Yavsc.Helpers
|
|
||||||
@using Yavsc.Billing
|
|
||||||
@using Yavsc.Models
|
|
||||||
@using Yavsc.Models.Billing
|
|
||||||
@using Yavsc.ViewModels.Gen
|
|
||||||
@using Microsoft.AspNetCore.Mvc
|
|
||||||
@using Microsoft.AspNetCore.Html
|
|
||||||
|
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
|
||||||
|
|
||||||
@inject Microsoft.Extensions.Options.IOptions<SiteSettings> SiteSettings
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using static Yavsc.Constants;
|
||||||
namespace Yavsc.Blogs.Tests;
|
namespace Yavsc.Blogs.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Behavioral tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
|
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
|
||||||
/// <c>POST /api/v1/blogacl</c> with a JSON body of
|
/// <c>POST /api/v1/blogacl</c> with a JSON body of
|
||||||
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
|
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
|
||||||
///
|
///
|
||||||
|
|
@ -84,7 +84,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
// rather than being rewritten to ClaimTypes.NameIdentifier.
|
// rather than being rewritten to ClaimTypes.NameIdentifier.
|
||||||
// The controller, however, reads the user id via
|
// The controller, however, reads the user id via
|
||||||
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
|
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
|
||||||
// an explicit NameIdentifier claim to keep the legacy lookup happy.
|
// an explicit nameid claim to keep the legacy lookup happy.
|
||||||
http.DefaultRequestHeaders.Authorization =
|
http.DefaultRequestHeaders.Authorization =
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
"Bearer",
|
"Bearer",
|
||||||
|
|
@ -106,7 +106,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
|
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
|
||||||
/// shape <c>{ "circleId": <id> }</c> — the exact body the
|
/// shape <c>{ "circleId": <id> }</c> — the exact body the
|
||||||
/// PostIt client builds from <see cref="CircleAuthorization"/>
|
/// PostIt client builds from <see cref="CircleAuthorization"/>
|
||||||
/// (which only carries <c>CircleId</c>). The server deserializes
|
/// (which only carries <c>CircleId</c>). The server deserialises
|
||||||
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
|
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
|
||||||
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
|
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
|
||||||
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
|
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Yavsc.Blogspot;
|
|
||||||
using Yavsc.Models;
|
|
||||||
using Yavsc.Models.Blog;
|
|
||||||
using Yavsc.Blogs.Tests.Fixtures;
|
|
||||||
|
|
||||||
namespace Yavsc.Blogs.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Direct service-level tests for <see cref="BlogSpotService.Index"/>.
|
|
||||||
///
|
|
||||||
/// <para>The HTTP tests in <see cref="BlogApiTests"/> all drive the
|
|
||||||
/// <c>[Authorize("BlogScope")]</c>-protected <c>BlogApiController</c>,
|
|
||||||
/// so the request is always authenticated by the time it reaches the
|
|
||||||
/// service. The MVC <c>BlogspotController.Index</c> action, by
|
|
||||||
/// contrast, is <c>[AllowAnonymous]</c> — that is the one caller that
|
|
||||||
/// actually exercises the unauthenticated branch of
|
|
||||||
/// <see cref="BlogSpotService.Index"/>, the branch that reads from
|
|
||||||
/// <c>blogSpotPublications</c> and projects to <c>p.Post</c>.</para>
|
|
||||||
///
|
|
||||||
/// <para>These tests pin that behaviour at the service layer: a
|
|
||||||
/// <c>BlogPost</c> that has a <c>BlogSpotPublication</c> row is
|
|
||||||
/// listed for an anonymous viewer, and one without is not. Seeding
|
|
||||||
/// runs against the same shared SQLite store as the rest of the
|
|
||||||
/// <c>Yavsc Blogs</c> collection, so the real
|
|
||||||
/// <see cref="BlogSpotService"/> (registered by
|
|
||||||
/// <see cref="BlogsWebServerFixture"/>) is resolved from DI and run
|
|
||||||
/// end-to-end — no mocking.</para>
|
|
||||||
/// </summary>
|
|
||||||
[Collection("Yavsc Blogs")]
|
|
||||||
public sealed class BlogSpotServiceIndexTests : IClassFixture<BlogsWebServerFixture>
|
|
||||||
{
|
|
||||||
private readonly BlogsWebServerFixture _fixture;
|
|
||||||
|
|
||||||
public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture)
|
|
||||||
{
|
|
||||||
_fixture = fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An unauthenticated principal: a <see cref="ClaimsIdentity"/>
|
|
||||||
/// with no authentication type has
|
|
||||||
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false, so
|
|
||||||
/// <see cref="BlogSpotService.Index"/> takes its anonymous
|
|
||||||
/// branch. This mirrors what the JwtBearer middleware hands the
|
|
||||||
/// MVC <c>[AllowAnonymous]</c> action when no bearer token is
|
|
||||||
/// presented.</summary>
|
|
||||||
private static ClaimsPrincipal Anonymous()
|
|
||||||
=> new(new ClaimsIdentity());
|
|
||||||
|
|
||||||
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
|
|
||||||
/// fixture's DI container and run <paramref name="body"/> against it,
|
|
||||||
/// disposing the request scope (and its scoped DbContext) afterwards.
|
|
||||||
/// The service itself is not <see cref="IDisposable"/>; the scope is,
|
|
||||||
/// so the scope is what we must dispose to avoid leaking a DbContext
|
|
||||||
/// against the shared SQLite connection across tests.</summary>
|
|
||||||
private async Task<T> WithServiceAsync<T>(
|
|
||||||
Func<BlogSpotService, Task<T>> body)
|
|
||||||
{
|
|
||||||
using var scope = _fixture.Services.CreateScope();
|
|
||||||
var service = scope.ServiceProvider.GetRequiredService<BlogSpotService>();
|
|
||||||
return await body(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Publish(long postId)
|
|
||||||
{
|
|
||||||
using var scope = _fixture.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
// The publication flag is the existence of a row in
|
|
||||||
// blogSpotPublications — same write SetPublishAsync performs
|
|
||||||
// in production. Inserting it directly is the most faithful
|
|
||||||
// way to seed "this post is published" without routing
|
|
||||||
// through the authorize-gated PUT/publish endpoint.
|
|
||||||
db.blogSpotPublications.Add(new BlogSpotPublication { PostId = postId });
|
|
||||||
db.SaveChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Index_lists_a_published_post_for_anonymous_access()
|
|
||||||
{
|
|
||||||
_fixture.ResetDatabase();
|
|
||||||
_fixture.SeedUser("alice");
|
|
||||||
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
|
|
||||||
|
|
||||||
Publish(publishedId);
|
|
||||||
|
|
||||||
var posts = await WithServiceAsync(async svc =>
|
|
||||||
{
|
|
||||||
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
|
|
||||||
return result.OfType<BlogPost>().ToArray();
|
|
||||||
});
|
|
||||||
|
|
||||||
var listed = Assert.Single(posts);
|
|
||||||
Assert.Equal("Published for all", listed.Title);
|
|
||||||
// The service serves IsPublished from the
|
|
||||||
// blogSpotPublications table — the anonymous branch does
|
|
||||||
// not Include the Publication navigation, so this proves
|
|
||||||
// the value is hydrated by the service, not derived from
|
|
||||||
// the EF graph.
|
|
||||||
Assert.True(listed.IsPublished);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Index_serves_IsPublished_false_for_a_draft_for_anonymous_access()
|
|
||||||
{
|
|
||||||
// Anonymous viewers never receive a draft (the branch
|
|
||||||
// queries blogSpotPublications), so this is checked through
|
|
||||||
// the authenticated branch: the service must still serve
|
|
||||||
// IsPublished == false for a post with no publication row,
|
|
||||||
// not the default of a freshly loaded entity.
|
|
||||||
_fixture.ResetDatabase();
|
|
||||||
_fixture.SeedUser("alice");
|
|
||||||
var draftId = _fixture.SeedBlogPost("alice", "Draft, not published");
|
|
||||||
|
|
||||||
var viewer = new ClaimsPrincipal(new ClaimsIdentity(
|
|
||||||
[new Claim("sub", "alice")], authenticationType: "Bearer"));
|
|
||||||
|
|
||||||
var posts = await WithServiceAsync(async svc =>
|
|
||||||
{
|
|
||||||
var result = await svc.Index(viewer, id: "", skip: 0, take: 25);
|
|
||||||
return result.OfType<BlogPost>().ToArray();
|
|
||||||
});
|
|
||||||
|
|
||||||
var draft = Assert.Single(posts);
|
|
||||||
Assert.Equal(draftId, draft.Id);
|
|
||||||
Assert.False(draft.IsPublished);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Index_does_not_list_an_unpublished_post_for_anonymous_access()
|
|
||||||
{
|
|
||||||
_fixture.ResetDatabase();
|
|
||||||
_fixture.SeedUser("alice");
|
|
||||||
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
|
|
||||||
_fixture.SeedBlogPost("alice", "Draft, not published");
|
|
||||||
|
|
||||||
// Only the first post is published; the draft has no
|
|
||||||
// BlogSpotPublication row.
|
|
||||||
Publish(publishedId);
|
|
||||||
|
|
||||||
var titles = await WithServiceAsync(async svc =>
|
|
||||||
{
|
|
||||||
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
|
|
||||||
return result.Select(p => p.Title).ToArray();
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Contains("Published for all", titles);
|
|
||||||
Assert.DoesNotContain("Draft, not published", titles);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Index_returns_an_empty_list_for_anonymous_access_when_nothing_is_published()
|
|
||||||
{
|
|
||||||
_fixture.ResetDatabase();
|
|
||||||
_fixture.SeedUser("alice");
|
|
||||||
// A draft with no publication row: invisible to anonymous.
|
|
||||||
_fixture.SeedBlogPost("alice", "Draft, not published");
|
|
||||||
|
|
||||||
var result = await WithServiceAsync(svc =>
|
|
||||||
svc.Index(Anonymous(), id: "", skip: 0, take: 25));
|
|
||||||
|
|
||||||
Assert.Empty(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
src/Yavsc.Blogs.Tests/Directory.Packages.props
Normal file
7
src/Yavsc.Blogs.Tests/Directory.Packages.props
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<Project>
|
||||||
|
<!--
|
||||||
|
Yavsc.Blogs.Tests has no project-specific package versions. All
|
||||||
|
package versions are declared at the repository root.
|
||||||
|
-->
|
||||||
|
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||||
|
</Project>
|
||||||
|
|
@ -33,7 +33,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
||||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
|
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseUrls("http://127.0.0.1:5105");
|
builder.WebHost.UseUrls("http://127.0.0.1:5104");
|
||||||
|
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
||||||
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
|
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
|
||||||
|
|
@ -88,7 +88,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
|
||||||
_app.MapControllers();
|
_app.MapControllers();
|
||||||
_app.StartAsync().GetAwaiter().GetResult();
|
_app.StartAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
Addresses = ["http://127.0.0.1:5105"];
|
Addresses = ["http://127.0.0.1:5104"];
|
||||||
Services = _app.Services;
|
Services = _app.Services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" />
|
||||||
<PackageReference Include="xunit.v3.common" />
|
<PackageReference Include="xunit.v3.common" />
|
||||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
|
|
|
||||||
11
src/Yavsc.Org.Tests/Directory.Packages.props
Normal file
11
src/Yavsc.Org.Tests/Directory.Packages.props
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project>
|
||||||
|
<!-- Pull in shared package versions from the repository root. -->
|
||||||
|
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||||
|
|
||||||
|
<!-- Yavsc.Org.Tests-specific versions -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
136
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal file
136
src/Yavsc.Org.Tests/EstimateSignatureFileHelperTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Server.Helpers;
|
||||||
|
using Yavsc.Server.Models.FileSystem;
|
||||||
|
|
||||||
|
namespace Yavsc.Org.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
|
||||||
|
/// helper. Scope is intentionally narrow: the file-naming format,
|
||||||
|
/// the strokes counter, and the on-disk write path. The controller
|
||||||
|
/// (authz, db persistence, signalR notification) is out of scope
|
||||||
|
/// for this commit and will get a dedicated integration test once
|
||||||
|
/// the Yavsc.Api test project is set up.
|
||||||
|
/// </summary>
|
||||||
|
public class EstimateSignatureFileHelperTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempRoot;
|
||||||
|
private SiteSettings siteSettings;
|
||||||
|
|
||||||
|
public EstimateSignatureFileHelperTests()
|
||||||
|
{
|
||||||
|
// UserFilesDirName is a process-wide static; we redirect
|
||||||
|
// it to a per-test temp dir so concurrent tests don't
|
||||||
|
// collide and the host filesystem is not littered.
|
||||||
|
_tempRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempRoot);
|
||||||
|
this.siteSettings = new SiteSettings { Blog = _tempRoot };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_tempRoot, recursive: true); }
|
||||||
|
catch { /* best effort — the OS will clean Temp eventually */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
|
||||||
|
{
|
||||||
|
var name = EstimateSignatureFileHelper.FileNameFormat(
|
||||||
|
SignatureType.Pro, 42, 638_000_000_000_000_000L);
|
||||||
|
Assert.Equal("sign-pro-42-638000000000000000.json", name);
|
||||||
|
|
||||||
|
var cli = EstimateSignatureFileHelper.FileNameFormat(
|
||||||
|
SignatureType.Client, 7, 1L);
|
||||||
|
Assert.Equal("sign-client-7-1.json", cli);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new int[] { }, 0)]
|
||||||
|
[InlineData(new[] { 1, 100, 200 }, 1)]
|
||||||
|
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
|
||||||
|
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
|
||||||
|
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
|
||||||
|
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
|
||||||
|
{
|
||||||
|
// We don't read the count back from the helper (it's a
|
||||||
|
// private method), but the JSON envelope must reflect
|
||||||
|
// it; this verifies the public behaviour end-to-end.
|
||||||
|
_ = expectedStrokeCount;
|
||||||
|
// Arrange
|
||||||
|
var user = MakeUser("alice");
|
||||||
|
var payload = new SignaturePadPayload
|
||||||
|
{
|
||||||
|
CoordinateMax = 10_000,
|
||||||
|
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
Strokes = strokes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var fi = Run(user, 123L, SignatureType.Pro, payload, this.siteSettings);
|
||||||
|
|
||||||
|
// Assert: file exists, sits under the user's root, and
|
||||||
|
// parses as a yavsc.signature/v1 envelope.
|
||||||
|
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
|
||||||
|
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
|
||||||
|
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
|
||||||
|
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
|
||||||
|
Assert.Equal("Pro", root.GetProperty("type").GetString());
|
||||||
|
Assert.Equal("alice", root.GetProperty("signerName").GetString());
|
||||||
|
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
|
||||||
|
{
|
||||||
|
var user = MakeUser("bob");
|
||||||
|
// Capture TestContext.Current.CancellationToken outside the
|
||||||
|
// lambda so xUnit1051 sees a real CancellationToken argument
|
||||||
|
// (the lambda body runs on a different stack frame).
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||||
|
user, 1L, SignatureType.Pro, payload: null!,
|
||||||
|
siteSettings, token: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
|
||||||
|
{
|
||||||
|
var user = MakeUser("bob");
|
||||||
|
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
||||||
|
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
|
||||||
|
user, 0L, SignatureType.Pro, payload, this.siteSettings, token: ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
private static FileReceivedInfo Run(
|
||||||
|
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload, SiteSettings siteSettings)
|
||||||
|
{
|
||||||
|
// The helper is async; tests that don't care about the
|
||||||
|
// result can call it sync via .GetAwaiter().GetResult()
|
||||||
|
// because we know it never throws in the happy path.
|
||||||
|
return EstimateSignatureFileHelper
|
||||||
|
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, siteSettings, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal MakeUser(string username)
|
||||||
|
{
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[] { new Claim(ClaimTypes.Name, username) },
|
||||||
|
authenticationType: "test"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http;
|
|
||||||
using IdentityServer8.Stores;
|
using IdentityServer8.Stores;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Yavsc.Tests.Shared;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Yavsc.Org.Tests.Smoke;
|
namespace Yavsc.Org.Tests.Smoke;
|
||||||
|
|
@ -44,48 +40,4 @@ public class AccountSmokeTests : IClassFixture<TestWebApplicationFactory>
|
||||||
|
|
||||||
Assert.Null(exception);
|
Assert.Null(exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: anonymous access to a restricted page used to redirect to
|
|
||||||
// the ASP.NET Identity default LoginPath "/Account/Login", which has no
|
|
||||||
// controller here. The application cookie must redirect to the real
|
|
||||||
// sign-in endpoint at /signin instead.
|
|
||||||
[Fact]
|
|
||||||
public async Task Anonymous_access_to_protected_page_redirects_to_signin()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
||||||
{
|
|
||||||
AllowAutoRedirect = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// EstimateController is [Authorize]; GET /Estimate hits Index().
|
|
||||||
var response = await client.GetAsync("/Estimate", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
||||||
var location = response.Headers.Location;
|
|
||||||
Assert.NotNull(location);
|
|
||||||
Assert.Equal("/signin", location.AbsolutePath);
|
|
||||||
Assert.Contains("ReturnUrl=%2FEstimate", location.Query);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: the POST Delete action existed, but neither the GET
|
|
||||||
// Delete view nor a rendered confirmation page was reachable —
|
|
||||||
// Views/Account/Delete.cshtml was missing. An authenticated GET
|
|
||||||
// must now return the confirmation page (200).
|
|
||||||
[Fact]
|
|
||||||
public async Task Authenticated_get_account_delete_returns_confirmation_page()
|
|
||||||
{
|
|
||||||
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
|
||||||
{
|
|
||||||
AllowAutoRedirect = false,
|
|
||||||
});
|
|
||||||
client.DefaultRequestHeaders.Add(
|
|
||||||
TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
|
|
||||||
|
|
||||||
var response = await client.GetAsync("/Account/Delete", TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
||||||
var body = await response.Content.ReadAsStringAsync(
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
Assert.Contains("Supprimer mon compte", body);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
|
||||||
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
<AssemblyVersion>1.1.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.1.0.0</FileVersion>
|
<FileVersion>1.1.0.0</FileVersion>
|
||||||
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
|
<InformationalVersion>1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
||||||
<Version>1.1.0-estimate.1</Version>
|
<Version>1.1.0-feat-sign-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
<PackageReference Include="xunit.v3" />
|
<PackageReference Include="xunit.v3" />
|
||||||
<PackageReference Include="xunit.v3.common" />
|
<PackageReference Include="xunit.v3.common" />
|
||||||
<PackageReference Include="xunit.v3.extensibility.core" />
|
<PackageReference Include="xunit.v3.extensibility.core" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings.json">
|
<Content Include="appsettings.json">
|
||||||
|
|
|
||||||
|
|
@ -1061,7 +1061,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
|
||||||
}
|
}
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize]
|
[HttpPost, Authorize]
|
||||||
public async Task<IActionResult> Delete(UnregisterViewModel model)
|
public async Task<IActionResult> Delete(UnregisterViewModel model)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,73 @@ namespace Yavsc.Org.Controllers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: Blog/Create
|
||||||
|
[Authorize()]
|
||||||
|
public IActionResult Create(string title)
|
||||||
|
{
|
||||||
|
var result = new BlogPostEditViewModel
|
||||||
|
(new Models.Blog.BlogPost
|
||||||
|
{
|
||||||
|
Title = title
|
||||||
|
}, true);
|
||||||
|
SetLangItems();
|
||||||
|
return View(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Blog/Create
|
||||||
|
[HttpPost, Authorize, ValidateAntiForgeryToken]
|
||||||
|
public IActionResult Create(Models.Blog.BlogPost blogInput)
|
||||||
|
{
|
||||||
|
if (ModelState.IsValid)
|
||||||
|
{
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
Models.Blog.BlogPost post = blogSpotService.Create(uid, blogInput);
|
||||||
|
blogSpotService.AttachFiles(Request.Form.Files, uid, post.Id);
|
||||||
|
return RedirectToAction("Index");
|
||||||
|
}
|
||||||
|
return View("Edit", blogInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize()]
|
||||||
|
// GET: Blog/Edit/5
|
||||||
|
public async Task<IActionResult> Edit(long? id)
|
||||||
|
{
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var blog = await blogSpotService.GetPostForEdition(User, id.Value);
|
||||||
|
if (blog == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SetLangItems();
|
||||||
|
return View(blog);
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (AuthorizationFailureException)
|
||||||
|
{
|
||||||
|
return new ChallengeResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Blog/Edit/5
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken, Authorize()]
|
||||||
|
public async Task<IActionResult> Edit(BlogPostEditViewModel blogEdit)
|
||||||
|
{
|
||||||
|
if (ModelState.IsValid)
|
||||||
|
{
|
||||||
|
await blogSpotService.Modify(User, blogEdit);
|
||||||
|
ViewBag.StatusMessage = "Post modified";
|
||||||
|
return RedirectToAction("Index");
|
||||||
|
}
|
||||||
|
return View(blogEdit);
|
||||||
|
}
|
||||||
|
|
||||||
// GET: Blog/Delete/5
|
// GET: Blog/Delete/5
|
||||||
[ActionName("Delete"), Authorize()]
|
[ActionName("Delete"), Authorize()]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ namespace Yavsc.Controllers
|
||||||
using Helpers;
|
using Helpers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Models;
|
using Models;
|
||||||
using ViewModels.FrontOffice;
|
using ViewModels.FrontOffice;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
@ -20,20 +21,22 @@ namespace Yavsc.Controllers
|
||||||
readonly ILogger _logger;
|
readonly ILogger _logger;
|
||||||
readonly IStringLocalizer _SR;
|
readonly IStringLocalizer _SR;
|
||||||
private readonly IBillingService _billing;
|
private readonly IBillingService _billing;
|
||||||
|
private readonly SiteSettings _siteSettings;
|
||||||
|
|
||||||
public FrontOfficeController(ApplicationDbContext context,
|
public FrontOfficeController(ApplicationDbContext context,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IBillingService billing,
|
IBillingService billing,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IStringLocalizer<FrontOfficeController> SR)
|
IStringLocalizer<FrontOfficeController> SR,
|
||||||
|
IOptions<SiteSettings> siteSettings)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
_logger = loggerFactory.CreateLogger<FrontOfficeController>();
|
||||||
_SR = SR;
|
_SR = SR;
|
||||||
_billing = billing;
|
_billing = billing;
|
||||||
|
_siteSettings = siteSettings.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ActionResult Index()
|
public ActionResult Index()
|
||||||
{
|
{
|
||||||
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
@ -43,9 +46,9 @@ namespace Yavsc.Controllers
|
||||||
{
|
{
|
||||||
EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted
|
EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted
|
||||||
&& c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(),
|
&& c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(),
|
||||||
EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && (c.Status == QueryStatus.ProAccepted || c.Status == QueryStatus.Accepted)
|
EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted
|
||||||
&& c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(),
|
&& c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(),
|
||||||
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && (e.Query.Status == QueryStatus.ProAccepted || e.Query.Status == QueryStatus.Accepted)).Count(),
|
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(),
|
||||||
|
|
||||||
BillToSignAsCliCount = 0,
|
BillToSignAsCliCount = 0,
|
||||||
NewPayementsCount = 0
|
NewPayementsCount = 0
|
||||||
|
|
@ -77,5 +80,61 @@ namespace Yavsc.Controllers
|
||||||
return View(result);
|
return View(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Produces("text/x-tex"), Authorize, Route("estimate-{id}.tex")]
|
||||||
|
[HttpGet]
|
||||||
|
public ViewResult EstimateTex(long id)
|
||||||
|
{
|
||||||
|
var estimate = _context.Estimates.Include(x => x.Query)
|
||||||
|
.Include(x => x.Query.Client)
|
||||||
|
.Include(x => x.Query.PerformerProfile)
|
||||||
|
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
|
||||||
|
.Include(x => x.Query.PerformerProfile.Performer)
|
||||||
|
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
|
||||||
|
Response.ContentType = "text/x-tex";
|
||||||
|
return View("Estimate.tex", estimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize, Route("Estimate-{id}.pdf")]
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult EstimatePdf(long id)
|
||||||
|
{
|
||||||
|
ViewBag.TempDir = _siteSettings.TempDir;
|
||||||
|
ViewBag.BillsDir = _siteSettings.Bills;
|
||||||
|
var estimate = _context.Estimates.Include(x => x.Query)
|
||||||
|
.Include(x => x.Query.Client)
|
||||||
|
.Include(x => x.Query.PerformerProfile)
|
||||||
|
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
|
||||||
|
.Include(x => x.Query.PerformerProfile.Performer)
|
||||||
|
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
|
||||||
|
if (estimate == null)
|
||||||
|
throw new Exception("No data");
|
||||||
|
return View("Estimate.pdf", estimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult EstimateProValidation()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult EstimateClientValidation()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
}
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult BillValidation()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
}
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult BillAcquitment()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,18 +215,6 @@ public static class HostingExtensions
|
||||||
|
|
||||||
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
|
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
|
||||||
|
|
||||||
// The default application cookie LoginPath is "/Account/Login", which
|
|
||||||
// has no controller here — anonymous access to a restricted page must
|
|
||||||
// redirect to the actual sign-in endpoint instead. The paths mirror
|
|
||||||
// the route attributes on AccountController (Constants.SigninPath etc.,
|
|
||||||
// which carry a "~/" prefix that the cookie middleware does not want).
|
|
||||||
services.ConfigureApplicationCookie(options =>
|
|
||||||
{
|
|
||||||
options.LoginPath = "/signin";
|
|
||||||
options.LogoutPath = "/signout";
|
|
||||||
options.AccessDeniedPath = "/Account/AccessDenied";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dev-only: Chromium rejects SameSite=None without Secure on http://
|
// Dev-only: Chromium rejects SameSite=None without Secure on http://
|
||||||
// (e.g. http://localhost:5000). The default Identity cookie policy
|
// (e.g. http://localhost:5000). The default Identity cookie policy
|
||||||
// sets SameSite=None, which is invalid without Secure. Force Lax in
|
// sets SameSite=None, which is invalid without Secure. Force Lax in
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Markdig;
|
using AsciiDocSharp;
|
||||||
|
using AsciiDocSharp.Converters.Html;
|
||||||
using Microsoft.AspNetCore.Html;
|
using Microsoft.AspNetCore.Html;
|
||||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||||
|
|
||||||
namespace Yavsc.Helpers
|
namespace Yavsc.Helpers
|
||||||
{
|
{
|
||||||
public class MarkdownTagHelper : TagHelper
|
public class AsciidocTagHelper : TagHelper
|
||||||
{
|
{
|
||||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||||
{
|
{
|
||||||
|
|
@ -18,7 +19,6 @@ namespace Yavsc.Helpers
|
||||||
if (string.IsNullOrWhiteSpace(text)) return;
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
||||||
{
|
{
|
||||||
if (context.AllAttributes.ContainsName("summary"))
|
if (context.AllAttributes.ContainsName("summary"))
|
||||||
{
|
{
|
||||||
|
|
@ -34,10 +34,16 @@ namespace Yavsc.Helpers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
var processor = new AsciiDocProcessor(
|
||||||
var result = Markdown.ToHtml(text, pipeline);
|
|
||||||
|
);
|
||||||
|
var htmlConverter = new HtmlDocumentConverter();
|
||||||
|
|
||||||
output.Content.AppendHtml(result);
|
var document = processor.ParseFromText(text);
|
||||||
|
var htmlResult = processor.ConvertDocument(document, htmlConverter);
|
||||||
|
|
||||||
|
|
||||||
|
output.Content.AppendHtml(htmlResult);
|
||||||
}
|
}
|
||||||
catch (ArgumentException ex)
|
catch (ArgumentException ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
198
src/Yavsc.Org/Helpers/TeXHelpers.cs
Normal file
198
src/Yavsc.Org/Helpers/TeXHelpers.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Html;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||||
|
|
||||||
|
namespace Yavsc.Helpers
|
||||||
|
{
|
||||||
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
using ViewModels.Gen;
|
||||||
|
public class TeXString : HtmlString
|
||||||
|
{
|
||||||
|
|
||||||
|
public TeXString(TeXString teXString): base(teXString.ToString())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public TeXString(string str) : base(str)
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TeXString operator+ (TeXString a, TeXString b) {
|
||||||
|
return new TeXString(a.ToString()+b.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
public class Replacement
|
||||||
|
{
|
||||||
|
string target;
|
||||||
|
string replacement;
|
||||||
|
public Replacement(string target, string replacement)
|
||||||
|
{
|
||||||
|
this.target = target;
|
||||||
|
this.replacement = replacement;
|
||||||
|
}
|
||||||
|
public string Execute(string source)
|
||||||
|
{
|
||||||
|
return source?.Replace(target, replacement) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static class TeXHelpers
|
||||||
|
{
|
||||||
|
public static readonly Replacement[] SpecialCharsDefaultRendering =
|
||||||
|
{
|
||||||
|
new Replacement("<","\\textless"),
|
||||||
|
new Replacement(">","\\textgreater"),
|
||||||
|
new Replacement("©","\\copyright"),
|
||||||
|
new Replacement("®","\\textregistered"),
|
||||||
|
new Replacement("\\","\\textbackslash"),
|
||||||
|
new Replacement("™","\\texttrademark"),
|
||||||
|
new Replacement("¶","\\P"),
|
||||||
|
new Replacement("|","\\textbar"),
|
||||||
|
new Replacement("%","\\%"),
|
||||||
|
new Replacement("{","\\{"),
|
||||||
|
new Replacement("}","\\}"),
|
||||||
|
new Replacement("_","\\_"),
|
||||||
|
new Replacement("#","\\#"),
|
||||||
|
new Replacement("$","\\$"),
|
||||||
|
new Replacement("_","\\_"),
|
||||||
|
new Replacement("¿","\\textquestiondown"),
|
||||||
|
new Replacement("§","\\S"),
|
||||||
|
new Replacement("£","\\pounds"),
|
||||||
|
new Replacement("&","\\&"),
|
||||||
|
new Replacement("¡","\\textexclamdown"),
|
||||||
|
new Replacement("†","\\dag"),
|
||||||
|
new Replacement("–","\\textendash"),
|
||||||
|
new Replacement("°","\\textdegree")
|
||||||
|
};
|
||||||
|
|
||||||
|
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
|
||||||
|
{
|
||||||
|
if (source==null) return new TeXString(defaultValue);
|
||||||
|
string result=source;
|
||||||
|
foreach (var r in SpecialCharsDefaultRendering)
|
||||||
|
{
|
||||||
|
result = r.Execute(result);
|
||||||
|
}
|
||||||
|
return new TeXString(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
|
||||||
|
{
|
||||||
|
if (source==null) return new TeXString(defaultValue);
|
||||||
|
string result=source;
|
||||||
|
foreach (var r in SpecialCharsDefaultRendering)
|
||||||
|
{
|
||||||
|
result = r.Execute(result);
|
||||||
|
}
|
||||||
|
result = result.Replace("\n","\\tabularnewline ");
|
||||||
|
return new TeXString(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static string NewLinesWith(this string target, string separator)
|
||||||
|
{
|
||||||
|
var items = target.Split(new char[] { '\n' }).Where(
|
||||||
|
s => !string.IsNullOrWhiteSpace(s));
|
||||||
|
|
||||||
|
return string.Join(separator, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
|
||||||
|
{
|
||||||
|
if (source == null) return new TeXString(defaultValue);
|
||||||
|
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
|
||||||
|
var alines = source.Split(',');
|
||||||
|
var texlines = alines.Select(l=>l.ToTeX().ToString());
|
||||||
|
return new TeXString(string.Join(lineSeparator,texlines));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
|
||||||
|
{
|
||||||
|
string errorMsg = null;
|
||||||
|
var billdir = Model.DestDir;
|
||||||
|
var tempdir = settings.TempDir;
|
||||||
|
string name = Model.BaseFileName;
|
||||||
|
string fullname = new FileInfo(
|
||||||
|
System.IO.Path.Combine(tempdir, name)).FullName;
|
||||||
|
string ofullname = new FileInfo(
|
||||||
|
System.IO.Path.Combine(billdir, name)).FullName;
|
||||||
|
|
||||||
|
FileInfo fi = new FileInfo(fullname + ".tex");
|
||||||
|
FileInfo fo = new FileInfo(ofullname + ".pdf");
|
||||||
|
using (StreamWriter sw = new StreamWriter(fi.FullName))
|
||||||
|
{
|
||||||
|
sw.Write(Model.TeXSource);
|
||||||
|
}
|
||||||
|
if (!fi.Exists)
|
||||||
|
{
|
||||||
|
errorMsg = "Source write failed";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using (Process p = new Process())
|
||||||
|
{
|
||||||
|
p.StartInfo.WorkingDirectory = tempdir;
|
||||||
|
p.StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = tempdir,
|
||||||
|
FileName = "/usr/bin/texi2pdf",
|
||||||
|
Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}"
|
||||||
|
};
|
||||||
|
p.Start();
|
||||||
|
p.WaitForExit();
|
||||||
|
if (p.ExitCode != 0)
|
||||||
|
{
|
||||||
|
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fi.Delete();
|
||||||
|
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
|
||||||
|
di.Delete(true);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Model.Generated = fo.Exists;
|
||||||
|
Model.GenerationErrorMessage = new HtmlString(errorMsg);
|
||||||
|
return fo.Exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string RenderViewToString(
|
||||||
|
this Controller controller, IViewEngine engine,
|
||||||
|
IActionContextAccessor contextAccessor,
|
||||||
|
string viewName, object model, bool isMainPage = true)
|
||||||
|
{
|
||||||
|
using (var sw = new StringWriter())
|
||||||
|
{
|
||||||
|
if (engine == null)
|
||||||
|
throw new InvalidOperationException("no engine");
|
||||||
|
|
||||||
|
// try to find the specified view
|
||||||
|
controller.TryValidateModel(model);
|
||||||
|
ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage);
|
||||||
|
|
||||||
|
// create the associated context
|
||||||
|
ViewContext viewContext = new ViewContext();
|
||||||
|
viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor;
|
||||||
|
viewContext.HttpContext = contextAccessor.ActionContext.HttpContext;
|
||||||
|
viewContext.TempData = controller.TempData;
|
||||||
|
viewContext.View = viewResult.View;
|
||||||
|
viewContext.Writer = sw;
|
||||||
|
// write the render view with the given context to the stringwriter
|
||||||
|
viewResult.View.RenderAsync(viewContext).Wait();
|
||||||
|
return sw.GetStringBuilder().ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Yavsc.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class blogPostId : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_blogSpotPublications_BlogSpot_BlogpostId",
|
|
||||||
table: "blogSpotPublications");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "BlogpostId",
|
|
||||||
table: "blogSpotPublications",
|
|
||||||
newName: "PostId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_blogSpotPublications_BlogSpot_PostId",
|
|
||||||
table: "blogSpotPublications",
|
|
||||||
column: "PostId",
|
|
||||||
principalTable: "BlogSpot",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_blogSpotPublications_BlogSpot_PostId",
|
|
||||||
table: "blogSpotPublications");
|
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
|
||||||
name: "PostId",
|
|
||||||
table: "blogSpotPublications",
|
|
||||||
newName: "BlogpostId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_blogSpotPublications_BlogSpot_BlogpostId",
|
|
||||||
table: "blogSpotPublications",
|
|
||||||
column: "BlogpostId",
|
|
||||||
principalTable: "BlogSpot",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Yavsc.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class DropSignatureFilePath : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "FilePath",
|
|
||||||
table: "Signatures");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "FilePath",
|
|
||||||
table: "Signatures",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ namespace Yavsc.Migrations
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.12")
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
@ -1521,6 +1521,10 @@ namespace Yavsc.Migrations
|
||||||
b.Property<long>("EstimateId")
|
b.Property<long>("EstimateId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("FilePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("SignerId")
|
b.Property<string>("SignerId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
@ -1689,10 +1693,10 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("PostId")
|
b.Property<long>("BlogpostId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.HasKey("PostId");
|
b.HasKey("BlogpostId");
|
||||||
|
|
||||||
b.ToTable("blogSpotPublications");
|
b.ToTable("blogSpotPublications");
|
||||||
});
|
});
|
||||||
|
|
@ -3954,13 +3958,13 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
|
b.HasOne("Yavsc.Models.Blog.BlogPost", "BlogPost")
|
||||||
.WithOne("Publication")
|
.WithMany()
|
||||||
.HasForeignKey("Yavsc.Models.BlogSpotPublication", "PostId")
|
.HasForeignKey("BlogpostId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Post");
|
b.Navigation("BlogPost");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b =>
|
modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b =>
|
||||||
|
|
@ -4610,9 +4614,6 @@ namespace Yavsc.Migrations
|
||||||
|
|
||||||
b.Navigation("Comments");
|
b.Navigation("Comments");
|
||||||
|
|
||||||
b.Navigation("Publication")
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Tags");
|
b.Navigation("Tags");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
108
src/Yavsc.Org/ViewComponents/BillViewComponent.cs
Normal file
108
src/Yavsc.Org/ViewComponents/BillViewComponent.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Yavsc.Billing;
|
||||||
|
using Yavsc.Helpers;
|
||||||
|
using Yavsc.Models;
|
||||||
|
using Yavsc.ViewModels;
|
||||||
|
using Yavsc.ViewModels.Gen;
|
||||||
|
using Yavsc.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Yavsc.ViewComponents
|
||||||
|
{
|
||||||
|
public class BillViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
readonly ApplicationDbContext dbContext;
|
||||||
|
readonly IBillingService billing;
|
||||||
|
readonly IStringLocalizer<BillViewComponent> localizer;
|
||||||
|
private readonly SiteSettings siteSettings;
|
||||||
|
|
||||||
|
public BillViewComponent(ApplicationDbContext dbContext,
|
||||||
|
IStringLocalizer<BillViewComponent> localizer,
|
||||||
|
IBillingService billing,
|
||||||
|
IOptions<SiteSettings> siteSettings)
|
||||||
|
{
|
||||||
|
this.billing = billing;
|
||||||
|
this.dbContext = dbContext;
|
||||||
|
this.localizer = localizer;
|
||||||
|
this.siteSettings = siteSettings.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IViewComponentResult> InvokeAsync(
|
||||||
|
string code,
|
||||||
|
IBillable billable,
|
||||||
|
OutputFormat format,
|
||||||
|
bool asBill,
|
||||||
|
SiteSettings settings
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var di = new DirectoryInfo(settings.Bills);
|
||||||
|
var dia = new DirectoryInfo(settings.Avatars);
|
||||||
|
ViewBag.BillsDir = di.FullName;
|
||||||
|
ViewBag.AvatarsDir = dia.FullName;
|
||||||
|
ViewBag.AsBill = asBill; // vrai pour une facture, sinon, c'est un devis
|
||||||
|
ViewBag.Acquitted = billable.GetIsAcquitted();
|
||||||
|
|
||||||
|
ViewBag.BillingCode = code;
|
||||||
|
var client = await dbContext.Users
|
||||||
|
.Include(u=>u.PostalAddress)
|
||||||
|
.SingleAsync(u=>u.Id == billable.ClientId);
|
||||||
|
ViewBag.Client = client;
|
||||||
|
var performer = await dbContext.Users
|
||||||
|
.Include(u=>u.BankInfo)
|
||||||
|
.Include(u=>u.PostalAddress)
|
||||||
|
.SingleAsync(u=>u.Id == billable.PerformerId);
|
||||||
|
ViewBag.Performer = performer;
|
||||||
|
string clientAddress = client.PostalAddress?.Address ?? null;
|
||||||
|
ViewBag.ClientAddress = clientAddress.SplitAddressToTeX();
|
||||||
|
|
||||||
|
|
||||||
|
var profile = await dbContext.Performers
|
||||||
|
.Include(p=>p.OrganizationAddress)
|
||||||
|
.SingleAsync(p=>p.PerformerId == billable.PerformerId);
|
||||||
|
ViewBag.PerformerProfile = profile;
|
||||||
|
ViewBag.ActivityLabel = (await dbContext.Activities.SingleAsync(a => a.Code == billable.ActivityCode)).Name;
|
||||||
|
|
||||||
|
var proAddr = profile.OrganizationAddress.Address;
|
||||||
|
ViewBag.PerformerOrganizationAddress = proAddr.SplitAddressToTeX() ;
|
||||||
|
ViewBag.FooterPerformerOrganizationAddress = proAddr.SplitAddressToTeX(", ");
|
||||||
|
|
||||||
|
ViewBag.PerformerAddress = performer.PostalAddress?.Address.SplitAddressToTeX() ;
|
||||||
|
switch (format) {
|
||||||
|
case OutputFormat.LaTeX :
|
||||||
|
return this.View("Bill_tex", billable);
|
||||||
|
case OutputFormat.Pdf :
|
||||||
|
string tex = null;
|
||||||
|
var oldWriter = ViewComponentContext.ViewContext.Writer;
|
||||||
|
using (var writer = new StringWriter())
|
||||||
|
{
|
||||||
|
this.ViewComponentContext.ViewContext.Writer = writer;
|
||||||
|
var resultTex = View("Bill_tex", billable);
|
||||||
|
await resultTex.ExecuteAsync(this.ViewComponentContext);
|
||||||
|
tex = writer.ToString();
|
||||||
|
}
|
||||||
|
ViewComponentContext.ViewContext.Writer = oldWriter;
|
||||||
|
|
||||||
|
var pdfGenerationViewModel = new PdfGenerationViewModel
|
||||||
|
{
|
||||||
|
Temp = siteSettings.TempDir,
|
||||||
|
TeXSource = tex,
|
||||||
|
DestDir = siteSettings.Bills,
|
||||||
|
BaseFileName = billable.GetFileBaseName(billing)
|
||||||
|
};
|
||||||
|
if (settings.GenerateEstimatePdf(pdfGenerationViewModel)) {
|
||||||
|
return this.View(new { Generated = pdfGenerationViewModel.BaseFileName+".pdf" });
|
||||||
|
} else {
|
||||||
|
return View(new { Error = pdfGenerationViewModel.GenerationErrorMessage } );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewBag.BillFileInfo = billable.GetBillInfo(billing, siteSettings);
|
||||||
|
ViewBag.Settings = settings;
|
||||||
|
return View("Default",billable);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,7 @@ namespace Yavsc.ViewComponents
|
||||||
}
|
}
|
||||||
public async Task<IViewComponentResult> InvokeAsync(long id)
|
public async Task<IViewComponentResult> InvokeAsync(long id)
|
||||||
{
|
{
|
||||||
var comment = await context.Comment
|
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id);
|
||||||
.Include(c => c.Children)
|
|
||||||
.Include(c => c.Author)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id==id);
|
|
||||||
if (comment == null)
|
if (comment == null)
|
||||||
throw new InvalidOperationException();
|
throw new InvalidOperationException();
|
||||||
ViewBag.apictlr = "/api/v1/blogcomments";
|
ViewBag.apictlr = "/api/v1/blogcomments";
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ namespace Yavsc.ViewModels.Gen
|
||||||
{
|
{
|
||||||
public class PdfGenerationViewModel
|
public class PdfGenerationViewModel
|
||||||
{
|
{
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string TeXSource { get; set; }
|
public string TeXSource { get; set; }
|
||||||
[Required]
|
[Required]
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
@model UnregisterViewModel
|
|
||||||
@{
|
|
||||||
ViewBag.Title = "Supprimer mon compte";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h2>@ViewBag.Title</h2>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8">
|
|
||||||
<p>
|
|
||||||
Cette action est <strong>immédiate et irréversible</strong>. Toutes les données
|
|
||||||
associées à votre compte seront supprimées, et vous serez déconnecté.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form asp-controller="Account" asp-action="Delete" method="post" class="form-horizontal" role="form">
|
|
||||||
@Html.Hidden("UserId")
|
|
||||||
<button type="submit" class="btn btn-danger">Confirmer la suppression</button>
|
|
||||||
<a asp-controller="Home" asp-action="Index" class="btn btn-default">Annuler</a>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
88
src/Yavsc.Org/Views/Blogspot/Create.cshtml
Normal file
88
src/Yavsc.Org/Views/Blogspot/Create.cshtml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
@model BlogPostEditViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Blog post edition";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section header {
|
||||||
|
|
||||||
|
<link href="~/css/main/dropzone.css" rel="stylesheet">
|
||||||
|
<link href="~/css/dropzone-basic.min.css" rel="stylesheet">
|
||||||
|
<script src="~/js/dropzone.bundle.min.js" asp-append-version="true"></script>
|
||||||
|
|
||||||
|
@{ await Html.RenderPartialAsync("_FSScriptsPartial"); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Blog post</h2>
|
||||||
|
<label><input type="checkbox" id="vcbtn" />Editer le code source Markdown</label>
|
||||||
|
|
||||||
|
<div asp-validation-summary="All" class="text-danger"></div>
|
||||||
|
|
||||||
|
<img class="blogphoto" alt="" src="@Model.Photo" title="Photo associée au post">
|
||||||
|
<h2 title="Titre du post" class="blogtitle" id="titleview" >@Model.Title</h2>
|
||||||
|
|
||||||
|
<div title="Contenu du post" id="contentview">@Model.Article</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<form asp-controller="Blogspot" asp-action="Create">
|
||||||
|
<div class="form-horizontal">
|
||||||
|
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
|
<div class="form-group mdcoding">
|
||||||
|
<label asp-for="Title" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input asp-for="Title" class="form-control" data-from="titleview"/>
|
||||||
|
<span asp-validation-for="Title" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Photo" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input asp-for="Photo" class="form-control" />
|
||||||
|
<span asp-validation-for="Photo" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mdcoding">
|
||||||
|
<label asp-for="Article" class="col-md-2 control-label" ></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<textarea asp-for="Article" class="form-control" id="Article" data-from="contentview">
|
||||||
|
</textarea>
|
||||||
|
<span asp-validation-for="Article" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="ACL" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
@await Component.InvokeAsync("CirclesControl", Model)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Publish" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input asp-for="Publish" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-2 col-md-10">
|
||||||
|
<button class="btn btn-primary" >Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@await Component.InvokeAsync(name: "Directory",arguments: new {subdir="."})
|
||||||
|
<div >
|
||||||
|
@{ await Html.RenderPartialAsync("_PostFilesPartial"); }
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a asp-action="Index">Back to List</a>
|
||||||
|
</div>
|
||||||
|
|
@ -1,235 +1,105 @@
|
||||||
@model BlogPost
|
@model BlogPost
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = Model.Title;
|
ViewBag.Title=Model.Title;
|
||||||
}
|
}
|
||||||
@section header {
|
@section scripts {
|
||||||
|
|
||||||
|
<script src="~/js/comment.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
$.psc.blogcomment.prototype.options.lang = '@System.Globalization.CultureInfo.CurrentUICulture.Name';
|
||||||
|
$.psc.blogcomment.prototype.options.apictrlr = '/api/v1/blogcomments';
|
||||||
|
$.psc.blogcomment.prototype.options.authorId = '@User.GetUserId()';
|
||||||
|
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#cmtBtn').click(function() {
|
||||||
|
$.ajax({
|
||||||
|
async: true,
|
||||||
|
cache: false,
|
||||||
|
type: 'POST',
|
||||||
|
method: 'POST',
|
||||||
|
contentType: "application/json",
|
||||||
|
data: JSON.stringify({
|
||||||
|
Article: $('#Comment').val(),
|
||||||
|
ReceiverId: @Model.Id
|
||||||
|
}),
|
||||||
|
error: function(xhr,data) {
|
||||||
|
if (xhr.status === 400)
|
||||||
|
{
|
||||||
|
if (xhr.responseJSON)
|
||||||
|
{
|
||||||
|
$('#commentValidation').html(
|
||||||
|
xhr.responseJSON.Article);
|
||||||
|
} else {
|
||||||
|
$('#commentValidation').html(
|
||||||
|
"Une erreur est survenue : "+xhr.status+"<br/>"+
|
||||||
|
"<code><pre>"+xhr.responseText+"</pre></code>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
success: function (data) {
|
||||||
|
var comment = $('#Comment').val();
|
||||||
|
$('#Comment').val('');
|
||||||
|
$('#commentValidation').empty();
|
||||||
|
var nnode = '<div data-type="blogcomment" data-id="'+data.id+'" data-allow-edit="True" data-allow-moderate="@ViewBag.moderatoFlag" data-date="'+data.dateCreated+'" data-username="@User.GetUserName()">'+comment+'</div>';
|
||||||
|
$('#comments').append($(nnode).blogcomment())
|
||||||
|
},
|
||||||
|
url:'/api/v1/blogcomments'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.blog-details .post {
|
.avatar, .commentmeta, .blogcomment p , .subcomments {
|
||||||
display: grid;
|
display: inline-block;
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.blog-details .post-photo {
|
|
||||||
width: 128px;
|
|
||||||
height: 128px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: .375rem;
|
|
||||||
}
|
|
||||||
.blog-details .post-title {
|
|
||||||
margin: 0 0 .25rem;
|
|
||||||
}
|
|
||||||
.blog-details .post-meta {
|
|
||||||
font-size: .85rem;
|
|
||||||
color: var(--bs-secondary-color, #6c757d);
|
|
||||||
margin-bottom: .5rem;
|
|
||||||
}
|
|
||||||
.blog-details .post-article {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
#comments {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
}
|
||||||
.blogcomment {
|
.blogcomment {
|
||||||
margin: .3em 0;
|
display: inline-block;
|
||||||
padding: .5rem .75rem;
|
margin:.3em;
|
||||||
border-left: 3px solid var(--bs-border-color, #dee2e6);
|
|
||||||
/* Quartz ships white body text on a coloured page bg, so
|
|
||||||
use a semi-transparent black overlay rather than
|
|
||||||
--bs-tertiary-bg (which is near-white in the quartz
|
|
||||||
light variant and would render white-on-white). */
|
|
||||||
background: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: .25rem;
|
|
||||||
}
|
|
||||||
.blogcomment .comment-body > :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.blogcomment .commentmeta {
|
|
||||||
display: block;
|
|
||||||
font-size: .75rem;
|
|
||||||
color: var(--bs-secondary-color, #6c757d);
|
|
||||||
margin-top: .25rem;
|
|
||||||
}
|
|
||||||
.blogcomment .reply-btn {
|
|
||||||
padding: 0;
|
|
||||||
font-size: .75rem;
|
|
||||||
}
|
|
||||||
.subcomments {
|
|
||||||
margin-left: 1.25rem;
|
|
||||||
margin-top: .5rem;
|
|
||||||
}
|
|
||||||
.reply-form {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
margin: .5rem 0;
|
|
||||||
max-width: 40rem;
|
|
||||||
}
|
|
||||||
.reply-form .form-control {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.comment-form .input-group {
|
|
||||||
max-width: 40rem;
|
|
||||||
}
|
|
||||||
#commentValidation,
|
|
||||||
.reply-validation {
|
|
||||||
color: var(--bs-danger, #dc3545);
|
|
||||||
font-size: .8rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
}
|
}
|
||||||
@section scripts {
|
|
||||||
<script>
|
|
||||||
$(document).ready(function () {
|
|
||||||
var api = '/api/v1/blogcomments';
|
|
||||||
var receiverId = @Model.Id;
|
|
||||||
var userName = @Json.Serialize(User.GetUserName());
|
|
||||||
// Reply buttons are rendered server-side only when the
|
|
||||||
// user is authenticated, so a non-empty userName here
|
|
||||||
// means freshly appended nodes may carry one too.
|
|
||||||
var canReply = !!userName;
|
|
||||||
|
|
||||||
// Build a plain, escaped comment node from a just-posted
|
<div class="post">
|
||||||
// comment. The server-side Comment view component is what
|
<div class="float-left">
|
||||||
// markdown-renders and decorates persisted comments; a
|
<img class="photo" src="@Model.Photo" >
|
||||||
// newly created one is plain text until the page reloads.
|
|
||||||
function buildCommentNode(text, data, parentId) {
|
|
||||||
var $node = $('<div class="blogcomment"></div>')
|
|
||||||
.attr('data-type', 'blogcomment')
|
|
||||||
.attr('data-id', data.id)
|
|
||||||
.attr('data-receiver-id', receiverId);
|
|
||||||
if (parentId != null) $node.attr('data-parent-id', parentId);
|
|
||||||
$node.append($('<div class="comment-body"></div>').append($('<p></p>').text(text)));
|
|
||||||
$node.append($('<span class="commentmeta"></span>')
|
|
||||||
.text('— ' + (userName || 'Vous') + ', posté à ' + new Date(data.dateCreated).toLocaleTimeString()));
|
|
||||||
if (canReply) {
|
|
||||||
$node.append($('<button type="button" class="btn btn-link btn-sm reply-btn">Répondre</button>'));
|
|
||||||
}
|
|
||||||
$node.append($('<div class="subcomments"></div>'));
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postComment(text, parentId, $validation) {
|
|
||||||
return $.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
contentType: 'application/json',
|
|
||||||
url: api,
|
|
||||||
data: JSON.stringify({
|
|
||||||
Article: text,
|
|
||||||
ReceiverId: receiverId,
|
|
||||||
ParentId: parentId
|
|
||||||
})
|
|
||||||
}).fail(function (xhr) {
|
|
||||||
var msg;
|
|
||||||
if (xhr.status === 400 && xhr.responseJSON) {
|
|
||||||
msg = xhr.responseJSON.Article || xhr.responseJSON.title || 'Validation error';
|
|
||||||
} else {
|
|
||||||
msg = 'Une erreur est survenue : ' + xhr.status;
|
|
||||||
}
|
|
||||||
($validation || $('#commentValidation')).text(msg);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top-level comment form.
|
|
||||||
$('#cmtBtn').click(function () {
|
|
||||||
var text = $('#Comment').val();
|
|
||||||
if (!text) return;
|
|
||||||
$('#commentValidation').empty();
|
|
||||||
postComment(text, null, $('#commentValidation')).done(function (data) {
|
|
||||||
$('#comments').append(buildCommentNode(text, data, null));
|
|
||||||
$('#Comment').val('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tree replies — delegated so it works for every nested
|
|
||||||
// comment, including ones appended at runtime.
|
|
||||||
$('#comments').on('click', '.reply-btn', function () {
|
|
||||||
var $comment = $(this).closest('[data-type="blogcomment"]');
|
|
||||||
// Toggle: if a reply form is already open for this
|
|
||||||
// comment, close it instead of opening a second one.
|
|
||||||
var $existing = $comment.children('.reply-form');
|
|
||||||
if ($existing.length) { $existing.remove(); return; }
|
|
||||||
|
|
||||||
var $form = $('<div class="reply-form"></div>');
|
|
||||||
$form.append('<input class="form-control reply-input" placeholder="..." />');
|
|
||||||
$form.append('<button type="button" class="btn btn-secondary reply-submit">Envoyer</button>');
|
|
||||||
$form.append('<span class="reply-validation"></span>');
|
|
||||||
// Insert before this comment's .subcomments so nested
|
|
||||||
// replies land in the right place.
|
|
||||||
$comment.children('.subcomments').before($form);
|
|
||||||
$form.find('.reply-input').focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#comments').on('click', '.reply-submit', function () {
|
|
||||||
var $form = $(this).closest('.reply-form');
|
|
||||||
var $comment = $form.closest('[data-type="blogcomment"]');
|
|
||||||
var parentId = parseInt($comment.attr('data-id'), 10);
|
|
||||||
var text = $form.find('.reply-input').val();
|
|
||||||
if (!text) return;
|
|
||||||
var $val = $form.find('.reply-validation');
|
|
||||||
$val.empty();
|
|
||||||
postComment(text, parentId, $val).done(function (data) {
|
|
||||||
var $sub = $comment.children('.subcomments');
|
|
||||||
$sub.append(buildCommentNode(text, data, parentId));
|
|
||||||
$form.remove();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="blog-details">
|
|
||||||
<div class="post card">
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Photo))
|
|
||||||
{
|
|
||||||
<img class="post-photo" src="@Model.Photo" alt="@Model.Title" />
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<h1 class="post-title">@Model.Title</h1>
|
|
||||||
<div class="post-meta">
|
|
||||||
@Html.DisplayFor(m => m.Author)
|
|
||||||
@if (Model.DateModified > Model.DateCreated)
|
|
||||||
{
|
|
||||||
<span> · modifié le @Model.DateModified.ToString("dddd d MMM yyyy à H:mm")</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span> · posté le @Model.DateCreated.ToString("dddd d MMM yyyy à H:mm")</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="post-body post-article">
|
|
||||||
<markdown>@Model.Article</markdown>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h1 ismarkdown>@Model.Title</h1>
|
||||||
|
|
||||||
|
@Html.DisplayFor(m=>m.Author)
|
||||||
|
<asciidoc>@Html.DisplayFor(m=>m.Article)</asciidoc>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="comments">
|
<div id="comments">
|
||||||
@if (Model.Comments != null)
|
@if (Model.Comments!=null) {
|
||||||
{
|
foreach (var comment in Model.Comments.Where(c=>c.ParentId==null)) {
|
||||||
foreach (var comment in Model.Comments.Where(c => c.ParentId == null))
|
|
||||||
{
|
|
||||||
@await Component.InvokeAsync("Comment", new { id = comment.Id })
|
@await Component.InvokeAsync("Comment", new { id = comment.Id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (User.GetUserId()!=null) {
|
||||||
@if (User.GetUserId() != null)
|
<div class="form-horizontal">
|
||||||
{
|
<div class="input-group" >
|
||||||
<div class="comment-form form-horizontal">
|
<input name="Comment" id="Comment" class="form-control" placeholder="..."/>
|
||||||
<div class="input-group">
|
<span class="input-group-btn">
|
||||||
<input name="Comment" id="Comment" class="form-control" placeholder="..." />
|
<span id="commentValidation"></span>
|
||||||
<span class="input-group-btn">
|
<input type="button" value="Comment" class="btn btn-secondary"
|
||||||
<span id="commentValidation"></span>
|
data-receiverId="@Model.Id" id="cmtBtn"
|
||||||
<input type="button" value="Comment" class="btn btn-secondary" id="cmtBtn" />
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span asp-validation-for="Article" class="text-danger" ></span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else {
|
||||||
{
|
<p><i>Vous devez être identifié pour commenter.</i> </p>
|
||||||
<p><i>Vous devez être identifié pour commenter.</i></p>
|
}
|
||||||
|
|
||||||
|
@if ((await AuthorizationService.AuthorizeAsync(User, Model, new EditPermission())).Succeeded) {
|
||||||
|
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-link">Edit</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a asp-action="Index" class="btn btn-link">Back to List</a>
|
<a asp-action="Index" class="btn btn-link">Back to List</a>
|
||||||
</div>
|
|
||||||
|
|
|
||||||
120
src/Yavsc.Org/Views/Blogspot/Edit.cshtml
Normal file
120
src/Yavsc.Org/Views/Blogspot/Edit.cshtml
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
@model BlogPostEditViewModel
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Blog post edition";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section header {
|
||||||
|
|
||||||
|
<link href="~/css/main/dropzone.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.box__dragndrop,
|
||||||
|
.box__uploading,
|
||||||
|
.box__success,
|
||||||
|
.box__error {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box.has-advanced-upload {
|
||||||
|
background-color: white;
|
||||||
|
outline: 2px dashed black;
|
||||||
|
outline-offset: -10px;
|
||||||
|
}
|
||||||
|
.box.has-advanced-upload .box__dragndrop {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.box.is-dragover {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
.ql-snow.ql-toolbar {
|
||||||
|
border: outset grey 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<script src="~/js/dropzone.bundle.min.js" asp-append-version="true"></script>
|
||||||
|
|
||||||
|
|
||||||
|
@{ await Html.RenderPartialAsync("_FSScriptsPartial"); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 >Blog post</h2>
|
||||||
|
<label><input type="checkbox" id="vcbtn" />Editer le code source Markdown</label>
|
||||||
|
|
||||||
|
<div asp-validation-summary="All" class="text-danger"></div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="Titletoolbar" class="hidden ql-snow ql-toolbar">
|
||||||
|
<button class="ql-format-button ql-italic"></button>
|
||||||
|
<button class="ql-format-button ql-underline"></button>
|
||||||
|
<button class="ql-format-button ql-strike"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img class="blogphoto" alt="" src="@Model.Photo" title="Photo associée au post">
|
||||||
|
<h2 title="Titre du post" class="blogtitle" id="titleview" >@Model.Title</h2>
|
||||||
|
|
||||||
|
<form asp-action="Edit">
|
||||||
|
<div class="form-horizontal">
|
||||||
|
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
@Html.HiddenFor(m=>m.Id)
|
||||||
|
|
||||||
|
<div class="form-group mdcoding">
|
||||||
|
<label asp-for="Title" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input asp-for="Title" class="form-control" data-from="titleview"/>
|
||||||
|
<span asp-validation-for="Title" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Photo" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input asp-for="Photo" class="form-control" />
|
||||||
|
<span asp-validation-for="Photo" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mdcoding">
|
||||||
|
<label asp-for="Article" class="col-md-2 control-label" ></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<textarea asp-for="Article" class="form-control"
|
||||||
|
id="Article" data-from="contentview">
|
||||||
|
</textarea>
|
||||||
|
<span asp-validation-for="Article" class="text-danger" >
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="ACL" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
@await Component.InvokeAsync("CirclesControl", Model)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Publish" class="col-md-2 control-label"></label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input type="checkbox" asp-for="Publish" class="form-control" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-2 col-md-10">
|
||||||
|
<input type="submit" value="Save" class="btn btn-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@await Component.InvokeAsync("Directory","")
|
||||||
|
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div >
|
||||||
|
@{ await Html.RenderPartialAsync("_PostFilesPartial"); }
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a asp-action="Index">Back to List</a>
|
||||||
|
</div>
|
||||||
|
|
@ -1,155 +1,92 @@
|
||||||
|
|
||||||
@model IEnumerable<IBlogPost>
|
@model IEnumerable<IBlogPost>
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "Blogs, l'index";
|
ViewBag.Title = "Blogs, l'index";
|
||||||
|
|
||||||
// Pagination reflects the Index(string id, int skip, int take)
|
|
||||||
// call profile. The service applies Skip/Take server-side, so
|
|
||||||
// the view only needs the current page params to build Prev/Next
|
|
||||||
// links — it does not receive a total count.
|
|
||||||
int currentSkip = Context.Request.Query.TryGetValue("skip", out var skipVal) && int.TryParse(skipVal, out var s) ? s : 0;
|
|
||||||
int currentTake = Context.Request.Query.TryGetValue("take", out var takeVal) && int.TryParse(takeVal, out var t) ? t : 25;
|
|
||||||
|
|
||||||
var posts = Model.ToList();
|
|
||||||
bool hasPrev = currentSkip > 0;
|
|
||||||
bool hasNext = posts.Count >= currentTake;
|
|
||||||
|
|
||||||
int prevSkip = Math.Max(0, currentSkip - currentTake);
|
|
||||||
int nextSkip = currentSkip + currentTake;
|
|
||||||
|
|
||||||
int maxSummaryLen = 256;
|
|
||||||
}
|
}
|
||||||
@section header {
|
@section header {
|
||||||
<style>
|
<style>
|
||||||
.blog-index {
|
.collapsed {
|
||||||
display: flex;
|
height: 1em;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
.blog-index .post {
|
.sametitlegrip {
|
||||||
display: grid;
|
text-decoration: underline;
|
||||||
grid-template-columns: auto 1fr;
|
cursor: pointer;
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
.blog-index .post-photo {
|
.sametitle {
|
||||||
width: 96px;
|
overflow: hidden;
|
||||||
height: 96px;
|
transition: height 1s;
|
||||||
object-fit: cover;
|
|
||||||
border-radius: .375rem;
|
|
||||||
}
|
}
|
||||||
.blog-index .post-body {
|
td {
|
||||||
min-width: 0;
|
transition: height 1s;
|
||||||
}
|
}
|
||||||
.blog-index .index-post-title {
|
div.row {
|
||||||
margin: 0 0 .25rem;
|
border-bottom: dashed black 1px;
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
}
|
||||||
.blog-index .post-meta {
|
</style>
|
||||||
font-size: .8rem;
|
}
|
||||||
color: var(--bs-secondary-color, #6c757d);
|
@section scripts {
|
||||||
}
|
<script>
|
||||||
.blog-index .post-author img {
|
$(document).ready(function () {
|
||||||
width: 1.25rem;
|
$(".sametitle").addClass("collapsed")
|
||||||
height: 1.25rem;
|
.on("mouseover",function(){
|
||||||
border-radius: 50%;
|
$(this).removeClass("collapsed")
|
||||||
vertical-align: middle;
|
}).on("mouseout",function(){
|
||||||
margin-right: .25rem;
|
$(this).addClass("collapsed")
|
||||||
}
|
});
|
||||||
.blog-index .actiongroup {
|
}
|
||||||
margin-top: .5rem;
|
)
|
||||||
display: flex;
|
</script>
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
.blog-index .post-summary {
|
|
||||||
margin: .25rem 0;
|
|
||||||
}
|
|
||||||
.blog-index .post-summary > p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.blog-pager {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
}
|
}
|
||||||
<h2>@ViewBag.Title</h2>
|
<h2>@ViewBag.Title</h2>
|
||||||
<p class="text-success">@ViewBag.StatusMessage</p>
|
<p class="text-success">@ViewBag.StatusMessage</p>
|
||||||
|
@if (User.IsSignedIn()) {
|
||||||
@if (!posts.Any())
|
<p>
|
||||||
{
|
<a asp-action="Create">Create a new article</a>
|
||||||
<p>Néant</p>
|
</p>
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="blog-index">
|
|
||||||
@foreach (var post in posts)
|
|
||||||
{
|
|
||||||
var canEdit = (await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded;
|
|
||||||
<article class="post card">
|
|
||||||
@if (!string.IsNullOrEmpty(post.Photo))
|
|
||||||
{
|
|
||||||
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink">
|
|
||||||
<img class="post-photo" src="@post.Photo" alt="@post.Title" />
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
<div class="post-body">
|
|
||||||
<h3 class="index-post-title">
|
|
||||||
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink">@post.Title</a>
|
|
||||||
</h3>
|
|
||||||
<div class="post-summary">
|
|
||||||
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink text-decoration-none">
|
|
||||||
<markdown summary="@maxSummaryLen">@post.Article</markdown>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="post-meta">
|
|
||||||
@if (post.Author != null)
|
|
||||||
{
|
|
||||||
<span class="post-author">
|
|
||||||
@if (!string.IsNullOrEmpty(post.Author.Avatar))
|
|
||||||
{
|
|
||||||
<img src="@post.Author.Avatar" alt="" />
|
|
||||||
}
|
|
||||||
@@@post.Author.UserName
|
|
||||||
</span>
|
|
||||||
<span> · </span>
|
|
||||||
}
|
|
||||||
<span>posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")</span>
|
|
||||||
@if (post.DateModified > post.DateCreated)
|
|
||||||
{
|
|
||||||
<span> · modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="actiongroup">
|
|
||||||
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
|
|
||||||
@if (canEdit)
|
|
||||||
{
|
|
||||||
<a asp-action="Delete" asp-route-id="@post.Id" class="btn btn-danger">Delete</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="blog-pager" aria-label="Pagination">
|
<div class="blog-index">
|
||||||
@if (hasPrev)
|
@{
|
||||||
{
|
int maxTextLen = 75;
|
||||||
<a asp-action="Index"
|
foreach (var post in Model) {
|
||||||
asp-route-skip="@prevSkip"
|
<div class="post card">
|
||||||
asp-route-take="@currentTake"
|
|
||||||
class="btn btn-outline-secondary">← Précédent</a>
|
|
||||||
}
|
<a asp-action="Details" asp-route-id="@post.Id" class="bloglink" >
|
||||||
else
|
<div class="float-left"><img class="photo card-photo" src="@post.Photo" ></div>
|
||||||
{
|
<h3 class="index-post-title">@post.Title</h3>
|
||||||
<span></span>
|
</a>
|
||||||
}
|
<div>
|
||||||
@if (hasNext)
|
<a asp-action="Details" asp-route-id="@post.Id">
|
||||||
{
|
<asciidoc summary="@maxTextLen">@post.Article</asciidoc></a>
|
||||||
<a asp-action="Index"
|
<span style="font-size:x-small;">@Html.DisplayFor(m => post.Author)</span>
|
||||||
asp-route-skip="@nextSkip"
|
<span style="font-size:xx-small;">
|
||||||
asp-route-take="@currentTake"
|
posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm")
|
||||||
class="btn btn-outline-secondary">Suivant →</a>
|
@if ((post.DateModified - post.DateCreated).Minutes > 0){
|
||||||
}
|
@:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm")
|
||||||
</nav>
|
}
|
||||||
}
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="actiongroup">
|
||||||
|
@if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded)
|
||||||
|
{
|
||||||
|
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a asp-action="Details" asp-route-id="@post.Id" class="btn btn-light">Details</a>
|
||||||
|
}
|
||||||
|
@if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded)
|
||||||
|
{
|
||||||
|
<a asp-action="Edit" asp-route-id="@post.Id" class="btn btn-primary">Edit</a>
|
||||||
|
|
||||||
|
<a asp-action="Delete" asp-route-id="@post.Id" class="btn btn-danger">Delete</a>
|
||||||
|
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(Model.Count()==0){<p>Néant</p>}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,28 @@
|
||||||
<h2 markdown="@ViewBag.Title"></h2>
|
<h2 markdown="@ViewBag.Title"></h2>
|
||||||
<p class="text-success">@ViewBag.StatusMessage</p>
|
<p class="text-success">@ViewBag.StatusMessage</p>
|
||||||
|
|
||||||
<table class="table">
|
<p>
|
||||||
|
<a asp-action="Create" asp-route-title="@ViewBag.Title">Poster au même titre</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
|
||||||
@foreach (var item in Model) {
|
@foreach (var item in Model) {
|
||||||
<tr>
|
<tr>
|
||||||
<td><a asp-action="Details" asp-route-id="@item.Id" class="bloglink">
|
<td><a asp-action="Details" asp-route-id="@item.Id" class="bloglink">
|
||||||
<img src="@item.Photo" class="blogphoto"></a>
|
<img src="@item.Photo" class="blogphoto"></a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<markdown>@((item.Article?.Length > 256) ? item.Article.Substring(0, 256) + " ..." : item.Article)</markdown>
|
<asciidoc>@((item.Article?.Length > 256) ? item.Article.Substring(0, 256) + " ..." : item.Article)</asciidoc>
|
||||||
<span style="font-size:x-small;">(@item.Author.UserName </span>,
|
<span style="font-size:x-small;">(@item.Author.UserName </span>,
|
||||||
<span style="font-size:xx-small;">
|
<span style="font-size:xx-small;">
|
||||||
posté le @item.DateCreated.ToString("dddd d MMM yyyy à H:mm")
|
posté le @item.DateCreated.ToString("dddd d MMM yyyy à H:mm")
|
||||||
@if ((item.DateModified - item.DateCreated).Minutes > 0){
|
@if ((item.DateModified - item.DateCreated).Minutes > 0){
|
||||||
@:- Modifié le @item.DateModified.ToString("dddd d MMM yyyy à H:mm")
|
@:- Modifié le @item.DateModified.ToString("dddd d MMM yyyy à H:mm")
|
||||||
})
|
})
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<ul class="actiongroup">
|
<ul class="actiongroup">
|
||||||
@if ((await AuthorizationService.AuthorizeAsync(User, item, new ReadPermission())).Succeeded) {
|
@if ((await AuthorizationService.AuthorizeAsync(User, item, new ReadPermission())).Succeeded) {
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -28,7 +32,8 @@
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if ((await AuthorizationService.AuthorizeAsync(User, item, new EditPermission())).Succeeded) {
|
@if ((await AuthorizationService.AuthorizeAsync(User, item, new EditPermission())).Succeeded) {
|
||||||
|
<li><a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-primary">Edit</a>
|
||||||
|
</li>
|
||||||
<li><a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-danger">Delete</a>
|
<li><a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-danger">Delete</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue