Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f7760c5be4 |
|||
|
9fc196f9eb |
|||
|
4a0c59e105 |
|||
|
46fa53c513 |
|||
|
c1f447636b |
|||
|
94a9239cd5 |
|||
|
a94a5a0497 |
|||
|
8c8ab70d38 |
|||
|
f0792a844f |
|||
|
26e4fde1e7 |
|||
|
70f8b7986e |
|||
|
1d12166611 |
|||
|
5104cbc3f1 |
|||
|
7844449c99 |
|||
|
f1a17cd8fe |
|||
|
4554886e33 |
|||
|
a307885310 |
|||
|
d8c13c81d7 |
|||
|
8d2d6754f6 |
|||
|
94d8eb538c |
|||
|
daf5949392 |
|||
|
2c8be78175 |
|||
|
e1ce67a76e |
|||
|
384f6bc300 |
|||
|
a17a9cef9a |
|||
|
ed3dc733d3 |
|||
|
5553f2f290 |
|||
|
3fe7b8b46c |
|||
|
a793996b70 |
|||
|
8e3862ed26 |
|||
|
70ab05522b |
|||
|
eb58fc8862 |
|||
|
040446c0b8 |
|||
|
bad09cc7af |
|||
|
fbf42cc1db |
|||
|
ba40698cc9 |
|||
|
35d784fdf1 |
|||
|
9c1f552bca |
|||
|
bbc37d3095 |
|||
|
7caae103fe |
|||
|
f44dab0334 |
|||
|
97d0584292 |
|||
|
70a123b840 |
|||
|
adc27a006b |
|||
|
827fe6b41f |
|||
|
36ffe4683c |
|||
|
2ad7a55e34 |
|||
|
709846c58d |
|||
|
9aef10f281 |
|||
|
e28959b068 |
|||
|
22c33c5388 |
|||
|
60c10781f0 |
|||
|
625e062b4b |
|||
|
1455ba5187 |
|||
|
605d885917 |
|||
|
efc2296858 |
|||
|
6a69a20364 |
|||
|
f09c657183 |
|||
|
93fd4e61ad |
|||
|
d5f11e83a4 |
|||
|
11ae66f85b |
|||
|
8d1348197d |
|||
|
483abb78ec |
|||
|
b3c2674235 |
|||
|
db5c202918 |
|||
|
1773c5aaea |
|||
|
67da921bce |
257 changed files with 24043 additions and 2491 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=pgpassword
|
POSTGRES_PASSWORD=<your pg password>
|
||||||
|
|
||||||
ANTHROPIC_API_KEY=none
|
ANTHROPIC_API_KEY=none
|
||||||
ANTHROPIC_MAX_TOKENS=0
|
ANTHROPIC_MAX_TOKENS=0
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,29 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building the solution..."
|
echo "🏗️ Building the solution..."
|
||||||
cd /src/_src && dotnet build --verbosity normal
|
cd /src/_src && dotnet build --verbosity normal
|
||||||
|
- name: install dependencies
|
||||||
|
run: |
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
apt install -y texlive-binaries texlive-luatex texlive-pictures \
|
||||||
|
texlive-fonts-extra texlive-lang-french texlive-lang-english \
|
||||||
|
texlive-lang-portuguese texlive-lang-spanish \
|
||||||
|
texlive-latex-recommended texlive-latex-extra
|
||||||
- name: Test
|
- name: Test
|
||||||
|
artifacts:
|
||||||
|
name: test-results
|
||||||
|
path: test-results.html
|
||||||
|
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Lancement des tests..."
|
echo "🚀 Lancement des tests..."
|
||||||
cd /src/_src && dotnet test \
|
cd /src/_src && dotnet test \
|
||||||
--verbosity normal \
|
--report-xunit \
|
||||||
--filter="Category!=Platform-Android"
|
--report-xunit-html \
|
||||||
|
--report-xunit-html-filename "test-results.html"
|
||||||
|
TEST_EXIT_CODE=$?
|
||||||
|
echo "Test exit code: $TEST_EXIT_CODE"
|
||||||
|
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||||
|
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
|
||||||
|
exit $TEST_EXIT_CODE
|
||||||
|
else
|
||||||
|
echo "✅ All tests passed successfully."
|
||||||
|
fi
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -43,3 +43,8 @@ DataDir/
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# gitleaks scan output (local secret-scan report, not for the repo)
|
||||||
|
/gitleaks-report.json
|
||||||
|
|
||||||
|
# Exclude test reports (local test report, not for the repo)
|
||||||
|
/test-reports
|
||||||
|
|
|
||||||
18
.vscode/launch.json
vendored
18
.vscode/launch.json
vendored
|
|
@ -22,11 +22,9 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Android Debug",
|
"name": "Android Debug",
|
||||||
"type": "mono",
|
"type": "maui",
|
||||||
"preLaunchTask": "run-debug-android",
|
"request": "launch",
|
||||||
"request": "attach",
|
"project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj"
|
||||||
"address": "localhost",
|
|
||||||
"port": 55555
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Android Attach - Debug",
|
"name": "Android Attach - Debug",
|
||||||
|
|
@ -43,9 +41,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Yavsc Org",
|
"name": "Yavsc Org",
|
||||||
"type": "dotnet",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
|
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
|
||||||
|
"env": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"cwd": "${workspaceFolder}/src/Yavsc.Org",
|
||||||
|
"preLaunchTask": "build-web-yavsc-org"
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Yavsc Blogs",
|
"name": "Yavsc Blogs",
|
||||||
|
|
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
|
@ -45,5 +45,10 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dotnet.defaultSolution": "yavsc.sln"
|
"dotnet.defaultSolution": "yavsc.sln",
|
||||||
|
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"dotnet build": true,
|
||||||
|
"dotnet test": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
.vscode/tasks.json
vendored
54
.vscode/tasks.json
vendored
|
|
@ -1,27 +1,17 @@
|
||||||
{
|
{
|
||||||
"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": "build-debug-android",
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
|
"detail": "Build the PostIt Android project in debug mode",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
|
"runOptions": {
|
||||||
|
|
||||||
|
},
|
||||||
|
"problemMatcher": ["$msCompile"],
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
|
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
|
||||||
"env": {
|
"env": {
|
||||||
|
|
@ -43,6 +33,7 @@
|
||||||
"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,
|
||||||
|
|
@ -102,6 +93,34 @@
|
||||||
},
|
},
|
||||||
"isBackground": true
|
"isBackground": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "build-web-yavsc-org",
|
||||||
|
"type": "process",
|
||||||
|
"problemMatcher": ["$msCompile"],
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": ["build"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "src/Yavsc.Org"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"kind": "build"
|
||||||
|
},
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build-web-yavsc-blogs",
|
||||||
|
"type": "process",
|
||||||
|
"problemMatcher": ["$msCompile"],
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": ["build"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "src/Yavsc.Blogs"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"kind": "build"
|
||||||
|
},
|
||||||
|
"isBackground": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "dotnet: build-postit-desktop",
|
"label": "dotnet: build-postit-desktop",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
|
|
@ -109,6 +128,7 @@
|
||||||
"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"
|
||||||
|
|
|
||||||
250
CHANGELOG.md
250
CHANGELOG.md
|
|
@ -1,5 +1,251 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.8-rc19] - Blogs UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
La saisie du billet est maintenant plutôt possible,
|
||||||
|
en petit écran.
|
||||||
|
|
||||||
|
## [1.0.8-rc18] - unstable
|
||||||
|
|
||||||
|
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
|
||||||
|
|
||||||
|
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
|
||||||
|
(arbre de stockage indexé par le login, pas par le `sub`), boutons
|
||||||
|
d'action des listes réparés (binding `$parent`), revendication `name`
|
||||||
|
enfin présente dans les access tokens (branchement du `ProfileService`),
|
||||||
|
et négociation HTTP/3 côté client PostIt.
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
|
||||||
|
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
|
||||||
|
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
|
||||||
|
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
|
||||||
|
desktop sans libmsquic).
|
||||||
|
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
|
||||||
|
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
|
||||||
|
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
|
||||||
|
(`CanExecute`) et déclenche l'appel API au clic.
|
||||||
|
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
|
||||||
|
mint un vrai JWT via le token endpoint IdentityServer (password grant,
|
||||||
|
`openid profile`) et asserte que l'access token porte `name`=login et
|
||||||
|
que `GetUserName()` résout vers ce login.
|
||||||
|
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
|
||||||
|
découple `sub` et login, téléverse sous le login, et asserte que
|
||||||
|
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
|
||||||
|
le `sub` n'existe pas).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [Yavsc.Org] Branchement du `ProfileService` custom
|
||||||
|
(`AddProfileService<ProfileService>()` après
|
||||||
|
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
|
||||||
|
IdentityServer8 retombait sur son profile service par défaut, qui
|
||||||
|
n'émet que les claims listés dans les `UserClaims` des API resources.
|
||||||
|
`name` n'en faisait jamais partie, donc tout access token était sans
|
||||||
|
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
|
||||||
|
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
|
||||||
|
`User.Identity.Name` injoignable. Le service émet désormais
|
||||||
|
`name`=`user.UserName`, `email` et les rôles pour tout client dont
|
||||||
|
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
|
||||||
|
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
|
||||||
|
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
|
||||||
|
courts de claims tels qu'émis).
|
||||||
|
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
|
||||||
|
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
|
||||||
|
la racine de stockage par utilisateur.
|
||||||
|
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
|
||||||
|
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
|
||||||
|
de test ; réensemencement des identity resources openid/profile dans
|
||||||
|
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
|
||||||
|
in-memory partagé et les laissait absentes → `invalid_scope`).
|
||||||
|
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
|
||||||
|
(défaut = subject) pour refléter la forme production où
|
||||||
|
`sub` (GUID) ≠ `name` (login).
|
||||||
|
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
|
||||||
|
sérialiser avec le build d'hôte qui mute les registres statiques
|
||||||
|
`BillingService`/`Config` (race parallèle pré-existante).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
|
||||||
|
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
|
||||||
|
stockage perso est indexé par le login partout ailleurs
|
||||||
|
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
|
||||||
|
le endpoint de téléchargement et les avatars utilisent
|
||||||
|
`user.UserName`). Une fois l'upload corrigé, les fichiers
|
||||||
|
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
|
||||||
|
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
|
||||||
|
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
|
||||||
|
l'utilisateur en base et liste sous `user.UserName`.
|
||||||
|
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
|
||||||
|
interpole toujours par `uid`.
|
||||||
|
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
|
||||||
|
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
|
||||||
|
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
|
||||||
|
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
|
||||||
|
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
|
||||||
|
pas un contrôle — donc résolvait à `null` et le `Button` restait
|
||||||
|
désactivé (grisé) et inerte au clic. Corrigé en
|
||||||
|
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
|
||||||
|
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
|
||||||
|
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
|
||||||
|
pas sélectionnables/copiables : le texte était un `TextBlock` (non
|
||||||
|
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
|
||||||
|
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
|
||||||
|
renvoyait un 40x alors que le fichier était bien supprimé du disque.
|
||||||
|
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
|
||||||
|
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
|
||||||
|
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
|
||||||
|
du contrôleur transformait cette exception en 400, d'où le message
|
||||||
|
perçu « le fichier n'existe pas sur le serveur » tandis que la
|
||||||
|
suppression avait réussi. La taille est désormais capturée avant la
|
||||||
|
suppression.
|
||||||
|
* PostIt default settings
|
||||||
|
|
||||||
|
## [1.0.8-rc17] - unstable
|
||||||
|
|
||||||
|
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
|
||||||
|
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
|
||||||
|
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
|
||||||
|
production où `IsPublished` disparaissait du payload de liste du blog API.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
|
||||||
|
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
|
||||||
|
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
|
||||||
|
le détail de demande et la page d'édition de devis.
|
||||||
|
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
|
||||||
|
(`EstimateApiController`) et les demandes front-office
|
||||||
|
(`FrontOfficeApiController`).
|
||||||
|
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
|
||||||
|
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
|
||||||
|
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
|
||||||
|
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
|
||||||
|
`UserFileAttachments`.
|
||||||
|
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
|
||||||
|
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
|
||||||
|
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
|
||||||
|
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
|
||||||
|
`README.nginx.md`.
|
||||||
|
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
|
||||||
|
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
|
||||||
|
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
|
||||||
|
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
|
||||||
|
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
|
||||||
|
`FileSystemApiTests` (pièces jointes et stockage perso).
|
||||||
|
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
|
||||||
|
production sur le vhost Org. La preprod et la prod partageant la même
|
||||||
|
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
|
||||||
|
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
|
||||||
|
la config par nginx.
|
||||||
|
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
|
||||||
|
provient de la variable d'environnement
|
||||||
|
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
|
||||||
|
source.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
|
||||||
|
de la liste du blog API. Le endpoint `GetBlogspot` retourne
|
||||||
|
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
|
||||||
|
déclaré (l'interface), qui omet les propriétés du type concret —
|
||||||
|
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
|
||||||
|
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
|
||||||
|
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
|
||||||
|
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
|
||||||
|
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
|
||||||
|
`CommentsController` (Index/Details/…) atterrissaient sur la même route
|
||||||
|
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
|
||||||
|
`SwaggerGeneratorException` (conflit method/path) au chargement de
|
||||||
|
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
|
||||||
|
les actions scaffoldées.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
@ -369,6 +615,10 @@ 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-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
|
||||||
|
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
|
||||||
|
[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
|
||||||
|
|
|
||||||
|
|
@ -6,44 +6,69 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
<PackageVersion Include="DotMarkdown" Version="0.3.0" />
|
||||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
<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="AsciiDocSharp" Version="0.1.0" />
|
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.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.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||||
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
|
||||||
|
<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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
|
||||||
<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>
|
||||||
25
Dockerfile
25
Dockerfile
|
|
@ -24,41 +24,24 @@
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1
|
ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2
|
||||||
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
|
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# (1) Manifest + fichiers de projet pour le cache de restore
|
|
||||||
COPY *.sln ./
|
|
||||||
COPY Directory.*.props ./
|
|
||||||
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
|
|
||||||
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
|
|
||||||
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
|
|
||||||
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
|
|
||||||
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
|
|
||||||
COPY src/cli/*.csproj ./src/cli/
|
|
||||||
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
|
|
||||||
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
|
|
||||||
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
|
|
||||||
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
|
|
||||||
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
|
|
||||||
|
|
||||||
# (2) Tout le code source
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# (4) Restore
|
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
|
|
||||||
# (5) Build des trois services web principaux
|
# Build des trois services web principaux
|
||||||
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||||
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
|
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||||
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
|
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
|
||||||
|
|
||||||
# (6) Build APK Android (utilisé par le workflow docker-publish-android).
|
# Build APK Android (utilisé par le workflow docker-publish-android).
|
||||||
ARG ANDROID_TARGET_RID=android-arm64
|
ARG ANDROID_TARGET_RID=android-arm64
|
||||||
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
|
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
|
||||||
|
|
||||||
# (7) Publish des trois services web (les artifacts sont consommés par
|
# Publish des trois services web (les artifacts sont consommés par
|
||||||
# les stages publish-org / publish-api / publish-blogs ci-dessous).
|
# les stages publish-org / publish-api / publish-blogs ci-dessous).
|
||||||
RUN mkdir -p /app/publish
|
RUN mkdir -p /app/publish
|
||||||
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org
|
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org
|
||||||
|
|
|
||||||
28
Makefile
28
Makefile
|
|
@ -13,11 +13,37 @@ 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
|
||||||
|
|
||||||
test:
|
test:
|
||||||
ASPNETCORE_ENVIRONMENT=Development dotnet test
|
ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
|
||||||
|
--report-xunit-html \
|
||||||
|
--results-directory "test-reports"
|
||||||
|
|
||||||
|
test-postit:
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
||||||
|
--report-xunit \
|
||||||
|
--report-xunit-html \
|
||||||
|
--results-directory "test-reports" \
|
||||||
|
--report-xunit-html-filename "postit-test-results.html" \
|
||||||
|
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
|
||||||
|
|
||||||
|
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
|
||||||
|
Api-backend-test:
|
||||||
|
Blogs-backend-test:
|
||||||
|
Org-backend-test:
|
||||||
|
%-backend-test:
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development dotnet test \
|
||||||
|
--report-xunit \
|
||||||
|
--report-xunit-html \
|
||||||
|
--results-directory "test-reports" \
|
||||||
|
--report-xunit-html-filename "$*-backend-test-results.html" \
|
||||||
|
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj
|
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -61,9 +61,40 @@ 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` : `make pushInProd CONFIGURATION=Release`.
|
`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
|
||||||
|
|
||||||
puis, pour une première installation
|
puis, pour une première installation
|
||||||
`make install_service`.
|
`make install_service`.
|
||||||
|
|
|
||||||
140
contrib/Makefile
140
contrib/Makefile
|
|
@ -1,23 +1,32 @@
|
||||||
|
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 $(SLNDIR)/.env
|
include .$(APP_INSTALL_ENV).env
|
||||||
include .env
|
|
||||||
|
|
||||||
generated/:
|
reinstall: copy-binaries
|
||||||
@mkdir -p $@
|
@for project in $(APP_PROJECT_NAMES); do \
|
||||||
|
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||||
|
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
|
||||||
|
@echo "💯 backend started"
|
||||||
|
|
||||||
generated/yavscOrg.service:
|
reinstall-prod:
|
||||||
generated/yavscBlogs.service:
|
make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
|
||||||
generated/yavscApi.service:
|
|
||||||
|
|
||||||
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
|
||||||
|
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="$($*_PORT)" \
|
||||||
BASEAPPDIR="$(BASEAPPDIR)" \
|
APPDIR="$(APPDIR)" \
|
||||||
APP_SYSLOG_ID="yavsc$*" \
|
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)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)" \
|
||||||
|
|
@ -34,11 +43,83 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
|
||||||
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
|
||||||
|
|
||||||
copy-services: copy-service-Org copy-service-Blogs copy-service-Api
|
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
|
||||||
copy-service-Org: /etc/systemd/system/yavscOrg.service
|
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||||
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
|
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
|
||||||
copy-service-Api: /etc/systemd/system/yavscApi.service
|
#
|
||||||
|
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
|
||||||
|
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
|
||||||
|
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
|
||||||
|
NGINX_TEMPLATE := template.vhost
|
||||||
|
NGINX_OUT_DIR := generated/nginx
|
||||||
|
NGINX_HOSTS := Org Blogs Api
|
||||||
|
DOMAIN_ROOT ?= pschneider.fr
|
||||||
|
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
|
||||||
|
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
|
||||||
|
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
|
||||||
|
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
|
||||||
|
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
|
||||||
|
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
|
||||||
|
# prod Org -> yavsc.pschneider.fr
|
||||||
|
# preprod Org -> preprod-yavsc.pschneider.fr
|
||||||
|
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
|
||||||
|
|
||||||
|
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
|
||||||
|
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
|
||||||
|
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
|
||||||
|
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
|
||||||
|
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
|
||||||
|
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
|
||||||
|
@cat $(NGINX_TEMPLATE) \
|
||||||
|
| DOMAIN="$(call nginx_domain,$*)" \
|
||||||
|
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
|
||||||
|
HTTP_HOST="$(HTTP_HOST)" \
|
||||||
|
BACKEND_PORT="$($*_PORT)" \
|
||||||
|
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
|
||||||
|
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
|
||||||
|
@echo Created nginx vhost: $@
|
||||||
|
|
||||||
|
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
|
||||||
|
|
||||||
|
generate-nginx-prod:
|
||||||
|
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||||
|
|
||||||
|
$(NGINX_OUT_DIR):
|
||||||
|
@mkdir -p $@
|
||||||
|
|
||||||
|
# --- appsettings (générés depuis template.appsettings-*.json) ---
|
||||||
|
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
|
||||||
|
#
|
||||||
|
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
|
||||||
|
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
|
||||||
|
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
|
||||||
|
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
|
||||||
|
# contient un \). jq lit le secret depuis l'environnement (export) ->
|
||||||
|
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
|
||||||
|
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
|
||||||
|
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
|
||||||
|
export ASPNETCORE_Smtp__Password
|
||||||
|
APPSETTINGS_OUT_DIR := generated/appsettings
|
||||||
|
APPSETTINGS_HOSTS := org api blogs
|
||||||
|
|
||||||
|
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
|
||||||
|
@jq --arg domain "$(DOMAIN_ROOT)" \
|
||||||
|
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
|
||||||
|
template.appsettings-$*.json > $@
|
||||||
|
@echo Created appsettings: $@
|
||||||
|
|
||||||
|
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
|
||||||
|
|
||||||
|
$(APPSETTINGS_OUT_DIR):
|
||||||
|
@mkdir -p $@
|
||||||
|
|
||||||
|
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
|
||||||
|
|
||||||
|
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); \
|
||||||
|
|
@ -52,39 +133,36 @@ 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}/* $(BASEAPPDIR) ; \
|
sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
|
||||||
done
|
done
|
||||||
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
|
@sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
|
||||||
|
@sync
|
||||||
|
|
||||||
/etc/systemd/system/yavsc%.service: generated/yavsc%.service
|
$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)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: build_publish copy-binaries copy-services
|
install: 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 \
|
||||||
sudo systemctl enable yavsc$${project} ; \
|
echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
|
||||||
sudo systemctl start yavsc$${project} ; \
|
sudo systemctl enable $(APP_INSTALL_PREFIX)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 \
|
||||||
sudo systemctl stop yavsc$${project} ; \
|
echo "🔥 Stopping $(APP_INSTALL_PREFIX)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
|
||||||
|
|
@ -93,7 +171,7 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
|
||||||
|
|
||||||
showConfig:
|
showConfig:
|
||||||
@echo CONFIGURATION: $(CONFIGURATION)
|
@echo CONFIGURATION: $(CONFIGURATION)
|
||||||
@echo BASEAPPDIR: $(BASEAPPDIR)
|
@echo APPDIR: $(APPDIR)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -107,3 +185,5 @@ showBlogsLogs:
|
||||||
clean:
|
clean:
|
||||||
@rm -rf generated
|
@rm -rf generated
|
||||||
|
|
||||||
|
generated/:
|
||||||
|
@mkdir -p $@
|
||||||
|
|
|
||||||
134
contrib/README.nginx.md
Normal file
134
contrib/README.nginx.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# Vhosts nginx — génération par `make generate-nginx`
|
||||||
|
|
||||||
|
Les vhosts nginx sont **générés** à l'image des unités de service systemd
|
||||||
|
(`template.service` + `envsubst` → `generated/*.service`) : un template
|
||||||
|
unique `contrib/template.vhost` est substitué par `envsubst` vers
|
||||||
|
`contrib/generated/nginx/`, qui est **gitignoré**.
|
||||||
|
|
||||||
|
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
|
||||||
|
|
||||||
|
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
|
||||||
|
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
|
||||||
|
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
|
||||||
|
`template.appsettings-*.json`) est une capacité complémentaire — non
|
||||||
|
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
|
||||||
|
uniquement), mais disponible si Kestrel est un jour exposé en direct.
|
||||||
|
|
||||||
|
Directives générées (par vhost) :
|
||||||
|
```nginx
|
||||||
|
listen 443 ssl; listen [::]:443 ssl; http2 on;
|
||||||
|
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
|
||||||
|
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||||
|
ssl_protocols TLSv1.3;
|
||||||
|
```
|
||||||
|
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
|
||||||
|
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
|
||||||
|
confondus).
|
||||||
|
|
||||||
|
## Prérequis côté host
|
||||||
|
|
||||||
|
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
|
||||||
|
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
|
||||||
|
`listen ... quic` suffit à activer h3) :
|
||||||
|
```bash
|
||||||
|
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
|
||||||
|
```
|
||||||
|
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
|
||||||
|
backports ou `nginx-full`.
|
||||||
|
2. **UDP 443 ouvert** :
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 443/udp
|
||||||
|
sudo ufw status | grep '443/udp' # → ALLOW
|
||||||
|
```
|
||||||
|
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
|
||||||
|
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
|
||||||
|
échoue sur le bind IPv6).
|
||||||
|
4. Certbot / certs Let's Encrypt déjà en place (chemins
|
||||||
|
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
|
||||||
|
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
|
||||||
|
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
|
||||||
|
|
||||||
|
## Génération
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd contrib
|
||||||
|
|
||||||
|
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
|
||||||
|
make generate-nginx
|
||||||
|
|
||||||
|
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
|
||||||
|
make generate-nginx-prod
|
||||||
|
# ou manuellement :
|
||||||
|
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
|
||||||
|
```
|
||||||
|
|
||||||
|
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
|
||||||
|
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
|
||||||
|
`https` en prod, `http` en preprod.
|
||||||
|
|
||||||
|
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|
||||||
|
|-------|--------------|-------------|-------------------------|------------------------|
|
||||||
|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
|
||||||
|
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
|
||||||
|
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
|
||||||
|
|
||||||
|
Domaines : `<prefix><subdomain>.pschneider.fr`
|
||||||
|
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
|
||||||
|
|
||||||
|
## Déploiement sur le host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
|
||||||
|
# (idem yavscApi, yavscBlogs)
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vérification (sur le host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Module h3 présent
|
||||||
|
nginx -V 2>&1 | grep -o 'http_v3_module'
|
||||||
|
|
||||||
|
# 2. UDP 443 ouvert
|
||||||
|
sudo ufw status | grep '443/udp'
|
||||||
|
|
||||||
|
# 3. En-tête Alt-Svc annoncé
|
||||||
|
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
|
||||||
|
|
||||||
|
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
|
||||||
|
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
|
||||||
|
|
||||||
|
# 5. Fallback h2 et h1 toujours fonctionnels
|
||||||
|
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||||
|
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
|
||||||
|
```
|
||||||
|
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
|
||||||
|
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
|
||||||
|
Network → colonne « Protocole » → `h3` après mise à niveau.
|
||||||
|
|
||||||
|
## Comment ça marche (template + envsubst)
|
||||||
|
|
||||||
|
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
|
||||||
|
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
|
||||||
|
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
|
||||||
|
via `envsubst` avec une **allowlist** :
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette allowlist limite la substitution aux 5 variables citées, de sorte
|
||||||
|
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
|
||||||
|
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
|
||||||
|
allowlist, `envsubst` détruirait ces variables nginx.
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
|
||||||
|
systemd + nginx).
|
||||||
|
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
|
||||||
|
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
|
||||||
|
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
|
||||||
|
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
|
||||||
|
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).
|
||||||
33
contrib/template.appsettings-api.json
Normal file
33
contrib/template.appsettings-api.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"Site": {
|
||||||
|
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||||
|
"Audience": ["api"],
|
||||||
|
"CorsAllowedOrigins": [
|
||||||
|
"https://preprod-blogs.$DOMAIN"
|
||||||
|
],
|
||||||
|
"Bills": "bills",
|
||||||
|
"TempDir": "temp"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Kestrel": {
|
||||||
|
"Endpoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "http://localhost:92"
|
||||||
|
},
|
||||||
|
"Https": {
|
||||||
|
"Url": "https://localhost:93",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
|
||||||
|
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
|
||||||
|
},
|
||||||
|
"Protocols": "Http1AndHttp2AndHttp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
contrib/template.appsettings-blogs.json
Normal file
40
contrib/template.appsettings-blogs.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"Site": {
|
||||||
|
"Title": "Yavsc Blogs",
|
||||||
|
"Description": "A collection of blogs about software development, technology, and programming.",
|
||||||
|
"Keywords": "software development, technology, programming, coding, blogs",
|
||||||
|
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||||
|
"Audience": ["blogs"],
|
||||||
|
"CorsAllowedOrigins": [
|
||||||
|
"https://preprod-api.$DOMAIN"
|
||||||
|
],
|
||||||
|
"Blog": "blog"
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information",
|
||||||
|
"Microsoft.AspNetCore.Authentication": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Kestrel": {
|
||||||
|
"Endpoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "http://localhost:90"
|
||||||
|
},
|
||||||
|
"Https": {
|
||||||
|
"Url": "https://localhost:91",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
|
||||||
|
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
|
||||||
|
},
|
||||||
|
"Protocols": "Http1AndHttp2AndHttp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
contrib/template.appsettings-org.json
Normal file
74
contrib/template.appsettings-org.json
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Site": {
|
||||||
|
"Title": "yavsc",
|
||||||
|
"Slogan": "Yavsc!",
|
||||||
|
"StyleSheet": "/css/default.css",
|
||||||
|
"Authority": "https://preprod-yavsc.$DOMAIN",
|
||||||
|
"Audience": [
|
||||||
|
"blogs",
|
||||||
|
"api"
|
||||||
|
],
|
||||||
|
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
|
||||||
|
"ApiUrl": "https://preprod-api.$DOMAIN",
|
||||||
|
"CorsAllowedOrigins": [
|
||||||
|
"https://preprod-blogs.$DOMAIN",
|
||||||
|
"https://preprod-api.$DOMAIN"
|
||||||
|
],
|
||||||
|
"Banner": "/images/banner-dewang.jpg",
|
||||||
|
"Owner": {
|
||||||
|
"Name": "Paul Schneider",
|
||||||
|
"EMail": "paul@pschneider.fr"
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"Name": "Paul Schneider",
|
||||||
|
"EMail": "paul@pschneider.fr"
|
||||||
|
},
|
||||||
|
"Quota": 200000000,
|
||||||
|
"Blog": "blog",
|
||||||
|
"Avatars": "avatars",
|
||||||
|
"Bills": "bills",
|
||||||
|
"TempDir": "temp",
|
||||||
|
"DataDir": "data",
|
||||||
|
"GitRepository": "git"
|
||||||
|
},
|
||||||
|
"Smtp": {
|
||||||
|
"Host": "mercure.pschneider.fr",
|
||||||
|
"Port": 465,
|
||||||
|
"SenderName": "Paul Schneider",
|
||||||
|
"SenderEmail": "paul@pschneider.fr",
|
||||||
|
"UserName": "paul",
|
||||||
|
"Password": "$SMTP_PASSWORD"
|
||||||
|
},
|
||||||
|
"DataProtection": {
|
||||||
|
"Keys": {
|
||||||
|
"Dir": "DataProtection"
|
||||||
|
},
|
||||||
|
"RSAParamFile": "RSA-Params.json",
|
||||||
|
"ExpiresInHours": 168
|
||||||
|
},
|
||||||
|
"Moderation": {
|
||||||
|
"AutoApproveThreshold": 0.7,
|
||||||
|
"AutoRejectThreshold": 0.9
|
||||||
|
},
|
||||||
|
"Kestrel": {
|
||||||
|
"Endpoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "http://localhost:88"
|
||||||
|
},
|
||||||
|
"Https": {
|
||||||
|
"Url": "https://localhost:89",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
|
||||||
|
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
|
||||||
|
},
|
||||||
|
"Protocols": "Http1AndHttp2AndHttp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,10 @@ RestartSec=5s
|
||||||
Type=simple
|
Type=simple
|
||||||
User=yavsc
|
User=yavsc
|
||||||
Group=yavsc
|
Group=yavsc
|
||||||
WorkingDirectory=$DESTDIR
|
WorkingDirectory=$APPDIR
|
||||||
ExecStart=$DESTDIR/Yavsc.$APP_NAME
|
ExecStart=$APPDIR/Yavsc.$APP_NAME
|
||||||
Restart=always
|
Restart=always
|
||||||
Environment="HOME=$DESTDIR"
|
Environment="HOME=$APPDIR"
|
||||||
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,8 +29,6 @@ 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]
|
||||||
|
|
|
||||||
61
contrib/template.vhost
Normal file
61
contrib/template.vhost
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
|
||||||
|
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
|
||||||
|
#
|
||||||
|
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
|
||||||
|
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
|
||||||
|
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
|
||||||
|
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
|
||||||
|
#
|
||||||
|
# Variables substituées par envsubst (allowlist) :
|
||||||
|
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
|
||||||
|
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
|
||||||
|
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
|
||||||
|
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
|
||||||
|
# même IP, donc seule la prod (Org) l'active.
|
||||||
|
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
|
||||||
|
# préservées (hors allowlist).
|
||||||
|
|
||||||
|
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
|
||||||
|
# ACME ; ne pas servir l'app en HTTP clair.)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $DOMAIN;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
# 1. TCP : HTTPS classique + HTTP/2
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
|
||||||
|
listen 443 quic$QUIC_OPTS;
|
||||||
|
listen [::]:443 quic$QUIC_OPTS;
|
||||||
|
http3 on;
|
||||||
|
|
||||||
|
server_name $DOMAIN;
|
||||||
|
|
||||||
|
# 3. Certificats Let's Encrypt (Certbot).
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
|
||||||
|
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
|
||||||
|
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
|
||||||
|
ssl_protocols TLSv1.3;
|
||||||
|
|
||||||
|
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
|
||||||
|
add_header Alt-Svc 'h3=":443"; ma=86400' always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 100s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
include server-status.conf;
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: web-runtime
|
target: web-runtime
|
||||||
args:
|
args:
|
||||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||||
secrets:
|
secrets:
|
||||||
- source: yavsc_appsettings
|
- source: yavsc_appsettings
|
||||||
target: yavsc_appsettings
|
target: yavsc_appsettings
|
||||||
|
|
@ -53,9 +53,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
network_mode: host
|
||||||
- yavsc-internal
|
|
||||||
- yavsc-public
|
|
||||||
# volumes:
|
# volumes:
|
||||||
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
|
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
|
||||||
# # Sans ce montage, 'docker compose up' échoue côté web avec
|
# # Sans ce montage, 'docker compose up' échoue côté web avec
|
||||||
|
|
@ -73,7 +71,7 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: api-runtime
|
target: api-runtime
|
||||||
args:
|
args:
|
||||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||||
secrets:
|
secrets:
|
||||||
- source: yavsc_appsettings
|
- source: yavsc_appsettings
|
||||||
target: yavsc_appsettings
|
target: yavsc_appsettings
|
||||||
|
|
@ -90,9 +88,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
network_mode: host
|
||||||
- yavsc-internal
|
|
||||||
- yavsc-public
|
|
||||||
# volumes:
|
# volumes:
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
|
||||||
|
|
@ -105,7 +101,7 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: blogs-runtime
|
target: blogs-runtime
|
||||||
args:
|
args:
|
||||||
BUILD_ENV_TAG: debian12-dotnet10-android36-v1
|
BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
|
||||||
secrets:
|
secrets:
|
||||||
- source: yavsc_appsettings
|
- source: yavsc_appsettings
|
||||||
target: yavsc_appsettings
|
target: yavsc_appsettings
|
||||||
|
|
@ -122,9 +118,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
network_mode: host
|
||||||
- yavsc-internal
|
|
||||||
- yavsc-public
|
|
||||||
# volumes:
|
# volumes:
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
|
||||||
|
|
|
||||||
5
global.json
Normal file
5
global.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"runner": "Microsoft.Testing.Platform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,28 +4,31 @@
|
||||||
<!-- 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.1</AvaloniaVersionBase>
|
<AvaloniaVersionBase>12.1.2</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="$(AvaloniaVersionBase)" />
|
<PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
|
||||||
<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.19.0" />
|
<PackageVersion Include="Material.Avalonia" Version="3.20.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" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.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.11" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using Android.App;
|
using Android.App;
|
||||||
using Android.Content;
|
using Android.Content;
|
||||||
using Android.Content.PM;
|
using Android.Content.PM;
|
||||||
|
using Android.Views;
|
||||||
using AndroidX.Core.Provider;
|
using AndroidX.Core.Provider;
|
||||||
using AndroidX.Emoji2.Text;
|
using AndroidX.Emoji2.Text;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
|
@ -15,7 +16,9 @@ namespace PostIt.Android;
|
||||||
Theme = "@style/MyTheme.NoActionBar",
|
Theme = "@style/MyTheme.NoActionBar",
|
||||||
Icon = "@drawable/icon",
|
Icon = "@drawable/icon",
|
||||||
MainLauncher = true,
|
MainLauncher = true,
|
||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
WindowSoftInputMode = SoftInput.AdjustResize,
|
||||||
|
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
|
||||||
|
]
|
||||||
public class MainActivity : AvaloniaMainActivity
|
public class MainActivity : AvaloniaMainActivity
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AndroidResource Include="Icon.png">
|
<AndroidResource Include="Icon.png">
|
||||||
|
|
@ -27,4 +27,14 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
|
||||||
|
extension's injected HotReload agent (references Microsoft.Maui.*).
|
||||||
|
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
|
||||||
|
this file's item groups, so the removal must run in a target. -->
|
||||||
|
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
|
||||||
|
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
|
||||||
{
|
{
|
||||||
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
|
||||||
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
|
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||||
|
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
await vm.SendCommand.ExecuteAsync(null);
|
await vm.SendCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
|
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||||
Assert.Equal(HttpMethod.Post, api.LastMethod);
|
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||||
|
|
||||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
|
||||||
Assert.Equal(42, payload.CommandId);
|
Assert.Equal(42, payload.CommandId);
|
||||||
Assert.Equal("cli-1", payload.ClientId);
|
Assert.Equal("cli-1", payload.ClientId);
|
||||||
Assert.Equal("Brush", payload.CommandType);
|
Assert.Equal("Brush", payload.CommandType);
|
||||||
|
|
@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
await vm.SendCommand.ExecuteAsync(null);
|
await vm.SendCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
var payload = Assert.IsType<EstimateDto>(api.LastBody);
|
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
|
||||||
|
var payload = Assert.IsType<EstimateDto>(postCall.Body);
|
||||||
Assert.Equal(2, payload.Bill.Count);
|
Assert.Equal(2, payload.Bill.Count);
|
||||||
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
|
||||||
Assert.True(vm.HasSent);
|
Assert.True(vm.HasSent);
|
||||||
|
|
@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests
|
||||||
public object? LastBody { get; private set; }
|
public object? LastBody { get; private set; }
|
||||||
public Exception? Failure { get; init; }
|
public Exception? Failure { get; init; }
|
||||||
|
|
||||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
|
||||||
|
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||||
|
|
||||||
|
private void Record(HttpMethod method, string path, object? body)
|
||||||
{
|
{
|
||||||
LastMethod = method;
|
LastMethod = method;
|
||||||
LastPath = path;
|
LastPath = path;
|
||||||
LastBody = body;
|
LastBody = body;
|
||||||
|
Calls.Add((method, path, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, body);
|
||||||
|
|
||||||
if (Failure is not null)
|
if (Failure is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests
|
||||||
|
|
||||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
LastMethod = method;
|
Record(method, path, body);
|
||||||
LastPath = path;
|
|
||||||
LastBody = body;
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests
|
||||||
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
|
||||||
=> CallAsync(method, path, (object?)null, ct);
|
=> CallAsync(method, path, (object?)null, ct);
|
||||||
|
|
||||||
|
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, null);
|
||||||
|
return Task.FromResult(Array.Empty<byte>());
|
||||||
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||||
|
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("Mes devis à valider", vm.Title);
|
Assert.Equal("Devis en attente de signature client", 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);
|
||||||
|
|
@ -28,12 +29,13 @@ 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||||
|
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("Mes devis en attente", vm.Title);
|
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
|
||||||
Assert.Equal(2, vm.Estimates.Count);
|
Assert.Equal(2, vm.Estimates.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +44,8 @@ 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
|
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||||
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
@ -63,7 +66,8 @@ 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
|
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
|
||||||
|
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
|
||||||
|
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
|
|
||||||
255
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
255
src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
157
src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal file
208
src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
using System.Net.Http;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using PostIt.Views;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless UI coverage for the "Mes fichiers" page
|
||||||
|
/// (<see cref="MyFilesPage"/>). The user reported that once a file
|
||||||
|
/// is listed, "l'item est inerte au click, et ses boutons restent
|
||||||
|
/// grisés, désactivés" — neither the row nor its action buttons
|
||||||
|
/// (Télécharger / Supprimer) respond to a click.
|
||||||
|
///
|
||||||
|
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
|
||||||
|
/// the Avalonia headless platform with a stub
|
||||||
|
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
|
||||||
|
/// realised visual tree to find the row's action buttons and
|
||||||
|
/// assert the two things that would silently break and reproduce
|
||||||
|
/// the "grayed / inert" symptom:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>The <c>Command</c> binding
|
||||||
|
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
|
||||||
|
/// resolved to a non-null command — a null command leaves the
|
||||||
|
/// button disabled (grayed) and click-silent.</description></item>
|
||||||
|
/// <item><description>The command is <b>armed</b>
|
||||||
|
/// (<c>CanExecute</c> true) for the listed file — an
|
||||||
|
/// <c>AsyncRelayCommand</c> with
|
||||||
|
/// <c>AllowConcurrentExecutions=false</c> (the
|
||||||
|
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
|
||||||
|
/// default) disables itself while running; a button stuck
|
||||||
|
/// "running" is exactly the grayed-and-inert report.</description></item>
|
||||||
|
/// <item><description>Executing the command actually invokes the
|
||||||
|
/// file download / delete on the API client — the row
|
||||||
|
/// "activates" on click instead of being inert.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>The OS save picker is absent under headless, so
|
||||||
|
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
|
||||||
|
/// download command's <c>catch</c> sets an error status — but the
|
||||||
|
/// API download is awaited <em>before</em> the picker, so the call
|
||||||
|
/// is recorded regardless. We do not <c>await</c> the command's
|
||||||
|
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
|
||||||
|
/// (platform-dependent) picker.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class MyFilesPageHeadlessTests
|
||||||
|
{
|
||||||
|
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
|
||||||
|
{
|
||||||
|
var api = new StubFsApi();
|
||||||
|
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
|
||||||
|
var vm = new MyFilesViewModel(fsClient);
|
||||||
|
var page = new MyFilesPage { DataContext = vm };
|
||||||
|
return (page, vm, api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mount the page the way the app does — pushed onto a
|
||||||
|
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
|
||||||
|
/// (a <see cref="ContentPage"/> realizes its content template via
|
||||||
|
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
|
||||||
|
/// listing a single file by awaiting
|
||||||
|
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
|
||||||
|
/// <see cref="MyFilesViewModel.Files"/> before the first layout
|
||||||
|
/// pass lets the <c>ItemsControl</c> realise its item templates
|
||||||
|
/// under headless.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
|
||||||
|
{
|
||||||
|
var (page, vm, api) = CreatePage();
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
var nav = new NavigationPage();
|
||||||
|
nav.PushAsync(page).GetAwaiter().GetResult();
|
||||||
|
var window = new Window { Content = nav };
|
||||||
|
window.Show();
|
||||||
|
// Let bindings / the ItemsControl item templates apply.
|
||||||
|
await Task.Delay(50);
|
||||||
|
return (page, vm, api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find the first action button in a file row whose content
|
||||||
|
/// matches <paramref name="content"/>. The file rows live in an
|
||||||
|
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
|
||||||
|
/// walk the visual tree.
|
||||||
|
/// </summary>
|
||||||
|
private static Button? FindRowButton(MyFilesPage page, string content)
|
||||||
|
=> page.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.FirstOrDefault(b => b.Content is string s && s == content);
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
|
||||||
|
{
|
||||||
|
var (page, vm, api) = await MountAsync();
|
||||||
|
|
||||||
|
// Sanity: the listing populated a file row.
|
||||||
|
Assert.NotEmpty(vm.Files);
|
||||||
|
Assert.NotNull(vm.Files[0].Id);
|
||||||
|
|
||||||
|
var downloadBtn = FindRowButton(page, "Télécharger");
|
||||||
|
Assert.NotNull(downloadBtn);
|
||||||
|
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
|
||||||
|
var param = downloadBtn.CommandParameter;
|
||||||
|
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
|
||||||
|
"Télécharger must be armed (CanExecute true) for a listed file.");
|
||||||
|
|
||||||
|
// Act: click → fire the command.
|
||||||
|
downloadBtn.Command.Execute(param);
|
||||||
|
|
||||||
|
// Assert: the row activated — the API download was invoked
|
||||||
|
// (it is awaited before the headless-absent save picker).
|
||||||
|
var recorded = false;
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
{
|
||||||
|
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
|
||||||
|
{
|
||||||
|
recorded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
Assert.True(recorded,
|
||||||
|
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[AvaloniaFact]
|
||||||
|
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
|
||||||
|
{
|
||||||
|
var (page, vm, api) = await MountAsync();
|
||||||
|
|
||||||
|
Assert.NotEmpty(vm.Files);
|
||||||
|
|
||||||
|
var deleteBtn = FindRowButton(page, "Supprimer");
|
||||||
|
Assert.NotNull(deleteBtn);
|
||||||
|
Assert.NotNull(deleteBtn!.Command);
|
||||||
|
var param = deleteBtn.CommandParameter;
|
||||||
|
Assert.True(deleteBtn.Command!.CanExecute(param),
|
||||||
|
"Supprimer must be armed (CanExecute true) for a listed file.");
|
||||||
|
|
||||||
|
deleteBtn.Command.Execute(param);
|
||||||
|
|
||||||
|
var recorded = false;
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
{
|
||||||
|
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
|
||||||
|
{
|
||||||
|
recorded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
Assert.True(recorded,
|
||||||
|
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubFsApi : IYavscApiClient
|
||||||
|
{
|
||||||
|
public HttpClient Http { get; } = new();
|
||||||
|
|
||||||
|
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
|
||||||
|
|
||||||
|
private void Record(HttpMethod method, string path, object? body)
|
||||||
|
=> Calls.Add((method, path, body));
|
||||||
|
|
||||||
|
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, body);
|
||||||
|
if (typeof(T) == typeof(UserDirectoryDto))
|
||||||
|
{
|
||||||
|
var dir = new UserDirectoryDto
|
||||||
|
{
|
||||||
|
UserName = "alice",
|
||||||
|
Files = new[]
|
||||||
|
{
|
||||||
|
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Task.FromResult((T)(object)dir);
|
||||||
|
}
|
||||||
|
return Task.FromResult(default(T)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Record(method, path, 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)
|
||||||
|
{
|
||||||
|
Record(method, path, null);
|
||||||
|
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,13 @@
|
||||||
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-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" />
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ public class SessionStatusBannerTests
|
||||||
// déconnecter, Se connecter, Paramètres. If any one is
|
// déconnecter, Se connecter, Paramètres. If any one is
|
||||||
// missing, the user has no way to trigger the
|
// missing, the user has no way to trigger the
|
||||||
// corresponding navigation event.
|
// corresponding navigation event.
|
||||||
Assert.Equal(4, buttons.Count);
|
Assert.True(buttons.Count>=4);
|
||||||
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
|
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
|
||||||
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
|
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
|
||||||
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
|
||||||
|
|
|
||||||
35
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal file
35
src/PostIt/PostIt.Tests/StatusBarTests.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless.XUnit;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using PostIt.Controls;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="StatusBar"/> surfaces the page's action status
|
||||||
|
/// (success / warning / error message). The user reported being
|
||||||
|
/// unable to copy these messages — the status text was a
|
||||||
|
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
|
||||||
|
/// now a <see cref="SelectableTextBlock"/> so the message can be
|
||||||
|
/// selected and copied (Ctrl+C / context menu). This test pins that:
|
||||||
|
/// if the message control regresses to a plain <c>TextBlock</c>,
|
||||||
|
/// selectability is lost and the test fails.
|
||||||
|
/// </summary>
|
||||||
|
public class StatusBarTests
|
||||||
|
{
|
||||||
|
[AvaloniaFact]
|
||||||
|
public void Status_message_is_rendered_by_a_SelectableTextBlock()
|
||||||
|
{
|
||||||
|
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
|
||||||
|
var window = new Window { Content = bar };
|
||||||
|
window.Show();
|
||||||
|
|
||||||
|
var message = bar.GetVisualDescendants()
|
||||||
|
.OfType<SelectableTextBlock>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
Assert.NotNull(message);
|
||||||
|
Assert.Equal("Échec de la suppression : boom", message!.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,5 +11,6 @@
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ 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
|
||||||
|
|
@ -203,4 +204,9 @@ public partial class App : Application
|
||||||
{
|
{
|
||||||
await View!.NavRoot.PopAsync();
|
await View!.NavRoot.PopAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void UseConfigFileWhenLoading(string configFile)
|
||||||
|
{
|
||||||
|
this.CliConfigFileSpecification = configFile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/PostIt/PostIt/CLIOptions/CLIOptions.cs
Normal file
33
src/PostIt/PostIt/CLIOptions/CLIOptions.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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,6 +46,21 @@ 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
|
||||||
|
|
@ -109,6 +124,7 @@ 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;
|
||||||
|
|
@ -181,6 +197,38 @@ 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
|
||||||
|
|
|
||||||
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
28
src/PostIt/PostIt/Controls/SignaturePadView.axaml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<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>
|
||||||
206
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
206
src/PostIt/PostIt/Controls/SignaturePadView.axaml.cs
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,10 @@
|
||||||
FontWeight="Bold"
|
FontWeight="Bold"
|
||||||
Foreground="{Binding Foreground}"
|
Foreground="{Binding Foreground}"
|
||||||
VerticalAlignment="Top"/>
|
VerticalAlignment="Top"/>
|
||||||
<TextBlock Text="{Binding Message}"
|
<!-- SelectableTextBlock so the user can select + copy the
|
||||||
|
status message (TextBlock is not selectable in
|
||||||
|
Avalonia). Keeps Text/TextWrapping/Foreground. -->
|
||||||
|
<SelectableTextBlock Text="{Binding Message}"
|
||||||
Foreground="{Binding Foreground}"
|
Foreground="{Binding Foreground}"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
|
|
||||||
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
61
src/PostIt/PostIt/Helpers/FileSaveHelpers.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers
|
||||||
|
|
||||||
var api = new YavscApiClient(settings, tokenStore);
|
var api = new YavscApiClient(settings, tokenStore);
|
||||||
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
var client = new BlogApiClient(api, settings.BlogsApiUrl);
|
||||||
|
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
|
|
@ -31,6 +32,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ public static class ServiceCollectionHelpers
|
||||||
// the navigation stack, each bound to a fresh
|
// the navigation stack, each bound to a fresh
|
||||||
// SettingsViewModel and missing any in-flight edits.
|
// SettingsViewModel and missing any in-flight edits.
|
||||||
services.AddSingleton<SettingsPage>();
|
services.AddSingleton<SettingsPage>();
|
||||||
|
services.AddSingleton<AboutPage>();
|
||||||
services.AddSingleton<HomePage>();
|
services.AddSingleton<HomePage>();
|
||||||
services.AddSingleton<SignaturePage>();
|
services.AddSingleton<SignaturePage>();
|
||||||
services.AddSingleton<CirclesPage>();
|
services.AddSingleton<CirclesPage>();
|
||||||
|
|
@ -63,6 +66,8 @@ 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>();
|
||||||
|
services.AddTransient<MyFilesPage>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
@ -74,8 +79,11 @@ 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(userFilesClient);
|
||||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
|
services.AddSingleton<AboutPageViewModel>();
|
||||||
services.AddSingleton<HomePageViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
services.AddSingleton<SignaturePageViewModel>();
|
services.AddSingleton<SignaturePageViewModel>();
|
||||||
services.AddSingleton<CirclesPageViewModel>();
|
services.AddSingleton<CirclesPageViewModel>();
|
||||||
|
|
|
||||||
|
|
@ -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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
</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">
|
||||||
|
|
@ -35,6 +36,7 @@
|
||||||
<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" />
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
_bearer = new BearerTokenHandler(this);
|
_bearer = new BearerTokenHandler(this);
|
||||||
Http = new HttpClient(_bearer, disposeHandler: true);
|
Http = new HttpClient(_bearer, disposeHandler: true);
|
||||||
|
|
||||||
|
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
|
||||||
|
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
|
||||||
|
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
|
||||||
|
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
|
||||||
|
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
|
||||||
|
// fetch, et Android si libmsquic n'est pas disponible. Tous les
|
||||||
|
// clients Yavsc.Api.Client partagent cette même instance `Http`.
|
||||||
|
Http.DefaultRequestVersion = new Version(3, 0);
|
||||||
|
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
|
||||||
|
|
||||||
_tokens = store.Load();
|
_tokens = store.Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -478,6 +488,41 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public class ViewLocator : IDataTemplate
|
||||||
{
|
{
|
||||||
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
BlogsViewModel => services.GetRequiredService<BlogsPage>(),
|
||||||
Settings => services.GetRequiredService<SettingsPage>(),
|
Settings => services.GetRequiredService<SettingsPage>(),
|
||||||
|
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
|
||||||
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
HomePageViewModel => services.GetRequiredService<HomePage>(),
|
||||||
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
|
||||||
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
|
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
|
||||||
|
|
@ -55,6 +56,8 @@ 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>(),
|
||||||
|
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
|
||||||
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,6 +7,7 @@ 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;
|
||||||
|
|
@ -130,13 +131,19 @@ 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 fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
Activity,
|
Activity,
|
||||||
Performer,
|
Performer,
|
||||||
Form,
|
Form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
|
frontClient,
|
||||||
|
fsClient,
|
||||||
details,
|
details,
|
||||||
IsReadOnly);
|
IsReadOnly,
|
||||||
|
canAttach: !IsReadOnly);
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
|
@ -171,5 +178,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsOngoingStatus(QueryStatus status)
|
private static bool IsOngoingStatus(QueryStatus status)
|
||||||
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
|
=> status is QueryStatus.Inserted
|
||||||
|
or QueryStatus.Accepted
|
||||||
|
or QueryStatus.ProAccepted
|
||||||
|
or QueryStatus.ClientAccepted
|
||||||
|
or QueryStatus.InProgress;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
|
@ -14,8 +15,18 @@ 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 UserFilesApiClient _fsClient;
|
||||||
private readonly BillingQueryDetailsDto _details;
|
private readonly BillingQueryDetailsDto _details;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vrai lorsque l'utilisateur courant est le client de la demande
|
||||||
|
/// et peut y attacher/détacher des fichiers de son espace perso.
|
||||||
|
/// Le fournisseur consulte et télécharge les pièces mais n'attache
|
||||||
|
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
|
||||||
|
/// </summary>
|
||||||
|
public bool CanAttach { get; }
|
||||||
|
|
||||||
public ActivityInfo Activity { get; }
|
public ActivityInfo Activity { get; }
|
||||||
public ActivityUserDisplayItem Performer { get; }
|
public ActivityUserDisplayItem Performer { get; }
|
||||||
public CommandFormSummary Form { get; }
|
public CommandFormSummary Form { get; }
|
||||||
|
|
@ -51,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
|
||||||
|
|
||||||
|
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||||
|
|
||||||
public override bool CanNavigateNext
|
public override bool CanNavigateNext
|
||||||
{
|
{
|
||||||
get => false;
|
get => false;
|
||||||
|
|
@ -68,19 +83,28 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
ActivityUserDisplayItem performer,
|
ActivityUserDisplayItem performer,
|
||||||
CommandFormSummary form,
|
CommandFormSummary form,
|
||||||
BillingApiClient billingClient,
|
BillingApiClient billingClient,
|
||||||
|
FrontOfficeApiClient frontClient,
|
||||||
|
UserFilesApiClient fsClient,
|
||||||
BillingQueryDetailsDto details,
|
BillingQueryDetailsDto details,
|
||||||
bool isReadOnly)
|
bool isReadOnly,
|
||||||
|
bool canAttach)
|
||||||
{
|
{
|
||||||
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||||
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));
|
||||||
|
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||||
_details = details ?? throw new ArgumentNullException(nameof(details));
|
_details = details ?? throw new ArgumentNullException(nameof(details));
|
||||||
IsReadOnly = isReadOnly;
|
IsReadOnly = isReadOnly;
|
||||||
|
CanAttach = canAttach;
|
||||||
|
|
||||||
this.SetInfoStatus("Details de commande charges.");
|
this.SetInfoStatus("Details de commande charges.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenEditorAsync()
|
private async Task OpenEditorAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -131,6 +155,157 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Charge les pièces jointes de la demande.</summary>
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadQueryAttachmentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _frontClient
|
||||||
|
.GetQueryAttachmentsAsync(BillingCode, Id)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
Attachments.Clear();
|
||||||
|
if (list is not null)
|
||||||
|
foreach (var a in list) Attachments.Add(a);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Non bloquant : les pièces jointes sont un complément.
|
||||||
|
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||||
|
/// est rattaché à la demande par référence via
|
||||||
|
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
|
||||||
|
/// (<see cref="CanAttach"/>).
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddQueryAttachmentAsync()
|
||||||
|
{
|
||||||
|
if (!CanAttach) return;
|
||||||
|
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var billingCode = BillingCode;
|
||||||
|
var queryId = Id;
|
||||||
|
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
|
||||||
|
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await picker.InitializeAsync().ConfigureAwait(true);
|
||||||
|
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null || !CanAttach) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
|
||||||
|
Attachments.Remove(attachment);
|
||||||
|
this.SetInfoStatus("Pièce jointe détachée de la demande.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||||
|
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download the estimate linked to this command as a LaTeX source
|
||||||
|
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
|
@ -166,6 +341,8 @@ 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",
|
||||||
|
|
@ -177,6 +354,8 @@ 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",
|
||||||
|
|
@ -188,6 +367,8 @@ 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",
|
||||||
|
|
@ -199,6 +380,8 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace PostIt.ViewModels;
|
||||||
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
|
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
{
|
{
|
||||||
private readonly EstimateApiClient _estimateClient;
|
private readonly EstimateApiClient _estimateClient;
|
||||||
|
private readonly UserFilesApiClient _fsClient;
|
||||||
private readonly BillingQuerySummaryDto _query;
|
private readonly BillingQuerySummaryDto _query;
|
||||||
|
|
||||||
public long QueryId => _query.Id;
|
public long QueryId => _query.Id;
|
||||||
|
|
@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||||
|
|
||||||
|
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
|
||||||
|
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
|
||||||
|
public partial long? EstimateId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
|
||||||
|
|
||||||
public decimal Total => Lines.Sum(line => line.LineTotal);
|
public decimal Total => Lines.Sum(line => line.LineTotal);
|
||||||
|
|
||||||
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
|
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
|
||||||
|
|
@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
|
public EstimateEditionPageViewModel(
|
||||||
|
BillingQuerySummaryDto query,
|
||||||
|
EstimateApiClient estimateClient,
|
||||||
|
UserFilesApiClient fsClient)
|
||||||
{
|
{
|
||||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||||
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
|
||||||
|
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
|
||||||
|
|
||||||
EstimateDescription = query.Description ?? string.Empty;
|
EstimateDescription = query.Description ?? string.Empty;
|
||||||
Lines.CollectionChanged += OnLinesCollectionChanged;
|
Lines.CollectionChanged += OnLinesCollectionChanged;
|
||||||
|
|
@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
|
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void AddLine()
|
private void AddLine()
|
||||||
{
|
{
|
||||||
|
|
@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
|
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
|
||||||
|
|
||||||
HasSent = true;
|
HasSent = true;
|
||||||
|
EstimateId = created.Id;
|
||||||
OnPropertyChanged(nameof(SendLabel));
|
OnPropertyChanged(nameof(SendLabel));
|
||||||
this.SetInfoStatus(
|
this.SetInfoStatus(
|
||||||
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
|
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
|
||||||
|
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
{
|
{
|
||||||
|
|
@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
|
||||||
await app.GoBackAsync().ConfigureAwait(true);
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Charge les pièces jointes rattachées au devis courant
|
||||||
|
/// (<c>GET estimate/{id}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task LoadAttachmentsAsync()
|
||||||
|
{
|
||||||
|
if (EstimateId is null || _estimateClient is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
|
||||||
|
Attachments.Clear();
|
||||||
|
if (list is not null)
|
||||||
|
foreach (var a in list) Attachments.Add(a);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
|
||||||
|
/// est rattaché au devis par référence via
|
||||||
|
/// <c>POST estimate/{id}/attachments</c>.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
|
||||||
|
private async Task AddAttachmentAsync()
|
||||||
|
{
|
||||||
|
if (EstimateId is null) return;
|
||||||
|
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var estimateId = EstimateId.Value;
|
||||||
|
var picker = new MyFilesViewModel(_fsClient, async fileId =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
|
||||||
|
await LoadAttachmentsAsync().ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await picker.InitializeAsync().ConfigureAwait(true);
|
||||||
|
await app.PushPageAsync(picker).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DetachFileAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null || EstimateId is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
|
||||||
|
Attachments.Remove(attachment);
|
||||||
|
this.SetInfoStatus("Pièce jointe détachée du devis.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
|
||||||
|
{
|
||||||
|
if (attachment is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
|
||||||
|
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal EstimateDto BuildPayload()
|
internal EstimateDto BuildPayload()
|
||||||
{
|
{
|
||||||
return new EstimateDto
|
return new EstimateDto
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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;
|
||||||
|
|
@ -18,10 +19,10 @@ namespace PostIt.ViewModels;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum EstimateListPerspective
|
public enum EstimateListPerspective
|
||||||
{
|
{
|
||||||
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary>
|
/// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
|
||||||
Client,
|
Client,
|
||||||
|
|
||||||
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary>
|
/// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
|
||||||
Provider,
|
Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +33,15 @@ 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
|
||||||
? "Mes devis à valider"
|
? "Devis en attente de signature client"
|
||||||
: "Mes devis en attente";
|
: "Devis en attente de signature fournisseur";
|
||||||
|
|
||||||
public string Subtitle => _perspective == EstimateListPerspective.Client
|
public string Subtitle => _perspective == EstimateListPerspective.Client
|
||||||
? "Point de vue client"
|
? "Point de vue client"
|
||||||
|
|
@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
|
||||||
protected set { _ = value; }
|
protected set { _ = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
|
public EstimateListPageViewModel(
|
||||||
|
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();
|
||||||
|
|
@ -153,4 +159,25 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
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,6 +8,7 @@ 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;
|
||||||
|
|
@ -153,15 +154,21 @@ 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 fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
|
|
||||||
var vm = new BillingQueryDetailsPageViewModel(
|
var vm = new BillingQueryDetailsPageViewModel(
|
||||||
activity,
|
activity,
|
||||||
performer,
|
performer,
|
||||||
form,
|
form,
|
||||||
_billingClient,
|
_billingClient,
|
||||||
|
frontClient,
|
||||||
|
fsClient,
|
||||||
details,
|
details,
|
||||||
isReadOnly: false);
|
isReadOnly: false,
|
||||||
|
canAttach: false);
|
||||||
|
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -240,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
throw new InvalidOperationException("Application PostIt indisponible.");
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
|
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
|
||||||
|
if (fsClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
|
||||||
await app.PushPageAsync(vm).ConfigureAwait(true);
|
await app.PushPageAsync(vm).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,11 +357,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
|
||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
QueryStatus.InProgress => 0,
|
QueryStatus.InProgress => 0,
|
||||||
QueryStatus.Accepted => 1,
|
QueryStatus.ClientAccepted => 1,
|
||||||
QueryStatus.Inserted => 2,
|
QueryStatus.ProAccepted => 2,
|
||||||
QueryStatus.Success => 3,
|
QueryStatus.Accepted => 2,
|
||||||
QueryStatus.Rejected => 4,
|
QueryStatus.Inserted => 3,
|
||||||
QueryStatus.Failed => 5,
|
QueryStatus.Success => 4,
|
||||||
|
QueryStatus.Rejected => 5,
|
||||||
|
QueryStatus.Failed => 6,
|
||||||
_ => 99,
|
_ => 99,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -36,8 +37,19 @@ 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 string DraftArticle { get; set; }
|
public partial TextDocument DraftArticleDocument {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
|
||||||
|
|
@ -381,6 +393,7 @@ 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
|
||||||
|
|
@ -464,7 +477,6 @@ 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()
|
||||||
|
|
|
||||||
77
src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs
Normal file
77
src/PostIt/PostIt/ViewModels/Layout/AboutPageViewModel.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel de la page « À propos de PostIt ». Page statique :
|
||||||
|
/// version de l'application, description, licence et liens vers le
|
||||||
|
/// dépôt Forgejo et son suivi de tickets.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AboutPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public const string RepositoryUrl = "https://forgejo.pschneider.fr/notazof/yavsc";
|
||||||
|
public const string IssuesUrl = RepositoryUrl + "/issues";
|
||||||
|
|
||||||
|
public AboutPageViewModel()
|
||||||
|
{
|
||||||
|
Version = ReadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Version { get; }
|
||||||
|
|
||||||
|
public string Description { get; } =
|
||||||
|
"PostIt est le client de la plateforme Yavsc : blog, devis, " +
|
||||||
|
"demandes de prestations et fichiers personnels.";
|
||||||
|
|
||||||
|
public string License { get; } =
|
||||||
|
"Licence WTFPL — Do What The Fuck You Want To Public License, version 2";
|
||||||
|
|
||||||
|
public override bool CanNavigateNext
|
||||||
|
{
|
||||||
|
get => false;
|
||||||
|
protected set => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanNavigatePrevious
|
||||||
|
{
|
||||||
|
get => true;
|
||||||
|
protected set => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task OpenRepository() => OpenUrlAsync(RepositoryUrl);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task OpenIssues() => OpenUrlAsync(IssuesUrl);
|
||||||
|
|
||||||
|
private static async Task OpenUrlAsync(string url)
|
||||||
|
{
|
||||||
|
var app = (App)App.Current!;
|
||||||
|
var topLevel = TopLevel.GetTopLevel(app.View);
|
||||||
|
if (topLevel is not null)
|
||||||
|
{
|
||||||
|
await topLevel.Launcher.LaunchUriAsync(new Uri(url)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadVersion()
|
||||||
|
{
|
||||||
|
var assembly = typeof(AboutPageViewModel).Assembly;
|
||||||
|
var info = assembly
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
|
?.InformationalVersion;
|
||||||
|
if (!string.IsNullOrEmpty(info))
|
||||||
|
{
|
||||||
|
// "1.1.0-estimate.1+364.Branch.x.Sha.y" → "1.1.0-estimate.1"
|
||||||
|
var plus = info.IndexOf('+');
|
||||||
|
return plus > 0 ? info[..plus] : info;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembly.GetName().Version?.ToString() ?? "inconnue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase
|
||||||
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
|
||||||
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
|
||||||
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
|
||||||
|
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
|
||||||
}
|
}
|
||||||
public IAsyncRelayCommand OpenBlogs { get; }
|
public IAsyncRelayCommand OpenBlogs { get; }
|
||||||
public IAsyncRelayCommand OpenActivities { get; }
|
public IAsyncRelayCommand OpenActivities { get; }
|
||||||
public IAsyncRelayCommand OpenProviderRequests { get; }
|
public IAsyncRelayCommand OpenProviderRequests { get; }
|
||||||
public IAsyncRelayCommand OpenClientEstimates { get; }
|
public IAsyncRelayCommand OpenClientEstimates { get; }
|
||||||
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
public IAsyncRelayCommand OpenProviderEstimates { get; }
|
||||||
|
public IAsyncRelayCommand OpenMyFiles { get; }
|
||||||
|
|
||||||
private async Task OpenActivitiesAsync()
|
private async Task OpenActivitiesAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -90,7 +92,36 @@ public class HomePageViewModel : ViewModelBase
|
||||||
throw new InvalidOperationException("Client devis indisponible.");
|
throw new InvalidOperationException("Client devis indisponible.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var vm = new EstimateListPageViewModel(estimateClient, perspective);
|
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
|
||||||
|
if (frontClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client front-office indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await app.PushPageAsync(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
|
||||||
|
/// servie par le host Blogs via <c>api/v1/fs</c>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task OpenMyFilesAsync()
|
||||||
|
{
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
|
||||||
|
if (fsClient is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Client fichiers indisponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new MyFilesViewModel(fsClient);
|
||||||
await vm.InitializeAsync();
|
await vm.InitializeAsync();
|
||||||
await app.PushPageAsync(vm);
|
await app.PushPageAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,4 +143,11 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
|
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<Settings>()).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task OpenAbout()
|
||||||
|
{
|
||||||
|
var app = (App)App.Current!;
|
||||||
|
await app.PushPageAsync(app.ServiceProvider!.GetRequiredService<AboutPageViewModel>()).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
229
src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
Normal file
229
src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PostIt.Helpers;
|
||||||
|
using Yavsc.Api.Client;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gestion de l'espace de stockage personnel (« My Files ») :
|
||||||
|
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
|
||||||
|
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
|
||||||
|
/// (fournisseur) ou à une demande (client) par référence : lorsque
|
||||||
|
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
|
||||||
|
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
|
||||||
|
/// puis revient à la page précédente.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
|
||||||
|
{
|
||||||
|
private readonly UserFilesApiClient _fs;
|
||||||
|
private readonly Action<long>? _onFilePicked;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sous-répertoire courant, relatif à la racine de l'espace perso
|
||||||
|
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string CurrentPath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
|
||||||
|
? "Mes fichiers"
|
||||||
|
: $"Mes fichiers / {CurrentPath}";
|
||||||
|
|
||||||
|
public bool IsPickerMode => _onFilePicked is not null;
|
||||||
|
|
||||||
|
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string StatusMessage { get; set; } = "Prêt.";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
|
||||||
|
|
||||||
|
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
|
||||||
|
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
|
||||||
|
|
||||||
|
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
|
||||||
|
{
|
||||||
|
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
|
||||||
|
_onFilePicked = onFilePicked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructeur pour le designer Avalonia.</summary>
|
||||||
|
public MyFilesViewModel() : this(null!) { }
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
|
||||||
|
Files.Clear();
|
||||||
|
SubDirectories.Clear();
|
||||||
|
if (dir?.Files is not null)
|
||||||
|
foreach (var f in dir.Files) Files.Add(f);
|
||||||
|
if (dir?.SubDirectories is not null)
|
||||||
|
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
|
||||||
|
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task OpenDirAsync(DirEntryDto? dir)
|
||||||
|
{
|
||||||
|
if (dir is null) return;
|
||||||
|
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
|
||||||
|
OnPropertyChanged(nameof(PathLabel));
|
||||||
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task UpAsync()
|
||||||
|
{
|
||||||
|
if (!CanGoUp) return;
|
||||||
|
CurrentPath = ParentPath(CurrentPath);
|
||||||
|
OnPropertyChanged(nameof(PathLabel));
|
||||||
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
|
||||||
|
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
|
||||||
|
{
|
||||||
|
if (uploads is null || uploads.Count == 0) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DeleteFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus($"Fichier supprimé.");
|
||||||
|
await RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DownloadFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Id is null) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
|
||||||
|
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
|
||||||
|
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task PickFileAsync(FileEntryDto? file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Id is null)
|
||||||
|
{
|
||||||
|
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var callback = _onFilePicked;
|
||||||
|
if (callback is null)
|
||||||
|
{
|
||||||
|
// Mode gestion : le téléchargement est l'action par défaut.
|
||||||
|
await DownloadFileAsync(file).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(file.Id.Value);
|
||||||
|
await GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task BackAsync()
|
||||||
|
{
|
||||||
|
await GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task GoBackAsync()
|
||||||
|
{
|
||||||
|
var app = (App?)Application.Current;
|
||||||
|
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
|
||||||
|
await app.GoBackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombinePath(string current, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current)) return name;
|
||||||
|
return $"{current}/{name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParentPath(string current)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
|
||||||
|
var idx = current.LastIndexOf('/');
|
||||||
|
return idx < 0 ? string.Empty : current[..idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -259,25 +259,13 @@ public partial class Settings : ViewModelBase
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
|
|
||||||
&& !string.IsNullOrWhiteSpace(envJson))
|
var app = App.Current as App;
|
||||||
|
if (app!=null)
|
||||||
|
if (app.CliConfigFileSpecification!=null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable.");
|
SettingsFileFullName = app.CliConfigFileSpecification;
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
|
@ -287,10 +275,18 @@ 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))
|
||||||
{
|
{
|
||||||
SettingsFileFullName = envPath;
|
FileInfo configByEnvFileInfo = new FileInfo(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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -536,6 +532,7 @@ 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,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind a freshly-constructed (or re-templated) control to this
|
/// Bind the inner <see cref="SignaturePadControl"/> of the page's
|
||||||
/// VM. Called from the view's code-behind once the control has
|
/// <see cref="SignaturePadView"/> to this VM. Called from the
|
||||||
/// been added to the visual tree and its template applied (so
|
/// page's code-behind once the view is in the visual tree (so the
|
||||||
/// <see cref="SignaturePadControl.CaptureArea"/> is wired).
|
/// control's <see cref="SignaturePadControl.CaptureArea"/> is
|
||||||
|
/// 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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
x:Class="PostIt.Views.BillingQueryDetailsPage"
|
x:Class="PostIt.Views.BillingQueryDetailsPage"
|
||||||
x:DataType="vm:BillingQueryDetailsPageViewModel"
|
x:DataType="vm:BillingQueryDetailsPageViewModel"
|
||||||
|
|
@ -14,7 +15,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,*">
|
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
|
||||||
<Button Grid.Column="0"
|
<Button Grid.Column="0"
|
||||||
Content="Retour"
|
Content="Retour"
|
||||||
Command="{Binding BackCommand}" />
|
Command="{Binding BackCommand}" />
|
||||||
|
|
@ -22,6 +23,12 @@
|
||||||
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">
|
||||||
|
|
@ -74,6 +81,53 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Pièces jointes (par référence à l'espace perso du client) -->
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Pièces jointes"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Ajouter depuis My Files"
|
||||||
|
Command="{Binding AddQueryAttachmentCommand}"
|
||||||
|
IsVisible="{Binding CanAttach}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:AttachmentDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#11000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DownloadQueryAttachmentCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Détacher"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).DetachQueryFileCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding $parent[ContentPage].((vm:BillingQueryDetailsPageViewModel)DataContext).CanAttach}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Expander Header="Metadonnees techniques"
|
<Expander Header="Metadonnees techniques"
|
||||||
IsExpanded="False">
|
IsExpanded="False">
|
||||||
<Border BorderThickness="1"
|
<Border BorderThickness="1"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
x:Class="PostIt.Views.EstimateEditionPage"
|
x:Class="PostIt.Views.EstimateEditionPage"
|
||||||
x:DataType="vm:EstimateEditionPageViewModel"
|
x:DataType="vm:EstimateEditionPageViewModel"
|
||||||
|
|
@ -133,6 +134,52 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10"
|
||||||
|
IsVisible="{Binding HasSent}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Pièces jointes"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Ajouter depuis My Files"
|
||||||
|
Command="{Binding AddAttachmentCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:AttachmentDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#11000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DownloadAttachmentCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Détacher"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:EstimateEditionPageViewModel)DataContext).DetachFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,34 +29,40 @@
|
||||||
CornerRadius="8"
|
CornerRadius="8"
|
||||||
Padding="10"
|
Padding="10"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8">
|
||||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Grid.Row="0"
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||||
Grid.Column="0"
|
<TextBlock Grid.Row="0"
|
||||||
Text="{Binding Title}"
|
Grid.Column="0"
|
||||||
FontWeight="Bold"
|
Text="{Binding Title}"
|
||||||
TextWrapping="Wrap" />
|
FontWeight="Bold"
|
||||||
<TextBlock Grid.Row="0"
|
TextWrapping="Wrap" />
|
||||||
Grid.Column="1"
|
<TextBlock Grid.Row="0"
|
||||||
Text="{Binding Id}"
|
Grid.Column="1"
|
||||||
FontSize="11"
|
Text="{Binding Id}"
|
||||||
Opacity="0.75"
|
FontSize="11"
|
||||||
HorizontalAlignment="Right" />
|
Opacity="0.75"
|
||||||
|
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,5 +1,6 @@
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -14,4 +15,19 @@ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
Normal file
146
src/PostIt/PostIt/Views/Activity/EstimateValidationPage.axaml
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<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>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,123 +1,165 @@
|
||||||
<ContentPage xmlns="https://github.com/avaloniaui"
|
<ContentPage
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
x:Class="PostIt.Views.Blogs.BlogsPage"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
xmlns:postitControls="using:PostIt.Controls"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:models="using:Yavsc.Blogspot"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:views="using:PostIt.Views.Blogs"
|
xmlns:md="using:MarkdownViewer.Core.Controls"
|
||||||
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
xmlns:models="using:Yavsc.Blogspot"
|
||||||
xmlns:md="using:MarkdownViewer.Core.Controls"
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
mc:Ignorable="d"
|
xmlns:views="using:PostIt.Views.Blogs"
|
||||||
x:Class="PostIt.Views.Blogs.BlogsPage"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
x:DataType="vm:BlogsViewModel"
|
HorizontalAlignment="Stretch"
|
||||||
HorizontalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch">
|
x:DataType="vm:BlogsViewModel"
|
||||||
|
mc:Ignorable="d">
|
||||||
<Design.DataContext>
|
<Design.DataContext>
|
||||||
<vm:BlogsViewModel />
|
<vm:BlogsViewModel />
|
||||||
</Design.DataContext>
|
</Design.DataContext>
|
||||||
<ScrollViewer
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
|
||||||
VerticalScrollBarVisibility="Visible"
|
Margin="5,5,10,5" HorizontalAlignment="Stretch" BringIntoViewOnFocusChange="False"
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
VerticalAlignment="Stretch">
|
||||||
Padding="5"
|
<StackPanel>
|
||||||
Margin="5"
|
|
||||||
>
|
|
||||||
|
|
||||||
|
<WrapPanel x:Name="BlogsHeaderPanel"
|
||||||
|
Margin="5"
|
||||||
|
Orientation="Horizontal">
|
||||||
|
<TextBlock
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="Blog Posts" />
|
||||||
|
<TextBox
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
PlaceholderText="Filter by title or author..."
|
||||||
|
Text="{Binding SearchText, Mode=TwoWay}" />
|
||||||
|
</WrapPanel>
|
||||||
|
<CommandBar x:Name="BlogsCommandBar"
|
||||||
|
HorizontalAlignment="Right" IsDynamicOverflowEnabled="True">
|
||||||
|
<CommandBar.PrimaryCommands>
|
||||||
|
<CommandBarButton Command="{Binding RefreshAsync}" Label="Refresh">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource RefreshIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarButton
|
||||||
|
x:Name="SaveButton"
|
||||||
|
Command="{Binding SaveAsync}"
|
||||||
|
Label="Save">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource SaveIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarButton Command="{Binding DeleteAsync}" Label="Delete">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource DeleteIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarButton
|
||||||
|
x:Name="AddAttachmentButton"
|
||||||
|
Click="AddAttachment_Click"
|
||||||
|
Label="Ajouter fichiers">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource AddIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarButton
|
||||||
|
x:Name="ManageAclButton"
|
||||||
|
Command="{Binding ManageAclAsync}"
|
||||||
|
Label="ACL">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource AclIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarButton
|
||||||
|
x:Name="OpenCirclesButton"
|
||||||
|
Command="{Binding OpenCirclesAsync}"
|
||||||
|
Label="Mes cercles">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource CircleIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
<CommandBarToggleButton
|
||||||
|
Command="{Binding TogglePublishCommand}"
|
||||||
|
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||||
|
Label="Publié">
|
||||||
|
<CommandBarToggleButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource PublishedIcon}" />
|
||||||
|
</CommandBarToggleButton.Icon>
|
||||||
|
</CommandBarToggleButton>
|
||||||
|
<CommandBarToggleButton IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}" Label="Markdown Viewer">
|
||||||
|
<CommandBarToggleButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
||||||
|
</CommandBarToggleButton.Icon>
|
||||||
|
</CommandBarToggleButton>
|
||||||
|
<CommandBarButton
|
||||||
|
x:Name="OpenSignatureDevButton"
|
||||||
|
Command="{Binding OpenSignatureDevAsync}"
|
||||||
|
Label="[DEV] Signature">
|
||||||
|
<CommandBarButton.Icon>
|
||||||
|
<PathIcon Data="{StaticResource SignIcon}" />
|
||||||
|
</CommandBarButton.Icon>
|
||||||
|
</CommandBarButton>
|
||||||
|
</CommandBar.PrimaryCommands>
|
||||||
|
</CommandBar>
|
||||||
|
|
||||||
<StackPanel HorizontalAlignment="Stretch">
|
<Grid Margin="12" RowSpacing="12" x:Name="MainGrid">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="5">
|
|
||||||
<TextBlock Text="Blog Posts" FontSize="20" FontWeight="SemiBold" />
|
|
||||||
<TextBox HorizontalAlignment="Right"
|
|
||||||
Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Filter by title or author..." />
|
|
||||||
</StackPanel>
|
|
||||||
<CommandBar IsDynamicOverflowEnabled="True"
|
|
||||||
HorizontalAlignment="Right" >
|
|
||||||
<CommandBar.PrimaryCommands>
|
|
||||||
<CommandBarButton Label="Refresh" Command="{Binding RefreshAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource RefreshIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarButton x:Name="SaveButton" Label="Save" Command="{Binding SaveAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource SaveIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarButton Label="Delete" Command="{Binding DeleteAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource DeleteIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarButton Label="Ajouter fichiers" Click="AddAttachment_Click"
|
|
||||||
x:Name="AddAttachmentButton">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource AddIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarButton x:Name="ManageAclButton"
|
|
||||||
Label="ACL"
|
|
||||||
Command="{Binding ManageAclAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource AclIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarButton x:Name="OpenCirclesButton"
|
|
||||||
Label="Mes cercles" Command="{Binding OpenCirclesAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource CircleIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
<CommandBarToggleButton Label="Publié"
|
|
||||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
|
|
||||||
<CommandBarToggleButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource PublishedIcon}" />
|
|
||||||
</CommandBarToggleButton.Icon>
|
|
||||||
</CommandBarToggleButton>
|
|
||||||
<CommandBarToggleButton Label="Markdown Viewer"
|
|
||||||
IsChecked="{Binding IsMarkdownViewerVisible, Mode=TwoWay}">
|
|
||||||
<CommandBarToggleButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource MarkdownIcon}" />
|
|
||||||
</CommandBarToggleButton.Icon>
|
|
||||||
</CommandBarToggleButton>
|
|
||||||
<CommandBarButton x:Name="OpenSignatureDevButton"
|
|
||||||
Label="[DEV] Signature" Command="{Binding OpenSignatureDevAsync}">
|
|
||||||
<CommandBarButton.Icon>
|
|
||||||
<PathIcon Data="{StaticResource SignIcon}" />
|
|
||||||
</CommandBarButton.Icon>
|
|
||||||
</CommandBarButton>
|
|
||||||
</CommandBar.PrimaryCommands>
|
|
||||||
</CommandBar>
|
|
||||||
|
|
||||||
<Grid Margin="12" RowSpacing="12">
|
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
|
<Border
|
||||||
<ListBox ItemsSource="{Binding FilteredPosts}"
|
Grid.Row="1"
|
||||||
SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
Padding="8"
|
||||||
|
BorderBrush="Gray"
|
||||||
|
BorderThickness="1">
|
||||||
|
<ListBox
|
||||||
|
x:Name="PostsListBox"
|
||||||
|
ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="models:BlogPostDto">
|
<DataTemplate x:DataType="models:BlogPostDto">
|
||||||
<StackPanel Spacing="4" >
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
|
<TextBlock FontWeight="SemiBold" Text="{Binding Title}" />
|
||||||
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
|
<TextBlock
|
||||||
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
|
FontSize="10"
|
||||||
|
Foreground="Gray"
|
||||||
|
Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" />
|
||||||
|
<TextBlock
|
||||||
|
FontSize="10"
|
||||||
|
Foreground="DarkSlateGray"
|
||||||
|
Text="{Binding AuthorId}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<md:MarkdownViewer Grid.Row="2" Name="MarkdownViewer"
|
<Border
|
||||||
IsVisible="{Binding IsMarkdownViewerVisible}"
|
Grid.Row="2"
|
||||||
MarkdownText="{Binding DraftArticle}" />
|
Padding="8"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
BorderBrush="Gray"
|
||||||
|
BorderThickness="1"
|
||||||
|
IsVisible="{Binding IsMarkdownViewerVisible}">
|
||||||
|
<md:MarkdownViewer Name="MarkdownViewer" MarkdownText="{Binding DraftArticle}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Row="2" BorderBrush="Gray" BorderThickness="1" Padding="8"
|
<Border
|
||||||
IsVisible="{Binding !IsMarkdownViewerVisible}">
|
Grid.Row="2"
|
||||||
<Grid RowSpacing="10"
|
Padding="8"
|
||||||
HorizontalAlignment="Stretch"
|
BorderBrush="Gray"
|
||||||
VerticalAlignment="Stretch">
|
BorderThickness="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
IsVisible="{Binding !IsMarkdownViewerVisible}">
|
||||||
|
<Grid
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
RowSpacing="10">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
|
@ -128,18 +170,43 @@
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
|
<TextBlock
|
||||||
<TextBox x:Name="DraftTitleTextBox"
|
Grid.Row="0"
|
||||||
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
|
FontWeight="SemiBold"
|
||||||
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
|
Text="Post detail" />
|
||||||
Text="{Binding DraftArticle, Mode=TwoWay}"
|
<TextBox
|
||||||
MinHeight="320"
|
x:Name="DraftTitleTextBox"
|
||||||
HorizontalAlignment="Stretch"
|
Grid.Row="1"
|
||||||
VerticalAlignment="Stretch" />
|
PlaceholderText="Title"
|
||||||
|
Text="{Binding DraftTitle, Mode=TwoWay}" />
|
||||||
|
|
||||||
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
|
|
||||||
|
<AvaloniaEdit:TextEditor
|
||||||
|
x:Name="DraftArticleTextEditor"
|
||||||
|
Grid.Row="2"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
Document="{Binding DraftArticleDocument, Mode=TwoWay}"
|
||||||
|
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
|
||||||
|
ShowLineNumbers="True"
|
||||||
|
WordWrap="True"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="LightGray"
|
||||||
|
CornerRadius="5"
|
||||||
|
Padding="5,5,5,15"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
TextInputOptions.Multiline="True"
|
||||||
|
TextInputOptions.ReturnKeyType="Default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Border x:Name="AttachmentsBorder"
|
||||||
|
Grid.Row="3"
|
||||||
|
Padding="8"
|
||||||
|
BorderBrush="LightGray"
|
||||||
|
BorderThickness="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="Pièces jointes" FontWeight="SemiBold" />
|
<TextBlock FontWeight="SemiBold" Text="Pièces jointes" />
|
||||||
<ItemsControl ItemsSource="{Binding DraftAttachments}">
|
<ItemsControl ItemsSource="{Binding DraftAttachments}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
|
|
@ -150,12 +217,11 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<postitControls:StatusBar Grid.Row="4"
|
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding ActionStatus}" x:Name="StatusBar" />
|
||||||
DataContext="{Binding ActionStatus}" />
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,75 @@
|
||||||
using System;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Controls.Platform;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
namespace PostIt.Views.Blogs;
|
namespace PostIt.Views.Blogs;
|
||||||
|
|
||||||
public partial class BlogsPage : ContentPage
|
public partial class BlogsPage : ContentPage
|
||||||
{
|
{
|
||||||
|
private IInputPane _inputPane;
|
||||||
|
|
||||||
public BlogsPage()
|
public BlogsPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnLoaded(RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnLoaded(e);
|
||||||
|
|
||||||
|
// Récupère l'accès au TopLevel de la fenêtre/application
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel != null)
|
||||||
|
{
|
||||||
|
_inputPane = topLevel.InputPane;
|
||||||
|
if (_inputPane != null)
|
||||||
|
{
|
||||||
|
// S'abonne aux changements d'état du clavier virtuel
|
||||||
|
_inputPane.StateChanged += OnInputPaneStateChanged;
|
||||||
|
|
||||||
|
// Applique la configuration initiale si le clavier est déjà présent
|
||||||
|
UpdateLayoutForKeyboard(_inputPane.OccludedRect.Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnUnloaded(RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_inputPane != null)
|
||||||
|
{
|
||||||
|
_inputPane.StateChanged -= OnInputPaneStateChanged;
|
||||||
|
}
|
||||||
|
base.OnUnloaded(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void UpdateLayoutForKeyboard(double keyboardHeight)
|
||||||
|
{
|
||||||
|
double visibleHeight = this.Bounds.Height - keyboardHeight;
|
||||||
|
if (keyboardHeight > 0)
|
||||||
|
{
|
||||||
|
DraftArticleTextEditor.MaxHeight = visibleHeight;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DraftArticleTextEditor.MaxHeight = double.PositiveInfinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInputPaneStateChanged(object? sender, InputPaneStateEventArgs e)
|
||||||
|
{
|
||||||
|
// e.EndRect contient le rectangle final du clavier virtuel
|
||||||
|
// e.EndRect.Height vous donne sa hauteur exacte en pixels indépendants du périphérique (pixels logiques)
|
||||||
|
UpdateLayoutForKeyboard(e.EndRect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnApplyTemplate(e);
|
base.OnApplyTemplate(e);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ using PostIt.Services;
|
||||||
using PostIt.ViewModels.Commands;
|
using PostIt.ViewModels.Commands;
|
||||||
|
|
||||||
namespace PostIt.Views.Commands;
|
namespace PostIt.Views.Commands;
|
||||||
|
using Mapsui.Tiling; // Ensure you have this namespace
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public partial class RdvPage : ContentPage
|
public partial class RdvPage : ContentPage
|
||||||
{
|
{
|
||||||
|
|
@ -44,6 +47,16 @@ public partial class RdvPage : ContentPage
|
||||||
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
|
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
InitializeMap();
|
InitializeMap();
|
||||||
|
// ... inside your Map setup logic ...
|
||||||
|
string version = GetType().Assembly.GetName().Version?.ToString() ?? "1.1";
|
||||||
|
// 1. Create a unique user-agent string for your app (e.g., "AppName/Version (ContactEmail)")
|
||||||
|
string myCustomUserAgent = $"PostIt/{version} (paul@pschneider.fr)";
|
||||||
|
|
||||||
|
// 2. Pass your custom user agent into the OpenStreetMap helper
|
||||||
|
var osmLayer = OpenStreetMap.CreateTileLayer(myCustomUserAgent);
|
||||||
|
|
||||||
|
// 3. add your newly identified layer
|
||||||
|
_locationMap.Map.Layers.Add(osmLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent()
|
private void InitializeComponent()
|
||||||
|
|
|
||||||
38
src/PostIt/PostIt/Views/Layout/AboutPage.axaml
Normal file
38
src/PostIt/PostIt/Views/Layout/AboutPage.axaml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
x:Class="PostIt.Views.AboutPage"
|
||||||
|
x:DataType="vm:AboutPageViewModel"
|
||||||
|
Header="À propos">
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:AboutPageViewModel />
|
||||||
|
</Design.DataContext>
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12"
|
||||||
|
Margin="16">
|
||||||
|
<TextBlock Text="PostIt"
|
||||||
|
FontSize="28"
|
||||||
|
FontWeight="Bold"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Version, StringFormat='Version {0}'}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Opacity="0.8"/>
|
||||||
|
<TextBlock Text="{Binding Description}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
MaxWidth="420"/>
|
||||||
|
<TextBlock Text="{Binding License}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
MaxWidth="420"
|
||||||
|
Opacity="0.8"
|
||||||
|
FontStyle="Italic"/>
|
||||||
|
<Button Content="Dépôt du projet"
|
||||||
|
Command="{Binding OpenRepositoryCommand}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<Button Content="Signaler un problème"
|
||||||
|
Command="{Binding OpenIssuesCommand}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ContentPage>
|
||||||
11
src/PostIt/PostIt/Views/Layout/AboutPage.axaml.cs
Normal file
11
src/PostIt/PostIt/Views/Layout/AboutPage.axaml.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class AboutPage : ContentPage
|
||||||
|
{
|
||||||
|
public AboutPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,13 +26,17 @@
|
||||||
Command="{Binding OpenProviderRequests}"
|
Command="{Binding OpenProviderRequests}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mes devis à valider"
|
<Button Content="Devis en attente de signature client"
|
||||||
Command="{Binding OpenClientEstimates}"
|
Command="{Binding OpenClientEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
<Button Content="Mes devis en attente"
|
<Button Content="Devis en attente de signature fournisseur"
|
||||||
Command="{Binding OpenProviderEstimates}"
|
Command="{Binding OpenProviderEstimates}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
|
<Button Content="Mes fichiers"
|
||||||
|
Command="{Binding OpenMyFiles}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
x:Class="PostIt.Views.SessionStatusBanner"
|
x:Class="PostIt.Views.SessionStatusBanner"
|
||||||
x:DataType="vm:SessionStatusViewModel">
|
x:DataType="vm:SessionStatusViewModel">
|
||||||
<Border DockPanel.Dock="Top"
|
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:SessionStatusViewModel />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
BorderBrush="#B0BEC5"
|
BorderBrush="#B0BEC5"
|
||||||
BorderThickness="0,0,0,1"
|
BorderThickness="0,0,0,1"
|
||||||
Padding="12,6">
|
Padding="12,6">
|
||||||
|
|
@ -23,6 +28,9 @@
|
||||||
<Button Content="Paramètres"
|
<Button Content="Paramètres"
|
||||||
Command="{Binding OpenSettingsCommand}"
|
Command="{Binding OpenSettingsCommand}"
|
||||||
DockPanel.Dock="Right"/>
|
DockPanel.Dock="Right"/>
|
||||||
|
<Button Content="À propos"
|
||||||
|
Command="{Binding OpenAboutCommand}"
|
||||||
|
DockPanel.Dock="Right"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
90
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml
Normal file
90
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
|
xmlns:client="using:Yavsc.Api.Client"
|
||||||
|
xmlns:postitControls="using:PostIt.Controls"
|
||||||
|
x:Class="PostIt.Views.MyFilesPage"
|
||||||
|
x:DataType="vm:MyFilesViewModel"
|
||||||
|
Header="Mes fichiers">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="3">
|
||||||
|
<TextBlock Text="{Binding PathLabel}"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold" />
|
||||||
|
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
|
||||||
|
Opacity="0.7"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Content="Retour"
|
||||||
|
Command="{Binding BackCommand}" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Content="Remonter"
|
||||||
|
Command="{Binding UpCommand}"
|
||||||
|
IsEnabled="{Binding CanGoUp}" />
|
||||||
|
<Button Grid.Column="4"
|
||||||
|
Content="Téléverser…"
|
||||||
|
Click="Upload_Click"
|
||||||
|
HorizontalAlignment="Right" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="2">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<ItemsControl ItemsSource="{Binding SubDirectories}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:DirEntryDto">
|
||||||
|
<Button Content="📁 {Binding Name}"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).OpenDirCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="client:FileEntryDto">
|
||||||
|
<Border BorderThickness="1"
|
||||||
|
BorderBrush="#22000000"
|
||||||
|
CornerRadius="6"
|
||||||
|
Padding="8"
|
||||||
|
Margin="0,0,0,6">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding Name}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="NoWrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Joindre"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).PickFileCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsVisible="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).IsPickerMode}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Télécharger"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DownloadFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Grid.Column="5"
|
||||||
|
Content="Supprimer"
|
||||||
|
Command="{Binding $parent[ContentPage].((vm:MyFilesViewModel)DataContext).DeleteFileCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
|
||||||
|
<postitControls:StatusBar Grid.Column="0"
|
||||||
|
DataContext="{Binding ActionStatus}" />
|
||||||
|
<ProgressBar Grid.Column="3"
|
||||||
|
Width="120"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsBusy}" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ContentPage>
|
||||||
58
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml.cs
Normal file
58
src/PostIt/PostIt/Views/MyFiles/MyFilesPage.axaml.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
|
||||||
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
public partial class MyFilesPage : ContentPage
|
||||||
|
{
|
||||||
|
public MyFilesPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Upload_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null || DataContext is not MyFilesViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
|
||||||
|
AllowMultiple = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
await using var stream = await file.OpenReadAsync();
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(memory);
|
||||||
|
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await vm.UploadAsync(uploads);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMimeType(string fileName)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".pdf" => "application/pdf",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,29 +29,15 @@
|
||||||
FontWeight="Bold" />
|
FontWeight="Bold" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Capture surface. The SignaturePadControl is render-
|
Capture surface. SignaturePadView owns the SignaturePadControl,
|
||||||
agnostic, so we host it inside a fixed-size Border
|
the capture-area Border, the ink overlay canvas, and the
|
||||||
that is the PART_CaptureArea, and overlay a Canvas
|
repaint logic; the page just sizes it from the VM and reads
|
||||||
for the visual feedback. The view's code-behind
|
the captured data through the view's API.
|
||||||
repaints the canvas on every RedrawRequested.
|
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1"
|
<controls:SignaturePadView Grid.Row="1"
|
||||||
x:Name="PadFrame"
|
x:Name="Pad"
|
||||||
Width="{Binding Width}"
|
CaptureWidth="{Binding Width}"
|
||||||
Height="{Binding Height}"
|
CaptureHeight="{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,9 +1,4 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -11,26 +6,15 @@ 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();
|
||||||
|
|
||||||
// Wire the capture area: the Pad itself is the control, the
|
// SignaturePadView owns the capture area wiring and the ink
|
||||||
// surrounding Border (PadFrame) is the hit-test region. We
|
// repaint; the page only forwards the view to the VM so it can
|
||||||
// set CaptureArea once the control's template has been
|
// read snapshots / subscribe to stroke events.
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,82 +23,12 @@ 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 || _control is null) return;
|
if (_vm is null || Pad is null) return;
|
||||||
|
|
||||||
_vm.Attach(_control);
|
_vm.Attach(Pad.SignaturePad);
|
||||||
_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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Authority": "https://localhost:5001",
|
"Authority": "https://yavsc.pschneider.fr",
|
||||||
"ClientId": "postit",
|
"ClientId": "postit",
|
||||||
"Scopes": [
|
"Scopes": [
|
||||||
"blogs",
|
"blogs",
|
||||||
|
|
@ -9,13 +9,8 @@
|
||||||
"RedirectUri": "postit://callback"
|
"RedirectUri": "postit://callback"
|
||||||
},
|
},
|
||||||
"DarkMode": true,
|
"DarkMode": true,
|
||||||
"BlogsApiUrl": "https://localhost:5003/api/v1/",
|
"BlogsApiUrl": "https://blogs.pschneider.fr/api/v1/",
|
||||||
"ApiUrl": "https://localhost:5005/api/v1/",
|
"ApiUrl": "https://api.pschneider.fr/api/v1/",
|
||||||
"SearchText": "",
|
"SearchText": "",
|
||||||
"ProviderOngoingRequestsSortOption": "",
|
"ProviderOngoingRequestsSortOption": ""
|
||||||
"Loaded": true,
|
|
||||||
"IsDirty": true,
|
|
||||||
"CanNavigateNext": false,
|
|
||||||
"CanNavigatePrevious": true,
|
|
||||||
"SaveCommand": {}
|
|
||||||
}
|
}
|
||||||
|
|
@ -15,5 +15,23 @@ namespace Yavsc.Blogspot
|
||||||
// BlogPost entity implements this getter by mapping its
|
// BlogPost entity implements this getter by mapping its
|
||||||
// ApplicationUser navigation to a BlogPostAuthorDto.
|
// ApplicationUser navigation to a BlogPostAuthorDto.
|
||||||
BlogPostAuthorDto? Author { get; }
|
BlogPostAuthorDto? Author { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this post is published. The server derives this
|
||||||
|
/// from the existence of a row in <c>BlogSpotPublication</c>
|
||||||
|
/// and serves it on the wire so clients (PostIt) can render
|
||||||
|
/// the current publication state without a follow-up request.
|
||||||
|
///
|
||||||
|
/// <para>This MUST be declared on the interface — not just on
|
||||||
|
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
|
||||||
|
/// because the blog API's list endpoint returns
|
||||||
|
/// <c>IEnumerable<IBlogPost></c>, and the blog API host
|
||||||
|
/// serialises with System.Text.Json, which emits only the
|
||||||
|
/// members of the declared (interface) type. When
|
||||||
|
/// <c>IsPublished</c> lived only on the concrete classes, STJ
|
||||||
|
/// dropped it from the list payload and PostIt read
|
||||||
|
/// <c>false</c> for every post, including published ones.</para>
|
||||||
|
/// </summary>
|
||||||
|
bool IsPublished { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,17 @@
|
||||||
|
|
||||||
public DateTime CreationTime { get; set; }
|
public DateTime CreationTime { get; set; }
|
||||||
|
|
||||||
public DateTime LastModified { get; set; }
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
|
||||||
|
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
|
||||||
|
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
|
||||||
|
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
|
||||||
|
/// encore backfillé). C'est cette valeur que le client lourd
|
||||||
|
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
|
||||||
|
/// </summary>
|
||||||
|
public long? Id { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,28 @@ 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,
|
Inserted = 0,
|
||||||
Rejected,
|
Rejected = 1,
|
||||||
Accepted,
|
Accepted = 2,
|
||||||
InProgress,
|
InProgress = 3,
|
||||||
// final states
|
// final states
|
||||||
Failed,
|
Failed = 4,
|
||||||
Success
|
Success = 5,
|
||||||
|
// 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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -39,6 +39,39 @@ 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>
|
||||||
|
|
|
||||||
56
src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
Normal file
56
src/Yavsc.Api.Client/Dtos/QueryAcceptanceDto.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
66
src/Yavsc.Api.Client/Dtos/UserFilesDtos.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Représentation d'un répertoire de l'espace de stockage personnel,
|
||||||
|
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
|
||||||
|
/// des helpers serveur : c'est un pur DTO de désérialisation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserDirectoryDto
|
||||||
|
{
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? SubPath { get; set; }
|
||||||
|
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
|
||||||
|
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileEntryDto
|
||||||
|
{
|
||||||
|
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
|
||||||
|
public long? Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DirEntryDto
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
|
||||||
|
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttachmentDto
|
||||||
|
{
|
||||||
|
public long FileId { get; set; }
|
||||||
|
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
public string? OwnerId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
|
||||||
|
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
|
||||||
|
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileReceivedDto
|
||||||
|
{
|
||||||
|
public long? FileId { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public long Length { get; set; }
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
public bool QuotaOffense { get; set; }
|
||||||
|
public bool Overridden { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
|
||||||
|
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);
|
||||||
|
|
@ -113,6 +113,41 @@ public sealed class EstimateApiClient
|
||||||
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
|
||||||
|
/// rattachées au devis <paramref name="id"/> par référence
|
||||||
|
/// (<c>GET estimate/{id}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attache un fichier de l'espace perso du fournisseur au devis
|
||||||
|
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
|
||||||
|
/// Réservé au fournisseur côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
|
||||||
|
body: new { fileId }, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Détache un fichier du devis <paramref name="id"/>
|
||||||
|
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
|
||||||
|
/// </summary>
|
||||||
|
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
private Uri ResolveBusinessBaseAddress()
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
|
|
||||||
177
src/Yavsc.Api.Client/FrontOfficeApiClient.cs
Normal file
177
src/Yavsc.Api.Client/FrontOfficeApiClient.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les pièces jointes (fichiers de l'espace perso du client)
|
||||||
|
/// rattachées à la demande <paramref name="queryId"/> par référence
|
||||||
|
/// (<c>GET front/query/{queryId}/attachments</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attache un fichier de l'espace perso du client à sa demande
|
||||||
|
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
|
||||||
|
/// </summary>
|
||||||
|
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Détache un fichier de la demande
|
||||||
|
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
|
||||||
|
/// </summary>
|
||||||
|
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(billingCode))
|
||||||
|
throw new ArgumentException("billingCode is required.", nameof(billingCode));
|
||||||
|
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
|
||||||
|
|
||||||
|
private Uri ResolveBusinessBaseAddress()
|
||||||
|
{
|
||||||
|
var raw = _businessBaseAddress();
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
throw new InvalidOperationException("Business base address is required.");
|
||||||
|
|
||||||
|
return new Uri(raw, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -73,4 +73,23 @@ 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>());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
112
src/Yavsc.Api.Client/UserFilesApiClient.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Yavsc.Api.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client HTTP de l'espace de stockage personnel
|
||||||
|
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
|
||||||
|
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
|
||||||
|
/// Bearer, refresh silencieux sur 401) est délégué à
|
||||||
|
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
|
||||||
|
/// DTO↔chemin.
|
||||||
|
///
|
||||||
|
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
|
||||||
|
/// segment de version déjà porté par l'URL de base
|
||||||
|
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
|
||||||
|
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
|
||||||
|
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserFilesApiClient
|
||||||
|
{
|
||||||
|
private const string PathPrefix = "fs";
|
||||||
|
|
||||||
|
private readonly IYavscApiClient _api;
|
||||||
|
private readonly Uri _baseAddress;
|
||||||
|
|
||||||
|
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
|
||||||
|
{
|
||||||
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
if (string.IsNullOrEmpty(blogsBaseAddress))
|
||||||
|
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
|
||||||
|
|
||||||
|
_baseAddress = new Uri(blogsBaseAddress);
|
||||||
|
_api.Http.BaseAddress = _baseAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
|
||||||
|
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||||
|
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Téléverse des fichiers dans le sous-répertoire
|
||||||
|
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
|
||||||
|
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
|
||||||
|
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
|
||||||
|
/// </summary>
|
||||||
|
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
throw new ArgumentException("At least one file is required.", nameof(files));
|
||||||
|
|
||||||
|
var path = string.IsNullOrWhiteSpace(subdir)
|
||||||
|
? PathPrefix
|
||||||
|
: $"{PathPrefix}/{subdir.TrimStart('/')}";
|
||||||
|
|
||||||
|
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime un fichier ou répertoire de l'espace perso
|
||||||
|
/// (<c>DELETE fs/{path}</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Task DeleteAsync(string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
throw new ArgumentException("Path is required.", nameof(path));
|
||||||
|
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Télécharge une pièce jointe référencée par son identifiant
|
||||||
|
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
|
||||||
|
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
|
||||||
|
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
|
||||||
|
/// devis/demande auquel le fichier est rattaché).
|
||||||
|
/// </summary>
|
||||||
|
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
|
||||||
|
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
|
||||||
|
/// une part <c>file</c> par fichier (type MIME retombant sur
|
||||||
|
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
|
||||||
|
/// tests backend qui assertent la forme filaire.
|
||||||
|
/// </summary>
|
||||||
|
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(file.Content);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||||
|
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
|
||||||
|
content.Add(fileContent, "file", file.FileName);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion>
|
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
|
||||||
<Version>1.1.0-feat-sign-estimate.1</Version>
|
<Version>1.1.0-estimate.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Yavsc.Api.Test.Fixtures;
|
|
||||||
using Yavsc.Helpers;
|
|
||||||
using Yavsc.Models;
|
|
||||||
using Yavsc.Tests.Shared;
|
|
||||||
|
|
||||||
namespace Yavsc.Api.Test;
|
|
||||||
|
|
||||||
[Collection("Yavsc Api")]
|
|
||||||
public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFixture>
|
|
||||||
{
|
|
||||||
private readonly ApiWebServerFixture _fixture;
|
|
||||||
|
|
||||||
public FrontOfficeApiControllerTests(ApiWebServerFixture fixture)
|
|
||||||
{
|
|
||||||
_fixture = fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpClient NewClient(string subject = "alice", string scope = "api")
|
|
||||||
{
|
|
||||||
var handler = new HttpClientHandler
|
|
||||||
{
|
|
||||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
|
||||||
};
|
|
||||||
|
|
||||||
var http = new HttpClient(handler)
|
|
||||||
{
|
|
||||||
BaseAddress = new Uri(_fixture.BaseAddress)
|
|
||||||
};
|
|
||||||
http.DefaultRequestHeaders.Authorization =
|
|
||||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
|
||||||
return http;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Front_accept_query_updates_status_without_server_error()
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, 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>();
|
|
||||||
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
|
|
||||||
|
|
||||||
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -157,6 +157,8 @@ 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()
|
||||||
|
|
@ -169,6 +171,8 @@ 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()
|
||||||
|
|
@ -181,6 +185,8 @@ 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()
|
||||||
|
|
@ -329,9 +335,9 @@ namespace Yavsc.ApiControllers
|
||||||
return new ChallengeResult();
|
return new ChallengeResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
|
var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", 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 = "Professional signature not found" });
|
if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
|
||||||
return File(fi.OpenRead(), "application/x-pdf", filename);
|
return File(fi.OpenRead(), "application/x-pdf", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,79 +407,12 @@ namespace Yavsc.ApiControllers
|
||||||
Strokes = body.Strokes,
|
Strokes = body.Strokes,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disk write first: a disk failure shouldn't leave
|
// The Signature row is staged by the shared persister; we
|
||||||
// a Signature row pointing at a file that doesn't
|
// SaveChanges here so the signature write is one
|
||||||
// exist. The file helper throws on filesystem
|
// transaction. The Strokes column is the source of truth —
|
||||||
// problems and propagates here.
|
// there is no on-disk copy to roll back on failure.
|
||||||
FileReceivedInfo fi;
|
var signature = await EstimateSignaturePersister.StageAsync(
|
||||||
try
|
dbContext, id, type, userId, payload, token);
|
||||||
{
|
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|
@ -482,10 +421,6 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Models.Billing;
|
using Yavsc.Models.Billing;
|
||||||
|
using Yavsc.Models.Blog;
|
||||||
using Yavsc.Server.Helpers;
|
using Yavsc.Server.Helpers;
|
||||||
|
|
||||||
namespace Yavsc.Controllers
|
namespace Yavsc.Controllers
|
||||||
|
|
@ -40,7 +41,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).Where(e => e.OwnerId == ownerId));
|
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId));
|
||||||
}
|
}
|
||||||
// GET: api/Estimate/5
|
// GET: api/Estimate/5
|
||||||
[HttpGet("{id}", Name = "GetEstimate")]
|
[HttpGet("{id}", Name = "GetEstimate")]
|
||||||
|
|
@ -51,7 +52,7 @@ namespace Yavsc.Controllers
|
||||||
return BadRequest(ModelState);
|
return BadRequest(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id);
|
Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id);
|
||||||
|
|
||||||
if (estimate == null)
|
if (estimate == null)
|
||||||
{
|
{
|
||||||
|
|
@ -201,23 +202,31 @@ 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)
|
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
||||||
.Where(e => e.ClientId == uid && e.ClientValidationDate == default));
|
.Where(e => e.ClientId == uid
|
||||||
|
&& e.ProviderValidationDate != default
|
||||||
|
&& e.ClientValidationDate == default));
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: api/estimate/asprovider
|
// GET: api/estimate/asprovider
|
||||||
// Ongoing estimates (not yet validated by the client) established
|
// Estimates awaiting the provider's signature, established by the
|
||||||
// by the current user as the provider.
|
// current user as the provider. Once the provider validates
|
||||||
|
// (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)
|
return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
|
||||||
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
|
.Where(e => e.OwnerId == uid && e.ProviderValidationDate == default));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
|
|
@ -229,6 +238,96 @@ namespace Yavsc.Controllers
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/v1/estimate/{id}/attachments
|
||||||
|
//
|
||||||
|
// Liste les fichiers de l'espace perso du fournisseur rattachés au
|
||||||
|
// devis, par référence (UploadedFile). Lecture ouverte aux deux
|
||||||
|
// parties (fournisseur/client) du devis et aux admin, comme
|
||||||
|
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
|
||||||
|
// les pièces (le téléchargement des octets reste servi par le host
|
||||||
|
// Blogs, qui autorise via cette même table de jointure).
|
||||||
|
[HttpGet("{id}/attachments")]
|
||||||
|
public IActionResult GetAttachments(long id)
|
||||||
|
{
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var attached = _context.EstimateAttachedFiles
|
||||||
|
.Where(a => a.EstimateId == id)
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
fileId = a.FileId,
|
||||||
|
path = a.File.Path,
|
||||||
|
size = a.File.Length,
|
||||||
|
contentType = a.File.ContentType,
|
||||||
|
ownerId = a.File.OwnerId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
return Ok(attached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/v1/estimate/{id}/attachments { fileId }
|
||||||
|
//
|
||||||
|
// Attache un fichier de l'espace perso du fournisseur au devis, par
|
||||||
|
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
|
||||||
|
// seul le fournisseur constitue le devis et y joint ses documents
|
||||||
|
// (dont les documents à faire signer par le client, per README).
|
||||||
|
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
|
||||||
|
[HttpPost("{id}/attachments")]
|
||||||
|
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (body == null || body.FileId <= 0)
|
||||||
|
return BadRequest(new { error = "fileId required" });
|
||||||
|
|
||||||
|
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
|
||||||
|
if (file == null) return BadRequest(new { error = "file not found" });
|
||||||
|
if (file.OwnerId != uid)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var existing = _context.EstimateAttachedFiles
|
||||||
|
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
|
||||||
|
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
|
||||||
|
|
||||||
|
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
|
||||||
|
{
|
||||||
|
EstimateId = id,
|
||||||
|
FileId = body.FileId
|
||||||
|
});
|
||||||
|
_context.SaveChanges(User.GetUserId());
|
||||||
|
return Ok(new { estimateId = id, fileId = body.FileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
|
||||||
|
//
|
||||||
|
// Détache un fichier du devis. Réservé au fournisseur.
|
||||||
|
[HttpDelete("{id}/attachments/{fileId}")]
|
||||||
|
public IActionResult DetachFile(long id, long fileId)
|
||||||
|
{
|
||||||
|
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
|
||||||
|
if (estimate == null) return NotFound();
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var link = _context.EstimateAttachedFiles
|
||||||
|
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
|
||||||
|
if (link == null) return NotFound();
|
||||||
|
_context.EstimateAttachedFiles.Remove(link);
|
||||||
|
_context.SaveChanges(User.GetUserId());
|
||||||
|
return Ok(new { estimateId = id, fileId });
|
||||||
|
}
|
||||||
|
|
||||||
private bool EstimateExists(long id)
|
private bool EstimateExists(long id)
|
||||||
{
|
{
|
||||||
return _context.Estimates.Count(e => e.Id == id) > 0;
|
return _context.Estimates.Count(e => e.Id == id) > 0;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
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.Models.Blog;
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|
@ -13,13 +21,38 @@ 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;
|
||||||
|
|
||||||
private IBillingService billing;
|
// The view-rendering dependencies (siteSettings, viewEngine) are
|
||||||
|
// optional: only EstimateTex/EstimatePdf use them, and those run in
|
||||||
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null)
|
// the Api host where Program.cs wires AddControllersWithViews +
|
||||||
|
// 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}")]
|
||||||
|
|
@ -28,29 +61,402 @@ namespace Yavsc.ApiControllers
|
||||||
return await dbContext.ListPerformersAsync(billing, actCode);
|
return await dbContext.ListPerformersAsync(billing, actCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("query/reject")]
|
// GET: api/front/query/{queryId}/estimate.tex
|
||||||
public IActionResult RejectQuery(string billingCode, long queryId)
|
//
|
||||||
|
// 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 (billingCode == null) return BadRequest("billingCode");
|
if (viewEngine is null || siteSettings is null)
|
||||||
if (queryId == 0) return BadRequest("queryId");
|
return Problem("TeX rendering is not configured on this host.",
|
||||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
if (billing == null) return BadRequest();
|
|
||||||
|
|
||||||
billing.Status = QueryStatus.Rejected;
|
var estimate = await LoadEstimateForRenderAsync(queryId, token);
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
|
||||||
return Ok();
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("query/accept")]
|
// GET: api/front/query/{queryId}/estimate.pdf
|
||||||
public IActionResult AcceptQuery(string billingCode, long queryId)
|
//
|
||||||
|
// 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 (billingCode == null) return BadRequest("billingCode");
|
if (viewEngine is null || siteSettings is null)
|
||||||
if (queryId == 0) return BadRequest("queryId");
|
{
|
||||||
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
|
string msg = "PDF rendering is not configured on this host.";
|
||||||
if (billing == null) return BadRequest();
|
logger.LogError(msg);
|
||||||
billing.Status = QueryStatus.Accepted;
|
return Problem(msg,
|
||||||
dbContext.SaveChanges(User.GetUserId());
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
return Ok();
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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")]
|
||||||
|
public async Task<IActionResult> RejectQuery(
|
||||||
|
string billingCode,
|
||||||
|
long queryId,
|
||||||
|
[FromBody] QueryAcceptanceRequest? body,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (HasSignature(body))
|
||||||
|
return BadRequest(new { Error = "signature not allowed on reject" });
|
||||||
|
|
||||||
|
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" });
|
||||||
|
|
||||||
|
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
|
||||||
|
// (provider or client) or an admin. Returns the role the caller
|
||||||
|
// 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))
|
||||||
|
return (null, ActorRole.None, BadRequest("billingCode"));
|
||||||
|
if (queryId == 0)
|
||||||
|
return (null, ActorRole.None, BadRequest("queryId"));
|
||||||
|
|
||||||
|
var query = await billing.GetBillAsync(billingCode, queryId);
|
||||||
|
if (query is null)
|
||||||
|
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;
|
||||||
|
|
||||||
|
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
|
||||||
|
//
|
||||||
|
// Liste les fichiers de l'espace perso du client rattachés à la
|
||||||
|
// demande, par référence (UploadedFile). Lecture ouverte aux deux
|
||||||
|
// parties (client/fournisseur) de la demande et aux admin ; le
|
||||||
|
// téléchargement des octets est servi par le host Blogs.
|
||||||
|
[HttpGet("query/{queryId}/attachments")]
|
||||||
|
public async Task<IActionResult> GetQueryAttachments(
|
||||||
|
string billingCode, long queryId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
var attached = await dbContext.QueryAttachedFiles
|
||||||
|
.Where(a => a.CommandId == queryId)
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
fileId = a.FileId,
|
||||||
|
path = a.File.Path,
|
||||||
|
size = a.File.Length,
|
||||||
|
contentType = a.File.ContentType,
|
||||||
|
ownerId = a.File.OwnerId
|
||||||
|
})
|
||||||
|
.ToListAsync(token);
|
||||||
|
return Ok(attached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
|
||||||
|
//
|
||||||
|
// Attache un fichier de l'espace perso du client à sa demande, par
|
||||||
|
// référence. Réservé au client (rôle Client) : c'est lui qui
|
||||||
|
// documente sa demande. Le fichier doit lui appartenir.
|
||||||
|
[HttpPost("query/{queryId}/attachments")]
|
||||||
|
public async Task<IActionResult> AttachQueryFile(
|
||||||
|
string billingCode, long queryId, [FromBody] AttachFileRequest? body, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
if (body == null || body.FileId <= 0)
|
||||||
|
return BadRequest(new { error = "fileId required" });
|
||||||
|
|
||||||
|
var uid = User.GetUserId();
|
||||||
|
var file = await dbContext.UploadedFiles
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
|
||||||
|
if (file == null) return BadRequest(new { error = "file not found" });
|
||||||
|
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var existing = await dbContext.QueryAttachedFiles
|
||||||
|
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
|
||||||
|
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
|
||||||
|
|
||||||
|
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
|
||||||
|
{
|
||||||
|
CommandId = queryId,
|
||||||
|
FileId = body.FileId
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
return Ok(new { queryId, fileId = body.FileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
|
||||||
|
//
|
||||||
|
// Détache un fichier de la demande. Réservé au client.
|
||||||
|
[HttpDelete("query/{queryId}/attachments/{fileId}")]
|
||||||
|
public async Task<IActionResult> DetachQueryFile(
|
||||||
|
string billingCode, long queryId, long fileId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
|
||||||
|
if (error is not null) return error;
|
||||||
|
if (query is null) return BadRequest(new { Error = "query not found" });
|
||||||
|
|
||||||
|
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
|
||||||
|
return new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
var link = await dbContext.QueryAttachedFiles
|
||||||
|
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
|
||||||
|
if (link == null) return NotFound();
|
||||||
|
dbContext.QueryAttachedFiles.Remove(link);
|
||||||
|
await dbContext.SaveChangesAsync(User.GetUserId(), token);
|
||||||
|
return Ok(new { queryId, fileId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum ActorRole { None, Provider, Client, Admin }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
325
src/Yavsc.Api/Helpers/TeXHelpers.cs
Normal file
325
src/Yavsc.Api/Helpers/TeXHelpers.cs
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
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,7 +55,11 @@ internal class Program
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.AddYavscCors(builder.Configuration)
|
.AddYavscCors(builder.Configuration)
|
||||||
.AddControllers();
|
.AddControllersWithViews();
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue