Changes In Branch dev Excluding Merge-Ins
This is equivalent to a diff from 7e69c7ec72 to a8a7379705
2024-02-21
| ||
22:21 | Move Dictionary to locales directory check-in: b562dd4ac5 user: bohwaz tags: trunk | |
2024-02-18
| ||
20:22 | Add expected amount to export Leaf check-in: a8a7379705 user: bohwaz tags: dev | |
20:18 | Merge changes from trunk check-in: 8b1a3592a4 user: bohwaz tags: dev | |
20:17 | Remove duplicate column check-in: 7e69c7ec72 user: bohwaz tags: trunk, stable | |
20:09 | Reverse order of comparison year in statements, like it was before check-in: acca6f7b6f user: bohwaz tags: trunk, stable | |
Modified doc/admin/api.md from [983a7a8a85] to [8812c5bfd3].
1 2 3 4 | Une API de type REST est disponible dans Paheko. Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu ==Configuration==, onglet ==Fonctions avancées==, puis ==API==. | > > > > | > > | > | > > | > > > > > > > | > > > | | | > > > | > > > > > > > | < | < | < < < | > | > > | > > > > > > > > | > > | | > > | > | < > > | < < < < | < > > > | > > > > > | < > | > > | | | | | > > > | > > > > > > > > | | | > | > | > | > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > | > > > > | > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > | | > | > | > > > > > | > > > > > > | < | | | > | < > < > > > > | > > | > > > > > > > > | < < < < > > | | > > > > > > | | > | > | > > > > > > > > > > > > > > > > > > > > > > > > | > | > > > > > > | | | > > > | > | | > | > > > > > > > > > > > > > > > > > > > > > > > > > | | | | < | < < < < | < < | | | > | < | < < < < < < < < | < > | > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 | # Introduction ### Débuter Une API de type REST est disponible dans Paheko. Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu ==Configuration==, onglet ==Fonctions avancées==, puis ==API==. L'API peut ensuite recevoir des requêtes REST sur l'URL `https://adresse_association/api{route}`. Remplacer =={route}== par une des routes de l'API (voir ci-dessous). La méthode HTTP (`GET`, `POST`, etc.) à utiliser est spécifiée pour chaque route. Des exemples sont donnés pour l'utilisation de l'outil `curl` en ligne de commande, si vous souhaitez utiliser un autre langage de programmation il faudra adapter votre code. ### Formats des requêtes et réponses Les paramètres peuvent être fournis sous les formes suivantes : * dans les paramètres de l'URL (query string) : pour toutes les méthodes * formulaire HTTP classique pour les requêtes `POST` : * `Content-Type: application/x-www-form-urlencoded` * ou `Content-Type: multipart/form-data` * objet JSON pour les requêtes POST : * `Content-Type: application/json` Les réponses sont renvoyées en JSON par défaut, sauf quand la route permet de choisir un autre format. Les formats ODS et XLSX ne sont disponibles à l'import que si le serveur est configuré pour convertir ces formats. De la même manière, le format XLSX n'est disponible que si le serveur est configuré pour générer ce format. ### Utiliser l'API N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera. En ligne de commande il est possible d'utiliser `curl`. Exemple pour télécharger la base de données : ``` curl -u test:secret https://[identifiant_association].paheko.cloud/api/download -o association.sqlite ``` On peut aussi utiliser `wget` en n'oubliant pas l'option `--auth-no-challenge` sinon l'authentification ne fonctionnera pas : ``` wget https://test:secret@[identifiant_association].paheko.cloud/api/download \ --auth-no-challenge \ -O association.sqlite ``` Exemple pour créer une écriture sous forme de formulaire : ``` curl -v -u test:secret \ https://[identifiant_association].paheko.cloud/api/accounting/transaction \ -F id_year=1 \ -F label=Test \ -F "date=01/02/2023" … ``` Ou sous forme d'objet JSON : ``` curl -v -u test:secret \ https://[identifiant_association].paheko.cloud/api/accounting/transaction \ -H 'Content-Type: application/json' \ -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023", …}' ``` ### Authentification L'API utilise l'authentification [`Basic` de HTTP](https://fr.wikipedia.org/wiki/Authentification_HTTP#M%C3%A9thode_%C2%AB_Basic_%C2%BB). ### Erreurs En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé `error` contenant le message d'erreur. # Routes ## Requêtes SQL ### POST sql.{FORMAT} Exécute une requête SQL en lecture | Paramètre | Type | Description | | :- | :- | :- | | `FORMAT` | `string` | Format de retour : `json`, `csv`, `ods` ou `xlsx` | | `sql` | `string` | Requête SQL à exécuter. | Si aucun format n'est passé (exemple : `…/api/sql`, sans point ni extension), `json` sera utilisé. Permet d'exécuter une requête SQL `SELECT` (uniquement, pas de requête `UPDATE`, `DELETE`, `INSERT`, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre `sql`. S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement. Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/sql \ -d 'SELECT nom, code_postal FROM users LIMIT 2;' ``` Exemple de réponse : ```response { "count": 65, "results": [ { "nom": "Ada Lovelace", "code_postal": null }, { "nom": "James Coincoin", "code_postal": "78990" } ] } ``` **Attention :** Les requêtes en écriture (`INSERT, DELETE, UPDATE, CREATE TABLE`, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues. ## Téléchargements ### GET download Télécharger la base de données Renvoie directement le fichier SQLite de la base de données. Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/download -o db.sqlite ``` ### GET download/files Télécharger un fichier ZIP contenant tous les fichiers _(Depuis la version 1.3.4)_ Les fichiers inclus sont : * documents * fichiers liés aux écritures, * fichiers liés des membres, * fichiers joints aux pages du site web * code des modules modifiés * corbeille * configuration : logo, icônes, etc. * anciennes versions des fichiers Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/download/files -o backup_files.zip ``` ## Site web _(Depuis la version 1.4.0)_ ### GET web Liste de toutes les pages du site web ### GET web/{PAGE_URI} Métadonnées de la page du site web | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | | `html` | `bool` | Si `true` ou `1`, une clé `html` sera ajoutée à la réponse avec le contenu de la page au format HTML. | Exemple de réponse : ```response [ { "id": 13, "uri": "actualite", "title": "Actualit\u00e9", "path": null, "draft": 0, "published": "2019-04-22 18:00:00", "modified": "2023-09-12 15:44:55" }, { "id": 66, "uri": "Affiches-des-bourses-aux-velos", "title": "Affiches des bourses aux v\u00e9los", "path": "Nos activit\u00e9s", "draft": 0, "published": "2019-07-18 19:05:00", "modified": "2023-04-04 14:44:04" }, … ] ``` ### PUT web/{PAGE_URI} Modifie le contenu de la page | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X PUT -d 'La bourse aura lieu le 28 septembre' ``` ### POST web/{PAGE_URI} Modifie les métadonnées de la page | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | | `id_parent` | `int|null` | Numéro de la catégorie parente de cette page. | | `uri` | `string` | Nouvelle adresse unique de la page. | | `title` | `string` | Titre de la page. | | `type` | `int` | Type de page. `1` pour les catégories, `2` pour les pages simples. | | `status` | `string` | Statut de la page. `online` si la page est en ligne, `draft` si la page est en brouillon. | | `format` | `string` | Format de la page : `markdown`, `encrypted` ou `skriv` | | `published` | `string` | Date et heure de publication au format `YYYY-MM-DD HH:mm:ss`. | | `modified` | `string` | Date et heure de modification au format `YYYY-MM-DD HH:mm:ss`. | | `content` | `string` | Contenu. | Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -F title="Bourse aux vélos du 28 septembre" ``` ### DELETE web/{PAGE_URI} Supprime la page et ses fichiers joints | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X DELETE ``` ### GET web/{PAGE_URI}.html Contenu de la page web au format HTML | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre.html ``` ### GET web/{PAGE_URI}/children Liste des pages et sous-catégories dans cette catégorie | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | Exemple de requête : ```request curl -u test:abcd https://paheko.monasso.tld/api/web/actualite/children ``` Exemple de réponse : ```response { "categories": [], "pages": [ { "id": 86, "id_parent": 13, "uri": "bourse-aux-velos-le-30-septembre-et-1er-octobre", "title": "Bourse aux v\u00e9los 30 septembre et 1er octobre", "type": 2, "status": "online", "format": "skriv", "published": "2023-10-01 18:00:00", "modified": "2023-09-11 23:41:41", "content": "…" }, … ] } ``` ### GET web/{PAGE_URI}/attachments Liste des fichiers joints à la page | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | ### GET web/{PAGE_URI}/{FILE_NAME} Récupérer le fichier joint à la page | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | | `FILENAME` | `string` | Nom du fichier. | ### DELETE web/{PAGE_URI}/{FILE_NAME} Supprime le fichier joint à la page | Paramètre | Type | Description | | :- | :- | :- | | `PAGE_URI` | `string` | Adresse unique de la page. | | `FILENAME` | `string` | Nom du fichier. | ## Membres ### GET user/categories Liste des catégories de membres _(Depuis la version 1.4.0)_ La liste est triée par nom, et inclue le nombre de membres de la catégorie dans la clé `count`. Exemple de réponse : ```response { "12": { "id": 12, "name": "Administration technique", "perm_web": 9, "perm_documents": 9, "perm_users": 9, "perm_accounting": 9, "perm_subscribe": 0, "perm_connect": 1, "perm_config": 9, "hidden": 0, "count": 1 } } ``` ### GET user/category/{ID}.{FORMAT} Exporte la liste des membres d'une catégorie | Paramètre | Type | Description | | :- | :- | :- | | `ID` | `int` | Identifiant unique de la catégorie. | | `FORMAT` | `string` | Format de sortie : `json`, `csv`, `ods` ou `xlsx` | _(Depuis la version 1.4.0)_ ### POST user/new Créer un nouveau membre | Paramètre | Type | Description | | :- | :- | :- | | `id_category` | `int` | Identifiant de la catégorie. Si absent, la catégorie par défaut sera utilisée. | | `password` | `string` | Mot de passe du membre. | | `force_duplicate` | `bool` | Si `true` ou `1`, alors aucune erreur ne sera renvoyée si le nom du membre correspond à un membre déjà existant. | _(Depuis la version 1.4.0)_ Attention, cette méthode comporte des restrictions : * il n'est pas possible de créer un membre dans une catégorie ayant accès à la configuration * il n'est pas possible de définir l'OTP ou la clé PGP du membre créé * seul un identifiant API ayant le droit "Administration" pourra créer des membres administrateurs Il est possible d'utiliser tous les champs de la fiche membre en utilisant la clé unique du champ. Sera renvoyée la liste des infos de la fiche membre. Si un membre avec le même nom existe déjà (et que `force_duplicate` n'est pas utilisé), une erreur `409` sera renvoyée. Exemple de requête : ```request curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new ``` ### GET user/{ID} Informations de la fiche d'un membre | Paramètre | Type | Description | | :- | :- | :- | | `ID` | `int` | Identifiant unique du membre (différent du numéro). | _(Depuis la version 1.4.0)_ Plusieurs clés supplémentaires sont retournées, en plus des champs de la fiche membre : * `has_password` * `has_pgp_key` * `has_otp` Exemple de réponse : ```response { "has_password": true, "has_otp": false, "has_pgp_key": false, "id": 1, "id_category": 8, "date_login": "2021-06-06 09:17:39", "date_updated": null, "id_parent": null, "is_parent": false, "preferences": null, "numero": 1, "nom": "Ada Lovelace", "notes": null, "groupe_information": true, "groupe_benevoles": false, "email": "ada@lovelace.org", "telephone": "010101010101", "adresse": null, "code_postal": "21000", "ville": "DIJON", "pays": "FR", "date_inscription": "2012-02-25" } ``` ### DELETE user/{ID} Supprime un membre | Paramètre | Type | Description | | :- | :- | :- | | `ID` | `int` | Identifiant unique du membre (différent du numéro). | _(Depuis la version 1.4.0)_ Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres. Note : il n'est pas possible de supprimer via l'API un membre appartenant à une catégorie ayant accès à la configuration. ### POST user/{ID} Modifie les infos de la fiche d'un membre | Paramètre | Type | Description | | :- | :- | :- | | `ID` | `int` | Identifiant unique du membre (différent du numéro). | _(Depuis la version 1.4.0)_ Notes : * il n'est pas possible de modifier la catégorie d'un membre * il n'est pas possible de modifier un membre appartenant à une catégorie ayant accès à la configuration. * il n'est pas possible de modifier le mot de passe, l'OTP ou la clé PGP du membre créé * il n'est pas possible de modifier des membres ayant accès à la configuration * seul un identifiant d'API ayant l'accès en "Administration" pourra modifier un membre administrateur ### POST user/import Importer un fichier de tableur de la liste des membres Formats de fichiers acceptés : CSV, ODS, XLSX. | Paramètre | Type | Description | | :- | :- | :- | | `mode` | `string` | Mode d'import du fichier. Voir ci-dessous pour les détails. _(Depuis la version 1.2.8)_ | | `skip_lines` | `int` | Nombre de lignes à ignorer. Défaut : `1`. | | `column` | `array` | Correspondance entre la colonne (clé, commence à zéro) et le champ de la fiche membre (valeur). | Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration. Le paramètre `mode` permet d'utiliser une de ces options pour spécifier le mode d'import : * `auto` (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné * `create` : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite * `update` : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite Exemple de requête : ```request curl -u test:abcd https://monpaheko.tld/api/user/import \ -F mode=create \ -F 'column[0]=nom_prenom' \ -F 'column[1]=code_postal' \ -F skip_lines=0 \ -F file=@membres.csv ``` Si aucun paramètre `column` n'est fourni, Paheko s'attend alors à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs *Nom et prénom* et *Adresse postale*, alors le fichier fourni devra ressembler à ceci : | Nom et prénom | Adresse postale | | :- | :- | | Ada Lovelace | 42 rue du binaire, 21000 DIJON | Ou à ceci : | nom_prenom | adresse_postale | | :- | :- | | Ada Lovelace | 42 rue du binaire, 21000 DIJON | La méthode renvoie un code HTTP `200 OK` si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse. Utilisez la route `user/import/preview` avant pour vérifier que l'import correspond à ce que vous attendez. Exemple pour modifier le nom du membre n°42 : ``` echo 'numero,nom' > membres.csv echo '42,"Nouveau nom"' >> membres.csv curl -u test:abcd https://monpaheko.tld/api/user/import -F file=@membres.csv ``` ### PUT user/import Importer un fichier de tableur de la liste des membres Formats de fichiers acceptés : CSV, ODS, XLSX. Identique à la même méthode en `POST`, mais les paramètres sont passés dans l'URL, et le fichier en contenu de la requête. Exemple de requête : ```request curl -u test:abcd https://monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&skip_lines=0 \ -T membres.csv ``` ### POST user/import/preview Prévisualise un import de membres, sans modifier les membres Identique à `user/import`, mais l'import n'est pas enregistré. À la place l'API indique les modifications qui seraient apportées. Renvoie un objet JSON comme ceci : * `errors` : liste des erreurs d'import * `created` : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée * `modified` : liste des membres modifiés, chaque membre aura une clé `id` et une clé `name`, ainsi qu'un objet `changed` contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés `old` et `new`, contenant respectivement l'ancienne valeur du champ et la nouvelle. * `unchanged` : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé `name` et une clé `id` indiquant le nom et l'identifiant unique numérique du membre Note : si `errors` n'est pas vide, alors il sera impossible d'importer le fichier avec `user/import`. Exemple de requête : ```request curl -u test:abcd https://monpaheko.tld/api/user/import/preview -F mode=update -F file=@/tmp/membres.csv ``` Exemple de réponse : ```response { "created": [ { "numero": 3434351, "nom": "Bla Bli Blu" } ], |
︙ | ︙ | |||
305 306 307 308 309 310 311 312 | "id": 2, "name": "Paul Muad'Dib" } ] } ``` | > | | | > > > > | | | > | > > > | | | | > > > > > > > > > > > > > > > > > > | > > > > > > | > > | > > > | > > > | | > > > | > < > | < | < > > > > > > > > > > > | > | > > | > > > > | > | | | > > > > > | > | > | < > | > > > | > | < | < > > > > > > > > > > > > > > > > > > | < > < > < > < > > > > | | > > > > > > > | > > | > > > > | > > > < > < | < > | > > > > > | | > > > | > > > > > > > > > > | > > > > > > > > > | | | | | < < < > | | > > | > > > > > > > > > > > | > | > > > > > > > > > > > > > | < > > | < | > | > > | > > > | > | > | > | > | > > | | > | > | > > > > | > | > > > | > > | > > > > | > | > > > | > | > | > | > > > > | > | < > > > > > > > > > > > | 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 | "id": 2, "name": "Paul Muad'Dib" } ] } ``` ### PUT user/import/preview Prévisualise un import de membres, sans modifier les membres Idem quel la méthode en `POST` mais les paramètres doivent être passés dans l'URL, et le fichier dans le corps de la requête. ## Activités ### PUT services/subscriptions/import Importer les inscriptions des membres aux activités Fichiers acceptés : CSV, XLSX, ODS. _(Depuis Paheko 1.3.2)_ Les activités et tarifs doivent déjà exister avant l'import. Les colonnes suivantes peuvent être utilisées : * Numéro de membre`**` * Activité`**` * Tarif * Date d'inscription`**` * Date d'expiration * Montant à régler * Payé ? Les colonnes suivies de deux astérisques (`**`) sont obligatoires. Exemple : ``` echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv curl -u test:abcd https://monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv ``` ## Erreurs Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit. ### POST errors/report Ajouter un rapport d'erreur au log Cette route permet d'ajouter une erreur au log de l'instance. Utile pour centraliser les erreurs de plusieurs instances. Paheko utilise le format d'erreurs de [AirBrake](https://docs.airbrake.io/docs/devops-tools/api/#post-data-schema-v3) et errbit. ### GET errors/log Log d'erreurs de l'instance ## Comptabilité ### GET accounting/years Liste des exercices Exemple de réponse : ```response [ { "id": 1, "label": "Premier exercice", "start_date": "2011-11-01", "end_date": "2013-01-31", "closed": 1, "id_chart": 1, "nb_transactions": 1194, "chart_name": "Plan comptable associatif 1999" }, … ] ``` ### GET accounting/charts Liste des plans comptables Exemple de réponse : ```response [ { "id": 2, "label": "Plan comptable associatif 2018", "country": "FR", "code": "PCA_2018", "archived": false } ] ``` ### GET accounting/charts/{ID_CHART}/accounts Liste des comptes pour le plan comptable indiqué | Paramètre | Type | Description | | :- | :- | :- | | `ID_CHART` | `int` | ID du plan comptable. | Exemple de réponse : ```response [ { "id": 312, "id_chart": 2, "code": "1", "label": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)", "description": null, "position": 2, "type": 0, "user": false, "bookmark": false }, … ] ``` ### GET accounting/years/{ID_YEAR}/journal Journal général des écritures de l'exercice indiqué | Paramètre | Type | Description | | :- | :- | :- | | `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. | Note : il est possible d'utiliser `current` comme paramètre pour `{ID_YEAR}` pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé. ### GET accounting/years/{ID_YEAR}/export/{TYPE}.{FORMAT} Export d'un exercice | Paramètre | Type | Description | | :- | :- | :- | | `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. | | `TYPE` | `string` | Type d'export : `full`, `grouped`, `simple` ou `fec`. `simple` ne contient pas les écritures avancées. | | `FORMAT` | `string` | Format d'export : `json`, `csv`, `ods` ou `xlsx` | _(Depuis la version 1.4.0)_ ### GET accounting/years/{ID_YEAR}/journal/{CODE} Journal des écritures d'un compte | Paramètre | Type | Description | | :- | :- | :- | | `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. | | `CODE` | `int|string` | Code du compte. | Exemple de réponse : ```response [ { "id": 9297, "id_line": 22401, "date": "2022-02-08", "debit": 0, "credit": 850, "change": 850, "sum": 850, "reference": "POS-SESSION-434", "type": 0, "label": "Session de caisse n\u00b0434", "line_label": null, "line_reference": null, "id_project": null, "project_code": null, "files": 1, "status": 0 }, … ] ``` ### GET accounting/years/{ID_YEAR}/journal/={ID} Journal des écritures d'un compte | Paramètre | Type | Description | | :- | :- | :- | | `ID_YEAR` | `int|string` | ID de l'exercice, ou `current`. | | `ID` | `int` | ID du compte. | ### POST accounting/transaction Créer une nouvelle écriture | Paramètre | Type | Description | | :- | :- | :- | | `id_year` | `int` | Identifiant de l'exercice. | | `date` | `string` | Date au format `YYYY-MM-DD` ou `DD/MM/YYYY` | | `type` | `string` | Type d'écriture. | | `reference` | `string|null` | Numéro de pièce comptable | | `notes` | `string|null` | Remarques (texte multi ligne) | | `linked_transactions` | `array(int, …)|null` | Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)* | `linked_users` | `array(int, …)|null` | Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)* | | `linked_subscriptions` | `array(int, …)|null` | Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.4.0)* | #### Types d'écriture | Type | Description | | :- | :- | | `expense` | Dépense | | `revenue` | Recette | | `transfer` | Virement | | `debt` | Dette | | `credit` | Créance | | `advanced` | Saisie avancée | Les écritures avancées (multi-lignes) ont obligatoirement le type `advanced`. Les autres écritures sont appelées *"écritures simplifiées"* et ne peuvent comporter que deux lignes. #### Paramètres des écritures simplifiées | Paramètre | Type | Description | | :- | :- | :- | | `amount` | `string` | Montant de l'écriture, au format flottant (`53,34`) | | `credit` | `string` | Code du compte porté au crédit | | `debit` | `string` | Code du compte porté au débit | | `id_project` | `int|null` | ID du projet à affecter | | `payment_reference` | `int|null` | référence de paiement | #### Paramètres des écritures avancées | Paramètre | Type | Description | | :- | :- | :- | | `lines` | `array(object, …)` | un tableau dont chaque élément est un objet correspondant à une ligne de l'écriture. | Structure de l'objet représentant une ligne de l'écriture : | Paramètre | Type | Description | | :- | :- | :- | | `account` | `string` | Code du compte | | `id_account` | `int` | Identifiant du compte (ID). Peut être utilisé en alternative au code du compte. | | `credit` | `string` | Montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa) | | `debit` | `string` | montant à inscrire au débit | | `label` | `string|null` | libellé de la ligne | | `reference` | `string|null` | référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées) | | `id_project` | `int|null` | ID du projet à affecter à cette ligne | Exemple de requête : ```request curl -F 'id_year=12' \ -F 'label=Test' \ -F 'date=01/02/2022' \ -F 'type=expense' \ -F 'amount=42,45' \ -F 'debit=512A' \ -F 'credit=601' ``` ### GET accounting/transaction/{ID_TRANSACTION} Détails de l'écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | Exemple de réponse : ```response { "id": 9302, "type": 0, "status": 0, "label": "Session de caisse n\u00b0439", "notes": null, "reference": "POS-SESSION-439", "date": "2022-02-12", "hash": null, "prev_id": null, "prev_hash": null, "id_year": 12, "id_creator": 5883, "url": "http:\/\/dev.paheko.localhost\/admin\/acc\/transactions\/details.php?id=9302", "lines": [ { "id": 22421, "id_transaction": 9302, "id_account": 542, "credit": 0, "debit": 3000, "reference": "CE342", "label": null, "reconciled": false, "id_project": null, "account_code": "5112", "account_label": "Ch\u00e8ques \u00e0 encaisser", "account_position": 3, "project_name": null, "account_selector": { "542": "5112 \u2014 Ch\u00e8ques \u00e0 encaisser" } }, … ] } ``` ### POST accounting/transaction/{ID_TRANSACTION} Modifier l'écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | Si l'écriture est verrouillée, ou dans un exercice clôturé, la modification sera impossible. Voir la route `POST accounting/transaction` (création d'une écriture) pour la liste des paramètres. ### GET accounting/transaction/{ID_TRANSACTION}/users Liste des membres liés à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | ### POST accounting/transaction/{ID_TRANSACTION}/users Met à jour la liste des membres liés à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | | `users` | `array(int, …)` | ID des membres. | Exemple de requête : ``` curl -v "https://…/api/accounting/transaction/9337/users" -F 'users[]=2' ``` ### DELETE accounting/transaction/{ID_TRANSACTION}/users Efface la liste des membres liés à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | ### GET accounting/transaction/{ID_TRANSACTION}/subscriptions Liste des inscriptions (aux activités) liées à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | _(Depuis la version 1.4.0)_ ### POST accounting/transaction/{ID_TRANSACTION}/subscriptions Met à jour la liste des inscriptions liées à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | | `subscriptions` | `array(int, …)` | ID des inscriptions. | _(Depuis la version 1.4.0)_ Exemple de requête : ``` curl -v "https://…/api/accounting/transaction/9337/subscriptions" -F 'subscriptions[]=2' ``` ### DELETE accounting/transaction/{ID_TRANSACTION}/subscriptions Efface la liste des inscriptions liées à une écriture | Paramètre | Type | Description | | :- | :- | :- | | `ID_TRANSACTION` | `int` | ID de l'écriture. | _(Depuis la version 1.4.0)_ |
Modified src/VERSION from [a3eb6e0b83] to [ffb2be659d].
|
| | | 1 | 1.4.0 |
Deleted src/include/data/schema.sql version [57116110a2].
|
| < |
Modified src/include/init.php from [87388ff3ec] to [8af4bbd2c7].
︙ | ︙ | |||
156 157 158 159 160 161 162 | } static $default_config = [ 'CACHE_ROOT' => DATA_ROOT . '/cache', 'SHARED_CACHE_ROOT' => DATA_ROOT . '/cache/shared', 'WEB_CACHE_ROOT' => DATA_ROOT . '/cache/web/%host%', 'DB_FILE' => DATA_ROOT . '/association.sqlite', | | | 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | } static $default_config = [ 'CACHE_ROOT' => DATA_ROOT . '/cache', 'SHARED_CACHE_ROOT' => DATA_ROOT . '/cache/shared', 'WEB_CACHE_ROOT' => DATA_ROOT . '/cache/web/%host%', 'DB_FILE' => DATA_ROOT . '/association.sqlite', 'DB_SCHEMA' => ROOT . '/include/migrations/schema.sql', 'PLUGINS_ROOT' => DATA_ROOT . '/plugins', 'PLUGINS_ALLOWLIST' => null, 'PLUGINS_BLOCKLIST' => null, 'ALLOW_MODIFIED_IMPORT' => true, 'SHOW_ERRORS' => true, 'MAIL_ERRORS' => false, 'ERRORS_REPORT_URL' => null, |
︙ | ︙ |
Modified src/include/lib/Paheko/API.php from [eec6be819a] to [d9259214ee].
1 2 3 4 5 6 | <?php namespace Paheko; use Paheko\Backup; use Paheko\Users\Session; | < < < < < < < < | < < < > > > > | > > > > | > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | <?php namespace Paheko; use Paheko\Backup; use Paheko\Users\Session; use Paheko\Search; use Paheko\Services\Subscriptions; use Paheko\Files\Files; use KD2\ErrorManager; class API { use API\Accounting; use API\User; use API\Web; protected string $path; protected array $params; protected bool $is_http_client = false; protected string $method; protected int $access; protected $file_pointer = null; protected ?string $allowed_files_root = null; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; const EXPORT_FORMATS = ['json', 'xlsx', 'ods', 'csv']; const SUCCESS = ['success' => true]; public function __construct(string $method, string $path, array $params) { if (!in_array($method, self::ALLOWED_METHODS)) { throw new APIException('Invalid request method: ' . $method, 405); } $this->path = trim($path, '/'); $this->method = $method; $this->params = $params; } public function __destruct() { if (null !== $this->file_pointer) { $this->closeFilePointer(); } } protected function requireMethod(string $method) { if ($this->method !== $method) { throw new APIException('Wrong request method', 405); } } public function setAllowedFilesRoot(?string $root): void { $this->allowed_files_root = rtrim($root, '/') . '/'; } public function isPathAllowed(string $path): bool |
︙ | ︙ | |||
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | } } protected function hasParam(string $param): bool { return array_key_exists($param, $this->params); } protected function download(string $uri) { if ($this->method != 'GET') { throw new APIException('Wrong request method', 400); } if ($uri === 'files') { Files::zipAll(); } elseif ($uri === '') { Backup::dump(); } else { throw new APIException('Unknown path: ' . $uri, 404); } return null; } | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | > > | > > | < | | | | 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | } } protected function hasParam(string $param): bool { return array_key_exists($param, $this->params); } protected function hasParamTrue(string $param): bool { return array_key_exists($param, $this->params) && in_array($this->params[$param], [1, true, 'true', 'TRUE', '1'], true); } protected function toArray($in, bool $recursive = true): array { if (!is_array($in) && is_iterable($in)) { $in = iterator_to_array($in); } $in = (array)$in; foreach ($in as $key => &$value) { if ($recursive && (is_array($value) || is_iterable($value))) { $value = $this->toArray($value); } elseif ($value instanceof \DateTime) { if ((int)$value->format('His')) { $value = $value->format('Y-m-d H:i:s'); } else { $value = $value->format('Y-m-d'); } } } unset($value); return $in; } public function exportJSON($in, $level = 2): void { $is_list = null; $in = $this->toArray($in, false); foreach ($in as $key => $value) { if (null === $is_list) { if ($key === 0) { echo "[\n"; $is_list = true; } else { echo "{\n"; $is_list = false; } } else { echo ",\n"; } echo str_repeat(" ", $level); if (!$is_list) { echo json_encode((string)$key) . ': '; } if (is_array($value) || is_object($value)) { $this->exportJSON($value, $level+2); } else { echo json_encode($value); } } $space = str_repeat(' ', max($level-2, 0)); if ($is_list === true) { echo "\n$space]"; } elseif ($is_list === false) { echo "\n$space}"; } else { echo "[]\n"; } flush(); } public function export($in): ?array { if (!$this->is_http_client) { $in = $this->toArray($in); return json_encode($in); } header("Content-Type: application/json; charset=utf-8", true); echo $this->exportJSON($in); return null; } protected function download(string $uri) { if ($this->method != 'GET') { throw new APIException('Wrong request method', 400); } if ($uri === 'files') { Files::zipAll(); } elseif ($uri === '') { Backup::dump(); } else { throw new APIException('Unknown path: ' . $uri, 404); } return null; } protected function sql(string $format) { $this->requireMethod('POST'); $body = $this->params['sql'] ?? self::getRequestInput(); $format = $format ?: ($this->params['format'] ?? 'json'); if (!in_array($format, self::EXPORT_FORMATS, true)) { throw new APIException('Invalid format. Supported formats: ' . implode(', ', self::EXPORT_FORMATS)); } if ($body === '') { throw new APIException('Missing SQL statement', 400); } try { $s = Search::fromSQL($body); $result = $s->iterateResults(); $header = $s->getHeader(); if ($format !== 'json') { $s->export($format); return null; } elseif (!$this->is_http_client) { return $this->export(['count' => $s->countResults(), 'results' => $result]); } else { // Stream results to client, in case request is slow header("Content-Type: application/json; charset=utf-8", true); printf("{\n \"count\": %d,\n \"results\":\n [\n", $s->countResults()); foreach ($result as $i => $row) { |
︙ | ︙ | |||
190 191 192 193 194 195 196 | } } catch (DB_Exception $e) { throw new APIException('Error in SQL statement: ' . $e->getMessage(), 400); } } | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | } } catch (DB_Exception $e) { throw new APIException('Error in SQL statement: ' . $e->getMessage(), 400); } } protected function services(string $uri): ?array { $fn = strtok($uri, '/'); $fn2 = strtok('/'); strtok(''); // CSV import |
︙ | ︙ | |||
689 690 691 692 693 694 695 | try { if (!filesize($path)) { throw new APIException('Invalid upload', 400); } $csv = new CSV_Custom; | | | | | | 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | try { if (!filesize($path)) { throw new APIException('Invalid upload', 400); } $csv = new CSV_Custom; $csv->setColumns(Subscriptions::listImportColumns()); $csv->setMandatoryColumns(Subscriptions::listMandatoryImportColumns()); $csv->loadFile($path); $csv->setTranslationTableAuto(); if (!$csv->loaded() || !$csv->ready()) { throw new APIException('Missing columns or error during columns matching of import table: ' . json_encode(Subscriptions::listMandatoryImportColumns()), 400); } Subscriptions::import($csv); return null; } finally { Utils::safe_unlink($path); } } else { |
︙ | ︙ | |||
780 781 782 783 784 785 786 787 788 789 790 | $this->access = $access; } public function route() { $uri = $this->path; $fn = strtok($uri, '/'); $uri = strtok(''); switch ($fn) { | > > > > > < < | 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | $this->access = $access; } public function route() { $uri = $this->path; if (substr($uri, 0, 3) === 'sql') { return $this->sql(trim(substr($uri, 3), '.')); } $fn = strtok($uri, '/'); $uri = strtok(''); switch ($fn) { case 'download': return $this->download($uri); case 'web': return $this->web($uri); case 'user': return $this->user($uri); case 'errors': |
︙ | ︙ | |||
846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 | try { $return = $api->route(); } catch (UserException|ValidationException $e) { throw new APIException($e->getMessage(), 400, $e); } if (null !== $return) { header("Content-Type: application/json; charset=utf-8", true); echo json_encode($return, JSON_PRETTY_PRINT); } } catch (APIException $e) { http_response_code($e->getCode()); header("Content-Type: application/json; charset=utf-8", true); echo json_encode(['error' => $e->getMessage()]); } } } | > > | 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 | try { $return = $api->route(); } catch (UserException|ValidationException $e) { throw new APIException($e->getMessage(), 400, $e); } $return = $api->export($return); if (null !== $return) { header("Content-Type: application/json; charset=utf-8", true); echo json_encode($return, JSON_PRETTY_PRINT); } } catch (APIException $e) { http_response_code($e->getCode()); header("Content-Type: application/json; charset=utf-8", true); echo json_encode(['error' => $e->getMessage()]); } } } |
Added src/include/lib/Paheko/API/Accounting.php version [b46bb90cf1].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | <?php namespace Paheko\API; use Paheko\Accounting\Accounts; use Paheko\Accounting\Charts; use Paheko\Accounting\Export; use Paheko\Accounting\Reports; use Paheko\Accounting\Transactions; use Paheko\Accounting\Years; use Paheko\Entities\Accounting\Transaction; use Paheko\APIException; use Paheko\Utils; trait Accounting { protected function accounting(string $uri): ?array { $fn = strtok($uri, '/'); $p1 = strtok('/'); $p2 = strtok(''); if ($fn == 'transaction') { if (!$p1) { $this->requireMethod('POST'); $this->requireAccess(Session::ACCESS_WRITE); $transaction = new Transaction; $transaction->importFromAPI($this->params); $transaction->save(); if (!empty($this->params['linked_users'])) { $transaction->updateLinkedUsers((array)$this->params['linked_users']); } if (!empty($this->params['linked_transactions'])) { $transaction->updateLinkedTransactions((array)$this->params['linked_transactions']); } if (!empty($this->params['linked_subscriptions'])) { $transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']); } if ($this->hasParam('move_attachments_from') && $this->isPathAllowed($this->params['move_attachments_from'])) { $file = Files::get($this->params['move_attachments_from']); if ($file && $file->isDir()) { $file->rename($transaction->getAttachementsDirectory()); } } return $transaction->asJournalArray(); } // Return or edit linked users elseif ($p1 && ctype_digit($p1) && $p2 == 'users') { $transaction = Transactions::get((int)$p1); if (!$transaction) { throw new APIException(sprintf('Transaction #%d not found', $p1), 404); } if ($this->method === 'POST') { $this->requireAccess(Session::ACCESS_WRITE); $transaction->updateLinkedUsers((array)($_POST['users'] ?? null)); return self::SUCCESS; } elseif ($this->method === 'DELETE') { $this->requireAccess(Session::ACCESS_WRITE); $transaction->updateLinkedUsers([]); return self::SUCCESS; } elseif ($this->method === 'GET') { return $transaction->listLinkedUsers(); } else { throw new APIException('Wrong request method', 405); } } // Return or edit linked subscriptions elseif ($p1 && ctype_digit($p1) && $p2 == 'subscriptions') { $transaction = Transactions::get((int)$p1); if (!$transaction) { throw new APIException(sprintf('Transaction #%d not found', $p1), 404); } if ($this->method === 'POST') { $this->requireAccess(Session::ACCESS_WRITE); $transaction->updateSubscriptionLinks((array)($_POST['subscriptions'] ?? null)); return self::SUCCESS; } elseif ($this->method === 'DELETE') { $this->requireAccess(Session::ACCESS_WRITE); $transaction->deleteAllSubscriptionLinks([]); return self::SUCCESS; } elseif ($this->method === 'GET') { return $transaction->listLinkedSubscriptions(); } else { throw new APIException('Wrong request method', 405); } } elseif ($p1 && ctype_digit($p1) && !$p2) { $transaction = Transactions::get((int)$p1); if (!$transaction) { throw new APIException(sprintf('Transaction #%d not found', $p1), 404); } if ($this->method === 'GET') { return $transaction->asJournalArray(); } elseif ($this->method === 'POST') { $this->requireAccess(Session::ACCESS_WRITE); $transaction->importFromAPI($this->params); $transaction->save(); if (!empty($this->params['linked_users'])) { $transaction->updateLinkedUsers((array)$this->params['linked_users']); } if (!empty($this->params['linked_transactions'])) { $transaction->updateLinkedTransactions((array)$this->params['linked_transactions']); } if (!empty($this->params['linked_subscriptions'])) { $transaction->updateSubscriptionLinks((array)$this->params['linked_subscriptions']); } return $transaction->asJournalArray(); } else { throw new APIException('Wrong request method', 400); } } else { throw new APIException('Unknown transactions route', 404); } } elseif ($fn == 'charts') { $this->requireMethod('GET'); if ($p1 && ctype_digit($p1) && $p2 === 'accounts') { $a = new Accounts((int)$p1); return array_map(fn($c) => $c->asArray(), $a->listAll()); } elseif (!$p1 && !$p2) { return array_map(fn($c) => $c->asArray(), Charts::list()); } else { throw new APIException('Unknown charts action', 404); } } elseif ($fn == 'years') { $this->requireMethod('GET'); if (!$p1 && !$p2) { return Years::list(); } $id_year = null; if ($p1 === 'current') { $id_year = Years::getCurrentOpenYearId(); } elseif ($p1 && ctype_digit($p1)) { $id_year = (int)$p1; } if (!$id_year) { throw new APIException('Missing year in request, or no open years exist', 400); } $year = Years::get($id_year); if (!$year) { throw new APIException('Invalid year.', 400, $e); } if ($p2 === 'journal') { try { return Reports::getJournal(['year' => $id_year]); } catch (\LogicException $e) { throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e); } } elseif (0 === strpos($p2, 'journal/')) { $account = substr($p2, strlen('journal/')); $a = $year->chart()->accounts(); if (substr($account, 0, 1) === '=') { $account = $a->get(intval(substr($account, 1))); } else { $account = $a->getWithCode($account); } if (!$account) { throw new APIException('Unknown account id or code.', 400, $e); } $list = $account->listJournal($year->id, false); $list->setTitle(sprintf('Journal - %s - %s', $account->code, $account->label)); $list->loadFromQueryString(); $list->setPageSize(null); $list->orderBy('date', false); return $list->iterate(); } elseif (0 === strpos($p2, 'export/')) { strtok($p2, '/'); $type = strtok('.'); $format = strtok('') ?: 'json'; try { Export::export($year, $format, $type); } catch (\InvalidArgumentException $e) { throw new APIException($e->getMessage(), 400, $e); } return null; } else { throw new APIException('Unknown years action', 404); } } else { throw new APIException('Unknown accounting action', 404); } } } |
Added src/include/lib/Paheko/API/User.php version [0179eda829].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | <?php namespace Paheko\API; use Paheko\Users\Categories; use Paheko\Users\DynamicFields; use Paheko\Users\Users; use Paheko\APIException; trait User { protected function user(string $uri): ?array { $fn = strtok($uri, '/'); $fn2 = strtok('/'); strtok(''); if ($fn === 'categories') { return Categories::listWithStats(); } elseif ($fn === 'category') { $id = (int) strtok($fn2, '.'); $format = strtok(''); try { Users::exportCategory($format ?: 'json', $id); } catch (\InvalidArgumentException $e) { throw new APIException($e->getMessage(), 400, $e); } return null; } elseif ($fn === 'new') { $this->requireAccess(Session::ACCESS_WRITE); $user = Users::create(); $user->importForm($this->params); $user->setNumberIfEmpty(); if (empty($this->params['force_duplicate']) && $user->checkDuplicate()) { throw new APIException('This user seems to be a duplicate of an existing one', 409); } if (!empty($this->params['id_category']) && !$user->setCategorySafeNoConfig($this->params['id_category'])) { throw new APIException('You are not allowed to create a user in this category', 403); } if (isset($this->params['password'])) { $user->importSecurityForm(false, ['password' => $this->params['password'], 'password_confirmed' => $this->params['password']]); } $user->save(); return $user->exportAPI(); } elseif (ctype_digit($fn)) { $user = Users::get((int)$fn); if (!$user) { throw new APIException('The requested user ID does not exist', 404); } if ($this->method === 'POST') { $this->requireAccess(Session::ACCESS_WRITE); try { $user->validateCanChange(); } catch (UserException $e) { throw new APIException($e->getMessage(), 403, $e); } $user->importForm($this->params); $user->save(); } elseif ($this->method === 'DELETE') { $this->requireAccess(Session::ACCESS_ADMIN); try { $user->validateCanChange(); } catch (UserException $e) { throw new APIException($e->getMessage(), 403, $e); } $user->delete(); return self::SUCCESS; } return $user->exportAPI(); } elseif ($fn === 'import') { $fp = null; if ($this->method === 'PUT') { $params = $this->params; } elseif ($this->method === 'POST') { $params = $_POST; } else { throw new APIException('Wrong request method', 400); } $mode = $params['mode'] ?? 'auto'; if (!in_array($mode, ['auto', 'create', 'update'])) { throw new APIException('Unknown mode. Only "auto", "create" and "update" are accepted.', 400); } $this->requireAccess(Session::ACCESS_ADMIN); $path = tempnam(CACHE_ROOT, 'tmp-import-api'); if ($this->method === 'POST') { if (empty($_FILES['file']['tmp_name']) || !empty($_FILES['file']['error'])) { throw new APIException('Empty file or no file was sent.', 400); } $path = $_FILES['file']['tmp_name'] ?? null; } else { $fp = fopen($path, 'wb'); stream_copy_to_stream($this->file_pointer, $fp); fclose($fp); $this->closeFilePointer(); } try { if (!filesize($path)) { throw new APIException('Empty CSV file', 400); } $csv = new CSV_Custom; $df = DynamicFields::getInstance(); $csv->setColumns($df->listImportAssocNames()); $required_fields = $df->listImportRequiredAssocNames($mode === 'update' ? true : false); $csv->setMandatoryColumns(array_keys($required_fields)); $csv->loadFile($path); $csv->skip((int)($params['skip_lines'] ?? 1)); if (!empty($params['column']) && is_array($params['column'])) { $csv->setIndexedTable($params['column']); } else { $csv->setTranslationTableAuto(); } if (!$csv->loaded() || !$csv->ready()) { throw new APIException('Missing columns or error during columns matching of import table', 400); } if ($fn2 === 'preview') { $report = Users::importReport($csv, $mode); $report['unchanged'] = array_map( fn($user) => ['id' => $user->id(), 'name' => $user->name()], $report['unchanged'] ); $report['created'] = array_map( fn($user) => $user->asDetailsArray(), $report['created'] ); $report['modified'] = array_map( function ($user) { $out = ['id' => $user->id(), 'name' => $user->name(), 'changed' => []]; foreach ($user->getModifiedProperties() as $key => $value) { $out['changed'][$key] = ['old' => $value, 'new' => $user->$key]; } return $out; }, $report['modified'] ); return $report; } else { Users::import($csv, $mode); return null; } } finally { Utils::safe_unlink($path); } } else { throw new APIException('Unknown user action', 404); } } } |
Added src/include/lib/Paheko/API/Web.php version [622538d5ce].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | <?php namespace Paheko\API; use Paheko\Web\Web as PWeb; use Paheko\APIException; trait Web { protected function web(string $uri): ?array { if ($this->method != 'GET') { throw new APIException('Wrong request method', 400); } $fn = strtok($uri, '/'); $param = strtok(''); if (!$fn) { $this->requireMethod('GET'); $list = PWeb::getAllList(); $list->setPageSize(null); return $this->export($list->iterate()); } if (substr($fn, 0, -5) === '.html') { $fn = substr($fn, 0, -5); $param = 'html'; } $page = PWeb::getByURI($fn); if (!$page) { throw new APIException('Page not found', 404); } if (!$param) { if ($this->method === 'GET') { $out = $page->asArray(true); if ($this->hasParamTrue('html')) { $out['html'] = $page->render(); } return $out; } elseif ($this->method === 'DELETE') { $this->requireAccess(Session::ACCESS_ADMIN); $page->delete(); return self::SUCCESS; } elseif ($this->method === 'PUT') { $this->requireAccess(Session::ACCESS_WRITE); $page->set('content', self::getRequestInput()); $page->saveNewVersion(); return self::SUCCESS; } elseif ($this->method === 'POST') { $this->requireAccess(Session::ACCESS_WRITE); $page->importForm($this->params); $page->saveNewVersion(); return self::SUCCESS; } else { throw new APIException('Invalid request method', 405); } } elseif ($param === 'html') { $this->requireMethod('GET'); http_response_code(200); header('Content-Type: text/html; charset=utf-8', true); echo $page->html(); return null; } elseif ($param === 'children') { $this->requireMethod('GET'); return [ 'categories' => array_map(fn($p) => $p->asArray(true), PWeb::listCategories($page->id())), 'pages' => array_map(fn($p) => $p->asArray(true), PWeb::listPages($page->id())), ]; } elseif ($param === 'attachments') { $this->requireMethod('GET'); return $page->listAttachments(); } else { $this->requireMethod('GET'); $attachment = Files::get(File::CONTEXT_WEB . '/' . $page->uri . '/' . $param); if (!$attachment) { throw new APIException('Attachment not found', 404); } if ($this->method === 'GET') { $attachment->serve(); return null; } elseif ($this->method === 'DELETE') { $this->requireAccess(Session::ACCESS_WRITE); $attachment->delete(); return self::SUCCESS; } else { throw new APIException('Invalid method', 405); } } } } |
Modified src/include/lib/Paheko/Accounting/Reports.php from [e7c25e62cb] to [ef64e160e0].
︙ | ︙ | |||
55 56 57 58 59 60 61 | } if (!empty($criterias['creator'])) { $where[] = sprintf($transactions_alias . 'id_creator = %d', $criterias['creator']); } if (!empty($criterias['subscription'])) { | | | 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | } if (!empty($criterias['creator'])) { $where[] = sprintf($transactions_alias . 'id_creator = %d', $criterias['creator']); } if (!empty($criterias['subscription'])) { $where[] = sprintf($transactions_alias . 'id IN (SELECT tu.id_transaction FROM acc_transactions_users tu WHERE tu.id_subscription = %d)', $criterias['subscription']); } if (!empty($criterias['project'])) { $where[] = sprintf($lines_alias . 'id_project = %d', $criterias['project']); } if (!empty($criterias['account'])) { |
︙ | ︙ |
Modified src/include/lib/Paheko/DB.php from [959b0d731a] to [921ecf06d2].
1 2 3 4 5 6 7 8 9 | <?php namespace Paheko; use KD2\DB\SQLite3; use KD2\DB\DB_Exception; use KD2\ErrorManager; use Paheko\Users\DynamicFields; | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php namespace Paheko; use KD2\DB\SQLite3; use KD2\DB\DB_Exception; use KD2\ErrorManager; use Paheko\Users\DynamicFields; use Paheko\Email\Addresses; class DB extends SQLite3 { /** * Application ID pour SQLite * @link https://www.sqlite.org/pragma.html#pragma_application_id */ |
︙ | ︙ | |||
239 240 241 242 243 244 245 | static public function registerCustomFunctions($db) { $db->createFunction('dirname', [Utils::class, 'dirname']); $db->createFunction('basename', [Utils::class, 'basename']); $db->createFunction('unicode_like', [self::class, 'unicodeLike']); $db->createFunction('transliterate_to_ascii', [Utils::class, 'unicodeTransliterate']); | | | 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | static public function registerCustomFunctions($db) { $db->createFunction('dirname', [Utils::class, 'dirname']); $db->createFunction('basename', [Utils::class, 'basename']); $db->createFunction('unicode_like', [self::class, 'unicodeLike']); $db->createFunction('transliterate_to_ascii', [Utils::class, 'unicodeTransliterate']); $db->createFunction('email_hash', [Addresses::class, 'hash']); $db->createFunction('md5', 'md5'); $db->createFunction('uuid', [Utils::class, 'uuid']); $db->createFunction('print_binary', fn($value) => sprintf('%032d', decbin($value))); $db->createFunction('print_dynamic_field', function($name, $value) { $field = DynamicFields::get($name); |
︙ | ︙ |
Modified src/include/lib/Paheko/DynamicList.php from [16f7c52ac0] to [453807817e].
︙ | ︙ | |||
17 18 19 20 21 22 23 24 25 26 27 28 29 30 | * but it will not be included in HTML table * - If the key 'select' exists, then it will be used as the SELECT clause * - If the key 'label' exists, it will be used in the HTML table as its header * (if not, the result will still be available in the loop, just it will not generate a column in the HTML table) * - If the key 'export' is TRUE, then the column will ONLY be included in CSV/ODS/XLSX exports * - If the key 'export' is FALSE, then the column will NOT be included in exports * (if the key `export` is NULL, or not set, then the column will be included both in HTML and in exports) */ protected array $columns; /** * List of tables (including joins) */ protected string $tables; | > > > | 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | * but it will not be included in HTML table * - If the key 'select' exists, then it will be used as the SELECT clause * - If the key 'label' exists, it will be used in the HTML table as its header * (if not, the result will still be available in the loop, just it will not generate a column in the HTML table) * - If the key 'export' is TRUE, then the column will ONLY be included in CSV/ODS/XLSX exports * - If the key 'export' is FALSE, then the column will NOT be included in exports * (if the key `export` is NULL, or not set, then the column will be included both in HTML and in exports) * - If the key 'only_with_order' exists and is a column alias (key), this column will only appear * if the order is using the designated column for ORDER BY clause. * - If the key 'order' exists, it will be used for ordering this column. %s will be replaced with DESC or ASC. */ protected array $columns; /** * List of tables (including joins) */ protected string $tables; |
︙ | ︙ |
Added src/include/lib/Paheko/Email/Addresses.php version [5f2851677a].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | <?php namespace Paheko\Email; use Paheko\Config; use Paheko\DB; use Paheko\DynamicList; use Paheko\Plugins; use Paheko\UserException; use Paheko\Utils; use Paheko\Entities\Email\Address; use Paheko\Entities\Files\File; use Paheko\Entities\Users\User; use Paheko\Users\DynamicFields; use Paheko\UserTemplate\UserTemplate; use Paheko\Web\Render\Render; use Paheko\Files\Files; use KD2\Mail_Message; use KD2\SMTP; use KD2\DB\EntityManager as EM; class Addresses { /** * Antispam services that require to do a manual action to accept emails */ const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/'; const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com', 'proton.me']; /** * Return NULL if address is valid, or a string for an error message if invalid */ static public function checkForErrors(string $email, bool $mx_check = true): ?string { if (trim($email) === '') { return 'Adresse e-mail vide'; } $local_part = null; $host = null; $email = self::normalize($email, $local_part, $host); if (!$email) { return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'; } // Ce domaine n'existe pas (MX inexistant), erreur de saisie courante if ($host == 'gmail.fr') { return 'Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"'; } if (preg_match('![/@]!', $local_part)) { return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'; } if (!SMTP::checkEmailIsValid($email, false)) { if (!trim($host)) { return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'; } foreach (self::COMMON_DOMAINS as $common_domain) { similar_text($common_domain, $host, $percent); if ($percent > 90) { return sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain); } } return 'Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'; } // Windows does not support MX lookups if (PHP_OS_FAMILY == 'Windows' || !$mx_check) { return null; } getmxrr($host, $mx_list); if (empty($mx_list)) { return 'Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.'; } foreach ($mx_list as $mx) { if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) { return 'Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.'; } } return null; } static public function isValid(string $address, bool $check_mx = true): bool { return self::checkForErrors($address, $check_mx) === null; } static public function validate(string $address, bool $check_mx = true): void { $error = self::checkForErrors($address); if (null !== $error) { throw new UserException($error); } } static public function normalize(string $address, ?string &$local_part = null, ?string &$host = null): ?string { $address = strtolower(trim($address)); $pos = strrpos($address, '@'); if (!$pos) { return null; } $local_part = substr($address, 0, $pos); $host = substr($address, $pos + 1); $host = idn_to_ascii($host); $address = $local_part . '@' . $host; return $address; } /** * Normalize email address and create a hash from this */ static public function hash(string $address): string { $address = self::normalize($address); return sha1($address); } /** * Return an Email entity from the optout code */ static public function getFromOptout(string $code): ?Address { $hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT)); if (!$hash) { return null; } $hash = bin2hex($hash); return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash); } /** * Sets the address as invalid (no email can be sent to this address ever) */ static public function markAddressAsInvalid(string $address): void { $e = self::get($address); if (!$e) { return; } $e->set('invalid', true); $e->set('optout', false); $e->set('verified', false); $e->save(); } /** * Return an Email entity from an email address */ static public function get(string $address): ?Address { return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE hash = ?;', self::hash($address)); } /** * Return an Email entity from an ID */ static public function getByID(int $id): ?Address { return EM::findOne(Address::class, 'SELECT * FROM @TABLE WHERE id = ?;', $id); } /** * Return or create a new email entity */ static public function getOrCreate(string $address): Address { $e = self::get($address); $e ??= self::create($address); return $e; } static public function create(string $address): Address { $e = new Address; $e->setAddress($address); $e->save(); return $e; } static public function listRejectedUsers(): DynamicList { $db = DB::getInstance(); $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField()); $columns = [ 'id' => [ 'select' => 'a.id', ], 'identity' => [ 'label' => 'Membre', 'select' => DynamicFields::getNameFieldsSQL('u'), ], 'email' => [ 'label' => 'Adresse', 'select' => $email_field, ], 'user_id' => [ 'select' => 'u.id', ], 'hash' => [ ], 'status' => [ 'label' => 'Statut', ], 'sent_count' => [ 'label' => 'Messages envoyés', ], 'last_sent' => [ 'label' => 'Dernière tentative d\'envoi', ], 'optout' => [], 'fail_count' => [], ]; $tables = sprintf('emails_addresses a INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND a.hash = email_hash(%1$s)', $email_field); $conditions = 'a.status < 0'; $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('last_sent', true); $list->setModifier(function (&$row) { $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null; }); return $list; } /** * Handle a bounce message * @param string $raw_message Raw MIME message from SMTP */ static public function handleBounce(string $raw_message): ?array { $message = new Mail_Message; $message->parse($raw_message); $return = $message->identifyBounce(); $address = $return['recipient'] ?? null; $signal = Plugins::fire('email.bounce', false, compact('address', 'message', 'return', 'raw_message')); if ($signal && $signal->isStopped()) { return null; } if (!$return) { return null; } if ($return['type'] == 'autoreply') { // Ignore auto-responders return $return; } elseif ($return['type'] == 'genuine') { // Forward emails that are not automatic to the organization email $config = Config::getInstance(); $new = new Mail_Message; $new->setHeaders([ 'To' => $config->org_email, 'Subject' => 'Réponse à un message que vous avez envoyé', ]); $new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.'); $new->attachMessage($message->output()); self::sendMessage(self::CONTEXT_SYSTEM, $new); return $return; } return self::handleManualBounce($return['recipient'], $return['type'], $return['message']); } static public function handleManualBounce(string $address, string $type, ?string $message): ?array { $return = compact('address', 'type', 'message'); $email = self::getOrCreate($address); if (!$email) { return null; } $email->hasFailed($return); Plugins::fire('email.bounce.save.before', false, compact('email', 'address', 'return', 'type', 'message')); $email->save(); return $return; } static public function getFromHeader(string $name = null, string $email = null): string { $config = Config::getInstance(); if (null === $name) { $name = $config->org_name; } if (null === $email) { $email = $config->org_email; } $name = str_replace('"', '\\"', $name); $name = str_replace(',', '', $name); // Remove commas return sprintf('"%s" <%s>', $name, $email); } static public function getVerificationLimitDate(): \DateTime { $delay = Address::RESEND_VERIFICATION_DELAY . ' hours ago'; return new \DateTime($delay); } } |
Deleted src/include/lib/Paheko/Email/Emails.php version [70fa0ab420].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Modified src/include/lib/Paheko/Email/Mailings.php from [d36b26c7af] to [31ae3ddd32].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?php namespace Paheko\Email; use Paheko\Entities\Email\Mailing; use Paheko\DB; use Paheko\DynamicList; use KD2\DB\EntityManager; class Mailings { static public function getList(): DynamicList { | > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <?php namespace Paheko\Email; use Paheko\Entities\Email\Mailing; use Paheko\DB; use Paheko\DynamicList; use Paheko\Users\DynamicFields; use Paheko\Search; use Paheko\Entities\Search as SearchEntity; use Paheko\Users\Categories; use Paheko\UserException; use Paheko\Services\Services; use KD2\DB\EntityManager; class Mailings { static public function getList(): DynamicList { |
︙ | ︙ | |||
36 37 38 39 40 41 42 | } static public function get(int $id): ?Mailing { return EntityManager::findOneById(Mailing::class, $id); } | | > | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | } static public function get(int $id): ?Mailing { return EntityManager::findOneById(Mailing::class, $id); } static public function create(string $subject, string $target_type, ?string $target_value, ?string $target_label): Mailing { $db = DB::getInstance(); $db->begin(); $m = new Mailing; $m->set('subject', $subject); $m->importForm(compact('subject', 'target_type', 'target_value', 'target_label')); $m->save(); $m->populate(); $db->commit(); return $m; } static public function anonymize(): void { $em = EntityManager::getInstance(Mailing::class); $db = DB::getInstance(); $db->begin(); foreach ($em->iterate('SELECT * FROM @TABLE WHERE sent < datetime(\'now\', \'-6 month\') AND anonymous = 0;') as $m) { $m->anonymize(); $m->set('anonymous', true); $m->save(); } $db->commit(); } static public function listTargets(string $type): array { if ($type === 'field') { $list = self::listCheckboxFieldsTargets(); } elseif ($type === 'category') { $list = Categories::listWithStats(Categories::WITHOUT_HIDDEN); } elseif ($type === 'service') { $list = iterator_to_array(Services::listWithStats(true)->iterate()); } elseif ($type === 'search') { $list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId()); $list = array_filter($list, fn($s) => $s->hasUserId()); array_walk($search_list, function (&$s) { $s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()]; }); } else { throw new \InvalidArgumentException('Unknown target type: ' . $type); } if (!count($list)) { throw new UserException('Il n\'y aucun résultat correspondant à cette cible d\'envoi.'); } return $list; } static public function listCheckboxFieldsTargets(): array { $fields = DynamicFields::getInstance()->fieldsByType('checkbox'); if (!count($fields)) { return []; } $db = DB::getInstance(); $sql = []; foreach ($fields as $field) { $sql[] = sprintf('SELECT %s AS name, %s AS label, COUNT(*) AS count FROM users WHERE %s = 1 AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)', $db->quote($field->name), $db->quote($field->label), $db->quoteIdentifier($field->name) ); } $sql = implode(' UNION ALL ', $sql); return $db->get($sql); } static public function getOptoutUsersList(): DynamicList { $db = DB::getInstance(); $email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField()); $columns = [ 'id' => [ 'select' => 'a.id', ], 'identity' => [ 'label' => 'Membre', 'select' => DynamicFields::getNameFieldsSQL('u'), ], 'email' => [ 'label' => 'Adresse', 'select' => $email_field, ], 'user_id' => [ 'select' => 'u.id', ], 'hash' => [ ], 'status' => [ 'label' => 'Désinscription', 'select' => 'CASE WHEN a.optout = 1 THEN \'Désinscription globale\' ELSE o.target_label END', ], 'sent_count' => [ 'label' => 'Messages envoyés', ], 'last_sent' => [ 'label' => 'Dernière tentative d\'envoi', ], 'optout' => [], 'target_type' => [], 'target_label' => [], ]; $tables = sprintf('users u INNER JOIN emails_addresses a ON a.hash = email_hash(%1$s) LEFT JOIN mailings_optouts o ON o.email_hash = a.hash', $email_field); $conditions = sprintf('%s IS NOT NULL AND %1$s != \'\' AND (a.optout = 1 OR o.email_hash IS NOT NULL)', $email_field); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('last_sent', true); $list->setModifier(function (&$row) { $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null; }); return $list; } } |
Added src/include/lib/Paheko/Email/Queue.php version [fcda273eb9].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 | <?php namespace Paheko\Email; use Paheko\Entities\Email\Message; use Paheko\DB; use Paheko\DynamicList; class Queue { static public function append(int $context, string $email, array $data = []) { } /** * Add a message to the sending queue using templates * @param int $context * @param iterable $recipients List of recipients, this accepts a wide range of types: * - a single e-mail address * - array of e-mail addresses as values ['a@b.c', 'd@e.f'] * - array of user entities * - array where each key is the email address, and the value is an array or a \stdClass containing * pgp_key, data and user items * @param string $sender * @param string $subject * @param UserTemplate|string $content * @return void */ static public function queue(int $context, iterable $recipients, ?string $sender, string $subject, $content, array $attachments = []): void { if (DISABLE_EMAIL) { return; } foreach ($attachments as $i => $file) { if (!is_object($file) || !($file instanceof File) || $file->context() != $file::CONTEXT_ATTACHMENTS) { throw new \InvalidArgumentException(sprintf('Attachment #%d is not a valid file', $i)); } } $list = []; // Build email list foreach ($recipients as $key => $r) { $data = []; $emails = []; $user = null; $pgp_key = null; if (is_array($r)) { $user = $r['user'] ?? null; $data = $r['data'] ?? null; $pgp_key = $r['pgp_key'] ?? null; } elseif (is_object($r) && $r instanceof User) { $user = $r; $data = $r->asArray(); $pgp_key = $user->pgp_key ?? null; } elseif (is_object($r)) { $user = $r->user ?? null; $data = $r->data ?? null; $pgp_key = $user->pgp_key ?? ($r->pgp_key ?? null); } // Get e-mail address from key if (is_string($key) && false !== strpos($key, '@')) { $emails[] = $key; } // Get e-mail address from value elseif (is_string($r) && false !== strpos($r, '@')) { $emails[] = $r; } // Get email list from user object elseif ($user) { $emails = $user->getEmails(); } else { // E-mail not found continue; } // Filter out invalid addresses foreach ($emails as $key => $value) { if (!preg_match('/.+@.+\..+$/', $value)) { unset($emails[$key]); } } if (!count($emails)) { continue; } $data = compact('user', 'data', 'pgp_key'); foreach ($emails as $value) { $list[$value] = $data; } } if (!count($list)) { return; } $recipients = $list; unset($list); $is_system = $context === Message::CONTEXT_SYSTEM; $template = (!$is_system && $content instanceof UserTemplate) ? $content : null; if ($template) { $template->toggleSafeMode(true); } $signal = Plugins::fire('email.queue.before', true, compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments')); // queue handling was done by a plugin, stop here if ($signal && $signal->isStopped()) { return; } $db = DB::getInstance(); $db->begin(); $html = null; $main_tpl = null; // Apart from SYSTEM emails, all others should be wrapped in the email.html template if (!$is_system) { $main_tpl = new UserTemplate('web/email.html'); } if (!$is_system && !$template) { // If E-Mail does not have placeholders, we can render the MarkDown just once for HTML $html = Render::render(Render::FORMAT_MARKDOWN, null, $content); } foreach ($recipients as $recipient => $r) { $data = $r['data']; $recipient_pgp_key = $r['pgp_key']; // We won't try to reject invalid/optout recipients here, // it's done in the queue clearing (more efficient) $recipient_hash = Addresses::hash($recipient); // Replace placeholders: {{$name}}, etc. if ($template) { $template->assignArray((array) $data, null, false); // Disable HTML escaping for plaintext emails $template->setEscapeDefault(null); $content = $template->fetch(); // Add Markdown rendering $content_html = Render::render(Render::FORMAT_MARKDOWN, null, $content); } else { $content_html = $html; } if (!$is_system) { // Wrap HTML content in the email skeleton $main_tpl->assignArray([ 'html' => $content_html, 'address' => $recipient, 'data' => $data, 'context' => $context, 'from' => $sender, ]); $content_html = $main_tpl->fetch(); } $signal = Plugins::fire('email.queue.insert', true, compact('context', 'recipient', 'sender', 'subject', 'content', 'recipient_hash', 'recipient_pgp_key', 'content_html', 'attachments')); if ($signal && $signal->isStopped()) { // queue insert was done by a plugin, stop here continue; } unset($signal); $db->insert('emails_queue', compact('sender', 'subject', 'context', 'recipient', 'recipient_pgp_key', 'recipient_hash', 'content', 'content_html')); // Clean up memory unset($content_html); $id = $db->lastInsertId(); foreach ($attachments as $file) { $db->insert('emails_queue_attachments', ['id_queue' => $id, 'path' => $file->path]); } } $db->commit(); $signal = Plugins::fire('email.queue.after', true, compact('context', 'recipients', 'sender', 'subject', 'content', 'attachments')); if ($signal && $signal->isStopped()) { return; } // If no crontab is used, then the queue should be run now if (!USE_CRON) { self::run(); } // Always send system emails right away elseif ($is_system) { self::run(Message::CONTEXT_SYSTEM); } } /** * Run the queue of emails that are waiting to be sent */ static public function runQueue(?int $context = null): ?int { $db = DB::getInstance(); $queue = self::listQueueAndMarkAsSending($context); $ids = []; $save_sent = function () use (&$ids, $db) { if (!count($ids)) { return null; } $db->exec(sprintf('UPDATE emails_queue SET status = %d WHERE %s;', Message::STATUS_SENT, $db->where('id', $ids))); $ids = []; }; $limit_time = strtotime('1 month ago'); $count = 0; $all_attachments = []; // listQueue nettoie déjà la queue foreach ($queue as $row) { // We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time if ($row->optout || $row->invalid || $row->fail_count >= Message::FAIL_LIMIT) { if ($row->context != Message::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) { self::delete($row->id); continue; } } // Create email address in database if (!$row->email_hash) { $email = Addresses::getOrCreate($row->recipient); if (!$email->canSend()) { // Email address is invalid, skip self::delete($row->id); continue; } } $headers = [ 'From' => $row->sender, 'To' => $row->recipient, 'Subject' => $row->subject, ]; try { $attachments = $db->getAssoc('SELECT id, path FROM emails_queue_attachments WHERE id_queue = ?;', $row->id); $all_attachments = array_merge($all_attachments, $attachments); $sent = self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key, $attachments); // Keep waiting until email is sent if (!$sent) { continue; } } catch (\Exception $e) { // If sending fails, at least save what has been sent so far // so they won't get re-sent again $save_sent(); throw $e; } $ids[] = $row->id; $count++; // Mark messages as sent from time to time // to avoid starting from the beginning if the queue is killed // and also avoid passing too many IDs to SQLite at once if (count($ids) >= 50) { $save_sent(); } } // Update emails list and send count // then delete messages from queue $db->begin(); $db->exec(sprintf(' UPDATE emails_queue SET status = %d WHERE %s; INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE status = %1$d; UPDATE %3$s SET sent_count = sent_count + 1, last_sent = datetime() WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE status = %1$d); DELETE FROM emails_queue WHERE status = %1$d;', Message::STATUS_SENT, $db->where('id', $ids), Email::TABLE)); $db->commit(); $unused_attachments = array_diff($all_attachments, $db->getAssoc('SELECT id, path FROM emails_queue_attachments;')); foreach ($unused_attachments as $path) { $file = Files::get($path); if ($file) { $file->delete(); } } return $count; } /** * Lists the queue, marks listed elements as "sending" * @return array */ static protected function listQueueAndMarkAsSending(?int $context = null): array { $queue = self::list($context); if (!count($queue)) { return $queue; } $ids = []; foreach ($queue as $row) { $ids[] = $row->id; } $db = DB::getInstance(); $db->update('emails_queue', ['status' => Message::STATUS_SENDING, 'sending_started' => new \DateTime], $db->where('id', $ids)); return $queue; } /** * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much * * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending * the same email over and over. * * @param int|null $context Context to list, leave NULL to have all contexts * @return array */ static protected function list(?int $context = null): array { // Clean-up the queue from reject emails self::removeRejectedRecipients(); // Reset messages that failed during the queue run self::resetFailed(); $condition = null === $context ? '' : sprintf(' AND context = %d', $context); return DB::getInstance()->get(sprintf('SELECT q.*, a.optout, a.verified, a.hash AS email_hash, a.invalid, a.fail_count, strftime(\'%%s\', a.last_sent) AS last_sent FROM emails_queue q LEFT JOIN emails_addresses a ON a.hash = q.recipient_hash WHERE q.status = %d %s;', Message::STATUS_WAITING, $condition)); } /** * Supprime de la queue les messages liés à des adresses invalides * ou qui ne souhaitent plus recevoir de message * @return boolean */ static protected function removeRejectedRecipients(): void { DB::getInstance()->delete('emails_queue', 'recipient_hash IN (SELECT hash FROM emails_addresses WHERE (invalid = 1 OR fail_count >= ?) AND last_sent >= datetime(\'now\', \'-1 month\'));', self::FAIL_LIMIT); } /** * If emails have been marked as sending but sending failed, mark them for resend after a while */ static protected function resetFailed(): void { $sql = 'UPDATE emails_queue SET status = %d, sending_started = NULL WHERE status = %d AND sending_started < datetime(\'now\', \'-3 hours\');'; $sql = sprintf($sql, Message::STATUS_WAITING, Message::STATUS_SENDING); DB::getInstance()->exec($sql); } /** * Supprime un message de la queue d'envoi */ static protected function delete(int $id): bool { return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id); } static public function count(): int { return DB::getInstance()->count('emails_queue', 'status = ' . Message::STATUS_WAITING); } static public function getList(): DynamicList { $columns = [ 'id' => [], 'context' => [ 'label' => 'Contexte', ], 'status' => [ 'label' => 'Statut', 'order' => 'status %s, id %1$s', ], 'sender' => [ 'label' => 'Expéditeur', ], 'recipient' => [ 'label' => 'Destinataire', ], 'subject' => [ 'label' => 'Sujet', ], ]; $list = new DynamicList($columns, 'emails_queue'); $list->orderBy('status', true); return $list; } static public function createMessage(int $context, ?string $subject = null, ?string $body = null, ?string $html_body = null): Message { $msg = new Message; $msg->set('context', $context); if (null !== $subject) { $msg->set('subject', $subject); } if (null !== $body) { $msg->set('body', $body); } if (null !== $html_body) { $msg->set('html_body', $html_body); } return $msg; } } |
Modified src/include/lib/Paheko/Entities/Accounting/Account.php from [94745dbc9e] to [51d2aace20].
︙ | ︙ | |||
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | const TYPE_NEGATIVE_RESULT = 12; const TYPE_APPROPRIATION_RESULT = 13; const TYPE_CREDIT_REPORT = 14; const TYPE_DEBIT_REPORT = 15; const TYPES_NAMES = [ '', 'Banque', 'Caisse', 'Attente d\'encaissement', 'Tiers', 'Dépenses', 'Recettes', 'Bénévolat — Emploi', // Used to be Analytique 'Bénévolat — Contribution', 'Ouverture', 'Clôture', 'Résultat excédentaire', 'Résultat déficitaire', 'Affectation du résultat', 'Report à nouveau créditeur', 'Report à nouveau débiteur', ]; /** * Show only these types of accounts in the quick account view */ const COMMON_TYPES = [ self::TYPE_BANK, | > > > | 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | const TYPE_NEGATIVE_RESULT = 12; const TYPE_APPROPRIATION_RESULT = 13; const TYPE_CREDIT_REPORT = 14; const TYPE_DEBIT_REPORT = 15; const TYPE_TEMPORARY_TRANSFER = 16; const TYPES_NAMES = [ '', 'Banque', 'Caisse', 'Attente d\'encaissement', 'Tiers', 'Dépenses', 'Recettes', 'Bénévolat — Emploi', // Used to be Analytique 'Bénévolat — Contribution', 'Ouverture', 'Clôture', 'Résultat excédentaire', 'Résultat déficitaire', 'Affectation du résultat', 'Report à nouveau créditeur', 'Report à nouveau débiteur', 'Virements internes', ]; /** * Show only these types of accounts in the quick account view */ const COMMON_TYPES = [ self::TYPE_BANK, |
︙ | ︙ | |||
147 148 149 150 151 152 153 154 155 156 157 158 159 160 | /** * Codes that should be enforced according to type (and vice-versa) */ const LOCAL_TYPES = [ 'FR' => [ self::TYPE_BANK => '512', self::TYPE_CASH => '53', self::TYPE_OUTSTANDING => '511', self::TYPE_THIRD_PARTY => '4', self::TYPE_EXPENSE => '6', self::TYPE_REVENUE => '7', self::TYPE_VOLUNTEERING_EXPENSE => '86', self::TYPE_VOLUNTEERING_REVENUE => '87', | > | 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | /** * Codes that should be enforced according to type (and vice-versa) */ const LOCAL_TYPES = [ 'FR' => [ self::TYPE_BANK => '512', self::TYPE_TEMPORARY_TRANSFER => '580', self::TYPE_CASH => '53', self::TYPE_OUTSTANDING => '511', self::TYPE_THIRD_PARTY => '4', self::TYPE_EXPENSE => '6', self::TYPE_REVENUE => '7', self::TYPE_VOLUNTEERING_EXPENSE => '86', self::TYPE_VOLUNTEERING_REVENUE => '87', |
︙ | ︙ |
Modified src/include/lib/Paheko/Entities/Accounting/Transaction.php from [090c1a364f] to [877151f889].
︙ | ︙ | |||
1134 1135 1136 1137 1138 1139 1140 | 'label' => self::TYPES_NAMES[self::TYPE_EXPENSE], 'help' => null, ], self::TYPE_TRANSFER => [ 'accounts' => [ [ 'label' => 'De', | | | | 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 | 'label' => self::TYPES_NAMES[self::TYPE_EXPENSE], 'help' => null, ], self::TYPE_TRANSFER => [ 'accounts' => [ [ 'label' => 'De', 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER], 'direction' => 'credit', 'defaults' => [ self::TYPE_EXPENSE => 'credit', self::TYPE_REVENUE => 'debit', ], ], [ 'label' => 'Vers', 'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_TEMPORARY_TRANSFER], 'direction' => 'debit', ], ], 'label' => self::TYPES_NAMES[self::TYPE_TRANSFER], 'help' => 'Dépôt en banque, virement interne, etc.', ], self::TYPE_DEBT => [ |
︙ | ︙ |
Modified src/include/lib/Paheko/Entities/Accounting/TransactionSubscriptionsTrait.php from [6d7d38b625] to [ee23f0d7c1].
︙ | ︙ | |||
10 11 12 13 14 15 16 | */ trait TransactionSubscriptionsTrait { public function linkToSubscription(int $id_subscription) { $db = EntityManager::getInstance(self::class)->DB(); | | | | | | | > > | < | | | | 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | */ trait TransactionSubscriptionsTrait { public function linkToSubscription(int $id_subscription) { $db = EntityManager::getInstance(self::class)->DB(); return $db->preparedQuery('REPLACE INTO acc_transactions_users (id_transaction, id_subscription, id_user) SELECT ?, id, id_user FROM services_subscriptions WHERE id = ?;', $this->id(), $id_subscription ); } public function deleteAllSubscriptionLinks(): void { $db = EntityManager::getInstance(self::class)->DB(); $db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription IS NOT NULL', $this->id()); } public function deleteSubscriptionLink(int $id): void { $db = EntityManager::getInstance(self::class)->DB(); $db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription = ?', $this->id(), $id); } public function listLinkedSubscriptions(): array { $db = EntityManager::getInstance(self::class)->DB(); $identity_column = DynamicFields::getNameFieldsSQL('u'); $number_column = DynamicFields::getNumberFieldSQL('u'); $sql = sprintf('SELECT sub.*, s.label, %s AS user_identity, %s AS user_number, l.id_subscription FROM users u INNER JOIN services_subscriptions sub ON sub.id_user = u.id INNER JOIN services s ON s.id = sub.id_service INNER JOIN acc_transactions_users l ON l.id_subscription = sub.id WHERE l.id_transaction = ?;', $identity_column, $number_column); return $db->get($sql, $this->id()); } public function updateSubscriptionLinks(array $subscriptions): void { $subscriptions = array_values($subscriptions); foreach ($subscriptions as $i => $subscription) { if (!(is_int($subscription) || (is_string($subscription) && ctype_digit($subscription)))) { throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid subscription ID', $i, $subscription)); } } $db = EntityManager::getInstance(self::class)->DB(); $db->begin(); $this->deleteAllSubscriptionLinks(); foreach ($subscriptions as $id) { $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_subscription, id_user) SELECT ?, id, id_user FROM services_subscriptions WHERE id = ?;', $this->id(), (int)$id ); } $db->commit(); } } |
Modified src/include/lib/Paheko/Entities/Accounting/TransactionUsersTrait.php from [5038ed021f] to [25697bf675].
1 2 3 4 5 6 7 8 9 10 11 12 | <?php namespace Paheko\Entities\Accounting; use KD2\DB\EntityManager; use Paheko\Users\DynamicFields; trait TransactionUsersTrait { public function deleteLinkedUsers(): void { $db = EntityManager::getInstance(self::class)->DB(); | | | | | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | <?php namespace Paheko\Entities\Accounting; use KD2\DB\EntityManager; use Paheko\Users\DynamicFields; trait TransactionUsersTrait { public function deleteLinkedUsers(): void { $db = EntityManager::getInstance(self::class)->DB(); $db->delete('acc_transactions_users', 'id_transaction = ? AND id_subscription IS NULL', $this->id()); } public function updateLinkedUsers(array $users): void { $users = array_values($users); foreach ($users as $i => $user) { if (!(is_int($user) || (is_string($user) && ctype_digit($user)))) { throw new ValidationException(sprintf('Array item #%d: "%s" is not a valid user ID', $i, $user)); } } $db = EntityManager::getInstance(self::class)->DB(); $db->begin(); $this->deleteLinkedUsers(); foreach ($users as $id) { $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_subscription) VALUES (?, ?, NULL);', $this->id(), (int)$id); } $db->commit(); } public function listLinkedUsers(): array { $db = EntityManager::getInstance(self::class)->DB(); $identity_column = DynamicFields::getNameFieldsSQL('u'); $number_column = DynamicFields::getNumberFieldSQL('u'); $sql = sprintf('SELECT u.id, %s AS identity, %s AS number FROM users u INNER JOIN acc_transactions_users l ON l.id_subscription IS NULL AND l.id_user = u.id WHERE l.id_transaction = ? ORDER BY id;', $identity_column, $number_column); return $db->get($sql, $this->id()); } public function listLinkedUsersAssoc(): array { $db = EntityManager::getInstance(self::class)->DB(); $identity_column = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT u.id, %s AS identity FROM users u INNER JOIN acc_transactions_users l ON l.id_subscription IS NULL AND l.id_user = u.id WHERE l.id_transaction = ?;', $identity_column); return $db->getAssoc($sql, $this->id()); } } |
Added src/include/lib/Paheko/Entities/Email/Address.php version [c45468c3e3].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | <?php declare(strict_types=1); namespace Paheko\Entities\Email; use Paheko\Entity; use Paheko\UserException; use Paheko\Email\Addresses; use Paheko\Email\Templates as EmailsTemplates; use KD2\SMTP; use const Paheko\{WWW_URL, SECRET_KEY}; class Address extends Entity { const TABLE = 'emails_addresses'; const RESEND_VERIFICATION_DELAY = 24; /** * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification. */ const SOFT_BOUNCE_LIMIT = 5; const STATUS_UNKNOWN = 0; const STATUS_VERIFIED = 1; const STATUS_INVALID = -1; const STATUS_SOFT_BOUNCE_LIMIT_REACHED = -2; const STATUS_HARD_BOUNCE = -3; const STATUS_OPTOUT = -4; const STATUS_SPAM = -5; const STATUS_LIST = [ self::STATUS_UNKNOWN => 'OK', self::STATUS_VERIFIED => 'Vérifiée', self::STATUS_INVALID => 'Invalide', self::STATUS_SOFT_BOUNCE_LIMIT_REACHED => 'Trop d\'erreurs', self::STATUS_HARD_BOUNCE => 'Échec', self::STATUS_OPTOUT => 'Refus', self::STATUS_SPAM => 'Spam', ]; const STATUS_COLORS = [ self::STATUS_UNKNOWN => 'steelblue', self::STATUS_VERIFIED => 'darkgreen', self::STATUS_INVALID => 'crimson', self::STATUS_SOFT_BOUNCE_LIMIT_REACHED => 'darkorange', self::STATUS_HARD_BOUNCE => 'darkred', self::STATUS_OPTOUT => 'palevioletred', self::STATUS_SPAM => 'darkmagenta', ]; protected int $id; protected string $hash; protected int $status = self::STATUS_UNKNOWN; protected int $sent_count = 0; protected int $bounce_count = 0; protected ?string $log; protected \DateTime $added; protected ?\DateTime $last_sent; static public function getOptoutURL(string $hash = null): string { $hash = hex2bin($hash); $hash = base64_encode($hash); // Make base64 hash valid for URLs $hash = rtrim(strtr($hash, '+/', '-_'), '='); return sprintf('%s?un=%s', WWW_URL, $hash); } public function getVerificationCode(): string { $code = sha1($this->hash . SECRET_KEY); return substr($code, 0, 10); } public function sendVerification(string $email): void { if (Addresses::hash($email) !== $this->hash) { throw new UserException('Adresse email inconnue'); } $verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode(); EmailsTemplates::verifyAddress($email, $verify_url); } public function canSendVerificationAfterFail(): bool { $limit_date = Addresses::getVerificationLimitDate(); return isset($this->last_sent) ? $this->last_sent < $limit_date : false; } public function verify(string $code): bool { if ($code !== $this->getVerificationCode()) { return false; } $this->set('status', self::STATUS_VERIFIED); $this->set('bounce_count', 0); $this->log('Adresse vérifiée par le destinataire'); return true; } public function setAddress(string $address): bool { $this->set('added', new \DateTime); $this->set('hash', Addresses::hash($address)); $error = Addresses::checkForErrors($address); if (null !== $error) { $this->set('status', self::STATUS_INVALID); $this->log($error); } return $error === null; } public function canSend(): bool { if ($this->status < self::STATUS_UNKNOWN) { return false; } return true; } public function incrementSentCount(): void { $this->set('sent_count', $this->sent_count+1); } public function setOptout(string $message = null): void { $this->set('status', self::STATUS_OPTOUT); $this->log($message ?? 'Demande de désinscription'); } public function log(string $message): void { $log = $this->log ?? ''; if ($log) { $log .= "\n"; } $log .= date('d/m/Y H:i:s - ') . trim($message); $this->set('log', $log); } public function hasFailed(array $return): void { if (!isset($return['type'])) { throw new \InvalidArgumentException('Bounce email type not supplied in argument.'); } // Treat complaints as opt-out if ($return['type'] == 'complaint') { $this->set('status', self::STATUS_SPAM); $this->log("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']); } elseif ($return['type'] == 'permanent') { $this->set('status', self::STATUS_HARD_BOUNCE); $this->set('bounce_count', $this->bounce_count+1); $this->log($return['message']); } elseif ($return['type'] == 'temporary') { $this->set('bounce_count', $this->bounce_count+1); $this->log($return['message']); if ($this->bounce_count > self::SOFT_BOUNCE_LIMIT) { $this->set('status', self::STATUS_SOFT_BOUNCE_LIMIT_REACHED); } } } public function save(bool $selfcheck = true): bool { $optout = false; if ($this->isModified('optout')) { $optout = true; } $return = parent::save($selfcheck); if ($return && $optout) { // Delete all specific optouts when opting out of everything DB::getInstance()->preparedQuery('DELETE FROM mailings_optouts WHERE email_hash = ?;', $this->hash); } return $return; } public function getStatusColor(): string { return self::STATUS_COLORS[$this->status]; } public function getStatusLabel(): string { return self::STATUS_LIST[$this->status]; } } |
Deleted src/include/lib/Paheko/Entities/Email/Email.php version [f1da9fc120].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Modified src/include/lib/Paheko/Entities/Email/Mailing.php from [a4f1806841] to [e57761a85e].
1 2 3 4 5 6 7 8 9 10 11 | <?php namespace Paheko\Entities\Email; use Paheko\Config; use Paheko\CSV; use Paheko\DB; use Paheko\DynamicList; use Paheko\Entity; use Paheko\Log; use Paheko\UserException; | | | > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | <?php namespace Paheko\Entities\Email; use Paheko\Config; use Paheko\CSV; use Paheko\DB; use Paheko\DynamicList; use Paheko\Entity; use Paheko\Log; use Paheko\UserException; use Paheko\Email\Addresses; use Paheko\Users\DynamicFields; use Paheko\Users\Users; use Paheko\UserTemplate\UserTemplate; use Paheko\Web\Render\Render; use Paheko\Entities\Users\DynamicField; use DateTime; use stdClass; class Mailing extends Entity { const TABLE = 'mailings'; const NAME = 'Message collectif'; const PRIVATE_URL = '!users/email/mailing/details.php?id=%d'; const TARGETS_TYPES = [ 'all' => 'Tous les membres (sauf catégories cachées)', 'field' => 'Champ de la fiche membre', 'category' => 'Catégorie', 'service' => 'Inscrits à jour d\'une activité', 'search' => 'Recherche enregistrée', ]; protected ?int $id = null; protected string $subject; protected ?string $body; /** * We need to store these in order to have opt-out per-target */ protected ?string $target_type; protected ?string $target_value; protected ?string $target_label; /** * Leave sender name and email NULL to use org name + email */ protected ?string $sender_name; protected ?string $sender_email; /** |
︙ | ︙ | |||
53 54 55 56 57 58 59 | $this->assert(trim($this->subject) !== '', 'Le sujet ne peut rester vide.'); $this->assert(!isset($this->body) || trim($this->body) !== '', 'Le corps du message ne peut rester vide.'); if (isset($this->sender_name) || isset($this->sender_email)) { $this->assert(trim($this->sender_name) !== '', 'Le nom d\'expéditeur est vide.'); $this->assert(trim($this->sender_email) !== '', 'L\'adresse e-mail de l\'expéditeur est manquante.'); | > > | > > > > > | | > > > | | | | | | | | 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | $this->assert(trim($this->subject) !== '', 'Le sujet ne peut rester vide.'); $this->assert(!isset($this->body) || trim($this->body) !== '', 'Le corps du message ne peut rester vide.'); if (isset($this->sender_name) || isset($this->sender_email)) { $this->assert(trim($this->sender_name) !== '', 'Le nom d\'expéditeur est vide.'); $this->assert(trim($this->sender_email) !== '', 'L\'adresse e-mail de l\'expéditeur est manquante.'); $error = Addresses::checkForErrors($this->sender_email); $this->assert($error === null, 'L\'adresse e-mail de l\'expéditeur est invalide : ' . $error); } } public function getTargetTypeLabel(): string { return self::TARGETS_TYPES[$this->target_type] ?? ''; } public function populate(): void { if ($this->target_type !== 'all' && empty($this->target_value)) { throw new \InvalidArgumentException('Missing target ID'); } if ($this->target_type === 'field') { $recipients = Users::iterateEmailsByField($this->target_value, true); } elseif ($this->target_type === 'all') { $recipients = Users::iterateEmailsByCategory(null); } elseif ($this->target_type === 'category') { $recipients = Users::iterateEmailsByCategory((int) $this->target_value); } elseif ($this->target_type === 'search') { $recipients = Users::iterateEmailsBySearch((int) $this->target_value); } elseif ($this->target_type === 'service') { $recipients = Users::iterateEmailsByActiveService((int) $this->target_value); } else { throw new \InvalidArgumentException('Invalid target'); } $db = DB::getInstance(); $db->begin(); |
︙ | ︙ | |||
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | } if (!$count) { $db->rollback(); throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.'); } $db->commit(); } public function addRecipient(string $email, ?stdClass $data = null): void { if (!$this->exists()) { throw new \LogicException('Mailing does not exist'); } $email = strtolower(trim($email)); | > > > > > > > > > > | | < < < < < < < | | < < < | 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | } if (!$count) { $db->rollback(); throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.'); } $this->cleanupRecipients(); $db->commit(); } /** * Remove opt-out recipients from list */ public function cleanupRecipients(): void { } public function addRecipient(string $email, ?stdClass $data = null): void { if (!$this->exists()) { throw new \LogicException('Mailing does not exist'); } $email = strtolower(trim($email)); $e = Addresses::getOrCreate($email); if (!$e->canSend()) { $data = null; } else { $this->cleanExtraData($data); } DB::getInstance()->insert('mailings_recipients', [ 'id_mailing' => $this->id, 'id_email' => $e ? $e->id : null, 'email' => $email, 'extra_data' => $data ? json_encode($data) : null, ]); |
︙ | ︙ | |||
219 220 221 222 223 224 225 | ], 'name' => [ 'label' => 'Nom', 'select' => $this->getNameFieldsSQL('r'), ], 'status' => [ 'label' => 'Erreur', | | | > > > > | 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | ], 'name' => [ 'label' => 'Nom', 'select' => $this->getNameFieldsSQL('r'), ], 'status' => [ 'label' => 'Erreur', 'select' => sprintf('CASE WHEN o.email_hash IS NOT NULL THEN \'Désinscription de cet envoi\' ELSE (%s) END', Emails::getRejectionStatusClause('e')), ], 'has_extra_data' => [ 'select' => 'r.extra_data IS NOT NULL', ], ]; $tables = 'mailings_recipients AS r LEFT JOIN emails_addresses a ON a.id = r.id_email LEFT JOIN mailings_optouts o ON a.hash = o.email_hash AND o.target_type = :target_type AND o.target_value = :target_value'; $conditions = 'id_mailing = ' . $this->id; $list = new DynamicList($columns, $tables, $conditions); $list->setParameter(':target_type', $this->target_type); $list->setParameter(':target_value', $this->target_value); $list->orderBy('email', false); $list->setTitle('Liste des destinataires'); return $list; } public function countRecipients(): int { |
︙ | ︙ |
Added src/include/lib/Paheko/Entities/Email/Message.php version [c6e40e85ff].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 | <?php namespace Paheko\Entities\Email; use Paheko\Config; use Paheko\Entity; use const Paheko\{DISABLE_EMAIL, MAIL_RETURN_PATH, MAIL_SENDER}; use const Paheko\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY, SMTP_HELO_HOSTNAME}; use KD2\SMTP; use KD2\Security; use KD2\Mail_Message; class Message extends Entity { const TABLE = 'emails_queue'; protected int $context; protected int $status = self::WAITING; protected ?string $sender; protected string $recipient; protected string $recipient_hash; protected ?string $recipient_pgp_key; protected string $subject; protected string $body; protected string $html_body; protected array $attachments; protected ?string $context_optout; const STATUS_WAITING = 0; const STATUS_SENDING = 1; const STATUS_SENT = 2; const STATUS_LIST = [ self::STATUS_WAITING => 'En attente', self::STATUS_SENDING => 'Envoi en cours', self::STATUS_SENT => 'Envoyé', ]; const STATUS_COLORS = [ self::STATUS_WAITING => 'cadetblue', self::STATUS_SENDING => 'chocolate', self::STATUS_SENT => 'darkgreen', ]; const CONTEXT_SYSTEM = 0; const CONTEXT_BULK = 1; const CONTEXT_PRIVATE = 2; const CONTEXT_NOTIFICATION = 3; const CONTEXT_LIST = [ self::CONTEXT_SYSTEM => 'Système', self::CONTEXT_BULK => 'Collectif', self::CONTEXT_PRIVATE => 'Privé', self::CONTEXT_NOTIFICATION => 'Notification', ]; public function selfCheck(): void { $this->assert(in_array($this->context, self::CONTEXT_LIST), 'Contexte inconnu'); $this->assert(in_array($this->status, self::STATUS_LIST), 'Statut inconnu'); $this->assert(strlen($this->subject), 'Sujet vide'); $this->assert(strlen($this->body), 'Corps vide'); $this->assert(strlen($this->recipient), 'Destinataire absent'); $this->assert(strlen($this->recipient_hash) === 40, 'Hash invalide'); } public function setBodyFromUserTemplate(UserTemplate $template, array $data = [], bool $markdown = false): void { // Replace placeholders: {{$name}}, etc. $template->assignArray((array) $data, null, false); // Disable HTML escaping for plaintext emails $template->setEscapeDefault(null); $this->body = $template->fetch(); if ($markdown) { $this->markdownToHTML(); } } public function markdownToHTML(): void { $this->body_html = Render::render(Render::FORMAT_MARKDOWN, null, $this->body); } public function wrapHTML(): ?string { if ($this->context === self::CONTEXT_SYSTEM) { return null; } if (null === self::$main_tpl) { self::$main_tpl = new UserTemplate('web/email.html'); } // Wrap HTML content in the email skeleton $main_tpl->assignArray([ 'html' => $this->body_html, 'address' => $this->recipient, 'context' => $this->context, 'sender' => $this->sender, 'message' => $this, ]); return $main_tpl->fetch(); } public function getOptoutText(): string { $out = "Vous recevez ce message car vous êtes dans nos contacts.\n"; if (isset($this->context_optout)) { $out .= "Pour vous désinscrire uniquement de ces envois, cliquez ici :\n"; $out .= "[context_optout_url]\n\n"; } $out .= "Pour ne plus jamais recevoir aucun message de notre part cliquez ici :\n"; $out .= "[optout_url]\n\n"; return $out; } public function getOptoutFooter(): string { return strtr($this->getOptoutText(), [ '[context_optout_url]' => $this->getContextSpecificOptoutURL(), '[optout_url]' => $this->getOptoutURL(), ]); } public function appendHTMLOptoutFooter(): string { $text = nl2br(htmlspecialchars($this->getOptoutText())); if (isset($this->context_optout)) { $button = sprintf('<a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 5px; background: #eee; border: 1px outset #ccc;">Me désinscrire de ces envois uniquement</a></p>', $this->getContextSpecificOptoutURL()); $text = str_replace('[context_optout_url]', $button, $text); } $button = sprintf('<a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 3px; background: #eee; border: 1px outset #ccc;">Me désinscrire de <b>tous les envois</b></a></p>', $this->getOptoutURL()); $text = str_replace('[optout_url]', $button, $text); $footer = '<p style="color: #666; background: #fff; padding: 10px; text-align: center; font-size: 9pt">'; $footer .= $text; $html = $this->body_html; if (stripos($html, '</body>') !== false) { $html = str_ireplace('</body>', $footer . '</body>', $html); } else { $html .= $footer; } return $html; } public function getOptoutURL(): string { return Email::getOptoutURL($this->recipient_hash); } public function getContextSpecificOptoutURL(): ?string { if (!isset($this->context_optout)) { return null; } return Email::getOptoutURL() . '&c=' . $this->context_optout; } public function setRecipient(string $email, ?string $pgp_key = null) { $this->set('recipient', $email); $this->set('recipient_pgp_key', $pgp_key); } public function queue(): bool { return $this->save(); } public function createSMTPMessage(): Mail_Message { $config = Config::getInstance(); $message = new Mail_Message; $message->setHeader('From', $this->sender ?? self::getDefaultFromHeader()); $message->setHeader('To', $this->recipient); $message->setHeader('Subject', $this->subject); if (!$message->getFrom()) { $message->setHeader('From', self::getFromHeader()); } if (MAIL_SENDER) { $message->setHeader('Reply-To', $message->getFromAddress()); $message->setHeader('From', self::getFromHeader($message->getFromName(), MAIL_SENDER)); } $message->setMessageId(); $text = $this->body; $html = $this->body_html; // Append unsubscribe, except for password reminders if ($this->context != self::CONTEXT_SYSTEM) { $url = $this->getContextSpecificOptoutURL() ?? $this->getOptoutURL(); // RFC 8058 $message->setHeader('List-Unsubscribe', sprintf('<%s>', $url)); $message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes'); $text .= sprintf("\n\n-- \n%s\n\n%s", $config->org_name, $this->getOptoutText()); if (null !== $html) { $html = $this->appendHTMLOptoutFooter(); } } $message->setBody($text); if (null !== $html) { $message->addPart('text/html', $html); } $message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email); $message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers foreach ($attachments as $path) { $file = Files::get($path); if (!$file) { continue; } $message->addPart($file->mime, $file->fetch(), $file->name); } static $can_use_encryption = null; if (null === $can_use_encryption) { $can_use_encryption = Security::canUseEncryption(); } if ($this->recipient_pgp_key && $can_use_encryption) { $message->encrypt($this->recipient_pgp_key); } return $message; } public function send(): bool { if (DISABLE_EMAIL) { return false; } $message = $this->createSMTPMessage(); $entity = $this; $context = $this->context; $fail = false; try { $signal = Plugins::fire('email.send.before', true, compact('message', 'context', 'entity'), ['sent' => null]); if ($signal && $signal->isStopped()) { return $signal->getOut('sent') ?? true; } if (SMTP_HOST) { $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY); $secure = constant($const); $smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure, SMTP_HELO_HOSTNAME); $smtp->send($message); } else { // Send using PHP mail() function $message->send(); } Plugins::fire('email.send.after', false, compact('context', 'message', 'entity')); return true; } catch (\Throwable $e) { $fail = true; } finally { if (!$fail) { $this->set('status', self::STATUS_SENT); } } } } |
Modified src/include/lib/Paheko/Entities/Services/Fee.php from [3116155735] to [db1614ccc0].
︙ | ︙ | |||
116 117 118 119 120 121 122 | } protected function checkFormula(): ?string { try { $db = DB::getInstance(); $sql = $this->getFormulaSQL(); | | | | 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | } protected function checkFormula(): ?string { try { $db = DB::getInstance(); $sql = $this->getFormulaSQL(); $db->protectSelect(['users' => null, 'services_subscriptions' => null, 'services' => null, 'services_fees' => null], $sql); return null; } catch (DB_Exception $e) { return $e->getMessage(); } } public function service() { return EntityManager::findOneById(Service::class, $this->id_service); } public function allUsersList(bool $include_hidden_categories = false): DynamicList { $identity = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'id_user' => [ 'select' => 'sub.id_user', ], 'service_label' => [ 'select' => 's.label', 'label' => 'Activité', 'export' => true, ], 'fee_label' => [ |
︙ | ︙ | |||
158 159 160 161 162 163 164 | ], 'identity' => [ 'label' => 'Membre', 'select' => $identity, ], 'paid' => [ 'label' => 'Payé ?', | | | | | | | | | | | | | | | | | | | | | 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 | ], 'identity' => [ 'label' => 'Membre', 'select' => $identity, ], 'paid' => [ 'label' => 'Payé ?', 'select' => 'sub.paid', 'order' => 'sub.paid %s, sub.date %1$s', ], 'paid_amount' => [ 'label' => 'Montant payé', 'select' => 'CASE WHEN link.id_subscription IS NOT NULL THEN SUM(l.credit) ELSE NULL END', ], 'date' => [ 'label' => 'Date', 'select' => 'sub.date', ], ]; $tables = 'services_subscriptions sub INNER JOIN users u ON u.id = sub.id_user INNER JOIN services_fees sf ON sf.id = sub.id_fee INNER JOIN services s ON s.id = sf.id_service INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_fee) AS su2 ON su2.id = sub.id LEFT JOIN acc_transactions_users link ON link.id_subscription = sub.id LEFT JOIN acc_transactions_lines l ON l.id_transaction = link.id_transaction'; $conditions = sprintf('sub.id_fee = %d', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list = new DynamicList($columns, $tables, $conditions); $list->groupBy('sub.id_user'); $list->orderBy('paid', true); $list->setCount('COUNT(DISTINCT sub.id_user)'); $list->setExportCallback(function (&$row) { $row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null; }); return $list; } public function activeUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_fee = %d AND (sub.expiry_date >= date() OR sub.expiry_date IS NULL) AND sub.paid = 1', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_fee = %d AND sub.paid = 0', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function expiredUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_fee = %d AND sub.expiry_date < date()', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function getUsers(bool $paid_only = false): array { $where = $paid_only ? 'AND paid = 1' : ''; $id_field = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT sub.id_user, %s FROM services_subscriptions sub INNER JOIN users u ON u.id = sub.id_user WHERE sub.id_fee = ? %s;', $id_field, $where); return DB::getInstance()->getAssoc($sql, $this->id()); } public function hasSubscriptions(): bool { return DB::getInstance()->test('services_subscriptions', 'id_fee = ?', $this->id()); } } |
Modified src/include/lib/Paheko/Entities/Services/Reminder.php from [dbea8dc3fe] to [51bb91ad7a].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | <?php namespace Paheko\Entities\Services; use Paheko\DynamicList; use Paheko\DB; use Paheko\Entity; use Paheko\ValidationException; use Paheko\Users\DynamicFields; use Paheko\Services\Reminders; use KD2\DB\EntityManager; class Reminder extends Entity { const NAME = 'Rappel'; const TABLE = 'services_reminders'; protected int $id; protected int $id_service; protected int $delay; protected string $subject; protected string $body; const DEFAULT_SUBJECT = 'Votre inscription arrive à expiration'; const DEFAULT_BODY = 'Bonjour {{$identity}},' . "\n\n" . 'Votre inscription pour « {{$label}} » arrive à échéance dans {{$nb_days}} jours.' . "\n\n" . 'Merci de nous contacter pour renouveler votre inscription.' . "\n\nCordialement."; public function selfCheck(): void | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php namespace Paheko\Entities\Services; use Paheko\DynamicList; use Paheko\DB; use Paheko\Entity; use Paheko\ValidationException; use Paheko\Users\DynamicFields; use Paheko\Services\Reminders; use KD2\DB\Date; use KD2\DB\EntityManager; class Reminder extends Entity { const NAME = 'Rappel'; const TABLE = 'services_reminders'; protected int $id; protected int $id_service; protected int $delay; protected string $subject; protected string $body; protected ?Date $not_before_date = null; const DEFAULT_SUBJECT = 'Votre inscription arrive à expiration'; const DEFAULT_BODY = 'Bonjour {{$identity}},' . "\n\n" . 'Votre inscription pour « {{$label}} » arrive à échéance dans {{$nb_days}} jours.' . "\n\n" . 'Merci de nous contacter pour renouveler votre inscription.' . "\n\nCordialement."; public function selfCheck(): void |
︙ | ︙ | |||
46 47 48 49 50 51 52 | public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } | < > > > > > > > > | | > > > | | | > > > > > > | 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } if (isset($source['delay_type'])) { if (1 == $source['delay_type'] && !empty($source['delay_before'])) { $source['delay'] = (int)$source['delay_before'] * -1; } elseif (2 == $source['delay_type'] && !empty($source['delay_after'])) { $source['delay'] = (int)$source['delay_after']; } else { $source['delay'] = 0; } } // Warning: inverse logic here if (!empty($source['yes_before'])) { $source['not_before_date'] = null; } elseif (isset($source['yes_before']) && empty($source['yes_before'])) { $source['not_before_date'] = date('Y-m-d'); } parent::importForm($source); } public function sentList(): DynamicList { $id_field = DynamicFields::getNameFieldsSQL('u'); $db = DB::getInstance(); $columns = [ 'id_user' => [ 'select' => 'srs.id_user', ], 'identity' => [ 'label' => 'Membre', 'select' => $id_field, ], 'reminder_date' => [ 'label' => 'Date d\'envoi', 'select' => 'srs.sent_date', 'order' => 'srs.sent_date %s, srs.id %1$s', ], ]; $tables = 'services_reminders_sent srs INNER JOIN users u ON u.id = srs.id_user'; $conditions = sprintf('srs.id_reminder = %d', $this->id()); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('reminder_date', true); return $list; } public function pendingList(): DynamicList { $db = DB::getInstance(); $columns = [ 'id_user' => [ 'select' => 'id', ], 'identity' => [ 'label' => 'Membre', ], 'expiry_date' => [ 'label' => 'Date d\'expiration', ], 'reminder_date' => [ 'label' => 'Date d\'envoi', ], ]; $conditions = sprintf('su.id_service = %d AND sr.id = %d', $this->id_service, $this->id); $tables = '(' . Reminders::getPendingSQL(false, $conditions) . ') AS pending'; $list = new DynamicList($columns, $tables); $list->orderBy('expiry_date', false); return $list; } public function getPreview(int $id_user): ?string { $conditions = sprintf('su.id_service = %d AND su.id_user = %d AND sr.id = %d', $this->id_service, $id_user, $this->id); $sql = Reminders::getPendingSQL(false, $conditions); $db = DB::getInstance(); foreach ($db->iterate($sql) as $reminder) { $m = Reminders::createMessage($reminder); return $m->getMessage($reminder); } return null; } public function deleteHistory(): void { $db = DB::getInstance(); $db->exec(sprintf('DELETE FROM services_reminders_sent WHERE id_reminder = %s;', $this->id)); } } |
Modified src/include/lib/Paheko/Entities/Services/ReminderMessage.php from [6b38646a9a] to [fbaef97c81].
︙ | ︙ | |||
20 21 22 23 24 25 26 | class ReminderMessage extends Entity { const TABLE = 'services_reminders_sent'; protected ?int $id; protected int $id_service; protected int $id_user; | | | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | class ReminderMessage extends Entity { const TABLE = 'services_reminders_sent'; protected ?int $id; protected int $id_service; protected int $id_user; protected ?int $id_reminder = null; protected Date $sent_date; protected Date $due_date; protected ?Reminder $_reminder = null; /** * @return UserTemplate|string |
︙ | ︙ | |||
72 73 74 75 76 77 78 | $reminder->user_amount = CommonModifiers::money_currency($reminder->user_amount ?? 0, true, false, false); $reminder->reminder_date = CommonModifiers::date_short($reminder->reminder_date); $reminder->expiry_date = CommonModifiers::date_short($reminder->expiry_date); return (array) $reminder; } | | > > > > | 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | $reminder->user_amount = CommonModifiers::money_currency($reminder->user_amount ?? 0, true, false, false); $reminder->reminder_date = CommonModifiers::date_short($reminder->reminder_date); $reminder->expiry_date = CommonModifiers::date_short($reminder->expiry_date); return (array) $reminder; } public function reminder(): ?Reminder { if (!$this->id_reminder) { return null; } $this->_reminder ??= Reminders::get($this->id_reminder); return $this->_reminder; } public function send(stdClass $reminder, $body = null) { $body ??= $this->getBody($reminder); |
︙ | ︙ |
Modified src/include/lib/Paheko/Entities/Services/Service.php from [6bcf955bd3] to [41f1b2037b].
︙ | ︙ | |||
21 22 23 24 25 26 27 28 29 30 31 32 33 34 | protected int $id; protected string $label; protected ?string $description = null; protected ?int $duration = null; protected ?Date $start_date = null; protected ?Date $end_date = null; public function selfCheck(): void { parent::selfCheck(); $this->assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné'); $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères'); $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères'); | > | 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | protected int $id; protected string $label; protected ?string $description = null; protected ?int $duration = null; protected ?Date $start_date = null; protected ?Date $end_date = null; protected bool $archived = false; public function selfCheck(): void { parent::selfCheck(); $this->assert(trim((string) $this->label) !== '', 'Le libellé doit être renseigné'); $this->assert(strlen((string) $this->label) <= 200, 'Le libellé doit faire moins de 200 caractères'); $this->assert(strlen((string) $this->description) <= 2000, 'La description doit faire moins de 2000 caractères'); |
︙ | ︙ | |||
52 53 54 55 56 57 58 59 60 61 62 63 64 65 | elseif (2 == $source['period']) { $source['duration'] = null; } else { $source['duration'] = $source['start_date'] = $source['end_date'] = null; } } parent::importForm($source); } public function fees() { return new Fees($this->id()); | > > > > | 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | elseif (2 == $source['period']) { $source['duration'] = null; } else { $source['duration'] = $source['start_date'] = $source['end_date'] = null; } } if (isset($source['archived_present']) && empty($source['archived'])) { $source['archived'] = false; } parent::importForm($source); } public function fees() { return new Fees($this->id()); |
︙ | ︙ | |||
85 86 87 88 89 90 91 | ], 'identity' => [ 'label' => 'Membre', 'select' => $id_field, ], 'status' => [ 'label' => 'Statut', | | | | | > > > > > | | | | | | | | | > | | | | | > | > > > > | 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | ], 'identity' => [ 'label' => 'Membre', 'select' => $id_field, ], 'status' => [ 'label' => 'Statut', 'select' => 'CASE WHEN sub.expiry_date < date() THEN -1 WHEN sub.expiry_date >= date() THEN 1 ELSE 0 END', ], 'paid' => [ 'label' => 'Payé ?', 'select' => 'sub.paid', 'order' => 'sub.paid %s, sub.date %1$s', ], 'expiry' => [ 'label' => 'Date d\'expiration', 'select' => 'MAX(sub.expiry_date)', ], 'fee' => [ 'label' => 'Tarif', 'select' => 'sf.label', ], 'amount' => [ 'label' => 'Montant de l\'inscription', 'select' => 'sub.expected_amount', 'export' => true, ], 'date' => [ 'label' => 'Date d\'inscription', 'select' => 'sub.date', ], ]; $tables = 'services_subscriptions AS sub INNER JOIN users u ON u.id = sub.id_user INNER JOIN services s ON s.id = sub.id_service LEFT JOIN services_fees sf ON sf.id = sub.id_fee INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_service) AS su2 ON su2.id = sub.id'; $conditions = sprintf('sub.id_service = %d', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list = new DynamicList($columns, $tables, $conditions); $list->groupBy('sub.id_user'); $list->orderBy('paid', true); $list->setCount('COUNT(DISTINCT sub.id_user)'); $list->setExportCallback(function (&$row) { $row->status = $row->status == -1 ? 'En retard' : ($row->status == 1 ? 'En cours' : ''); $row->paid = $row->paid ? 'Oui' : 'Non'; $row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null; }); return $list; } public function activeUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_service = %d AND (sub.expiry_date >= date() OR sub.expiry_date IS NULL) AND sub.paid = 1', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function unpaidUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_service = %d AND sub.paid = 0', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function expiredUsersList(bool $include_hidden_categories = false): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('sub.id_service = %d AND sub.expiry_date < date()', $this->id()); if (!$include_hidden_categories) { $conditions .= ' AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)'; } $list->setConditions($conditions); return $list; } public function hasSubscriptions(): bool { return DB::getInstance()->test('services_subscriptions', 'id_service = ?', $this->id()); } public function getUsers(bool $paid_only = false) { $where = $paid_only ? 'AND paid = 1' : ''; $id_field = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT sub.id_user, %s FROM services_subscriptions sub INNER JOIN users u ON u.id = sub.id_user WHERE subn.id_service = ? %s;', $id_field, $where ); return DB::getInstance()->getAssoc($sql, $this->id()); } public function long_label(): string { if ($this->duration) { $duration = sprintf('%d jours', $this->duration); |
︙ | ︙ |
Deleted src/include/lib/Paheko/Entities/Services/Service_User.php version [40f2eaf44b].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/include/lib/Paheko/Entities/Services/Subscription.php version [04703690c0].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | <?php namespace Paheko\Entities\Services; use Paheko\DB; use Paheko\Entity; use Paheko\Form; use Paheko\ValidationException; use Paheko\Services\Fees; use Paheko\Services\Services; use Paheko\Users\Users; use Paheko\Accounting\Transactions; use Paheko\Entities\Accounting\Transaction; use Paheko\Entities\Accounting\Line; use KD2\DB\Date; class Subscription extends Entity { const TABLE = 'services_subscriptions'; protected ?int $id; protected int $id_user; protected int $id_service; /** * This can be NULL if there is no fee for the service * @var null|int */ protected ?int $id_fee = null; protected bool $paid; protected ?int $expected_amount = null; protected Date $date; protected ?Date $expiry_date = null; protected $_service, $_fee; public function selfCheck(): void { $this->assert($this->id_service, 'Aucune activité spécifiée'); $this->assert($this->id_user, 'Aucun membre spécifié'); $this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date'); $db = DB::getInstance(); // don't allow an id_fee that does not match a service if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) { $this->set('id_fee', null); } } public function isDuplicate(bool $using_date = true): bool { if (!isset($this->id_user, $this->id_service)) { throw new \LogicException('Entity does not define either user or service'); } $params = [ 'id_user' => $this->id_user, 'id_service' => $this->id_service, 'id_fee' => $this->id_fee, ]; if ($using_date) { $params['date'] = $this->date->format('Y-m-d'); } $where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params)); $where = implode(' AND ', $where); if ($this->exists()) { $where .= sprintf(' AND id != %d', $this->id()); } return DB::getInstance()->test(self::TABLE, $where, array_values($params)); } public function importForm(?array $source = null) { if (null === $source) { $source = $_POST; } $service = null; if (!empty($source['id_service']) && empty($source['expiry_date'])) { $service = $this->_service = Services::get((int) $source['id_service']); if (!$service) { throw new \LogicException('The requested service is not found'); } if ($service->duration) { $dt = new Date; $dt->modify(sprintf('+%d days', $service->duration)); $this->set('expiry_date', $dt); } elseif ($service->end_date) { $this->set('expiry_date', $service->end_date); } else { $this->set('expiry_date', null); } } if (!empty($source['id_service'])) { if (!$service) { $service = $this->_service = Services::get((int) $source['id_service']); } } return parent::importForm($source); } public function service(): Service { if (null === $this->_service) { $this->_service = Services::get($this->id_service); } return $this->_service; } /** * Returns the Fee entity linked to this subscription * This can be NULL if there was no fee existing at the time of subscription * (that way you can use subscriptions without fees if you want) */ public function fee(): ?Fee { if (null === $this->id_fee) { return null; } if (null === $this->_fee) { $this->_fee = Fees::get($this->id_fee); } return $this->_fee; } public function addPayment(int $user_id, ?array $source = null): Transaction { if (null === $source) { $source = $_POST; } if (!$this->id_fee) { throw new \RuntimeException('Cannot add a payment to a subscription that is not linked to a fee'); } if (!$this->fee()->id_year) { throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié'); } if (empty($source['amount'])) { throw new ValidationException('Montant non précisé'); } $account = Form::getSelectorValue($source['account_selector'] ?? null); if (!$account) { throw new ValidationException('Aucune compte n\'a été sélectionné.'); } $label = $this->service()->label; if ($this->fee()->label != $label) { $label .= ' - ' . $this->fee()->label; } $label .= sprintf(' (%s)', Users::getName($this->id_user)); $transaction = Transactions::create(array_merge($source, [ 'type' => Transaction::TYPE_REVENUE, 'label' => $label, 'id_project' => $source['id_project'] ?? $this->fee()->id_project, 'simple' => [Transaction::TYPE_REVENUE => [ 'credit' => [$this->fee()->id_account => null], 'debit' => $source['account_selector'], ]], 'id_year' => $this->fee()->id_year, ])); $transaction->id_creator = $user_id; $transaction->id_year = $this->fee()->id_year; $transaction->type = Transaction::TYPE_REVENUE; $transaction->save(); $transaction->linkToSubscription($this->id()); return $transaction; } public function updateExpectedAmount(): void { $fee = $this->fee(); if ($fee && $fee->id_account && $this->id_user) { $this->set('expected_amount', $fee->getAmountForUser($this->id_user)); } else { $this->set('expected_amount', null); } } static public function createFromForm(array &$users, int $creator_id, bool $from_copy = false, ?array $source = null): self { if (null === $source) { $source = $_POST; } $db = DB::getInstance(); $db->begin(); if (!count($users)) { throw new ValidationException('Aucun membre n\'a été sélectionné.'); } $multiple_users = count($users) > 1; $errors = []; foreach ($users as $id => $name) { $su = new self; $su->date = new Date; $su->importForm($source); $su->id_user = (int) $id; if (empty($su->id_service)) { throw new ValidationException('Aucune activité n\'a été sélectionnée.'); } $su->updateExpectedAmount(); if ($su->isDuplicate($from_copy ? false : true)) { if ($from_copy) { continue; } else { $errors[] = $name; if (!$multiple_users) { throw new ValidationException(sprintf('%s : Cette activité a déjà été enregistrée pour ce membre et cette date', $name)); } unset($users[$id]); continue; } } $su->save(); if ($su->id_fee && $su->fee()->id_account && !empty($source['amount']) && !empty($source['create_payment'])) { try { $su->addPayment($creator_id, $source); } catch (ValidationException $e) { if ($e->getMessage() == 'Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé') { throw new ValidationException('Impossible d\'enregistrer l\'inscription : ce tarif d\'activité est lié à un exercice clôturé. Merci de modifier le tarif et choisir un autre exercice.', 0, $e); } else { throw $e; } } } } if (count($errors)) { $db->rollback(); throw new ValidationException(sprintf("Les membres suivants ne pourront pas être inscrits car ils sont déjà inscrits à cette activité et à la date indiquée :\n%s\n\nValidez à nouveau le formulaire pour confirmer les inscriptions des autres membres.", implode(', ', $errors))); } $db->commit(); return $su; } } |
Modified src/include/lib/Paheko/Entities/Users/User.php from [28f087dc00] to [2c50ce6906].
︙ | ︙ | |||
15 16 17 18 19 20 21 | use Paheko\Utils; use Paheko\UserException; use Paheko\ValidationException; use Paheko\Files\Files; use Paheko\Users\Categories; | | | | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | use Paheko\Utils; use Paheko\UserException; use Paheko\ValidationException; use Paheko\Files\Files; use Paheko\Users\Categories; use Paheko\Email\Queue; use Paheko\Email\Templates as EmailTemplates; use Paheko\Users\DynamicFields; use Paheko\Users\Session; use Paheko\Users\Users; use Paheko\Services\Subscriptions; use Paheko\Entities\Files\File; use KD2\SMTP; use KD2\DB\EntityManager as EM; use KD2\DB\Date; use KD2\ZipWriter; |
︙ | ︙ | |||
553 554 555 556 557 558 559 | $name = DynamicFields::getNameFieldsSQL(); return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id()); } public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null) { | < | > | > | | > > > | > > > | | 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 | $name = DynamicFields::getNameFieldsSQL(); return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id()); } public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null) { if (!$this->email()) { throw new UserException('Ce membre n\'a pas d\'adresse e-mail'); } $sender = $from ? $from->getNameAndEmail() : null; $message = Queue::createMessage(Message::CONTEXT_PRIVATE, $subject, $message); $message->setSender($sender); $message->setRecipient($this->email(), $this->pgp_key); $message->queue(); if ($send_copy && $from->email()) { $message = clone $message; $message->set('subject', 'Message envoyé : ' . $message->subject); $message->setRecipient($from->email()); $message->queue(); } } public function checkLoginFieldForUserEdit() { $session = Session::getInstance(); |
︙ | ︙ | |||
670 671 672 673 674 675 676 | } return $out; } public function downloadExport(): void { | | | 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 | } return $out; } public function downloadExport(): void { $services_list = Subscriptions::perUserList($this->id); $services_list->setPageSize(null); $export_data = [ 'user' => $this, 'services' => $services_list->asArray(true), ]; |
︙ | ︙ |
Modified src/include/lib/Paheko/Services/Fees.php from [a75ded20d2] to [368243dcc9].
︙ | ︙ | |||
102 103 104 105 106 107 108 | $db = DB::getInstance(); $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY)); $sql = sprintf('DROP TABLE IF EXISTS fees_list_stats; CREATE TEMP TABLE IF NOT EXISTS fees_list_stats (id_fee, id_user, ok, expired, paid); INSERT INTO fees_list_stats SELECT id_fee, id_user, | | | | | | | 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | $db = DB::getInstance(); $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY)); $sql = sprintf('DROP TABLE IF EXISTS fees_list_stats; CREATE TEMP TABLE IF NOT EXISTS fees_list_stats (id_fee, id_user, ok, expired, paid); INSERT INTO fees_list_stats SELECT id_fee, id_user, CASE WHEN (sub.expiry_date IS NULL OR sub.expiry_date >= date()) AND sub.paid = 1 THEN 1 ELSE 0 END, CASE WHEN sub.expiry_date < date() THEN 1 ELSE 0 END, paid FROM services_subscriptions sub INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_fee) sub2 ON sub2.id = sub.id INNER JOIN users u ON u.id = sub.id_user WHERE u.%s', $db->where('id_category', 'NOT IN', $hidden_cats)); $db->exec($sql); $columns = [ 'id' => [], 'formula' => [], |
︙ | ︙ |
Modified src/include/lib/Paheko/Services/Reminders.php from [0396b26bbf] to [7454ffb84c].
︙ | ︙ | |||
40 41 42 43 44 45 46 | 'date' => [ 'label' => 'Date d\'envoi du message', 'select' => 'srs.sent_date', ], ]; $tables = 'services_reminders_sent srs | | | 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | 'date' => [ 'label' => 'Date d\'envoi du message', 'select' => 'srs.sent_date', ], ]; $tables = 'services_reminders_sent srs LEFT JOIN services_reminders r ON r.id = srs.id_reminder INNER JOIN services s ON s.id = srs.id_service'; $conditions = sprintf('srs.id_user = %d', $user_id); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('date', true); return $list; } |
︙ | ︙ | |||
63 64 65 66 67 68 69 | } static public function listForService(int $service_id) { return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id); } | | | | | | | | | | | | > | | > | > > > > | 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | } static public function listForService(int $service_id) { return DB::getInstance()->get('SELECT * FROM services_reminders WHERE id_service = ? ORDER BY delay, subject;', $service_id); } static public function getPendingSQL(bool $due_only = true, string $conditions = '1') { $db = DB::getInstance(); $sql = 'SELECT u.*, %s AS identity, u.id AS id_user, date(sub.expiry_date, sr.delay || \' days\') AS reminder_date, ABS(julianday(date()) - julianday(sub.expiry_date)) AS nb_days, MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description, sub.expiry_date, sr.id AS id_reminder, sub.id_service, sub.id_user, sf.label AS fee_label, sf.amount, sf.formula FROM services_reminders sr INNER JOIN services s ON s.id = sr.id_service AND s.archived = 0 -- Select latest subscription to a service (MAX) only INNER JOIN (SELECT MAX(sub2.expiry_date) AS expiry_date, sub2.id_user, sub2.id_service, sub2.id_fee FROM services_subscriptions AS sub2 GROUP BY id_user, id_service) AS sub ON s.id = sub.id_service -- Select fee LEFT JOIN services_fees sf ON sf.id = sub.id_fee -- Join with users, but not ones part of a hidden category INNER JOIN users u ON sub.id_user = u.id AND (%s) AND (u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)) -- Join with sent reminders to exclude users that already have received this reminder LEFT JOIN (SELECT id, MAX(due_date) AS due_date, id_user, id_reminder FROM services_reminders_sent GROUP BY id_user, id_reminder) AS srs ON sub.id_user = srs.id_user AND srs.id_reminder = sr.id WHERE (sr.not_before_date IS NULL OR sr.not_before_date <= date(sub.expiry_date, sr.delay || \' days\')) AND (srs.id IS NULL OR srs.due_date < date(sub.expiry_date, (sr.delay - 1) || \' days\')) AND %s AND %s GROUP BY sub.id_user, sr.id_service ORDER BY sub.id_user'; $emails = DynamicFields::getEmailFields(); $emails = array_map(fn($e) => sprintf('u.%s IS NOT NULL', $db->quoteIdentifier($e)), $emails); $emails = implode(' OR ', $emails); $sql = sprintf($sql, DynamicFields::getNameFieldsSQL('u'), $emails, $due_only ? 'date() > date(su.expiry_date, sr.delay || \' days\')' : '1', $conditions ); return $sql; } static public function createMessage(stdClass $reminder): ReminderMessage { $m = new ReminderMessage; |
︙ | ︙ | |||
123 124 125 126 127 128 129 | /** * Envoi des rappels automatiques par e-mail * @return boolean TRUE en cas de succès */ static public function sendPending(): void { $db = DB::getInstance(); | | | 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | /** * Envoi des rappels automatiques par e-mail * @return boolean TRUE en cas de succès */ static public function sendPending(): void { $db = DB::getInstance(); $sql = self::getPendingSQL(true); $date = new \DateTime; $db->begin(); $body = null; foreach ($db->iterate($sql) as $row) { |
︙ | ︙ |
Modified src/include/lib/Paheko/Services/Services.php from [64d1da90fa] to [1cc5f0aae4].
︙ | ︙ | |||
39 40 41 42 43 44 45 | } static public function count() { return DB::getInstance()->count(Service::TABLE, 1); } | | < < < < < < < < < < | > | > > > > > > > | | | | | | | 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | } static public function count() { return DB::getInstance()->count(Service::TABLE, 1); } static public function listGroupedWithFees(?int $user_id = null) { $sql = 'SELECT id, label, duration, start_date, end_date, description, CASE WHEN end_date IS NOT NULL THEN end_date WHEN duration IS NOT NULL THEN date(\'now\', \'+\'||duration||\' days\') ELSE NULL END AS expiry_date FROM services WHERE archived = 0 ORDER BY label COLLATE U_NOCASE;'; $services = DB::getInstance()->getGrouped($sql); $fees = Fees::listAllByService($user_id); $out = []; foreach ($services as $service) { $out[$service->id] = $service; $out[$service->id]->fees = []; } foreach ($fees as $fee) { if (isset($out[$fee->id_service])) { $out[$fee->id_service]->fees[] = $fee; } } return $out; } static public function listArchivedWithStats(): DynamicList { $list = self::listWithStats(); $list->setConditions('archived = 1'); return $list; } static public function listWithStats(): DynamicList { $db = DB::getInstance(); $hidden_cats = array_keys(Categories::listAssoc(Categories::HIDDEN_ONLY)); $sql = sprintf('DROP TABLE IF EXISTS services_list_stats; CREATE TEMP TABLE IF NOT EXISTS services_list_stats (id_service, id_user, ok, expired, paid); INSERT INTO services_list_stats SELECT id_service, id_user, CASE WHEN (sub.expiry_date IS NULL OR sub.expiry_date >= date()) AND sub.paid = 1 THEN 1 ELSE 0 END, CASE WHEN sub.expiry_date < date() THEN 1 ELSE 0 END, paid FROM services_subscriptions sub INNER JOIN (SELECT id, MAX(date) FROM services_subscriptions GROUP BY id_user, id_service) sub2 ON sub2.id = sub.id INNER JOIN users u ON u.id = sub.id_user WHERE u.%s', $db->where('id_category', 'NOT IN', $hidden_cats)); $db->exec($sql); $columns = [ 'id' => [], |
︙ | ︙ | |||
124 125 126 127 128 129 130 | 'nb_users_unpaid' => [ 'label' => 'Membres en attente de règlement', 'order' => null, 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)', ], ]; | < < | | | | 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | 'nb_users_unpaid' => [ 'label' => 'Membres en attente de règlement', 'order' => null, 'select' => '(SELECT COUNT(DISTINCT id_user) FROM services_list_stats WHERE id_service = services.id AND paid = 0)', ], ]; $list = new DynamicList($columns, 'services', 'archived = 0'); $list->setPageSize(null); $list->orderBy('label', false); return $list; } static public function hasArchivedServices(): bool { return DB::getInstance()->test(Service::TABLE, 'archived = 1'); } } |
Deleted src/include/lib/Paheko/Services/Services_User.php version [c5d474b2db].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/include/lib/Paheko/Services/Subscriptions.php version [9b8db3b5a6].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | <?php namespace Paheko\Services; use Paheko\CSV_Custom; use Paheko\DB; use Paheko\DynamicList; use Paheko\Utils; use Paheko\UserException; use Paheko\Entities\Services\Subscription; use Paheko\Users\DynamicFields; use Paheko\Users\Users; use KD2\DB\EntityManager; class Subscriptions { static public function get(int $id) { return EntityManager::findOneById(Subscription::class, $id); } static public function countForUser(int $user_id) { return DB::getInstance()->count(Subscription::TABLE, 'id_user = ?', $user_id); } static public function listDistinctForUser(int $user_id) { return DB::getInstance()->get('SELECT s.label, MAX(sub.date) AS last_date, sub.expiry_date AS expiry_date, sf.label AS fee_label, sub.paid, s.end_date, CASE WHEN sub.expiry_date < date() THEN -1 WHEN sub.expiry_date >= date() THEN 1 ELSE 0 END AS status, CASE WHEN s.end_date < date() THEN 1 ELSE 0 END AS archived FROM services_subscriptions sub INNER JOIN services s ON s.id = sub.id_service LEFT JOIN services_fees sf ON sf.id = sub.id_fee WHERE sub.id_user = ? AND s.archived = 0 GROUP BY sub.id_service ORDER BY expiry_date DESC;', $user_id); } static public function perUserList(int $user_id, ?int $only_id = null, ?\DateTime $after = null): DynamicList { $columns = [ 'archived' => [ 'select' => 's.archived', ], 'id' => [ 'select' => 'sub.id', ], 'id_account' => [ 'select' => 'sf.id_account', ], 'id_year' => [ 'select' => 'sf.id_year', ], 'account_code' => [ 'select' => 'a.code', ], 'has_transactions' => [ 'select' => 'tu.id_transaction', ], 'label' => [ 'select' => 's.label', 'label' => 'Activité', ], 'fee' => [ 'label' => 'Tarif', 'select' => 'sf.label', ], 'date' => [ 'label' => 'Date d\'inscription', 'select' => 'sub.date', ], 'expiry' => [ 'label' => 'Date d\'expiration', 'select' => 'MAX(sub.expiry_date)', ], 'paid' => [ 'label' => 'Payé', 'select' => 'sub.paid', ], 'amount' => [ 'label' => 'Reste à régler', 'select' => 'CASE WHEN sub.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END', ], 'expected_amount' => [], ]; $tables = 'services_subscriptions sub INNER JOIN services s ON s.id = sub.id_service LEFT JOIN services_fees sf ON sf.id = sub.id_fee LEFT JOIN acc_accounts a ON sf.id_account = a.id LEFT JOIN acc_transactions_users tu ON tu.id_subscription = sub.id LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction'; $conditions = sprintf('sub.id_user = %d', $user_id); if ($only_id) { $conditions .= sprintf(' AND sub.id = %d', $only_id); } if ($after) { $conditions .= sprintf(' AND sub.date >= %s', DB::getInstance()->quote($after->format('Y-m-d'))); } $list = new DynamicList($columns, $tables, $conditions); $list->setExportCallback(function (&$row) { $row->amount = $row->amount ? Utils::money_format($row->amount, '.', '', false) : null; }); $list->orderBy('date', true); $list->groupBy('sub.id'); $list->setCount('COUNT(DISTINCT sub.id)'); return $list; } static protected function iterateImport(CSV_Custom $csv, array &$errors = null): \Generator { $number_field = DynamicFields::getNumberField(); $services = Services::listAssoc(); $fees = Fees::listGroupedById(); foreach ($csv->iterate() as $i => $row) { try { if (empty($row->$number_field)) { throw new UserException('Aucun numéro de membre n\'a été indiqué'); } $id_user = Users::getIdFromNumber($row->$number_field); if (!$id_user) { throw new UserException(sprintf('Le numéro de membre "%s" n\'existe pas', $row->$number_field)); } $id_service = array_search($row->service, $services); if (!$id_service) { throw new UserException(sprintf('L\'activité "%s" n\'existe pas', $row->service)); } if (empty($row->date)) { throw new UserException('La date est vide'); } $id_fee = null; if (!empty($row->fee)) { foreach ($fees as $fee) { if (strcasecmp($fee->label, $row->fee) === 0 && $fee->id_service === $id_service) { $id_fee = $fee->id; break; } } if (!$id_fee) { throw new UserException(sprintf('Le tarif "%s" n\'existe pas pour cette activité', $row->fee)); } } if (!empty($row->id)) { $su = self::get((int)$row->id); if (!$su) { throw new UserException(sprintf('L\'inscription numéro %d n\'existe pas', $row->id)); } } else { $su = new Subscription; $su->set('id_user', $id_user); $su->set('id_service', $id_service); $su->set('id_fee', $id_fee); } unset($row->fee, $row->service, $row->$number_field, $row->id_service, $row->id_fee, $row->id); if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') { $row->paid = false; } else { $row->paid = true; } $su->importForm((array)$row); yield $i => $su; } catch (UserException $e) { if (null !== $errors) { $errors[] = sprintf('Ligne %d : %s', $i, $e->getMessage()); continue; } throw $e; } } } static public function import(CSV_Custom $csv): void { $db = DB::getInstance(); $db->begin(); foreach (self::iterateImport($csv) as $i => $su) { try { $su->save(); } catch (UserException $e) { throw new UserException(sprintf('Ligne %d : %s', $i, $e->getMessage()), 0, $e); } } $db->commit(); } static public function getList(): DynamicList { $number_field = DynamicFields::getNumberFieldSQL('u'); $name_field = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'number' => [ 'label' => 'Numéro de membre', 'select' => $number_field, 'export' => true, ], 'name' => [ 'label' => 'Nom du membre', 'select' => $name_field, ], 'id' => [ 'label' => 'Numéro d\'inscription', 'select' => 'sub.id', 'export' => true, ], 'service' => [ 'label' => 'Activité', 'select' => 's.label', ], 'fee' => [ 'label' => 'Tarif', 'select' => 'sf.label', ], 'paid' => [ 'label' => 'Payé', 'select' => 'sub.paid', ], 'expected_amount' => [ 'label' => 'Montant de l\'inscription', 'select' => 'sub.expected_amount', 'export' => true, ], 'paid_amount' => [ 'label' => 'Montant réglé', 'select' => 'SUM(tl.credit)', 'export' => true, ], 'left_amount' => [ 'label' => 'Reste à régler', 'select' => 'CASE WHEN sub.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END', ], 'date' => [ 'label' => 'Date d\'inscription', 'select' => 'sub.date', ], 'expiry_date' => [ 'label' => 'Date d\'expiration', 'select' => 'sub.expiry_date', ], 'id_user' => ['select' => 'sub.id_user'], 'id_fee' => ['select' => 'sub.id_fee'], 'id_service' => ['select' => 'sub.id_service'], ]; $tables = 'services_subscriptions sub INNER JOIN services s ON s.id = sub.id_service INNER JOIN users u ON u.id = sub.id_user LEFT JOIN services_fees sf ON sf.id = sub.id_fee LEFT JOIN acc_transactions_users tu ON tu.id_subscription = sub.id LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction'; $list = new DynamicList($columns, $tables); $list->orderBy('id', true); $list->groupBy('sub.id'); $list->setTitle('Historique des inscriptions'); $list->setModifier(function (&$row) { $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date); $row->expiry_date = \DateTime::createFromFormat('!Y-m-d', $row->expiry_date); }); $list->setExportCallback(function (&$row) { $row->paid = $row->paid ? 'Oui' : ''; }); return $list; } static public function listImportColumns(): array { $number_field = DynamicFields::getNumberField(); return [ 'id' => 'Numéro d\'inscription', $number_field => 'Numéro de membre', 'service' => 'Activité', 'fee' => 'Tarif', 'paid' => 'Payé ?', 'expected_amount' => 'Montant à régler', 'date' => 'Date d\'inscription', 'expiry_date' => 'Date d\'expiration', ]; } static public function listMandatoryImportColumns(): array { $number_field = DynamicFields::getNumberField(); return [ $number_field, 'service', 'date', ]; } } |
Modified src/include/lib/Paheko/Upgrade.php from [e3ac5397cd] to [b25d601165].
︙ | ︙ | |||
182 183 184 185 186 187 188 | } if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc2', '<')) { require ROOT . '/include/migrations/1.3/1.3.0-rc2.php'; } if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc5', '<')) { | | | 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | } if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc2', '<')) { require ROOT . '/include/migrations/1.3/1.3.0-rc2.php'; } if (version_compare($v, '1.3.0-alpha1', '>=') && version_compare($v, '1.3.0-rc5', '<')) { throw new UserException('Merci de faire la mise à jour vers la dernière version de la 1.3.0'); } if (version_compare($v, '1.3.0-rc7', '<')) { require ROOT . '/include/migrations/1.3/1.3.0-rc7.php'; } if (version_compare($v, '1.3.0-rc12', '<')) { |
︙ | ︙ | |||
222 223 224 225 226 227 228 229 230 231 232 233 234 235 | } if (version_compare($v, '1.3.5', '<')) { $db->beginSchemaUpdate(); $db->import(ROOT . '/include/migrations/1.3/1.3.5.sql'); $db->commitSchemaUpdate(); } Plugins::upgradeAllIfRequired(); // Vérification de la cohérence des clés étrangères $db->foreignKeyCheck(); // Delete local cached files | > > > > | 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | } if (version_compare($v, '1.3.5', '<')) { $db->beginSchemaUpdate(); $db->import(ROOT . '/include/migrations/1.3/1.3.5.sql'); $db->commitSchemaUpdate(); } if (version_compare($v, '1.4.0', '<')) { require ROOT . '/include/migrations/1.4/1.4.0.php'; } Plugins::upgradeAllIfRequired(); // Vérification de la cohérence des clés étrangères $db->foreignKeyCheck(); // Delete local cached files |
︙ | ︙ |
Modified src/include/lib/Paheko/UserTemplate/CommonFunctions.php from [c020cd06b7] to [7337f3f3bb].
︙ | ︙ | |||
30 31 32 33 34 35 36 37 38 39 40 | 'icon', 'linkbutton', 'linkmenu', 'exportmenu', 'delete_form', 'edit_user_field', 'user_field', ]; static public function input(array $params) { | > | | 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | 'icon', 'linkbutton', 'linkmenu', 'exportmenu', 'delete_form', 'edit_user_field', 'user_field', 'tag', ]; static public function input(array $params) { static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit', 'copy', 'suffix', 'prefix_label', 'prefix_help', 'prefix_required']; // Extract params and keep attributes separated $attributes = array_diff_key($params, array_flip($params_list)); $params = array_intersect_key($params, array_flip($params_list)); extract($params, \EXTR_SKIP); if (!isset($name, $type)) { |
︙ | ︙ | |||
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | $attributes_string = implode(' ', $attributes_string); if (isset($label)) { $label = htmlspecialchars((string)$label); $label = preg_replace_callback('!\[icon=([\w-]+)\]!', fn ($match) => self::icon(['shape' => $match[1]]), $label); } if ($type === 'radio-btn') { if (!empty($attributes['disabled'])) { $attributes['class'] = ($attributes['class'] ?? '') . ' disabled'; } $radio = self::input(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null, 'disabled' => $attributes['disabled'] ?? null])); $input = sprintf('<dd class="radio-btn %s">%s <label for="%s"><div><h3>%s</h3>%s</div></label> </dd>', $attributes['class'] ?? '', $radio, $attributes['id'], $label, isset($params['help']) ? '<p class="help">' . nl2br(htmlspecialchars($params['help'])) . '</p>' : '' ); | > > > > > > > > > > > > > > > > | | 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | $attributes_string = implode(' ', $attributes_string); if (isset($label)) { $label = htmlspecialchars((string)$label); $label = preg_replace_callback('!\[icon=([\w-]+)\]!', fn ($match) => self::icon(['shape' => $match[1]]), $label); } $prefix = ''; if (!empty($params['prefix_label'])) { $prefix .= sprintf('<dt><label for="%s">%s</label>%s</dt>', $attributes['id'], htmlspecialchars($params['prefix_label']), $required_label ); } if (!empty($params['prefix_help'])) { $prefix .= sprintf('<dd class="help">%s</dd>', htmlspecialchars($params['prefix_help']) ); } if ($type === 'radio-btn') { if (!empty($attributes['disabled'])) { $attributes['class'] = ($attributes['class'] ?? '') . ' disabled'; } $radio = self::input(array_merge($params, ['type' => 'radio', 'label' => null, 'help' => null, 'disabled' => $attributes['disabled'] ?? null])); $input = sprintf('<dd class="radio-btn %s">%s <label for="%s"><div><h3>%s</h3>%s</div></label> </dd>', $attributes['class'] ?? '', $radio, $attributes['id'], $label, isset($params['help']) ? '<p class="help">' . nl2br(htmlspecialchars($params['help'])) . '</p>' : '' ); return $prefix . $input; } elseif ($type === 'select') { $input = sprintf('<select %s>', $attributes_string); if (empty($attributes['required']) || isset($attributes['default_empty'])) { $input .= sprintf('<option value="">%s</option>', $attributes['default_empty'] ?? ''); } |
︙ | ︙ | |||
353 354 355 356 357 358 359 | if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) { $input .= sprintf('<label for="%s"></label>', $attributes['id']); } return $input; } | | < < < < < < < < < < < < < < | 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 | if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) { $input .= sprintf('<label for="%s"></label>', $attributes['id']); } return $input; } $out = $prefix; $label = sprintf('<label for="%s">%s</label>', $attributes['id'], $label); if ($type == 'radio' || $type == 'checkbox') { $out .= sprintf('<dd>%s %s', $input, $label); if (isset($help)) { |
︙ | ︙ | |||
902 903 904 905 906 907 908 | if (!empty($params['link_name_id']) && ($name === 'identity' || ($field && $field->isName() && substr($out, 0, 2) !== '<a'))) { $out = sprintf('<a href="%s">%s</a>', Utils::getLocalURL('!users/details.php?id=' . (int)$params['link_name_id']), $out); } return $out; } | | > > > > > | 905 906 907 908 909 910 911 912 913 914 915 916 917 | if (!empty($params['link_name_id']) && ($name === 'identity' || ($field && $field->isName() && substr($out, 0, 2) !== '<a'))) { $out = sprintf('<a href="%s">%s</a>', Utils::getLocalURL('!users/details.php?id=' . (int)$params['link_name_id']), $out); } return $out; } static public function tag(array $params): string { return sprintf('<span class="tag" style="--tag-color: %s;">%s</span>', htmlspecialchars($params['color'] ?? '#999'), htmlspecialchars($params['label'] ?? '')); } } |
Modified src/include/lib/Paheko/UserTemplate/Functions.php from [7fe254a8dc] to [512bf19c21].
︙ | ︙ | |||
14 15 16 17 18 19 20 | use Paheko\DB; use Paheko\DynamicList; use Paheko\Extensions; use Paheko\Template; use Paheko\Utils; use Paheko\UserException; use Paheko\UserTemplate\UserTemplate; | | > | | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | use Paheko\DB; use Paheko\DynamicList; use Paheko\Extensions; use Paheko\Template; use Paheko\Utils; use Paheko\UserException; use Paheko\UserTemplate\UserTemplate; use Paheko\Email\Addresses; use Paheko\Email\Queue; use Paheko\Files\Files; use Paheko\Entities\Files\File; use Paheko\Entities\Module; use Paheko\Entities\Email\Message; use Paheko\Users\DynamicFields; use Paheko\Users\Session; use Paheko\Entities\Accounting\Transaction; use const Paheko\{ROOT, WWW_URL, BASE_URL, SECRET_KEY}; |
︙ | ︙ | |||
443 444 445 446 447 448 449 | if (!count($params['to'])) { throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line)); } foreach ($params['to'] as &$to) { $to = trim($to); | | | 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 | if (!count($params['to'])) { throw new Brindille_Exception(sprintf('Ligne %d: aucune adresse destinataire n\'a été précisée pour la fonction "mail"', $line)); } foreach ($params['to'] as &$to) { $to = trim($to); Addresses::validate($to); } unset($to); // Restrict sending recipients if (!$ut->isTrusted()) { $db = DB::getInstance(); |
︙ | ︙ | |||
487 488 489 490 491 492 493 | if (!$allowed) { throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m)); } } } } | | | > > | 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 | if (!$allowed) { throw new Brindille_Exception(sprintf('Ligne %d: l\'envoi d\'email à une adresse externe interdit l\'utilisation d\'une adresse web autre que le site de l\'association : %s', $line, $m)); } } } } $context = count($params['to']) === 1 ? Message::CONTEXT_PRIVATE : Message::CONTEXT_BULK; $message = Queue::createMessage($context, $params['subject'], $params['body'], $attachments); $message->setAttachments($attachments); $message->queueToArray($params['to']); if (!$ut->isTrusted()) { $internal += $internal_count; $external_count += $external_count; } } |
︙ | ︙ |
Modified src/include/lib/Paheko/UserTemplate/Modifiers.php from [2220119724] to [e428361f7a].
1 2 3 4 5 6 7 8 9 | <?php namespace Paheko\UserTemplate; use Paheko\DB; use Paheko\Utils; use Paheko\UserException; use Paheko\Users\DynamicFields; | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php namespace Paheko\UserTemplate; use Paheko\DB; use Paheko\Utils; use Paheko\UserException; use Paheko\Users\DynamicFields; use Paheko\Email\Addresses; use KD2\SMTP; use KD2\Brindille; use KD2\Brindille_Exception; class Modifiers |
︙ | ︙ | |||
91 92 93 94 95 96 97 | static public function match($str, $pattern) { return (int) (stripos($str, $pattern) !== false); } static public function check_email($str) { | | < < < < < < < < < < < | 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | static public function match($str, $pattern) { return (int) (stripos($str, $pattern) !== false); } static public function check_email($str) { return Addresses::check((string)$str); } /** * UTF-8 aware intelligent substr * @param string $str UTF-8 string * @param integer $length Maximum string length * @param string $placeholder Placeholder text to append at the string if it has been cut |
︙ | ︙ |
Modified src/include/lib/Paheko/UserTemplate/Sections.php from [47a344bbd1] to [ff0321951d].
︙ | ︙ | |||
825 826 827 828 829 830 831 | static public function subscriptions(array $params, UserTemplate $tpl, int $line): \Generator { $params['where'] ??= ''; $number_field = DynamicFields::getNumberField(); | | | | | | | | | | 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 | static public function subscriptions(array $params, UserTemplate $tpl, int $line): \Generator { $params['where'] ??= ''; $number_field = DynamicFields::getNumberField(); $params['select'] = sprintf('sub.expiry_date, sub.date, s.label, sub.paid, sub.expected_amount'); $params['tables'] = 'services_subscriptions sub INNER JOIN services s ON s.id = sub.id_service'; if (isset($params['user'])) { $params['where'] .= ' AND sub.id_user = :id_user'; $params[':id_user'] = (int) $params['user']; unset($params['user']); } if (isset($params['id_service'])) { $params['where'] .= ' AND sub.id_service = :id_service'; $params[':id_service'] = (int) $params['id_service']; unset($params['id_service']); } if (!empty($params['active'])) { $params['having'] = 'MAX(sub.expiry_date) >= date()'; unset($params['active']); } if (isset($params['active']) && empty($params['active'])) { $params['having'] = 'MAX(sub.expiry_date) < date()'; unset($params['active']); } if (empty($params['order'])) { $params['order'] = 'sub.id'; } $params['group'] = 'sub.id_user, sub.id_service'; return self::sql($params, $tpl, $line); } static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator { $db = DB::getInstance(); |
︙ | ︙ |
Modified src/include/lib/Paheko/Users/AdvancedSearch.php from [c95a087831] to [08202a5bdb].
︙ | ︙ | |||
156 157 158 159 160 161 162 | $columns['service'] = [ 'label' => 'Est inscrit à l\'activité', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), 'select' => '\'Inscrit\'', | | | | | | | 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | $columns['service'] = [ 'label' => 'Est inscrit à l\'activité', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), 'select' => '\'Inscrit\'', 'where' => 'id IN (SELECT id_user FROM services_subscriptions WHERE id_service %s)', ]; $columns['service_not'] = [ 'label' => 'N\'est pas inscrit à l\'activité', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), 'select' => '\'Inscrit\'', 'where' => 'id NOT IN (SELECT id_user FROM services_subscriptions WHERE id_service %s)', ]; $columns['service_active'] = [ 'label' => 'Est à jour de l\'activité', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), 'select' => '\'À jour\'', 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_subscriptions WHERE id_service %s GROUP BY id_user) WHERE edate >= date())', ]; $columns['service_expired'] = [ 'label' => 'N\'est pas à jour de l\'activité', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM services ORDER BY label COLLATE U_NOCASE;'), 'select' => '\'Expiré\'', 'where' => 'id IN (SELECT id_user FROM (SELECT id_user, MAX(expiry_date) AS edate FROM services_subscriptions WHERE id_service %s GROUP BY id_user) WHERE edate < date())', ]; $columns['date_login'] = [ 'label' => 'Date de dernière connexion', 'type' => 'date', 'null' => true, ]; return $columns; } public function schemaTables(): array { return [ 'users' => 'Membres', 'users_categories' => 'Catégories de membres', 'services' => 'Activités', 'services_fees' => 'Tarifs des activités', 'services_subscriptions' => 'Inscriptions aux activités', ]; } public function tables(): array { return array_merge(array_keys($this->schemaTables()), [ 'users_search', |
︙ | ︙ |
Modified src/include/lib/Paheko/Users/Users.php from [93d6fdd2e4] to [d2efc79000].
︙ | ︙ | |||
57 58 59 60 61 62 63 64 65 66 67 68 69 70 | static protected function iterateEmails(array $sql, string $email_column = '_email'): \Generator { foreach (DB::getInstance()->iterate(implode(' UNION ALL ', $sql)) as $row) { yield $row->$email_column => $row; } } /** * Return a list for all emails by category * @param int|null $id_category If NULL, then all categories except hidden ones will be returned */ static public function iterateEmailsByCategory(?int $id_category = null): iterable { | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | static protected function iterateEmails(array $sql, string $email_column = '_email'): \Generator { foreach (DB::getInstance()->iterate(implode(' UNION ALL ', $sql)) as $row) { yield $row->$email_column => $row; } } /** * Return a list for all emails for a specific mailing checkbox */ static public function iterateEmailsByField(string $field_name, $field_value): iterable { $db = DB::getInstance(); $field = DynamicFields::get($field_name); if (!$field) { throw new \InvalidArgumentException('Unknown field: ' . $field_name); } if (is_bool($field_value)) { $field_value = (int)$field_value; } else { $field_value = $db->quote($field_value); } $sql = []; $where = sprintf('%s = %d', $db->quoteIdentifier($field->name), $field_value); $where .= ' AND id_category IN (SELECT id FROM users_categories WHERE hidden = 0)'; $fields = DynamicFields::getEmailFields(); foreach ($fields as $field) { $sql[] = sprintf('SELECT *, %s AS _email, NULL AS preferences FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where); } return self::iterateEmails($sql); } /** * Return a list for all emails by category * @param int|null $id_category If NULL, then all categories except hidden ones will be returned */ static public function iterateEmailsByCategory(?int $id_category = null): iterable { |
︙ | ︙ | |||
88 89 90 91 92 93 94 | $db = DB::getInstance(); // Create a temporary table if (!$db->test('sqlite_temp_master', 'type = \'table\' AND name=\'users_active_services\'')) { $db->exec('DROP TABLE IF EXISTS users_active_services; CREATE TEMPORARY TABLE IF NOT EXISTS users_active_services (id, service); INSERT INTO users_active_services SELECT id_user, id_service FROM ( | | | 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | $db = DB::getInstance(); // Create a temporary table if (!$db->test('sqlite_temp_master', 'type = \'table\' AND name=\'users_active_services\'')) { $db->exec('DROP TABLE IF EXISTS users_active_services; CREATE TEMPORARY TABLE IF NOT EXISTS users_active_services (id, service); INSERT INTO users_active_services SELECT id_user, id_service FROM ( SELECT id_user, id_service, MAX(expiry_date) FROM services_subscriptions WHERE expiry_date IS NULL OR expiry_date >= date() GROUP BY id_user, id_service ); DELETE FROM users_active_services WHERE id IN (SELECT id FROM users WHERE id_category IN (SELECT id FROM users_categories WHERE hidden =1));'); } $fields = DynamicFields::getEmailFields(); |
︙ | ︙ |
Deleted src/include/migrations/1.3/1.3.0-rc5.php version [04b6f67038].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/include/migrations/1.3/1.3.0-rc5.sql version [870ceef216].
|
| < < < < < < < < |
Deleted src/include/migrations/1.3/schema.sql version [65f1fda055].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/include/migrations/1.4/1.4.0.php version [ad50315ca3].
> > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 | <?php namespace Paheko; use Paheko\Accounting\Charts; Charts::resetRules(['FR']); $db->beginSchemaUpdate(); $db->import(ROOT . '/include/migrations/1.4/1.4.0.sql'); $db->commitSchemaUpdate(); |
Added src/include/migrations/1.4/1.4.0.sql version [0cc6ba4452].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | -- Delete old unmaintained plugin DELETE FROM plugins_signals WHERE plugin = 'git_documents'; DELETE FROM plugins WHERE name = 'git_documents'; -- Fix access level of number field UPDATE config_users_fields SET user_access_level = 1 WHERE user_access_level = 2 AND name = 'numero'; -- Update services to add archived column ALTER TABLE services RENAME TO services_old; CREATE TABLE IF NOT EXISTS services -- Services types (French: cotisations) ( id INTEGER PRIMARY KEY NOT NULL, label TEXT NOT NULL, description TEXT NULL, duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))), archived INTEGER NOT NULL DEFAULT 0 ); INSERT INTO services SELECT *, CASE WHEN end_date IS NOT NULL AND end_date < datetime() THEN 1 ELSE 0 END FROM services_old; DROP TABLE services_old; -- Update services_reminders to add not_before_date ALTER TABLE services_reminders RENAME TO services_reminders_old; CREATE TABLE IF NOT EXISTS services_reminders -- Reminders for service expiry ( id INTEGER NOT NULL PRIMARY KEY, id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, delay INTEGER NOT NULL, -- Delay in days before or after expiry date subject TEXT NOT NULL, body TEXT NOT NULL, not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date ); INSERT INTO services_reminders SELECT *, NULL FROM services_reminders_old; DROP TABLE services_reminders_old; ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old; DROP INDEX IF EXISTS srs_index; DROP INDEX IF EXISTS srs_reminder; DROP INDEX IF EXISTS srs_user; -- Allow NULL for id_reminder CREATE TABLE IF NOT EXISTS services_reminders_sent -- Records of sent reminders, to keep track ( id INTEGER NOT NULL PRIMARY KEY, id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL, sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) ); INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old; DROP TABLE services_reminders_sent_old; CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); -- Rename services_users to services_subscriptions DROP INDEX IF EXISTS acc_transactions_users_service; ALTER TABLE services_users RENAME TO services_subscriptions; ALTER TABLE acc_transactions_users RENAME TO acc_transactions_users_old; CREATE TABLE IF NOT EXISTS acc_transactions_users -- Linking transactions and users ( id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, id_subscription INTEGER NULL REFERENCES services_subscriptions (id) ON DELETE CASCADE, PRIMARY KEY (id_transaction, id_user, id_subscription) ); INSERT INTO acc_transactions_users SELECT id_transaction, id_user, id_service_user FROM acc_transactions_users_old; DROP TABLE acc_transactions_users_old; CREATE INDEX IF NOT EXISTS acc_transactions_users_transaction ON acc_transactions_users (id_transaction); CREATE INDEX IF NOT EXISTS acc_transactions_user ON acc_transactions_users (id_user); CREATE INDEX IF NOT EXISTS acc_transactions_subscription ON acc_transactions_users (id_subscription); -- Update mailings ALTER TABLE mailings RENAME TO mailings_old; DROP INDEX IF EXISTS mailings_sent; CREATE TABLE IF NOT EXISTS mailings ( id INTEGER NOT NULL PRIMARY KEY, subject TEXT NOT NULL, body TEXT NULL, target_type TEXT NULL, target_value TEXT NULL, target_label TEXT NULL, sender_name TEXT NULL, sender_email TEXT NULL, sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent), anonymous INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent); INSERT INTO mailings (id, subject, body, sender_name, sender_email, sent, anonymous) SELECT id, subject, body, sender_name, sender_email, sent, anonymous FROM mailings_old; DROP TABLE mailings_old; CREATE TABLE IF NOT EXISTS mailings_optouts ( email_hash TEXT NOT NULL, target_type TEXT NOT NULL, target_value TEXT NOT NULL, target_label TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value); ALTER TABLE emails_queue RENAME TO emails_queue_old; CREATE TABLE IF NOT EXISTS emails_queue ( -- List of emails waiting to be sent id INTEGER NOT NULL PRIMARY KEY, sender TEXT NULL, recipient TEXT NOT NULL, recipient_hash TEXT NOT NULL, recipient_pgp_key TEXT NULL, subject TEXT NOT NULL, body TEXT NOT NULL, html_body TEXT NULL, added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(added) = added), status INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start sending_started TEXT NULL CHECK (datetime(sending_started) IS NULL OR datetime(sending_started) = sending_started), -- Will be filled with the datetime when the email queue sending has started context INTEGER NOT NULL ); INSERT INTO emails_queue SELECT id, sender, recipient, recipient_hash, recipient_pgp_key, subject, content, content_html, datetime(), sending, sending_started, context FROM emails_queue_old; CREATE INDEX IF NOT EXISTS emails_queue_status ON emails_queue (status); DROP TABLE emails_queue_old; CREATE TABLE IF NOT EXISTS emails_addresses ( -- List of emails addresses -- We are not storing actual email addresses here for privacy reasons -- So that we can keep the record (for opt-out reasons) even when the -- email address has been removed from the users table id INTEGER NOT NULL PRIMARY KEY, hash TEXT NOT NULL, status INTEGER NOT NULL, bounce_count INTEGER NOT NULL DEFAULT 0, sent_count INTEGER NOT NULL DEFAULT 0, log TEXT NULL, last_sent TEXT NULL, added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO emails_addresses SELECT id, hash, CASE WHEN invalid = 1 THEN -3 WHEN optout = 1 THEN -4 WHEN verified = 1 THEN 1 WHEN fail_count > 5 THEN -2 ELSE 0 END, fail_count, sent_count, fail_log, last_sent, added FROM emails; DROP TABLE emails; CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails_addresses (hash); ALTER TABLE mailings_recipients RENAME TO mailings_recipients_old; CREATE TABLE IF NOT EXISTS mailings_recipients ( id INTEGER NOT NULL PRIMARY KEY, id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE, email TEXT NULL, id_email TEXT NULL REFERENCES emails_addresses (id) ON DELETE CASCADE, extra_data TEXT NULL ); INSERT INTO mailings_recipients SELECT * FROM mailings_recipients_old; DROP INDEX mailings_recipients_id; CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id); DROP TABLE mailings_recipients_old; |
Added src/include/migrations/schema.sql version [1057212392].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 | --- --- This file contains the schema used when doing a fresh install --- Any schema change must be done in this file and the migration as well! --- CREATE TABLE IF NOT EXISTS config ( -- Configuration, key/value store key TEXT PRIMARY KEY NOT NULL, value TEXT NULL ); CREATE TABLE IF NOT EXISTS config_users_fields ( id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, sort_order INTEGER NOT NULL, type TEXT NOT NULL, label TEXT NOT NULL, help TEXT NULL, required INTEGER NOT NULL DEFAULT 0, user_access_level INTEGER NOT NULL DEFAULT 0, management_access_level INTEGER NOT NULL DEFAULT 1, list_table INTEGER NOT NULL DEFAULT 0, options TEXT NULL, default_value TEXT NULL, sql TEXT NULL, system TEXT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS config_users_fields_name ON config_users_fields (name); CREATE TABLE IF NOT EXISTS plugins ( id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, label TEXT NOT NULL, description TEXT NULL, author TEXT NULL, author_url TEXT NULL, version TEXT NOT NULL, menu INT NOT NULL DEFAULT 0, home_button INT NOT NULL DEFAULT 0, restrict_section TEXT NULL, restrict_level INT NULL, config TEXT NULL, enabled INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS plugins_name ON plugins (name); CREATE TABLE IF NOT EXISTS plugins_signals -- Link between plugins and signals ( signal TEXT NOT NULL, plugin TEXT NOT NULL REFERENCES plugins (name), callback TEXT NOT NULL, PRIMARY KEY (signal, plugin) ); CREATE TABLE IF NOT EXISTS modules -- List of modules ( id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, label TEXT NOT NULL, description TEXT NULL, author TEXT NULL, author_url TEXT NULL, menu INT NOT NULL DEFAULT 0, home_button INT NOT NULL DEFAULT 0, restrict_section TEXT NULL, restrict_level INT NULL, config TEXT NULL, enabled INTEGER NOT NULL DEFAULT 0, web INTEGER NOT NULL DEFAULT 0, system INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS modules_name ON modules (name); CREATE TABLE IF NOT EXISTS modules_templates -- List of forms special templates ( id INTEGER NOT NULL PRIMARY KEY, id_module INTEGER NOT NULL REFERENCES modules (id) ON DELETE CASCADE, name TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS modules_templates_name ON modules_templates (id_module, name); CREATE TABLE IF NOT EXISTS api_credentials ( id INTEGER NOT NULL PRIMARY KEY, label TEXT NOT NULL, key TEXT NOT NULL, secret TEXT NOT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_use TEXT NULL, access_level INT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS api_credentials_key ON api_credentials (key); CREATE TABLE IF NOT EXISTS searches -- Saved searches ( id INTEGER NOT NULL PRIMARY KEY, id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, -- If not NULL, then search will only be visible by this user label TEXT NOT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), target TEXT NOT NULL, -- "users" ou "accounting" type TEXT NOT NULL, -- "json" ou "sql" content TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS compromised_passwords_cache -- Cache des hash de mots de passe compromis ( hash TEXT NOT NULL PRIMARY KEY ); CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges -- Cache des préfixes de mots de passe compromis ( prefix TEXT NOT NULL PRIMARY KEY, date INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS emails_addresses ( -- List of emails addresses -- We are not storing actual email addresses here for privacy reasons -- So that we can keep the record (for opt-out reasons) even when the -- email address has been removed from the users table id INTEGER NOT NULL PRIMARY KEY, hash TEXT NOT NULL, status INTEGER NOT NULL, bounce_count INTEGER NOT NULL DEFAULT 0, sent_count INTEGER NOT NULL DEFAULT 0, log TEXT NULL, last_sent TEXT NULL, added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails_addresses (hash); CREATE TABLE IF NOT EXISTS emails_queue ( -- List of emails waiting to be sent id INTEGER NOT NULL PRIMARY KEY, sender TEXT NULL, recipient TEXT NOT NULL, recipient_hash TEXT NOT NULL, recipient_pgp_key TEXT NULL, subject TEXT NOT NULL, body TEXT NOT NULL, html_body TEXT NULL, added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(added) = added), status INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start sending_started TEXT NULL CHECK (datetime(sending_started) IS NULL OR datetime(sending_started) = sending_started), -- Will be filled with the datetime when the email queue sending has started context INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS emails_queue_status ON emails_queue (status); CREATE TABLE IF NOT EXISTS emails_queue_attachments ( id INTEGER NOT NULL PRIMARY KEY, id_queue INTEGER NOT NULL REFERENCES emails_queue (id) ON DELETE CASCADE, path TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS mailings ( id INTEGER NOT NULL PRIMARY KEY, subject TEXT NOT NULL, body TEXT NULL, target_type TEXT NULL, target_value TEXT NULL, target_label TEXT NULL, sender_name TEXT NULL, sender_email TEXT NULL, sent TEXT NULL CHECK (datetime(sent) IS NULL OR datetime(sent) = sent), anonymous INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS mailings_sent ON mailings (sent); CREATE TABLE IF NOT EXISTS mailings_recipients ( id INTEGER NOT NULL PRIMARY KEY, id_mailing INTEGER NOT NULL REFERENCES mailings (id) ON DELETE CASCADE, email TEXT NULL, id_email TEXT NULL REFERENCES emails_addresses (id) ON DELETE CASCADE, extra_data TEXT NULL ); CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id); CREATE TABLE IF NOT EXISTS mailings_optouts ( email_hash TEXT NOT NULL, target_type TEXT NOT NULL, target_value TEXT NOT NULL, target_label TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS mailings_optouts_unique ON mailings_optouts (email_hash, target_type, target_value); --- --- Users --- -- CREATE TABLE users (...); -- Organization users table, dynamically created, see config_users_fields table CREATE TABLE IF NOT EXISTS users_categories -- Users categories, mainly used to manage rights ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, -- Permissions, 0 = no access, 1 = read-only, 2 = read-write, 9 = admin perm_web INTEGER NOT NULL DEFAULT 1, perm_documents INTEGER NOT NULL DEFAULT 1, perm_users INTEGER NOT NULL DEFAULT 1, perm_accounting INTEGER NOT NULL DEFAULT 1, perm_subscribe INTEGER NOT NULL DEFAULT 0, perm_connect INTEGER NOT NULL DEFAULT 1, perm_config INTEGER NOT NULL DEFAULT 0, hidden INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS users_categories_hidden ON users_categories (hidden); CREATE INDEX IF NOT EXISTS users_categories_name ON users_categories (name); CREATE INDEX IF NOT EXISTS users_categories_hidden_name ON users_categories (hidden, name); CREATE TABLE IF NOT EXISTS users_sessions -- Permanent sessions for logged-in users ( selector TEXT NOT NULL PRIMARY KEY, hash TEXT NOT NULL, id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, expiry INT NOT NULL ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER NOT NULL PRIMARY KEY, id_user INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, type INTEGER NOT NULL, details TEXT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(created) IS NOT NULL AND datetime(created) = created), ip_address TEXT NULL ); CREATE INDEX IF NOT EXISTS logs_ip ON logs (ip_address, type, created); CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, type, created); CREATE INDEX IF NOT EXISTS logs_created ON logs (created); --- --- Services --- CREATE TABLE IF NOT EXISTS services -- Services types (French: cotisations) ( id INTEGER PRIMARY KEY NOT NULL, label TEXT NOT NULL, description TEXT NULL, duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date), end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date))) ); CREATE TABLE IF NOT EXISTS services_fees -- Services fees ( id INTEGER PRIMARY KEY NOT NULL, label TEXT NOT NULL, description TEXT NULL, amount INTEGER NULL, formula TEXT NULL, -- Formula to calculate fee amount dynamically (this contains a SQL statement) id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL if fee is not linked to accounting, this is reset using a trigger if the year is deleted id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL, -- NULL if fee is not linked to accounting id_project INTEGER NULL REFERENCES acc_projects (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS services_subscriptions -- Records of services and fees linked to users ( id INTEGER NOT NULL PRIMARY KEY, id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE, -- This can be NULL if there is no fee for the service paid INTEGER NOT NULL DEFAULT 0, expected_amount INTEGER NULL, date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date) ); CREATE UNIQUE INDEX IF NOT EXISTS services_subscriptions_unique ON services_subscriptions (id_user, id_service, id_fee, date); CREATE INDEX IF NOT EXISTS su_service ON services_subscriptions (id_service); CREATE INDEX IF NOT EXISTS su_fee ON services_subscriptions (id_fee); CREATE INDEX IF NOT EXISTS su_paid ON services_subscriptions (paid); CREATE INDEX IF NOT EXISTS su_expiry ON services_subscriptions (expiry_date); CREATE TABLE IF NOT EXISTS services_reminders -- Reminders for service expiry ( id INTEGER NOT NULL PRIMARY KEY, id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, delay INTEGER NOT NULL, -- Delay in days before or after expiry date subject TEXT NOT NULL, body TEXT NOT NULL, not_before_date TEXT NULL CHECK (date(not_before_date) IS NULL OR date(not_before_date) = not_before_date) -- Don't send reminder to users if they expire before this date ); CREATE TABLE IF NOT EXISTS services_reminders_sent -- Records of sent reminders, to keep track ( id INTEGER NOT NULL PRIMARY KEY, id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE, id_reminder INTEGER NULL REFERENCES services_reminders (id) ON DELETE SET NULL, sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date), due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date) ); CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date); CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder); CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user); -- -- Accounting -- CREATE TABLE IF NOT EXISTS acc_charts -- Accounting charts (plans comptables) ( id INTEGER NOT NULL PRIMARY KEY, country TEXT NOT NULL, code TEXT NULL, -- the code is NULL if the chart is user-created or imported label TEXT NOT NULL, archived INTEGER NOT NULL DEFAULT 0 -- 1 = archived, cannot be changed ); CREATE TABLE IF NOT EXISTS acc_accounts -- Accounts of the charts (comptes) ( id INTEGER NOT NULL PRIMARY KEY, id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ON DELETE CASCADE, code TEXT NOT NULL, -- can contain numbers and letters, eg. 53A, 53B... label TEXT NOT NULL, description TEXT NULL, position INTEGER NOT NULL, -- position in the balance sheet (position actif/passif/charge/produit) type INTEGER NOT NULL DEFAULT 0, -- type (category) of favourite account: bank, cash, third party, etc. user INTEGER NOT NULL DEFAULT 1, -- 0 = is part of the original chart, 0 = has been added by the user bookmark INTEGER NOT NULL DEFAULT 0 -- 1 = is marked as favorite ); CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart); CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type); CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position); CREATE INDEX IF NOT EXISTS acc_accounts_bookmarks ON acc_accounts (id_chart, bookmark, code); -- Balance des comptes par exercice CREATE VIEW IF NOT EXISTS acc_accounts_balances AS SELECT id_year, id, label, code, type, debit, credit, bookmark, CASE -- 3 = dynamic asset or liability depending on balance WHEN position = 3 AND (debit - credit) > 0 THEN 1 -- 1 = Asset (actif) comptes fournisseurs, tiers créditeurs WHEN position = 3 THEN 2 -- 2 = Liability (passif), comptes clients, tiers débiteurs ELSE position END AS position, CASE WHEN position IN (1, 4) -- 1 = asset, 4 = expense OR (position = 3 AND (debit - credit) > 0) THEN debit - credit ELSE credit - debit END AS balance, CASE WHEN debit - credit > 0 THEN 1 ELSE 0 END AS is_debt FROM ( SELECT t.id_year, a.id, a.label, a.code, a.position, a.type, a.bookmark, SUM(l.credit) AS credit, SUM(l.debit) AS debit FROM acc_accounts a INNER JOIN acc_transactions_lines l ON l.id_account = a.id INNER JOIN acc_transactions t ON t.id = l.id_transaction GROUP BY t.id_year, a.id ); CREATE TABLE IF NOT EXISTS acc_projects -- Analytical projects ( id INTEGER NOT NULL PRIMARY KEY, code TEXT NULL, label TEXT NOT NULL, description TEXT NULL, archived INTEGER NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX IF NOT EXISTS acc_projects_code ON acc_projects (code); CREATE INDEX IF NOT EXISTS acc_projects_list ON acc_projects (archived, code); CREATE TABLE IF NOT EXISTS acc_years -- Years (exercices) ( id INTEGER NOT NULL PRIMARY KEY, label TEXT NOT NULL, start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date), end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date), closed INTEGER NOT NULL DEFAULT 0, -- 0 = open, 1 = closed id_chart INTEGER NOT NULL REFERENCES acc_charts (id) ); CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed); -- Make sure id_account is reset when a year is deleted CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id; END; CREATE TABLE IF NOT EXISTS acc_transactions -- Transactions (écritures comptables) ( id INTEGER PRIMARY KEY NOT NULL, type INTEGER NOT NULL DEFAULT 0, -- Transaction type, zero is advanced status INTEGER NOT NULL DEFAULT 0, -- Status (bitmask) label TEXT NOT NULL, notes TEXT NULL, reference TEXT NULL, -- N° de pièce comptable date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date), hash TEXT NULL, prev_id INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL, prev_hash TEXT NULL, id_year INTEGER NOT NULL REFERENCES acc_years(id), id_creator INTEGER NULL REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year); CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date); CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year); CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status); CREATE INDEX IF NOT EXISTS acc_transactions_hash ON acc_transactions (hash); CREATE INDEX IF NOT EXISTS acc_transactions_reference ON acc_transactions (reference); CREATE TABLE IF NOT EXISTS acc_transactions_lines -- Transactions lines (lignes des écritures) ( id INTEGER PRIMARY KEY NOT NULL, id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, id_account INTEGER NOT NULL REFERENCES acc_accounts (id), credit INTEGER NOT NULL, debit INTEGER NOT NULL, reference TEXT NULL, -- Usually a payment reference (par exemple numéro de chèque) label TEXT NULL, reconciled INTEGER NOT NULL DEFAULT 0, id_project INTEGER NULL REFERENCES acc_projects(id) ON DELETE SET NULL, CONSTRAINT line_check1 CHECK ((credit * debit) = 0), CONSTRAINT line_check2 CHECK ((credit + debit) > 0) ); CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction); CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account); CREATE INDEX IF NOT EXISTS acc_transactions_lines_project ON acc_transactions_lines (id_project); CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled); CREATE TABLE IF NOT EXISTS acc_transactions_links ( id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE, id_related INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE CHECK (id_transaction != id_related), PRIMARY KEY (id_transaction, id_related) ); CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_transaction ON acc_transactions_links (id_transaction); CREATE INDEX IF NOT EXISTS acc_transactions_lines_id_related ON acc_transactions_links (id_related); CREATE TABLE IF NOT EXISTS acc_transactions_users -- Linking transactions and users ( id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE, id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, id_subscription INTEGER NULL REFERENCES services_subscriptions (id) ON DELETE CASCADE, PRIMARY KEY (id_transaction, id_user, id_subscription) ); CREATE INDEX IF NOT EXISTS acc_transactions_users_transaction ON acc_transactions_users (id_transaction); CREATE INDEX IF NOT EXISTS acc_transactions_user ON acc_transactions_users (id_user); CREATE INDEX IF NOT EXISTS acc_transactions_subscription ON acc_transactions_users (id_subscription); ---------- FILES ---------------- CREATE TABLE IF NOT EXISTS files -- Files metadata ( id INTEGER NOT NULL PRIMARY KEY, path TEXT NOT NULL, parent TEXT NULL REFERENCES files(path) ON DELETE CASCADE ON UPDATE CASCADE, name TEXT NOT NULL, -- File name type INTEGER NOT NULL, -- File type, 1 = file, 2 = directory mime TEXT NULL, size INT NULL, modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), image INT NOT NULL DEFAULT 0, md5 TEXT NULL, trash TEXT NULL CHECK (datetime(trash) IS NULL OR datetime(trash) = trash), CHECK (type = 2 OR (mime IS NOT NULL AND size IS NOT NULL)) ); -- Unique index as this is used to make up a file path CREATE UNIQUE INDEX IF NOT EXISTS files_unique ON files (path); CREATE INDEX IF NOT EXISTS files_parent ON files (parent); CREATE INDEX IF NOT EXISTS files_type_parent ON files (type, parent, path); CREATE INDEX IF NOT EXISTS files_name ON files (name); CREATE INDEX IF NOT EXISTS files_modified ON files (modified); CREATE INDEX IF NOT EXISTS files_trash ON files (trash); CREATE INDEX IF NOT EXISTS files_size ON files (size); CREATE TABLE IF NOT EXISTS files_contents -- Files contents (empty if using another storage backend) ( id INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, content BLOB NOT NULL ); CREATE VIRTUAL TABLE IF NOT EXISTS files_search USING fts4 -- Search inside files content ( tokenize=unicode61, -- Available from SQLITE 3.7.13 (2012) path TEXT NOT NULL, title TEXT NOT NULL, content TEXT NULL, -- Text content notindexed=path ); -- Delete/insert search item when item is deleted/inserted from files CREATE TRIGGER IF NOT EXISTS files_search_bd BEFORE DELETE ON files BEGIN DELETE FROM files_search WHERE docid = OLD.rowid; END; CREATE TRIGGER IF NOT EXISTS files_search_ai AFTER INSERT ON files BEGIN INSERT INTO files_search (docid, path, title, content) VALUES (NEW.rowid, NEW.path, NEW.name, NULL); END; CREATE TRIGGER IF NOT EXISTS files_search_au AFTER UPDATE OF name, path ON files BEGIN UPDATE files_search SET path = NEW.path, title = NEW.name WHERE docid = NEW.rowid; END; CREATE TABLE IF NOT EXISTS acc_transactions_files -- Link between transactions and files ( id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, id_transaction INTEGER NOT NULL REFERENCES acc_transactions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS acc_transactions_files_transaction ON acc_transactions_files (id_transaction); CREATE TABLE IF NOT EXISTS users_files -- Link between users and files ( id_file INTEGER NOT NULL PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, id_user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, field TEXT NOT NULL REFERENCES config_users_fields (name) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS users_files_user ON users_files (id_user); CREATE INDEX IF NOT EXISTS users_files_user_field ON users_files (id_user, field); CREATE TABLE IF NOT EXISTS web_pages ( id INTEGER NOT NULL PRIMARY KEY, id_parent INTEGER NULL REFERENCES web_pages(id) ON DELETE CASCADE, uri TEXT NOT NULL, -- Page identifier type INTEGER NOT NULL, -- 1 = Category, 2 = Page status TEXT NOT NULL, format TEXT NOT NULL, published TEXT NOT NULL CHECK (datetime(published) IS NOT NULL AND datetime(published) = published), modified TEXT NOT NULL CHECK (datetime(modified) IS NOT NULL AND datetime(modified) = modified), title TEXT NOT NULL, content TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri); CREATE INDEX IF NOT EXISTS web_pages_id_parent ON web_pages (id_parent); CREATE INDEX IF NOT EXISTS web_pages_published ON web_pages (published); CREATE INDEX IF NOT EXISTS web_pages_title ON web_pages (title); CREATE TABLE IF NOT EXISTS web_pages_versions ( id INTEGER NOT NULL PRIMARY KEY, id_page INTEGER NOT NULL REFERENCES web_pages ON DELETE CASCADE, id_user INTEGER NULL REFERENCES users (id) ON DELETE SET NULL, date TEXT NOT NULL CHECK (datetime(date) IS NOT NULL AND datetime(date) = date), size INTEGER NOT NULL, changes INTEGER NOT NULL, content TEXT NOT NULL ); |
Modified src/templates/_head.tpl from [d03c159649] to [94d4befe67].
︙ | ︙ | |||
87 88 89 90 91 92 93 | <li class="{if $current == 'users'} current{elseif $current_parent == 'users'} current_parent{/if}"><h3><a href="{$admin_uri}users/" accesskey="U">{icon shape="users"}<b>Membres</b></a></h3> <ul> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} <li{if $current == 'users/new'} class="current"{/if}><a href="{$admin_uri}users/new.php" accesskey="A">Ajouter</a></li> {/if} <li{if $current == 'users/services'} class="current"{/if}><a href="{$admin_uri}services/">Activités & cotisations</a></li> {if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} | | | 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | <li class="{if $current == 'users'} current{elseif $current_parent == 'users'} current_parent{/if}"><h3><a href="{$admin_uri}users/" accesskey="U">{icon shape="users"}<b>Membres</b></a></h3> <ul> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} <li{if $current == 'users/new'} class="current"{/if}><a href="{$admin_uri}users/new.php" accesskey="A">Ajouter</a></li> {/if} <li{if $current == 'users/services'} class="current"{/if}><a href="{$admin_uri}services/">Activités & cotisations</a></li> {if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} <li{if $current == 'users/mailing'} class="current"{/if}><a href="{$admin_uri}users/email/mailing/">Messages collectifs</a></li> {/if} </ul> </li> {/if} {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)} <li class="{if $current == 'acc'} current{elseif $current_parent == 'acc'} current_parent{/if}"><h3><a href="{$admin_uri}acc/">{icon shape="money"}<b>Comptabilité</b></a></h3> <ul> |
︙ | ︙ |
Modified src/templates/acc/transactions/details.tpl from [f4d950d530] to [b90ac4724c].
︙ | ︙ | |||
220 221 222 223 224 225 226 | <table class="list"> <caption>Inscriptions liées</caption> <tbody> {foreach from=$linked_subscriptions item="s"} <tr> <td class="num">{link href="!users/details.php?id=%d"|args:$s.id_user label=$s.user_number}</td> <td>{$s.user_identity}</td> | > | | 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | <table class="list"> <caption>Inscriptions liées</caption> <tbody> {foreach from=$linked_subscriptions item="s"} <tr> <td class="num">{link href="!users/details.php?id=%d"|args:$s.id_user label=$s.user_number}</td> <td>{$s.user_identity}</td> <td><small>{$s.label}</small></td> <td class="actions">{linkbutton href="!users/subscriptions.php?id=%d&only=%s"|args:$s.id_user:$s.id_subscription label="Inscription" shape="eye"}</td> </tr> {/foreach} </tbody> </table> {/if} {if count($linked_transactions)} |
︙ | ︙ |
Deleted src/templates/acc/transactions/service_user.tpl version [6640beb19f].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/templates/acc/transactions/subscription.tpl version [970268d6e6].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | {include file="_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"} <nav class="tabs"> {linkbutton href="!users/details.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="user"} {linkbutton href="!services/subscription/payment.php?id=%d"|args:$subscription_id label="Nouveau règlement" shape="plus" target="_dialog"} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} {linkbutton href="!services/subscription/link.php?id=%d"|args:$subscription_id label="Lier à une écriture" shape="check" target="_dialog"} {/if} </nav> {if empty($balance)} <p class="alert block">Aucune écriture n'est liée à cette inscription.</p> {else} {include file="acc/reports/_journal.tpl"} <h2 class="ruler">Solde des comptes</h2> <table class="list"> <thead> <tr> <td>Numéro</td> <th>Compte</th> <td class="money">Solde</td> </tr> </thead> <tbody> {foreach from=$balance item="account"} <tr> <td class="num"><a href="{$admin_url}acc/accounts/journal.php?id={$account.id}">{$account.code}</a></td> <th>{$account.label}</th> <td class="money">{$account.balance|raw|money:false}</td> </tr> {/foreach} </tbody> </table> {/if} {include file="_foot.tpl"} |
Modified src/templates/config/_menu.tpl from [8e80c00714] to [e3a976c730].
1 2 3 4 5 6 7 8 9 10 11 12 | {if !$dialog} <?php $sub_current ??= null; ?> <nav class="tabs"> <ul> <li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Configuration</a></li> <li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li> <li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}config/users/">Membres</a></li> <li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li> <li{if $current == 'ext'} class="current"{/if}><a href="{$admin_url}config/ext/">Extensions</a></li> <li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li> </ul> | | | > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | {if !$dialog} <?php $sub_current ??= null; ?> <nav class="tabs"> <ul> <li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Configuration</a></li> <li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li> <li{if $current == 'users'} class="current"{/if}><a href="{$admin_url}config/users/">Membres</a></li> <li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li> <li{if $current == 'ext'} class="current"{/if}><a href="{$admin_url}config/ext/">Extensions</a></li> <li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li> </ul> {if $current === 'users'} {if $sub_current === 'fields'} <aside>{linkbutton shape="plus" label="Ajouter un champ" href="new.php"}</aside> {/if} <ul class="sub"> <li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/users/">Préférences</a></li> <li{if $sub_current == 'fields'} class="current"{/if}><a href="{$admin_url}config/fields/">Fiche des membres</a></li> <li{if $sub_current == 'categories'} class="current"{/if}><a href="{$admin_url}config/categories/">Catégories & droits des membres</a></li> </ul> {elseif $current == 'advanced'} {if $sub_current === 'api'} <aside>{linkbutton shape="help" label="Documentation de l'API" href=$api_doc_url target="_dialog"}</aside> {/if} <ul class="sub"> <li{if $sub_current == 'audit'} class="current"{/if}><a href="{$admin_url}config/advanced/audit.php">Journal d'audit</a></li> <li{if $sub_current == 'api'} class="current"{/if}><a href="{$admin_url}config/advanced/api.php">Accès à l'API</a></li> <li{if $sub_current == 'sql'} class="current"{/if}><a href="{$admin_url}config/advanced/sql.php">SQL</a></li> {if ENABLE_TECH_DETAILS} <li{if $sub_current == 'errors'} class="current"{/if}><a href="{$admin_url}config/advanced/errors.php">Erreurs système</a></li> {if SQL_DEBUG} |
︙ | ︙ |
Modified src/templates/config/advanced/api.tpl from [d8e3b6d3d4] to [2a2af2a699].
︙ | ︙ | |||
44 45 46 47 48 49 50 | </form> {/if} <form method="post" action=""> <fieldset> <legend>Créer un nouvel identifiant</legend> <p class="help"> | | < | 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | </form> {/if} <form method="post" action=""> <fieldset> <legend>Créer un nouvel identifiant</legend> <p class="help"> Cet identifiant vous permettra de faire des requêtes vers <a href="{$api_doc_url}" target="_dialog">l'API</a>, pour modifier ou récupérer les informations de votre association.<br /> </p> <dl> {input type="text" name="label" label="Description" required=true} {input type="text" name="key" label="Identifiant" help="Seules les lettres minuscules, chiffres et tirets bas sont acceptés." pattern="[a-z0-9_]+" required=true default=$default_key} {input type="text" label="Mot de passe" default=$secret readonly="readonly" help="Ce mot de passe ne sera plus affiché, il est conseillé de le copier/coller et l'enregistrer de votre côté." name="secret" copy=true} {input type="select" required=true label="Autorisation d'accès" options=$access_levels name="access_level"} </dl> |
︙ | ︙ |
Modified src/templates/services/_nav.tpl from [1d93ffcca6] to [289e6f2205].
1 2 3 | {if !$dialog} <nav class="tabs"> <aside> | | | > > > | | > > > < | | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | {if !$dialog} <nav class="tabs"> <aside> {if $current === 'history' || $current === 'import'} {linkbutton href="!services/import.php" label="Import" shape="import"} {if $current === 'history'} {exportmenu right=true} {/if} {elseif $current === 'reminders'} {linkbutton href="!services/reminders/new.php" label="Nouveau rappel automatique" shape="plus"} {elseif $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} {linkbutton href="!services/subscription/select.php" label="Inscrire à une activité" shape="plus"} {/if} </aside> <ul> <li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}services/">Activités et cotisations</a></li> <li{if $current == 'history'} class="current"{/if}><a href="{$admin_url}services/history.php">Inscriptions</a></li> {if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} <li{if $current == 'reminders'} class="current"{/if}><a href="{$admin_url}services/reminders/">Rappels automatiques</a></li> {/if} </ul> {if !empty($has_archived_services)} <ul class="sub"> <li{if !$show_archived_services} class="current"{/if}>{link href="!services/" label="Activités courantes"}</li> <li{if $show_archived_services} class="current"{/if}>{link href="!services/?archived=1" label="Activités archivées"}</li> </ul> {/if} {if isset($current_service)} <ul class="sub"> <li class="title"> {$current_service->long_label()} |
︙ | ︙ |
Modified src/templates/services/_service_form.tpl from [78656695f6] to [7e9c55acbd].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | {form_errors} <form method="post" action="{$self_url}"> <fieldset> <legend>{$legend}</legend> <dl> {input name="label" type="text" required=1 label="Libellé" source=$service} {input name="description" type="textarea" label="Description" source=$service} <dt><label for="f_periodicite_jours">Durée de validité</label> <b title="Champ obligatoire">(obligatoire)</b></dt> {if $service && $service->exists()} <dd class="help">Attention, une modification de la durée renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.</dd> {/if} | > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | {form_errors} <form method="post" action="{$self_url}"> <fieldset> <legend>{$legend}</legend> <dl> {input name="label" type="text" required=1 label="Libellé" source=$service} {input name="description" type="textarea" label="Description" source=$service} {if $service && $service->exists()} {input type="checkbox" name="archived" value=1 label="Archiver cette activité" source=$service} <dd class="help">Si coché, les inscrits ne recevront plus de rappels, l'activité ne sera plus visible sur la fiche des membres, il ne sera plus possible d'y inscrire des membres.</dd> {/if} <dt><label for="f_periodicite_jours">Durée de validité</label> <b title="Champ obligatoire">(obligatoire)</b></dt> {if $service && $service->exists()} <dd class="help">Attention, une modification de la durée renseignée ici ne modifie pas la date d'expiration des activités déjà enregistrées.</dd> {/if} |
︙ | ︙ |
Modified src/templates/services/details.tpl from [814e435b61] to [37b6dda5e5].
︙ | ︙ | |||
56 57 58 59 60 61 62 | {/if} </td> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td>{$row.expiry|date_short}</td> <td>{$row.fee}</td> <td>{$row.date|date_short}</td> <td class="actions"> | | | 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | {/if} </td> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td>{$row.expiry|date_short}</td> <td>{$row.fee}</td> <td>{$row.date|date_short}</td> <td class="actions"> {linkbutton shape="user" label="Toutes les activités de ce membre" href="!users/subscriptions.php?id=%d"|args:$row.id_user} {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user} </td> </tr> {/foreach} </tbody> {if $can_action} |
︙ | ︙ |
Modified src/templates/services/fees/details.tpl from [01c98ab39a] to [0040e609a0].
︙ | ︙ | |||
39 40 41 42 43 44 45 | <td class="check">{input type="checkbox" name="selected[]" value=$row.id_user}</td> {/if} <th>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}</th> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td class="money">{if null === $row.paid_amount}<em title="Aucune écriture n'est liée à cette inscription">—</em>{else}{$row.paid_amount|raw|money_currency}{/if}</td> <td>{$row.date|date_short}</td> <td class="actions"> | | | 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | <td class="check">{input type="checkbox" name="selected[]" value=$row.id_user}</td> {/if} <th>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}</th> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td class="money">{if null === $row.paid_amount}<em title="Aucune écriture n'est liée à cette inscription">—</em>{else}{$row.paid_amount|raw|money_currency}{/if}</td> <td>{$row.date|date_short}</td> <td class="actions"> {linkbutton shape="user" label="Toutes les activités de ce membre" href="!users/subscriptions.php?id=%d"|args:$row.id_user} {linkbutton shape="alert" label="Rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$row.id_user} </td> </tr> {/foreach} </tbody> |
︙ | ︙ |
Modified src/templates/services/fees/index.tpl from [46e7db2d1b] to [5ace296f44].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | {include file="_head.tpl" title="%s — Tarifs"|args:$service.label current="users/services"} {include file="services/_nav.tpl" current="index" current_service=$service service_page="index"} {if $list->count()} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr> <th><a href="details.php?id={$row.id}">{$row.label}</a></th> <td> {if $row.formula} Formule {elseif $row.amount} {$row.amount|money_currency|raw} | > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | {include file="_head.tpl" title="%s — Tarifs"|args:$service.label current="users/services"} {include file="services/_nav.tpl" current="index" current_service=$service service_page="index"} {if $list->count()} {include file="common/dynamic_list_head.tpl"} <?php $total = ['nb_users_ok' => 0, 'nb_users_expired' => 0, 'nb_users_unpaid' => 0]; ?> {foreach from=$list->iterate() item="row"} <?php foreach ($total as $key => $v) { $total[$key] += $row->$key; } ?> <tr> <th><a href="details.php?id={$row.id}">{$row.label}</a></th> <td> {if $row.formula} Formule {elseif $row.amount} {$row.amount|money_currency|raw} |
︙ | ︙ | |||
26 27 28 29 30 31 32 33 34 35 36 37 38 39 | {linkbutton shape="edit" label="Modifier" href="!services/fees/edit.php?id=%d"|args:$row.id} {linkbutton shape="delete" label="Supprimer" href="!services/fees/delete.php?id=%d"|args:$row.id} {/if} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {else} <p class="block alert"> Il n'y a aucun tarif enregistré. Créez un premier tarif pour l'activité « {$service.label} » pour pouvoir y inscrire des membres. </p> | > > > > > > > > > | 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | {linkbutton shape="edit" label="Modifier" href="!services/fees/edit.php?id=%d"|args:$row.id} {linkbutton shape="delete" label="Supprimer" href="!services/fees/delete.php?id=%d"|args:$row.id} {/if} </td> </tr> {/foreach} </tbody> <tfoot> <tr> <td colspan="2">Total</td> <td class="num">{$total.nb_users_ok}</td> <td class="num">{$total.nb_users_expired}</td> <td class="num">{$total.nb_users_unpaid}</td> <td></td> </tr> </tfoot> </table> {$list->getHTMLPagination()|raw} {else} <p class="block alert"> Il n'y a aucun tarif enregistré. Créez un premier tarif pour l'activité « {$service.label} » pour pouvoir y inscrire des membres. </p> |
︙ | ︙ |
Added src/templates/services/history.tpl version [ca94324006].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | {include file="_head.tpl" title="Inscriptions" current="users/services"} {include file="services/_nav.tpl" current="history" service=null fee=null} {if $list->count()} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr> <td>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.name}</td> <th><a href="details.php?id={$row.id_service}">{$row.service}</a></th> <th><a href="fees/?id={$row.id_fee}">{$row.fee}</a></th> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td>{$row.left_amount|money_html}</td> <td>{$row.date|date_short}</td> <td>{$row.expiry_date|date_short}</td> <td class="actions"> {linkbutton href="!users/subscriptions.php?id=%d&only=%d"|args:$row.id_user:$row.id label="Détails" shape="eye"} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {else} <p class="block alert">Il n'y a aucune inscription enregistrée.</p> {/if} {include file="_foot.tpl"} |
Modified src/templates/services/import.tpl from [4484803cc7] to [5cf2f4904f].
|
| | | 1 2 3 4 5 6 7 8 | {include file="_head.tpl" title="Importer des inscriptions" current="users/services"} {include file="services/_nav.tpl" current="import" service=null fee=null} {form_errors} {if $_GET.msg == 'OK'} <p class="block confirm"> |
︙ | ︙ | |||
28 29 30 31 32 33 34 | Ce formulaire permet d'importer les inscriptions des membres aux activités. </p> <fieldset> <legend>Importer depuis un fichier</legend> <dl> {input type="file" name="file" label="Fichier à importer" required=true accept="csv"} | | | 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | Ce formulaire permet d'importer les inscriptions des membres aux activités. </p> <fieldset> <legend>Importer depuis un fichier</legend> <dl> {input type="file" name="file" label="Fichier à importer" required=true accept="csv"} {include file="common/_csv_help.tpl" csv=$csv more_text="Si le numéro d'inscription est fourni, l'inscription correspondante sera mise à jour."} </dl> </fieldset> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="load" label="Charger le fichier" shape="right" class="main"} </p> {/if} </form> {include file="_foot.tpl"} |
Modified src/templates/services/index.tpl from [1156c1e137] to [74054465a9].
︙ | ︙ | |||
37 38 39 40 41 42 43 | </table> {$list->getHTMLPagination()|raw} {else} <p class="block alert">Il n'y a aucune activité enregistrée.</p> {/if} | | | 37 38 39 40 41 42 43 44 45 46 47 48 | </table> {$list->getHTMLPagination()|raw} {else} <p class="block alert">Il n'y a aucune activité enregistrée.</p> {/if} {if empty($show_archived_services) && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} {include file="services/_service_form.tpl" legend="Ajouter une activité" service=null period=0} {/if} {include file="_foot.tpl"} |
Modified src/templates/services/reminders/_form.tpl from [a0f419c655] to [24c866413a].
1 2 3 4 5 6 7 8 | {form_errors} <form method="post" action="{$self_url}"> <fieldset> <legend>{$legend}</legend> <dl> {input type="select" name="id_service" options=$services_list label="Activité associée au rappel" required=1 source=$reminder} | < > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | {form_errors} <form method="post" action="{$self_url}"> <fieldset> <legend>{$legend}</legend> <dl> {input type="select" name="id_service" options=$services_list label="Activité associée au rappel" required=1 source=$reminder} <dt><label for="f_delay_type_0">Délai d'envoi</label> <b title="(Champ obligatoire)">obligatoire</b></dt> {input type="radio" name="delay_type" value=0 default=$delay_type label="Le jour de l'expiration de l'activité"} <dd> {input type="radio" name="delay_type" value=1 default=$delay_type} {input type="number" name="delay_before" min=1 max=999 default=$delay_before size=4} <label for="f_delay_type_1">jours <strong>avant</strong> expiration</label> </dd> <dd> {input type="radio" name="delay_type" value=2 default=$delay_type} {input type="number" name="delay_after" min=1 max=999 size=4 default=$delay_after} <label for="f_delay_type_2">jours <strong>après</strong> expiration</label> </dd> {if !$reminder->exists()} <?php $yes_before = ($reminder->not_before_date ?? null) === null; ?> {input type="radio" name="yes_before" value=1 default=$yes_before prefix_label="Envoyer ce rappel…" prefix_required=true label="À tous les membres" help="Même si leur inscription a expiré il y a longtemps, sauf s'ils ont déjà reçu un rappel pour cette activité"} {input type="radio" name="yes_before" value=0 default=$yes_before label="Seulement aux membres dont l'inscription n'a pas encore expiré" help="Seuls les inscriptions expirant dans le futur seront concernées"} {else} <dt><strong>Restriction d'envoi</strong></dt> {if $reminder.not_before_date} <dd>Aucun rappel ne sera envoyé aux inscriptions expirant avant le {$reminder.not_before_date|date_short} {else} <dd>Aucune restriction. Tous les membres recevront ce rappel, selon le délai choisi.</dd> {/if} {/if} {input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"} {input type="textarea" name="body" required=1 source=$reminder label="Texte du message envoyé" cols="90" rows="15"} <dd class="help"> Il est possible d'utiliser les mots-clés suivant dans le corps du mail, ils seront remplacés lors de l'envoi : {literal} <table class="list auto"> <tr> <th>{{$label}}</th> |
︙ | ︙ |
Modified src/templates/services/reminders/delete.tpl from [5e648894f4] to [95bcd98fcf].
1 2 3 4 5 6 7 | {include file="_head.tpl" title="Supprimer un rappel automatique" current="users/services"} {include file="services/_nav.tpl" current="reminders"} {include file="common/delete_form.tpl" legend="Supprimer ce rappel automatique ?" warning="Êtes-vous sûr de vouloir supprimer le rappel « %s » ?"|args:$reminder.subject | | | 1 2 3 4 5 6 7 8 9 10 | {include file="_head.tpl" title="Supprimer un rappel automatique" current="users/services"} {include file="services/_nav.tpl" current="reminders"} {include file="common/delete_form.tpl" legend="Supprimer ce rappel automatique ?" warning="Êtes-vous sûr de vouloir supprimer le rappel « %s » ?"|args:$reminder.subject confirm="Cocher cette case pour supprimer aussi l'historique des messages envoyés."} {include file="_foot.tpl"} |
Modified src/templates/services/reminders/details.tpl from [9bccdf4d03] to [827126e934].
︙ | ︙ | |||
20 21 22 23 24 25 26 | {$list->count()} </dd> {elseif $current_list === 'pending'} <dt>Nombre de rappels à envoyer</dt> <dd> {$list->count()} </dd> | < < < | < < > | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | {$list->count()} </dd> {elseif $current_list === 'pending'} <dt>Nombre de rappels à envoyer</dt> <dd> {$list->count()} </dd> {/if} </dl> {if $list->count()} {if $current_list === 'pending'} <p class="help">Note : cette liste ne prend pas en compte les membres qui ont une adresse e-mail invalide, ou qui se sont désinscrit des envois de messages.</p> {/if} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr> <th>{link href="!users/details.php?id=%d"|args:$row.id_user label=$row.identity}</th> {if $current_list === 'pending'} <td>{$row.expiry_date|date_short}</td> {/if} <td>{$row.reminder_date|date_short}</td> <td class="actions"> {if $current_list === 'pending'} {linkbutton href="preview.php?id_user=%d&id_reminder=%d"|args:$row.id_user:$reminder.id shape="eye" label="Prévisualiser" target="_dialog"} {/if} </td> </tr> {/foreach} |
︙ | ︙ |
Modified src/templates/services/reminders/user.tpl from [8f1d568b5d] to [b9222789b3].
︙ | ︙ | |||
8 9 10 11 12 13 14 | {foreach from=$list->iterate() item="row"} <tr> <th>{$row.label}</th> <td>{if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}</td> <td>{$row.date|date_short}</td> <td> | | | 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | {foreach from=$list->iterate() item="row"} <tr> <th>{$row.label}</th> <td>{if $row.delay > 0}{$row.delay} jours après l'expiration{elseif $row.delay < 0}{$row.delay|abs} jours avant l'expiration{else}le jour de l'expiration{/if}</td> <td>{$row.date|date_short}</td> <td> {linkbutton shape="menu" label="Inscriptions après ce rappel" href="!users/subscriptions.php?id=%d&after=%s"|args:$user_id,$row.date} </td> </tr> {/foreach} </tbody> </table> |
︙ | ︙ |
Added src/templates/services/subscription/_choice_form.tpl version [2bdf0b250d].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | <dl> <dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt> {foreach from=$grouped_services item="service"} <dd class="radio-btn"> {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null} <label for="f_id_service_{$service.id}"> <div> <h3>{$service.label}</h3> <p> {if $service.duration} {$service.duration} jours {elseif $service.start_date} du {$service.start_date|date_short} au {$service.end_date|date_short} {else} ponctuelle {/if} </p> {if $service.description} <p class="help"> {$service.description|escape|nl2br} </p> {/if} </div> </label> </dd> {foreachelse} <dd><p class="error block">Aucune activité trouvée</p></dd> {/foreach} </dl> {foreach from=$grouped_services item="service"} <?php if (!count($service->fees)) { continue; } ?> <dl data-service="s{$service.id}"> <dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt> {foreach from=$service.fees key="service_id" item="fee"} <dd class="radio-btn"> {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null} <label for="f_id_fee_{$fee.id}"> <div> <h3>{$fee.label}</h3> <p> {if !$fee.user_amount} prix libre ou gratuit {elseif $fee.user_amount && $fee.formula} <strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé) {elseif $fee.user_amount} <strong>{$fee.user_amount|raw|money_currency}</strong> {/if} </p> {if $fee.description} <p class="help"> {$fee.description|escape|nl2br} </p> {/if} </div> </label> </dd> {/foreach} </dl> {/foreach} |
Added src/templates/services/subscription/_form.tpl version [a7a08ba9d4].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | <?php assert(isset($create) && is_bool($create)); assert(isset($form_url) && is_string($form_url)); assert(isset($today) && $today instanceof \DateTimeInterface); assert($create === false || isset($account_targets)); assert(isset($grouped_services) && is_array($grouped_services)); ?> <form method="post" action="{$self_url}" data-focus="1" data-create="{$create|escape:json}"> <fieldset> <legend>Inscrire à une activité</legend> <dl> {if $create && $users} <dt> Membres à inscrire </dt> <dd> <details> <summary>{{%n membre sélectionné.}{%n membres sélectionnés.} n=$users|count}</summary> <table> {foreach from=$users key="id" item="name"} <tr> <td> <input type="hidden" name="users[{$id}]" value="{$name}" /> {if !empty($allow_users_edit)} {button shape="delete" onclick="this.parentNode.parentNode.remove();" title="Supprimer de la liste"} {/if} </td> <th> {$name} </th> </tr> {/foreach} </table> </details> </dd> {elseif $create && $copy_service} <dt>Recopier depuis l'activité</dt> <dd><strong>{$copy_service.label}</strong><input type="hidden" name="copy" value="s{$copy_service.id}" /></dd> <dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd> {elseif $create && $copy_fee} <dt>Recopier depuis le tarif</dt> <dd><strong>{$copy_fee->service()->label} — {$copy_fee.label}</strong><input type="hidden" name="copy" value="f{$copy_fee.id}" /></dd> <dd><em>{if $copy_only_paid}(seulement les inscriptions marquées comme payées){else}(toutes les inscriptions){/if}</em><input type="hidden" name="copy_only_paid" value="{$copy_service_only_paid}" /></dd> {/if} <dt><label for="f_service_ID">Activité</label> <b>(obligatoire)</b></dt> {foreach from=$grouped_services item="service"} <dd class="radio-btn"> {input type="radio" name="id_service" value=$service.id data-duration=$service.duration data-expiry=$service.expiry_date|date_short label=null source=$subscription} <label for="f_id_service_{$service.id}"> <div> <h3>{$service.label}</h3> <p> {if $service.duration} {$service.duration} jours {elseif $service.start_date} du {$service.start_date|date_short} au {$service.end_date|date_short} {else} ponctuelle {/if} </p> {if $service.description} <p class="help"> {$service.description|escape|nl2br} </p> {/if} </div> </label> </dd> {foreachelse} <dd><p class="error block">Aucune activité trouvée</p></dd> {/foreach} </dl> {foreach from=$grouped_services item="service"} <?php if (!count($service->fees)) { continue; } ?> <dl data-service="s{$service.id}"> <dt><label for="f_fee">Tarif</label> <b>(obligatoire)</b></dt> {foreach from=$service.fees key="service_id" item="fee"} <dd class="radio-btn"> {input type="radio" name="id_fee" value=$fee.id data-user-amount=$fee.user_amount data-account=$fee.id_account data-year=$fee.id_year label=null data-project=$fee.id_project source=$subscription} <label for="f_id_fee_{$fee.id}"> <div> <h3>{$fee.label}</h3> <p> {if $fee.user_amount && $fee.formula} <strong>{$fee.user_amount|raw|money_currency}</strong> (montant calculé) {elseif $fee.formula} montant calculé, variable selon les membres {elseif $fee.user_amount} <strong>{$fee.user_amount|raw|money_currency}</strong> {else} prix libre ou gratuit {/if} </p> {if $fee.description} <p class="help"> {$fee.description|escape|nl2br} </p> {/if} </div> </label> </dd> {/foreach} </dl> {/foreach} </fieldset> </fieldset> <fieldset> <legend>Détails</legend> <dl> {input type="date" name="date" required=1 default=$today source=$subscription label="Date d'inscription"} {input type="date" name="expiry_date" source=$subscription label="Date d'expiration de l'inscription"} {input type="checkbox" name="paid" value="1" source=$subscription default="1" label="Marquer cette inscription comme payée"} <dd class="help">Décocher cette case pour pouvoir suivre les règlements de personnes qui payent en plusieurs fois. Il sera possible de cocher cette case lorsque le solde aura été réglé.</dd> </dl> </fieldset> {if $create} <fieldset class="accounting"> <legend>{input type="checkbox" name="create_payment" value=1 default=1 label="Enregistrer en comptabilité"}</legend> <dl> {if !empty($users)} <dd class="help">Une écriture sera créée pour chaque membre inscrit.</dd> {/if} {input type="money" name="amount" label="Montant réglé par le membre" required=true help="En cas de règlement en plusieurs fois il sera possible d'ajouter des règlements via la page de suivi des activités de ce membre."} {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=0"|args:$account_targets name="account_selector" label="Compte de règlement" required=true} {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."} {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."} {input type="textarea" name="notes" label="Remarques"} {if count($projects) > 0} {input type="select" options=$projects name="id_project" label="Projet analytique" required=false default_empty="— Aucun —"} {/if} </dl> </fieldset> {/if} <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} </p> </form> |
Added src/templates/services/subscription/delete.tpl version [9a92a85389].
> > > > > > > > > | 1 2 3 4 5 6 7 8 9 | {include file="_head.tpl" title="%s : Supprimer une inscription"|args:$user_name current="users/services"} {include file="common/delete_form.tpl" legend="Supprimer l'inscription ?" warning="Êtes-vous sûr de vouloir supprimer l'inscription ?" alert="Les écritures comptables liées à cette inscription ne seront pas supprimées, la comptabilité demeurera inchangée." info="%s – à « %s — %s »"|args:$user_name,$service_name,$fee_name} {include file="_foot.tpl"} |
Added src/templates/services/subscription/edit.tpl version [5a188a656c].
> > > > > > > | 1 2 3 4 5 6 7 | {include file="_head.tpl" title="Modifier une inscription" current="users/services"} {form_errors} {include file="services/subscription/_form.tpl" create=false} {include file="_foot.tpl"} |
Added src/templates/services/subscription/link.tpl version [6f582c8e1f].
> > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | {include file="_head.tpl" title="Lier une inscription à une écriture" current="acc/accounts"} {form_errors} <form method="post" action="{$self_url}" data-focus="1"> <fieldset> <legend>Lier à une écriture</legend> <dl> {input type="number" label="Numéro de l'écriture" name="id_transaction" required=true} </dl> </fieldset> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} </p> </form> {include file="_foot.tpl"} |
Added src/templates/services/subscription/new.tpl version [03b96c821f].
> > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 | {include file="_head.tpl" title="Inscrire à une activité" current="users/services"} {if !$dialog} {include file="services/_nav.tpl" current="save" fee=null service=null} {/if} {form_errors} {include file="services/subscription/_form.tpl" create=true} {include file="_foot.tpl"} |
Added src/templates/services/subscription/payment.tpl version [d5e1991249].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | {include file="_head.tpl" title="Enregistrer un règlement" current="users/services"} {form_errors} <form method="post" action="{$self_url}" data-focus="1"> <fieldset> <legend>Enregistrer un règlement</legend> <dl> <dt>Membre sélectionné</dt> <dd><h3>{$user_name}</h3></dd> <dt><strong>Inscription</strong></dt> {input type="checkbox" name="paid" value="1" default=$su.paid label="Marquer cette inscription comme payée"} {input type="date" name="date" label="Date" required=1 source=$su} {input type="money" name="amount" label="Montant réglé par le membre" required=1} {input type="list" target="!acc/charts/accounts/selector.php?targets=%s&year=%d"|args:$account_targets,$fee.id_year name="account_selector" label="Compte de règlement" required=1} {input type="text" name="reference" label="Numéro de pièce comptable" help="Numéro de facture, de reçu, de note de frais, etc."} {input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc."} {if count($projects) > 0} {input type="select" options=$projects name="id_project" label="Projet analytique" default=$fee.id_project required=false default_empty="— Aucun —"} {/if} </dl> </fieldset> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} </p> </form> {include file="_foot.tpl"} |
Added src/templates/services/subscription/select.tpl version [58681029af].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | {include file="_head.tpl" title="Inscrire à une activité" current="membres/services"} {include file="services/_nav.tpl" current="save" fee=null service=null} {form_errors} <form method="post" action="new.php" data-focus="button"> <fieldset> <legend>Inscrire à une activité</legend> <dl> {input type="radio-btn" name="choice" value="1" label="Sélectionner des membres" default=1} {input type="radio-btn" name="choice" value="2" label="Recopier depuis une activité" help="Utile si vous avez une cotisation par année civile par exemple : copie les membres inscrits l'année précédente dans la nouvelle année."} {input type="radio-btn" name="choice" value="3" label="Tous les membres d'une catégorie"} </dl> </fieldset> <fieldset class="c1"> <legend>Inscrire des membres</legend> <dl> {input type="list" name="users" required=true label="Membres à inscrire" target="!users/selector.php" multiple=true} </dl> </fieldset> <fieldset class="c2"> <legend>Recopier depuis une activité</legend> <dl> {input type="select_groups" name="copy" label="Activité à recopier" options=$services required=true default=0} {input type="checkbox" name="copy_only_paid" value="1" label="Ne recopier que les membres dont l'inscription est payée"} </dl> </fieldset> <fieldset class="c3"> <legend>Tous les membres d'une catégorie</legend> <dl> {input type="select" name="category" label="Catégorie à inscrire" options=$categories required=true} </dl> </fieldset> <p class="submit"> <input type="hidden" name="paid" value="1" /> {button type="submit" name="next" label="Continuer" shape="right" class="main"} </p> </form> <script type="text/javascript"> {literal} function selectChoice() { let choice = $('#f_choice_1').form.choice.value; g.toggle('.c1', choice == 1); g.toggle('.c2', choice == 2); g.toggle('.c3', choice == 3); } $('#f_choice_1').onchange = selectChoice; $('#f_choice_2').onchange = selectChoice; $('#f_choice_3').onchange = selectChoice; selectChoice(); {/literal} </script> {include file="_foot.tpl"} |
Deleted src/templates/services/user/_choice_form.tpl version [2bdf0b250d].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/_service_user_form.tpl version [3477f32009].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/add.tpl version [a9b93751c5].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/delete.tpl version [9a92a85389].
|
| < < < < < < < < < |
Deleted src/templates/services/user/edit.tpl version [3f7ef103e9].
|
| < < < < < < < |
Deleted src/templates/services/user/index.tpl version [65f01862e2].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/link.tpl version [6f582c8e1f].
|
| < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/payment.tpl version [d5e1991249].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/services/user/subscribe.tpl version [956f4af913].
|
| < < < < < < < < < < < |
Modified src/templates/users/_details.tpl from [b3fb59fa84] to [53a6d4782f].
︙ | ︙ | |||
46 47 48 49 50 51 52 | ?> {include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path:$key} {elseif empty($value)} <em>(Non renseigné)</em> {elseif $field.type == 'email'} <a href="mailto:{$value|escape:'url'}">{$value}</a> {if !DISABLE_EMAIL && $show_message_button && !$email_button++} | | > | > > | < < < < < < < < < < < < < < < < | < < | 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | ?> {include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path:$key} {elseif empty($value)} <em>(Non renseigné)</em> {elseif $field.type == 'email'} <a href="mailto:{$value|escape:'url'}">{$value}</a> {if !DISABLE_EMAIL && $show_message_button && !$email_button++} {linkbutton href="!users/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail" target="_dialog"} {/if} {elseif $field.type == 'multiple'} <ul> {foreach from=$field.options key="b" item="name"} {if (int)$value & (0x01 << (int)$b)} <li>{$name}</li> {/if} {/foreach} </ul> {else} {if in_array($key, $id_fields)}<strong>{/if} {user_field field=$field value=$value user_id=$user.id} {if in_array($key, $id_fields)}</strong>{/if} {/if} </dd> {if $field.type == 'email' && $value} <?php $email = Email\Addresses::getOrCreate($value); $address = rawurlencode($value); ?> <dt>Statut e-mail</dt> <dd> {tag color=$email->getStatusColor() label=$email->getStatusLabel()} {linkbutton target="_dialog" label="Détails" href="!users/email/address.php?address=%s"|args:$address shape="mail"} </dd> {/if} {/foreach} </dl> |
Modified src/templates/users/_nav_user.tpl from [8a253455d6] to [12acdfccd2].
1 2 3 4 5 6 7 8 9 | <nav class="tabs"> <aside> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'details'} {linkbutton href="edit.php?id=%d"|args:$id shape="edit" label="Modifier" accesskey="M"} {/if} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $logged_user.id != $id && $current == 'details'} {linkbutton href="delete.php?id=%d"|args:$id shape="delete" label="Supprimer" target="_dialog" accesskey="S"} {/if} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'services'} | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <nav class="tabs"> <aside> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'details'} {linkbutton href="edit.php?id=%d"|args:$id shape="edit" label="Modifier" accesskey="M"} {/if} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $logged_user.id != $id && $current == 'details'} {linkbutton href="delete.php?id=%d"|args:$id shape="delete" label="Supprimer" target="_dialog" accesskey="S"} {/if} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current == 'services'} {linkbutton href="!services/subscription/new.php?user=%d"|args:$id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="K"} {/if} </aside> <ul> <li{if $current == 'details'} class="current"{/if}>{link href="!users/details.php?id=%d"|args:$id label="Fiche membre" accesskey="F"}</li> <li{if $current == 'services'} class="current"{/if}>{link href="!users/subscriptions.php?id=%d"|args:$id label="Inscriptions aux activités" accesskey="I"}</li> <li{if $current == 'reminders'} class="current"{/if}>{link href="!services/reminders/user.php?id=%d"|args:$id label="Rappels envoyés" accesskey="R"}</li> </ul> </nav> |
Modified src/templates/users/details.tpl from [e2f570012f] to [415d0f4e4a].
︙ | ︙ | |||
13 14 15 16 17 18 19 | {elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b> {elseif $service.status == 1} — <b class="confirm">à jour</b>{/if} {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} {if !$service.paid} — <b class="error">À payer !</b>{/if} </dd> {foreachelse} <dd> | | | | 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | {elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b> {elseif $service.status == 1} — <b class="confirm">à jour</b>{/if} {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} {if !$service.paid} — <b class="error">À payer !</b>{/if} </dd> {foreachelse} <dd> Ce membre n'est actuellement inscrit à aucune activité ou cotisation. </dd> {/foreach} <dd> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} {linkbutton href="!services/subscription/new.php?user=%d"|args:$user.id label="Inscrire à une activité" shape="plus" target="_dialog" accesskey="V"} {/if} </dd> {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)} {if !empty($transactions_linked)} <dt>Écritures comptables liées</dt> <dd><a href="{$admin_url}acc/transactions/user.php?id={$user.id}">{$transactions_linked} écritures comptables liées à ce membre</a></dd> {/if} |
︙ | ︙ |
Added src/templates/users/email/_nav.tpl version [3b9ed2172c].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | <nav class="tabs"> {if $current === 'rejected'} <aside> {exportmenu right=true} </aside> {elseif $current === 'index'} <aside> {linkbutton shape="plus" label="Nouveau message" href="edit.php"} </aside> {elseif $current === 'mailing'} <aside> {if !$mailing.sent} {linkbutton shape="edit" label="Modifier" href="edit.php?id=%d"|args:$mailing.id} {/if} {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id} </aside> {/if} <ul> <li{if $current === 'index' || $current === 'mailing'} class="current"{/if}>{link href="!users/email/mailing/" label="Messages collectifs"}</li> <li{if $current === 'optout'} class="current"{/if}>{link href="!users/email/optout.php" label="Désinscriptions"}</li> <li{if $current === 'rejected'} class="current"{/if}>{link href="!users/email/rejected.php" label="Adresses rejetées"}</li> {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)} <li {if $current === 'queue'}class="current"{/if}>{link href="!users/email/queue.php" label="File d'envoi"}</li> {/if} </ul> {if $current === 'mailing'} <ul class="sub"> <li class="title">{$mailing.subject}</li> <li>{link href="recipients.php?id=%d"|args:$mailing.id label="Destinataires"}</li> </ul> {/if} </nav> |
Added src/templates/users/email/address.tpl version [53622e4c17].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | {include file="_head.tpl" title="Adresse e-mail" current="users/mailing"} {if $_GET.msg === 'VERIFICATION_SENT'} <p class="confirm block"> Un message de demande de confirmation a bien été envoyé.<br /> Le destinataire doit désormais cliquer sur le lien dans ce message pour valider son adresse. </p> {/if} <div class="describe"> <dt>Adresse e-mail</dt> <dd>{if $raw_address}{$raw_address}{else}<em>anonymisée</em>{/if}</dd> <dt>Statut</dt> <dd>{tag label=$address->getStatusLabel() color=$address->getStatusColor()}</dd> <dt>Description du statut</dt> <dd> {if $address.status === $address::STATUS_VERIFIED} L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire. {elseif $address.status === $address::STATUS_INVALID} Cette adresse a une erreur de syntaxe, ou le serveur n'existe pas. {elseif $address.status === $address::STATUS_HARD_BOUNCE} Le serveur existe, mais l'adresse n'existe pas, ou bloque vos messages définitivement. {elseif $address.status === $address::STATUS_SOFT_BOUNCE_LIMIT_REACHED} L'adresse existe, mais a rencontré plus de {$max_fail_count} erreurs temporaires.<br /> Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine.<br /> Cette adresse ne recevra plus de message. {elseif $address.status === $address::STATUS_OPTOUT} L'adresse existe, mais le destinataire a demandé à ne recevoir de messages de votre part. {else} Cette adresse n'a pas rencontré de problème jusque là. {/if} </dd> <dt>Nombre de messages envoyés</dt> <dd>{$address.sent_count}</dd> <dt>Nombre d'erreurs temporaires</dt> <dd>{$address.bounce_count}</dd> </div> {include file="_foot.tpl"} |
Added src/templates/users/email/block.tpl version [d3cb829167].
> > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | {include file="_head.tpl" title="Désinscription d'adresse" current="users/mailing"} <form method="post" action="{$self_url}"> <fieldset> <legend>Désinscrire une adresse</legend> <h3 class="warning">Désinscrire l'adresse {$address} ?</h3> <p class="alert block"> Une fois cette adresse désinscrite, elle ne pourra plus recevoir aucun message de votre association (rappels, notifications, messages collectifs, etc.). </p> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="send" label="Désinscrire cette adresse" shape="right" class="main"} </p> </fieldset> </form> {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/delete.tpl version [287d96382b].
> > > > > > > > | 1 2 3 4 5 6 7 8 | {include file="_head.tpl" title="Supprimer un envoi de message collectif" current="users/mailing"} {include file="common/delete_form.tpl" legend="Supprimer ce message collectif ?" warning="Êtes-vous sûr de vouloir supprimer le message « %s » ?"|args:$mailing.subject info="La liste des destinataires sera également supprimée."} {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/details.tpl version [3189d327bf].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | {include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing" hide_title=true} {include file="../_nav.tpl" current="mailing"} {if $sent} <p class="confirm block">L'envoi du message a bien commencé. Il peut prendre quelques minutes avant d'avoir été expédié à tous les destinataires.</p> {/if} {form_errors} <form method="post" action=""> <dl class="describe"> {if $mailing.sent} <dt>Envoyé le</dt> <dd>{$mailing.sent|date_long:true}</dd> {else} <dt>Statut</dt> <dd> Brouillon<br /> {if $mailing.body && $count} <br />{button shape="right" label="Envoyer" class="main" name="send" type="submit"} {/if} </dd> <dt>Expéditeur</dt> <dd> {$mailing->getFrom()}<br/> </dd> {/if} {if $mailing.target_type} <dt>Cible</dt> <dd> {$mailing->getTargetTypeLabel()} — {$mailing.target_label} </dd> {/if} <dt>Destinataires</dt> <dd> {if $mailing.count} {{%n destinataire}{%n destinataires} n=$count}<br /> {linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id} {linkbutton shape="plus" label="Ajouter des destinataires" href="populate.php?id=%d"|args:$mailing.id} {else} {linkbutton class="main" shape="plus" label="Ajouter des destinataires" href="populate.php?id=%d"|args:$mailing.id} {/if} </dd> <dt>Sujet</dt> <dd><strong>{$mailing.subject}</strong></dd> <dt>Message</dt> <dd><pre class="preview"><code>{$mailing.body}</code></pre></dd> {if $count} <dt>Prévisualisation</dt> <dd>{linkbutton shape="eye" label="Prévisualiser le message" href="?id=%d&preview"|args:$mailing.id target="_dialog"}<br /> <small class="help">(Un destinataire sera choisi au hasard.)</small></dd> <dt></dt> <dd class="help">Note : la prévisualisation peut différer du rendu final, selon le logiciel utilisé par vos destinataires pour lire leurs messages.</dd> {/if} </dl> {csrf_field key=$csrf_key} </form> {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/edit.tpl version [8809e0af4c].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | {include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true} {form_errors} <form method="post" action="{$self_url}" data-focus="{if $mailing->exists()}textarea{else}1{/if}"> <fieldset class="header"> <legend>{if $mailing->exists()}Modifier le message collectif{else}Nouveau message collectif{/if}</legend> <p> {input type="text" name="subject" required=true class="full-width" placeholder="Sujet du message…" source=$mailing} </p> <div> <p class="sender_default {if $mailing.sender_name}hidden{/if}"> <strong>Expéditeur :</strong> {$config.org_name} <{$config.org_email}> {button label="Modifier" shape="edit" id="f_edit_sender"} </p> <dl class="sender_custom {if !$mailing.sender_name}hidden{/if}"> {input type="text" required=true name="sender_name" source=$mailing label="Nom de l'expéditeur" placeholder="Nom de l'expéditeur"} {input type="email" required=true name="sender_email" source=$mailing label="Adresse e-mail de l'expéditeur" placeholder="Adresse e-mail de l'expéditeur"} </dl> </div> </fieldset> <fieldset class="textEditor"> {input type="textarea" name="content" cols=35 rows=25 required=true class="full-width" data-attachments=0 data-savebtn=0 data-preview-url="!users/email/mailing/edit.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body} </fieldset> {if !$mailing->exists()} <p class="help">Vous pourrez sélectionner les destinataires à l'étape suivante.</p> {/if} <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="save" label="Enregistrer" shape="right" class="main"} </p> </form> <script type="text/javascript"> {literal} $('#f_edit_sender').onclick = () => { g.toggle('.sender_default', false); g.toggle('.sender_custom', true); } {/literal} {if !$mailing.sender_name} g.toggle('.sender_custom', false); {/if} </script> {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/index.tpl version [90aff71501].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | {include file="_head.tpl" title="Messages collectifs" current="users/mailing"} {include file="../_nav.tpl" current="index"} {if $_GET.msg === 'DELETE'} <p class="confirm block">Le message a bien été supprimé.</p> {elseif $_GET.msg === 'FORCED'} <p class="confirm block">La file d'attente a été envoyée.</p> {/if} {if !$list->count()} <p class="alert block">Aucun message collectif n'a été écrit.<br /> {linkbutton shape="plus" label="Écrire un nouveau message" href="new.php" target="_dialog"} </p> {else} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr> <th>{link href="details.php?id=%d"|args:$row.id label=$row.subject}</th> <td>{$row.nb_recipients}</td> <td>{if $row.sent}{$row.sent|relative_date:true}{else}Brouillon{/if}</td> <td class="actions"> {linkbutton shape="eye" label="Ouvrir" href="details.php?id=%d"|args:$row.id} {linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$row.id target="_dialog"} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {/if} {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/populate.tpl version [8dc2925045].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | {include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"} {form_errors} <form method="post" action="" data-focus=1> {if !$target_type} <fieldset> <legend>Sujet du message</legend> <dl> {input type="text" required="true" label="Sujet du message" name="subject" class="full-width"} </dl> </fieldset> <fieldset> <legend>Qui doit recevoir ce message ?</legend> <dl> {input type="radio-btn" name="target_type" value="field" label="Membres correspondant à une case à cocher (sauf ceux appartenant à une catégorie cachée)" required=true help="Par exemple les membres inscrits à la lettre d'information."} {input type="radio-btn" name="target_type" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true} {input type="radio-btn" name="target_type" value="category" label="Membres d'une seule catégorie" required=true} {input type="radio-btn" name="target_type" value="service" label="Membres inscrits à une activité, et à jour" required=true help="Les membres dont l'inscription a expiré ne recevront pas de message."} {input type="radio-btn" name="target_type" value="search" label="Membres renvoyés par une recherche enregistrée" required=true} </dl> </fieldset> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="step2" label="Continuer" shape="right" class="main"} </p> {elseif $target_type == 'field'} <fieldset> <legend>Quel champ de la fiche membre ?</legend> <dl> {foreach from=$list item="field"} {input type="radio" name="target_value" value=$field.name label=$field.label help="%d membres"|args:$field.count} {input type="hidden" name="labels[%s]"|args:$field.name default=$field.label} {/foreach} </dl> </fieldset> {elseif $target_type == 'category'} <fieldset> <legend>Quelle catégorie ?</legend> <dl> {foreach from=$list item="cat"} {input type="radio" name="target_value" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count} {input type="hidden" name="labels[%s]"|args:$cat.id default=$cat.name} {/foreach} </dl> </fieldset> {elseif $target_type == 'service'} <fieldset> <legend>Quelle activité ?</legend> <dl> {foreach from=$list item="service"} {input type="radio" name="target_value" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok} {input type="hidden" name="labels[%s]"|args:$service.id default=$service.label} {/foreach} </dl> </fieldset> {elseif $target_type == 'search'} <fieldset> <legend>Quelle recherche utiliser ?</legend> <dl> {foreach from=$list item="search"} {input type="radio" name="target_value" value=$search.id label=$search.label help="%d membres"|args:$search.count} {input type="hidden" name="labels[%s]"|args:$search.id default=$search.label} {/foreach} </dl> </fieldset> {/if} {if $target_type} <p class="help"><small>Note : le nombre de membres affiché ne prend pas en compte les membres qui ne disposent pas d'adresse e-mail, ou qui se sont désinscrits. Le nombre de destinataires réels sera affiché avant envoi.</small></p> <p class="submit"> {input type="hidden" name="subject"} {input type="hidden" name="target_type" default=$target_type} {csrf_field key=$csrf_key} {button type="submit" name="step3" label="Créer" shape="right" class="main"} </p> {/if} </form> {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/recipient_data.tpl version [d5ad4ec244].
> > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | {include file="_head.tpl" title="Données du destinataire" current="users/mailing"} <p class="help">Vous pouvez copier la variable (colonne de gauche) dans le corps du message : elle sera remplacée dans le message par le contenu (colonne à droite) spécifique à chaque destinataire.</p> <table class="list auto center"> <thead> <tr> <td>Variable</td> <td>Contenu</td> </tr> </thead> <tbody> {foreach from=$data key="name" item="value"} <tr> <td><code>{ldelim}{ldelim}${$name}{rdelim}{rdelim}</code></td> <td>{$value|escape|nl2br}</td> </tr> {/foreach} </tbody> </table> {include file="_foot.tpl"} |
Added src/templates/users/email/mailing/recipients.tpl version [f781d16b3b].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | {include file="_head.tpl" title="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"} {include file="../_nav.tpl" current="mailing"} <p> {linkbutton shape="left" label="Retour au message" href="details.php?id=%d"|args:$mailing.id} {exportmenu} </p> {if $mailing.anonymous} <p class="alert block"> Les informations personnelles des destinataires ont été supprimées automatiquement après un délai de six mois, conformément au RGPD. </p> {else} <p class="help"> Les informations personnelles des destinataires seront supprimées automatiquement après un délai de six mois, conformément au RGPD. </p> {/if} {form_errors} <form method="post" action=""> {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="r"} <tr> <td>{$r.email}</td> <td>{$r.name}</td> <td> {if $r.status} <span class="error">{$r.status}</span> {/if} </td> <td class="actions"> {if $r.has_extra_data} {linkbutton shape="menu" label="Données" href="recipient_data.php?id=%d&r=%d"|args:$mailing.id:$r.id target="_dialog"} {/if} {if $r.id_user} {linkbutton shape="user" label="Fiche membre" href="!users/details.php?id=%d"|args:$r.id_user} {/if} {if !$mailing.sent} {button shape="delete" label="Supprimer" name="delete" value=$r.id type="submit"} {/if} {if !$mailing.anonymous && $r.email} {linkbutton href="details.php?id=%d&preview=%d"|args:$mailing.id:$r.id label="Prévisualiser" shape="eye" target="_dialog"} {/if} </td> </tr> {/foreach} </tbody> </table> {csrf_field key=$csrf_key} {$list->getHTMLPagination()|raw} </form> {include file="_foot.tpl"} |
Added src/templates/users/email/optout.tpl version [93bde13c66].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | {include file="_head.tpl" title="Désinscriptions" current="users/mailing"} {include file="./_nav.tpl" current="optout"} {if isset($_GET['sent'])} <p class="confirm block"> Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message. </p> {/if} {if !$list->count()} <p class="alert block">Aucune adresse e-mail n'a demandé à être désinscrite pour le moment.</p> {else} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr{if $_GET.hl == $row.id} class="highlight"{/if} id="e_{$row.id}"> <th>{link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}</th> <td>{$row.email}</td> <td><b class="error">{$row.status}</b></td> <td class="num">{$row.sent_count}</td> <td>{$row.last_sent|date}</td> <td> {if $row.email && $row.optout} {linkbutton target="_dialog" label="Rétablir" href="!users/email/verify.php?address=%s"|args:$row.email shape="check"} {elseif $row.email && $row.target_type} {linkbutton target="_dialog" label="Supprimer" href="!users/email/optout_delete.php?address=%s"|args:$row.email shape="delete"} {/if} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {/if} {include file="_foot.tpl"} |
Added src/templates/users/email/queue.tpl version [ffd1e1f707].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | {include file="_head.tpl" title="File d'envoi" current="users/mailing"} {include file="./_nav.tpl" current="queue"} {if $_GET.msg === 'EMPTY'} <p class="confirm block"> Les messages en attente ont été envoyés. </p> {/if} <p class="help">Cette page affiche les e-mails qui sont en attente d'être envoyés.</p> {if !$count} <p class="alert block">Il n'y a aucun message en attente d'envoi.</p> {else} <p class="help"> {if USE_CRON} Il y a {$count} messages dans la file d'attente, ils seront envoyés dans quelques minutes par une tâche automatique. {else} Il y a {$count} messages dans la file d'attente, cliquez ici pour envoyer les messages : {linkbutton shape="right" label="Envoyer les messages en attente" href="?run=1"} {/if} </p> {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr> <td>{$contexts[$row.context]}</td> <td>{tag color=$statuses_colors[$row.status] label=$statuses[$row.status]}</td> <td>{$row.sender}</td> <td>{$row.recipient}</td> <td>{$row.subject}</td> <td class="actions"> {linkbutton href="?id=%d"|args:$row.id label="Ouvrir" target="_dialog" shape="eye"} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {/if} {include file="_foot.tpl"} |
Added src/templates/users/email/rejected.tpl version [1a476180ac].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | {include file="_head.tpl" title="Adresses rejetées" current="users/mailing"} {include file="./_nav.tpl" current="rejected"} {if isset($_GET['sent'])} <p class="confirm block"> Un message de demande de confirmation a bien été envoyé. Le destinataire doit désormais cliquer sur le lien dans ce message. </p> {/if} {if !$list->count()} <p class="alert block">Aucune adresse e-mail n'a été rejetée pour le moment. Cette page présentera les adresses e-mail invalides ou qui ont demandé à se désinscrire.</p> {else} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr{if $_GET.hl == $row.id} class="highlight"{/if} id="e_{$row.id}"> <th>{link href="!users/details.php?id=%d"|args:$row.user_id label=$row.identity}</th> <td>{$row.email}</td> <td>{tag label=$labels[$row.status] color=$colors[$row.status]}</td> <td class="num">{$row.sent_count}</td> <td>{$row.last_sent|date}</td> <td> {linkbutton target="_dialog" label="Détails" href="!users/email/address.php?id=%d"|args:$row.id shape="eye"} </td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {/if} {include file="_foot.tpl"} |
Added src/templates/users/email/verify.tpl version [648dec2f4d].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | {include file="_head.tpl" title="Vérification d'adresse" current="users/mailing"} <form method="post" action="{$self_url}"> <fieldset> <legend>Demander la vérification de l'adresse</legend> {if $address.optout} <p class="help"> Si le membre a cliqué par erreur sur le lien de désinscription, il est possible de rétablir l'envoi des messages.<br /> Le membre recevra alors un message contenant un lien pour se réinscrire. </p> {else} <p class="help"> Si l'adresse du membre a rencontré une erreur fatale, ou trop d'erreurs temporaires, il est possible de rétablir l'envoi des messages.<br /> Le membre recevra alors un message contenant un lien pour valider son adresse. </p> {/if} <p class="alert block"> Attention, n'utiliser cette procédure qu'à la demande du membre.<br /> En cas d'absence de consentement du membre, les messages aux autres membres pourront être bloqués par les serveurs destinataires. </p> <p class="submit"> {csrf_field key=$csrf_key} {button type="submit" name="send" label="Envoyer un message de vérification" shape="right" class="main"} </p> </fieldset> </form> {include file="_foot.tpl"} |
Deleted src/templates/users/mailing/block.tpl version [d3cb829167].
|
| < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/delete.tpl version [287d96382b].
|
| < < < < < < < < |
Deleted src/templates/users/mailing/details.tpl version [8eb4612382].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/index.tpl version [a078781ae8].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/new.tpl version [aff296b479].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/recipient_data.tpl version [d5ad4ec244].
|
| < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/recipients.tpl version [4b637ba142].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/rejected.tpl version [6aee1d93eb].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/verify.tpl version [3380d3fc0b].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/templates/users/mailing/write.tpl version [a5ee5946c0].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Modified src/templates/users/message.tpl from [68d6edcd71] to [a5fc52d86b].
1 2 3 4 5 6 7 8 | {include file="_head.tpl" title="Contacter un membre" current="membres"} {form_errors} <form method="post" action="{$self_url}"> <fieldset class="message"> <legend>Message</legend> <dl> | < < < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | {include file="_head.tpl" title="Contacter un membre" current="membres"} {form_errors} <form method="post" action="{$self_url}"> <fieldset class="message"> <legend>Message</legend> <dl> <dt>Destinataire</dt> <dd>{$recipient->getNameAndEmail()}</dd> {input type="radio-btn" name="sender" value="self" default="self" required=true label="Membre" help=$self->getNameAndEmail() prefix_label="Expéditeur" prefix_required=true} {input type="radio-btn" name="sender" value="org" default="self" required=true label="Association" help="%s <%s>"|args:$config.org_name:$config.org_email} {input type="text" name="subject" required=true label="Sujet" class="full-width"} {input type="textarea" name="message" required=true label="Message" rows=15 class="full-width"} {input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"} </dl> </fieldset> <p class="submit"> |
︙ | ︙ |
Added src/templates/users/subscriptions.tpl version [74131c5aed].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | {include file="_head.tpl" title="%s — Inscriptions aux activités et cotisations"|args:$user_name current="users/services"} {include file="users/_nav_user.tpl" id=$user_id current="services"} {form_errors} {if !$only} <dl class="cotisation"> <dt>Statut des inscriptions</dt> {foreach from=$services item="service"} <dd{if $service.archived} class="disabled"{/if}> {$service.label} {if $service.archived} <em>(activité passée)</em>{/if} {if $service.status == -1 && $service.end_date} — expirée {elseif $service.status == -1} — <b class="error">en retard</b> {elseif $service.status == 1 && $service.end_date} — <b class="confirm">en cours</b> {elseif $service.status == 1} — <b class="confirm">à jour</b>{/if} {if $service.status.expiry_date} — expire le {$service.expiry_date|date_short}{/if} {if !$service.paid} — <b class="error">À payer !</b>{/if} </dd> {foreachelse} <dd> Ce membre n'est actuellement inscrit à aucune activité ou cotisation. </dd> {/foreach} {if !$only && !$after} <dt>Nombre d'inscriptions pour ce membre</dt> <dd> {$list->count()} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)} {exportmenu href="?id=%d"|args:$user_id} {/if} </dd> {/if} </dl> {/if} {if $only} <p class="alert block">Cette liste ne montre qu'une seule inscription, liée à l'activité <strong>{$only_service.label}</strong><br /> {linkbutton shape="right" href="?id=%d"|args:$user_id label="Voir toutes les inscriptions"} </p> {/if} {include file="common/dynamic_list_head.tpl"} {foreach from=$list->iterate() item="row"} <tr{if $row.archived} class="disabled"{/if}> <th>{$row.label} {if $row.archived}<em>(archivée)</em>{/if}</th> <td>{$row.fee}</td> <td>{$row.date|date_short}</td> <td>{$row.expiry|date_short}</td> <td>{if $row.paid}<b class="confirm">Oui</b>{else}<b class="error">Non</b>{/if}</td> <td class="money">{if $row.expected_amount}{$row.amount|raw|money_currency:false} {if $row.amount}<br /><small class="help">(sur {$row.expected_amount|raw|money_currency:false})</small>{/if} {/if} </td> <td class="actions"> {if !$row.paid} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $row.id_account} {linkbutton shape="plus" label="Nouveau règlement" href="!services/subscription/payment.php?id=%d"|args:$row.id} {/if} {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)} {linkbutton shape="plus" label="Saisir une écriture liée" href="!acc/transactions/new.php?u[%d]=%d&00=%d&t=1&l=Paiement%%20activité&ar=%s&set_year=%d"|args:$user_id:$row.id:$row.expected_amount:$row.account_code:$row.id_year target="_dialog"} {/if} <br /> {/if} {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)} {linkbutton shape="menu" label="Liste des écritures" href="!acc/transactions/subscription.php?id=%d&user=%d"|args:$row.id,$user_id} {/if} {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)} {if $row.paid} {linkbutton shape="reset" label="Marquer comme non payé" href="?id=%d&su_id=%d&paid=0"|args:$user_id,$row.id} {else} {linkbutton shape="check" label="Marquer comme payé" href="?id=%d&su_id=%d&paid=1"|args:$user_id,$row.id} {/if} <br /> {linkbutton shape="edit" label="Modifier" href="!services/subscription/edit.php?id=%d"|args:$row.id} {linkbutton shape="delete" label="Supprimer" href="!services/subscription/delete.php?id=%d"|args:$row.id} {/if} </td> </tr> {foreachelse} <tr> <td colspan="7">Aucune inscription trouvée.</td> </tr> {/foreach} </tbody> </table> {$list->getHTMLPagination()|raw} {include file="_foot.tpl"} |
Modified src/www/_route.php from [fbc1235739] to [7f487761ce].
1 2 3 4 5 | <?php namespace Paheko; use Paheko\Web\Router; | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php namespace Paheko; use Paheko\Web\Router; use Paheko\Email\Addresses; if (empty($_SERVER['REQUEST_URI'])) { http_response_code(500); die('Appel non supporté'); } $uri = $_SERVER['REQUEST_URI']; |
︙ | ︙ | |||
34 35 36 37 38 39 40 | // Handle __un__subscribe URL: .../?un=XXXX if ((empty($uri) || $uri === '/') && !empty($_GET['un'])) { $params = array_intersect_key($_GET, ['un' => null, 'v' => null]); // RFC 8058 if (!empty($_POST['Unsubscribe']) && $_POST['Unsubscribe'] == 'Yes') { | | | 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | // Handle __un__subscribe URL: .../?un=XXXX if ((empty($uri) || $uri === '/') && !empty($_GET['un'])) { $params = array_intersect_key($_GET, ['un' => null, 'v' => null]); // RFC 8058 if (!empty($_POST['Unsubscribe']) && $_POST['Unsubscribe'] == 'Yes') { $email = Addresses::getFromOptout($params['un']); if (!$email) { throw new UserException('Adresse email introuvable.'); } $email->setOptout(); $email->save(); |
︙ | ︙ |
Modified src/www/admin/acc/transactions/details.php from [1a82cb539a] to [b62b8ead45].
︙ | ︙ | |||
29 30 31 32 33 34 35 | 'details' => $transaction->getDetails(), 'files' => $transaction->listFiles(), 'creator_name' => $transaction->id_creator ? Users::getName($transaction->id_creator) : null, 'files_edit' => $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE), 'file_parent' => $transaction->getAttachementsDirectory(), 'linked_users' => $transaction->listLinkedUsers(), 'linked_transactions' => $transaction->listLinkedTransactions(), | | | 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | 'details' => $transaction->getDetails(), 'files' => $transaction->listFiles(), 'creator_name' => $transaction->id_creator ? Users::getName($transaction->id_creator) : null, 'files_edit' => $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE), 'file_parent' => $transaction->getAttachementsDirectory(), 'linked_users' => $transaction->listLinkedUsers(), 'linked_transactions' => $transaction->listLinkedTransactions(), 'linked_subscriptions' => $transaction->listLinkedSubscriptions(), ]; $tpl->assign($variables); $tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_TRANSACTION, $variables)); $tpl->display('acc/transactions/details.tpl'); |
Deleted src/www/admin/acc/transactions/service_user.php version [c55459a2ec].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/www/admin/acc/transactions/subscription.php version [32cde7e24f].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | <?php namespace Paheko; use Paheko\Accounting\Reports; use Paheko\Accounting\Transactions; use Paheko\Accounting\Years; require_once __DIR__ . '/../../_inc.php'; $session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); $id = (int)qg('id'); $user = (int)qg('user'); $self_url = sprintf('!acc/transactions/subscription.php?id=%d&user=%d', $id, $user); $form->runIf(qg('unlink') !== null, function () use ($id) { $t = Transactions::get((int)qg('unlink')); $t->deleteSubscriptionLink($id); }, null, $self_url); $criterias = ['subscription' => $id]; $action = ['shape' => 'delete', 'href' => $self_url . '&unlink=%d', 'label' => 'Dé-lier cette écriture']; $tpl->assign('balance', Reports::getAccountsBalances($criterias)); $tpl->assign('journal', Reports::getJournal($criterias)); $tpl->assign('user_id', $user); $tpl->assign('subscription_id', $id); $tpl->assign(compact('action')); $tpl->display('acc/transactions/subscription.tpl'); |
Modified src/www/admin/config/advanced/api.php from [0867da5451] to [e8b46ed571].
︙ | ︙ | |||
19 20 21 22 23 24 25 | }, $csrf_key, Utils::getSelfURI()); $list = API_Credentials::list(); $default_key = API_Credentials::generateKey(); $secret = API_Credentials::generateSecret(); $access_levels = API_Entity::ACCESS_LEVELS; | | | 19 20 21 22 23 24 25 26 27 28 29 | }, $csrf_key, Utils::getSelfURI()); $list = API_Credentials::list(); $default_key = API_Credentials::generateKey(); $secret = API_Credentials::generateSecret(); $access_levels = API_Entity::ACCESS_LEVELS; $tpl->assign('api_doc_url', Utils::getLocalURL('!static/doc/api.html')); $tpl->assign(compact('list', 'csrf_key', 'default_key', 'secret', 'access_levels')); $tpl->display('config/advanced/api.tpl'); |
Modified src/www/admin/me/services.php from [01519cc713] to [6036b9331b].
1 2 3 | <?php namespace Paheko; | | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php namespace Paheko; use Paheko\Services\Subscriptions; use Paheko\Accounting\Reports; use Paheko\Entities\Accounting\Account; use Paheko\UserTemplate\Modules; require_once __DIR__ . '/_inc.php'; $tpl->assign('membre', $user); $list = Subscriptions::perUserList($user->id); $list->loadFromQueryString(); $tpl->assign(compact('list')); $services = Subscriptions::listDistinctForUser($user->id); $accounts = Reports::getAccountsBalances(['user' => $user->id, 'type' => Account::TYPE_THIRD_PARTY]); $variables = compact('list', 'services', 'accounts'); $tpl->assign($variables); $tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_MY_SERVICES, $variables)); $tpl->display('me/services.tpl'); |
Modified src/www/admin/optout.php from [a8e32e6d40] to [992e84897e].
1 2 3 | <?php namespace Paheko; | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php namespace Paheko; use Paheko\Email\Addresses; const LOGIN_PROCESS = true; require_once __DIR__ . '/_inc.php'; if (empty($_GET['un'])) { throw new UserException('Demande de désinscription incomplète.'); } $code = $_GET['un']; $email = Addresses::getFromOptout($code); $verify = null; if (!$email) { throw new UserException('Adresse email introuvable.'); } if (!empty($_GET['v'])) { |
︙ | ︙ |
Added src/www/admin/services/history.php version [c26070860d].
> > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?php namespace Paheko; use Paheko\Services\Subscriptions; require_once __DIR__ . '/_inc.php'; $list = Subscriptions::getList(); $list->loadFromQueryString(); $tpl->assign(compact('list')); $tpl->display('services/history.tpl'); |
Modified src/www/admin/services/import.php from [8b45124880] to [eb312706d4].
1 2 3 4 5 6 7 | <?php namespace Paheko; use Paheko\CSV_Custom; use Paheko\Users\Session; use Paheko\Users\Users; | | | | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | <?php namespace Paheko; use Paheko\CSV_Custom; use Paheko\Users\Session; use Paheko\Users\Users; use Paheko\Services\Subscriptions; require_once __DIR__ . '/_inc.php'; $session = Session::getInstance(); $session->requireAccess($session::SECTION_USERS, $session::ACCESS_ADMIN); $csrf_key = 'su_import'; $csv = new CSV_Custom($session, 'su_import'); $csv->setColumns(Subscriptions::listImportColumns()); $csv->setMandatoryColumns(Subscriptions::listMandatoryImportColumns()); $form->runIf('cancel', function() use ($csv) { $csv->clear(); }, $csrf_key, Utils::getSelfURI()); $form->runIf(f('load') && isset($_FILES['file']['tmp_name']), function () use ($csv) { $csv->load($_FILES['file']); }, $csrf_key, Utils::getSelfURI()); $form->runIf(f('import') && $csv->loaded(), function () use (&$csv) { $csv->skip((int)f('skip_first_line')); $csv->setTranslationTable(f('translation_table')); try { if (!$csv->ready()) { $csv->clear(); throw new UserException('Erreur dans le chargement du CSV'); } Subscriptions::import($csv); } finally { $csv->clear(); } }, $csrf_key, '!services/import.php?msg=OK'); $tpl->assign(compact('csv', 'csrf_key')); $tpl->display('services/import.tpl'); |
Modified src/www/admin/services/index.php from [0d2c08a2dd] to [1c9db068eb].
︙ | ︙ | |||
11 12 13 14 15 16 17 | $form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () { $service = new Service; $service->importForm(); $service->save(); Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id()); }, $csrf_key); | | | > > > > | > > | | 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | $form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && f('save'), function () { $service = new Service; $service->importForm(); $service->save(); Utils::redirect(ADMIN_URL . 'services/fees/?id=' . $service->id()); }, $csrf_key); $has_archived_services = Services::hasArchivedServices(); $show_archived_services = $_GET['archived'] ?? false; if ($show_archived_services) { $list = Services::listArchivedWithStats(); } else { $list = Services::listWithStats(); } $list->loadFromQueryString(); $tpl->assign(compact('csrf_key', 'has_archived_services', 'show_archived_services', 'list')); $tpl->display('services/index.tpl'); |
Modified src/www/admin/services/reminders/delete.php from [32c8f1317f] to [7fa839c1bc].
︙ | ︙ | |||
14 15 16 17 18 19 20 21 22 23 24 25 26 | if (!$reminder) { throw new UserException("Ce rappel n'existe pas"); } $csrf_key = 'reminder_delete_' . $reminder->id(); $form->runIf('delete', function () use ($reminder) { $reminder->delete(); }, $csrf_key, ADMIN_URL . 'services/reminders/'); $tpl->assign(compact('reminder', 'csrf_key')); $tpl->display('services/reminders/delete.tpl'); | > > > > | 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | if (!$reminder) { throw new UserException("Ce rappel n'existe pas"); } $csrf_key = 'reminder_delete_' . $reminder->id(); $form->runIf('delete', function () use ($reminder) { if (f('confirm_delete')) { $reminder->deleteHistory(); } $reminder->delete(); }, $csrf_key, ADMIN_URL . 'services/reminders/'); $tpl->assign(compact('reminder', 'csrf_key')); $tpl->display('services/reminders/delete.tpl'); |
Added src/www/admin/services/subscription/_form.php version [6c01868431].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | <?php namespace Paheko; use Paheko\Accounting\Projects; use Paheko\Services\Services; if (!defined('\Paheko\ROOT')) { die(); } assert(isset($tpl, $form_url, $create)); // If there is only one user selected we can calculate the amount $single_user_id = isset($users) && count($users) == 1 ? key($users) : null; $copy_service ??= null; $copy_service_only_paid ??= null; $users ??= null; $grouped_services = Services::listGroupedWithFees($single_user_id); $today = new \DateTime; $tpl->assign([ 'custom_js' => ['service_form.js'], ]); $tpl->assign(compact('form_url', 'today', 'grouped_services', 'create', 'copy_service', 'copy_service_only_paid')); $tpl->assign_by_ref('users', $users); $tpl->assign('projects', Projects::listAssoc()); |
Added src/www/admin/services/subscription/delete.php version [c6518954dd].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | <?php namespace Paheko; use Paheko\Services\Subscriptions; use Paheko\Users\Users; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $su = Subscriptions::get((int) qg('id')); if (!$su) { throw new UserException("Cette inscription n'existe pas"); } $csrf_key = 'su_delete_' . $su->id(); $user_id = $su->id_user; $form->runIf('delete', function () use ($su) { $su->delete(); }, $csrf_key, ADMIN_URL . 'users/subscriptions.php?id=' . $user_id); $user_name = Users::getName($su->id_user); $service_name = $su->service()->label; $fee_name = $su->id_fee ? $su->fee()->label : null; $tpl->assign(compact('csrf_key', 'user_name', 'fee_name', 'service_name')); $tpl->display('services/subscription/delete.tpl'); |
Added src/www/admin/services/subscription/edit.php version [769c92870f].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php namespace Paheko; use Paheko\Services\Subscriptions; use Paheko\Users\Users; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $subscription = Subscriptions::get((int) qg('id')); if (!$subscription) { throw new UserException("Cette inscription n'existe pas"); } $csrf_key = 'subscription_edit_' . $subscription->id(); $users = [$subscription->id_user => Users::getName($subscription->id_user)]; $form_url = sprintf('edit.php?id=%d&', $subscription->id()); $create = false; require __DIR__ . '/_form.php'; $form->runIf('save', function () use ($subscription) { $subscription->importForm(); $subscription->importForm(['paid' => (bool)f('paid')]); $subscription->updateExpectedAmount(); $subscription->save(); }, $csrf_key, ADMIN_URL . 'users/subscriptions.php?id=' . $subscription->id_user); $tpl->assign(compact('csrf_key', 'subscription')); $tpl->display('services/subscription/edit.tpl'); |
Added src/www/admin/services/subscription/link.php version [9079fc0131].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php namespace Paheko; use Paheko\Services\Subscriptions; use Paheko\Accounting\Transactions; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $session->requireAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ); $subscription = Subscriptions::get((int)qg('id')); if (!$subscription) { throw new UserException("Cette inscription n'existe pas"); } $csrf_key = 'service_link'; $form->runIf('save', function () use ($subscription) { $id = (int)f('id_transaction'); $transaction = Transactions::get($id); if (!$transaction) { throw new UserException('Impossible de trouver l\'écriture #' . $id); } $transaction->linkToSubscription($subscription->id); }, $csrf_key, '!acc/transactions/subscription.php?id=' . $subscription->id . '&user=' . $subscription->id_user); $tpl->assign(compact('csrf_key')); $tpl->display('services/subscription/link.tpl'); |
Added src/www/admin/services/subscription/new.php version [9be2c31d95].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | <?php namespace Paheko; use Paheko\Services\Fees; use Paheko\Services\Services; use Paheko\Users\Categories; use Paheko\Users\Users; use Paheko\Accounting\Projects; use Paheko\Entities\Services\Subscription; use Paheko\Entities\Accounting\Account; use Paheko\Entities\Accounting\Transaction; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); // This controller allows to either select a user if none has been provided in the query string // or subscribe a user to an activity (create a new Subscription entity) // If $user_id is null then the form is just a select to choose a user $count_all = Services::count(); if (!$count_all) { Utils::redirect(ADMIN_URL . 'services/?CREATE'); } $users = null; $copy_service = null; $copy_fee = null; $copy_only_paid = null; $allow_users_edit = true; $copy = substr((string) f('copy'), 0, 1); $copy_id = (int) substr((string) f('copy'), 1); if (qg('user') && ($name = Users::getName((int)qg('user')))) { $users = [(int)qg('user') => $name]; $allow_users_edit = false; } elseif (f('users') && is_array(f('users')) && count(f('users'))) { $users = f('users'); $users = array_filter($users, 'intval', \ARRAY_FILTER_USE_KEY); } elseif (($copy == 's' && ($copy_service = Services::get($copy_id))) || ($copy == 'f' && ($copy_fee = Fees::get($copy_id)))) { $copy_only_paid = (bool) f('copy_only_paid'); } elseif (f('category')) { $category = Categories::get((int)f('category')); if (!$category) { throw new UserException('Catégorie inconnue.'); } $users = iterator_to_array(Users::iterateAssocByCategory($category->id)); } elseif (qg('users')) { $users = explode(',', qg('users')); $users = array_map('intval', $users); $users = Users::getNames($users); } else { throw new UserException('Aucun membre n\'a été sélectionné'); } if (null !== $users) { natcasesort($users); } $form_url = '?'; $csrf_key = 'service_save'; $create = true; // Only load the form if a user has been selected require __DIR__ . '/_form.php'; $form->runIf('save', function () use ($session, &$users, $copy_service, $copy_fee, $copy_only_paid) { if ($copy_service) { $users = $copy_service->getUsers($copy_only_paid); } elseif ($copy_fee) { $users = $copy_fee->getUsers($copy_only_paid); } $su = Subscription::createFromForm($users, $session->getUser()->id, $copy_service ? true : false); Utils::reloadParentFrameIfDialog(); if (count($users) > 1) { $url = ADMIN_URL . 'services/details.php?id=' . $su->id_service; } else { $url = ADMIN_URL . 'users/subscriptions.php?id=' . $su->id_user; } Utils::redirect($url); }, $csrf_key); if (null !== $users && !count($users)) { throw new ValidationException('Aucun membre sélectionné ne peut être inscrit, car ils sont tous déjà inscrits à cette activité et à la date indiquée.'); } $t = new Transaction; $t->type = $t::TYPE_REVENUE; $types_details = $t->getTypesDetails(); $account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string; $subscription = null; $tpl->assign(compact('csrf_key', 'users', 'account_targets', 'subscription', 'allow_users_edit', 'copy_service', 'copy_fee', 'copy_only_paid')); $tpl->assign('projects', Projects::listAssoc()); $tpl->display('services/subscription/new.tpl'); |
Added src/www/admin/services/subscription/payment.php version [856f266536].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | <?php namespace Paheko; use Paheko\Services\Subscriptions; use Paheko\Accounting\Accounts; use Paheko\Accounting\Projects; use Paheko\Accounting\Years; use Paheko\Entities\Accounting\Account; use Paheko\Entities\Accounting\Transaction; use Paheko\Users\Users; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $su = Subscriptions::get((int)qg('id')); if (!$su) { throw new UserException("Cette inscription n'existe pas"); } $fee = $su->fee(); if (!$fee || !$fee->id_year) { throw new UserException('Cette inscription n\'est pas liée à un tarif relié à la comptabilité, il n\'est pas possible de saisir un règlement.'); } $user_name = Users::getName($su->id_user); $csrf_key = 'service_pay'; $form->runIf(f('save') || f('save_and_add_payment'), function () use ($su, $session) { $su->addPayment($session->getUser()->id); if ($su->paid != (bool) f('paid')) { $su->paid = (bool) f('paid'); $su->save(); } }, $csrf_key, '!users/subscriptions.php?id=' . $su->id_user); $t = new Transaction; $t->type = $t::TYPE_REVENUE; $types_details = $t->getTypesDetails(); $account_targets = $types_details[Transaction::TYPE_REVENUE]->accounts[1]->targets_string; $tpl->assign('projects', Projects::listAssoc()); $tpl->assign(compact('csrf_key', 'account_targets', 'user_name', 'su', 'fee')); $tpl->display('services/subscription/payment.tpl'); |
Added src/www/admin/services/subscription/select.php version [aa7c499a9e].
> > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <?php namespace Paheko; use Paheko\Services\Services; use Paheko\Users\Categories; use Paheko\Users\Session; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); // This controller allows to either select a user if none has been provided in the query string // or subscribe a user to an activity (create a new Subscription entity) // If $user_id is null then the form is just a select to choose a user $count_all = Services::count(); if (!$count_all) { Utils::redirect(ADMIN_URL . 'services/?CREATE'); } $services = Services::listAssocWithFees(); $categories = Categories::listAssoc(); $tpl->assign(compact('services', 'categories')); $tpl->display('services/subscription/select.tpl'); |
Deleted src/www/admin/services/user/_form.php version [0472d83149].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/add.php version [f211c6b3cd].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/delete.php version [258ad809c7].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/edit.php version [ac51e77e6c].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/index.php version [f8e9112ec8].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/link.php version [fadf825ef4].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/payment.php version [ffae6c1087].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/services/user/subscribe.php version [f757166bd5].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Modified src/www/admin/static/doc/api.html from [d4bb706a70] to [3d95d98e16].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>/home/bohwaz/fossil/paheko/tools/../doc/admin/api.md</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ | |||
43 44 45 46 47 48 49 | .web-content .nav strong a { color: darkred; box-shadow: 0px 0px 5px orange; } </style> <link rel="stylesheet" type="text/css" href="../../../content.css" /> </head> | | | | | | | > > | > > > | > > > > > > | > > > > | > > > > > > > > > | > | > > | > > > > | > > | > > | > > > | > > | > > | > | | | | < | > > > | > > | > > | > > > > > > > | > > > | < > > > > > > > > > | > > | > > > | > > > | > > > > | > > > > > | > > > > > > > > > > > | > | | < | > > > > > > > > > > > > > > > > | > > | < < > > > | < | > > > > > > > | < < < < < > > > > > > > > > > > > > > > > > | | | | | | | | | | | | | | | > > > > > > > > | < > > > > > > > > > > > > > > > > > > > > > > > > > | < > > > > > > > > > > > > > | | > > > | > > | < > > > > | > > > > | < < > | < > > > > > > > > > > > > > > | > > | < > | | > > > | > > > > > > > > > > > > > > > > | | | | | | | > | < | | > > > > > > > > > > > > > > > > > > > > > | | < | | | | | > | > > > > > > > > > > > > > > > > > > > > > > | > | > | | < < | | | | > > > > > > > > > > > > > > > > > > > > > > > | > > > | | > > > | | > | > > > > > > > | < | < > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | < | > | < > > > > > > > | < > > | < < < < > | < < > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | < < > > > > > | > > | < < < < > > > > > | > > | > > > > > > | | | | | > > > > > > | < < > > > > > > > > > > > > > > > > > > | < > > > > > > | > > | | < < < | < < < < < < < < < < < < < < < < < < < < < | < < < < | < < < | | | < | < < | 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 | .web-content .nav strong a { color: darkred; box-shadow: 0px 0px 5px orange; } </style> <link rel="stylesheet" type="text/css" href="../../../content.css" /> </head> <body><div class="web-content"><style type="text/css"> details.api { clear: both; list-style: none; padding: 0.2em 0.5em; transition: background-color .2s; background: #fff; padding: 0; border: 1px solid #ccc; margin-bottom: .7em; border-radius: .5rem; } details.api summary { cursor: pointer; display: flex; align-items: center; gap: .8rem; font-size: 1.2em; position: relative; padding: .5rem; padding-right: 2em; flex-wrap: wrap; } details.api summary::after { content: "⌄"; position: absolute; right: .5rem; bottom: .5em; font-size: 2em; line-height: .5em; transition: top .2s, transform .4s, color .2s; } details.api summary:hover::after { color: darkred; text-shadow: 0px 0px 5px orange; } details.api:not([open]):hover { background: #eee; box-shadow: 0px 0px 5px orange; } details.api[open] summary::after { transform: rotate(180deg); top: .75em; right: 0; } details.api[open] { padding: .5rem; } details.api[open] summary { margin-bottom: 1em; padding: 0; padding-right: 2em; } details.api summary b { display: block; border-radius: .3em; background: #333; padding: .1rem .4rem; color: #fff; width: 8ch; text-align: center; } details.api summary code { background: none; font-weight: bold; word-break: keep-all; } details.api summary code u { text-decoration: none; border: 1px dashed #999; color: darkblue; border-radius: .5rem; padding: .2rem; } details.api summary span { font-size: 1rem; } details.api summary b.method-GET { background: #8fbc8f; } details.api summary b.method-POST { background: #4682b4; } details.api summary b.method-PUT { background: #9370db; } details.api summary b.method-DELETE { background: #cd5c5c; } details.api summary h3 { margin: 0; } details.api.all { float: right; } details.api.all summary { margin: 0; font-size: .9rem; } @media screen and (max-width: 800px) { details.api summary { flex-direction: column; align-items: start; } details.api.all { float: none; } } </style> <details class="api all"><summary onclick="var open = !this.parentNode.hasAttribute('open'); document.querySelectorAll('details').forEach(elm => elm.open = open); return false;">Tout déplier / replier</summary></details><?xml encoding="utf-8" ?><h1 id="introduction">Introduction</h1><details class="api"><summary id="debuter" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Débuter</h3></summary><p>Une API de type REST est disponible dans Paheko.</p><p>Pour accéder à l'API il faut un identifiant et un mot de passe, à créer dans le menu <mark>Configuration</mark>, onglet <mark>Fonctions avancées</mark>, puis <mark>API</mark>.</p><p>L'API peut ensuite recevoir des requêtes REST sur l'URL <code>https://adresse_association/api{route}</code>.</p><p>Remplacer <mark>{route}</mark> par une des routes de l'API (voir ci-dessous).</p><p>La méthode HTTP (<code>GET</code>, <code>POST</code>, etc.) à utiliser est spécifiée pour chaque route.</p><p>Des exemples sont donnés pour l'utilisation de l'outil <code>curl</code> en ligne de commande, si vous souhaitez utiliser un autre langage de programmation il faudra adapter votre code.</p></details><details class="api"><summary id="formats-des-requetes-et-reponses" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Formats des requêtes et réponses</h3></summary><p>Les paramètres peuvent être fournis sous les formes suivantes :</p><ul> <li>dans les paramètres de l'URL (query string) : pour toutes les méthodes</li> <li>formulaire HTTP classique pour les requêtes <code>POST</code> :<ul> <li><code>Content-Type: application/x-www-form-urlencoded</code></li> <li>ou <code>Content-Type: multipart/form-data</code></li> </ul> </li> <li>objet JSON pour les requêtes POST :<ul> <li><code>Content-Type: application/json</code></li> </ul> </li> </ul><p>Les réponses sont renvoyées en JSON par défaut, sauf quand la route permet de choisir un autre format.</p><p>Les formats ODS et XLSX ne sont disponibles à l'import que si le serveur est configuré pour convertir ces formats.</p><p>De la même manière, le format XLSX n'est disponible que si le serveur est configuré pour générer ce format.</p></details><details class="api"><summary id="utiliser-l-api" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Utiliser l'API</h3></summary><p>N'importe quel client HTTP capable de gérer TLS (HTTPS) et l'authentification basique fonctionnera.</p><p>En ligne de commande il est possible d'utiliser <code>curl</code>. Exemple pour télécharger la base de données :</p><pre><code>curl -u test:secret https://[identifiant_association].paheko.cloud/api/download -o association.sqlite</code></pre><p>On peut aussi utiliser <code>wget</code> en n'oubliant pas l'option <code>--auth-no-challenge</code> sinon l'authentification ne fonctionnera pas :</p><pre><code>wget https://test:secret@[identifiant_association].paheko.cloud/api/download \ --auth-no-challenge \ -O association.sqlite</code></pre><p>Exemple pour créer une écriture sous forme de formulaire :</p><pre><code>curl -v -u test:secret \ https://[identifiant_association].paheko.cloud/api/accounting/transaction \ -F id_year=1 \ -F label=Test \ -F "date=01/02/2023" …</code></pre><p>Ou sous forme d'objet JSON :</p><pre><code>curl -v -u test:secret \ https://[identifiant_association].paheko.cloud/api/accounting/transaction \ -H 'Content-Type: application/json' \ -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023", …}'</code></pre></details><details class="api"><summary id="authentification" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Authentification</h3></summary><p>L'API utilise l'authentification <a href="https://fr.wikipedia.org/wiki/Authentification_HTTP#M%C3%A9thode_%C2%AB_Basic_%C2%BB" rel="noreferrer noopener external" target="_blank"><code>Basic</code> de HTTP</a>.</p></details><details class="api"><summary id="erreurs" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><h3>Erreurs</h3></summary><p>En cas d'erreur un code HTTP 4XX sera fourni, et le contenu sera un objet JSON avec une clé <code>error</code> contenant le message d'erreur.</p></details><h1 id="routes">Routes</h1><h2 id="requetes-sql">Requêtes SQL</h2><details class="api"><summary id="post-sql-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/sql.<u>{FORMAT}</u></code> <span>Exécute une requête SQL en lecture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>FORMAT</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Format de retour : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td> </tr> <tr> <td style="text-align: left;"><code>sql</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Requête SQL à exécuter.</td> </tr> </tbody> </table><p>Si aucun format n'est passé (exemple : <code>…/api/sql</code>, sans point ni extension), <code>json</code> sera utilisé.</p><p>Permet d'exécuter une requête SQL <code>SELECT</code> (uniquement, pas de requête <code>UPDATE</code>, <code>DELETE</code>, <code>INSERT</code>, etc.) sur la base de données. La requête SQL doit être passée dans le corps de la requête HTTP, ou dans le paramètre <code>sql</code>.</p><p>S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.</p><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/sql \ -d 'SELECT nom, code_postal FROM users LIMIT 2;'</code></pre><p>Exemple de réponse :</p><pre><code class="language-response">{ "count": 65, "results": [ { "nom": "Ada Lovelace", "code_postal": null }, { "nom": "James Coincoin", "code_postal": "78990" } ] }</code></pre><p><strong>Attention :</strong> Les requêtes en écriture (<code>INSERT, DELETE, UPDATE, CREATE TABLE</code>, etc.) ne sont pas acceptées, il n'est pas possible de modifier la base de données directement via Paheko, afin d'éviter les soucis de données corrompues.</p></details><h2 id="telechargements">Téléchargements</h2><details class="api"><summary id="get-download" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/download</code> <span>Télécharger la base de données</span></summary><p>Renvoie directement le fichier SQLite de la base de données.</p><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/download -o db.sqlite</code></pre></details><details class="api"><summary id="get-download-files" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/download/files</code> <span>Télécharger un fichier ZIP contenant tous les fichiers</span></summary><p><em>(Depuis la version 1.3.4)</em></p><p>Les fichiers inclus sont :</p><ul> <li>documents</li> <li>fichiers liés aux écritures,</li> <li>fichiers liés des membres,</li> <li>fichiers joints aux pages du site web</li> <li>code des modules modifiés</li> <li>corbeille</li> <li>configuration : logo, icônes, etc.</li> <li>anciennes versions des fichiers</li> </ul><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/download/files -o backup_files.zip</code></pre></details><h2 id="site-web">Site web</h2><p><em>(Depuis la version 1.4.0)</em></p><details class="api"><summary id="get-web" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web</code> <span>Liste de toutes les pages du site web</span></summary></details><details class="api"><summary id="get-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Métadonnées de la page du site web</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> <tr> <td style="text-align: left;"><code>html</code></td> <td style="text-align: left;"><code>bool</code></td> <td style="text-align: left;">Si <code>true</code> ou <code>1</code>, une clé <code>html</code> sera ajoutée à la réponse avec le contenu de la page au format HTML.</td> </tr> </tbody> </table><p>Exemple de réponse :</p><pre><code class="language-response">[ { "id": 13, "uri": "actualite", "title": "Actualit\u00e9", "path": null, "draft": 0, "published": "2019-04-22 18:00:00", "modified": "2023-09-12 15:44:55" }, { "id": 66, "uri": "Affiches-des-bourses-aux-velos", "title": "Affiches des bourses aux v\u00e9los", "path": "Nos activit\u00e9s", "draft": 0, "published": "2019-07-18 19:05:00", "modified": "2023-04-04 14:44:04" }, … ]</code></pre></details><details class="api"><summary id="put-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Modifie le contenu de la page</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X PUT -d 'La bourse aura lieu le 28 septembre'</code></pre></details><details class="api"><summary id="post-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Modifie les métadonnées de la page</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> <tr> <td style="text-align: left;"><code>id_parent</code></td> <td style="text-align: left;"><code>int|null</code></td> <td style="text-align: left;">Numéro de la catégorie parente de cette page.</td> </tr> <tr> <td style="text-align: left;"><code>uri</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Nouvelle adresse unique de la page.</td> </tr> <tr> <td style="text-align: left;"><code>title</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Titre de la page.</td> </tr> <tr> <td style="text-align: left;"><code>type</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Type de page. <code>1</code> pour les catégories, <code>2</code> pour les pages simples.</td> </tr> <tr> <td style="text-align: left;"><code>status</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Statut de la page. <code>online</code> si la page est en ligne, <code>draft</code> si la page est en brouillon.</td> </tr> <tr> <td style="text-align: left;"><code>format</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Format de la page : <code>markdown</code>, <code>encrypted</code> ou <code>skriv</code></td> </tr> <tr> <td style="text-align: left;"><code>published</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Date et heure de publication au format <code>YYYY-MM-DD HH:mm:ss</code>.</td> </tr> <tr> <td style="text-align: left;"><code>modified</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Date et heure de modification au format <code>YYYY-MM-DD HH:mm:ss</code>.</td> </tr> <tr> <td style="text-align: left;"><code>content</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Contenu.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -F title="Bourse aux vélos du 28 septembre"</code></pre></details><details class="api"><summary id="delete-web-page_uri" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/web/<u>{PAGE_URI}</u></code> <span>Supprime la page et ses fichiers joints</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre -X DELETE</code></pre></details><details class="api"><summary id="get-web-page_uri-html" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>.html</code> <span>Contenu de la page web au format HTML</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/bourse-28-septembre.html</code></pre></details><details class="api"><summary id="get-web-page_uri-children" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/children</code> <span>Liste des pages et sous-catégories dans cette catégorie</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://paheko.monasso.tld/api/web/actualite/children</code></pre><p>Exemple de réponse :</p><pre><code class="language-response">{ "categories": [], "pages": [ { "id": 86, "id_parent": 13, "uri": "bourse-aux-velos-le-30-septembre-et-1er-octobre", "title": "Bourse aux v\u00e9los 30 septembre et 1er octobre", "type": 2, "status": "online", "format": "skriv", "published": "2023-10-01 18:00:00", "modified": "2023-09-11 23:41:41", "content": "…" }, … ] }</code></pre></details><details class="api"><summary id="get-web-page_uri-attachments" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/attachments</code> <span>Liste des fichiers joints à la page</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> </tbody> </table></details><details class="api"><summary id="get-web-page_uri-file_name" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/web/<u>{PAGE_URI}</u>/<u>{FILE_NAME}</u></code> <span>Récupérer le fichier joint à la page</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> <tr> <td style="text-align: left;"><code>FILENAME</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Nom du fichier.</td> </tr> </tbody> </table></details><details class="api"><summary id="delete-web-page_uri-file_name" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/web/<u>{PAGE_URI}</u>/<u>{FILE_NAME}</u></code> <span>Supprime le fichier joint à la page</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>PAGE_URI</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Adresse unique de la page.</td> </tr> <tr> <td style="text-align: left;"><code>FILENAME</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Nom du fichier.</td> </tr> </tbody> </table></details><h2 id="membres">Membres</h2><details class="api"><summary id="get-user-categories" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/categories</code> <span>Liste des catégories de membres</span></summary><p><em>(Depuis la version 1.4.0)</em></p><p>La liste est triée par nom, et inclue le nombre de membres de la catégorie dans la clé <code>count</code>.</p><p>Exemple de réponse :</p><pre><code class="language-response">{ "12": { "id": 12, "name": "Administration technique", "perm_web": 9, "perm_documents": 9, "perm_users": 9, "perm_accounting": 9, "perm_subscribe": 0, "perm_connect": 1, "perm_config": 9, "hidden": 0, "count": 1 } }</code></pre></details><details class="api"><summary id="get-user-category-id-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/category/<u>{ID}</u>.<u>{FORMAT}</u></code> <span>Exporte la liste des membres d'une catégorie</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant unique de la catégorie.</td> </tr> <tr> <td style="text-align: left;"><code>FORMAT</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Format de sortie : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="post-user-new" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/new</code> <span>Créer un nouveau membre</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>id_category</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant de la catégorie. Si absent, la catégorie par défaut sera utilisée.</td> </tr> <tr> <td style="text-align: left;"><code>password</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Mot de passe du membre.</td> </tr> <tr> <td style="text-align: left;"><code>force_duplicate</code></td> <td style="text-align: left;"><code>bool</code></td> <td style="text-align: left;">Si <code>true</code> ou <code>1</code>, alors aucune erreur ne sera renvoyée si le nom du membre correspond à un membre déjà existant.</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p><p>Attention, cette méthode comporte des restrictions :</p><ul> <li>il n'est pas possible de créer un membre dans une catégorie ayant accès à la configuration</li> <li>il n'est pas possible de définir l'OTP ou la clé PGP du membre créé</li> <li>seul un identifiant API ayant le droit "Administration" pourra créer des membres administrateurs</li> </ul><p>Il est possible d'utiliser tous les champs de la fiche membre en utilisant la clé unique du champ.</p><p>Sera renvoyée la liste des infos de la fiche membre.</p><p>Si un membre avec le même nom existe déjà (et que <code>force_duplicate</code> n'est pas utilisé), une erreur <code>409</code> sera renvoyée.</p><p>Exemple de requête :</p><pre><code class="language-request">curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new</code></pre></details><details class="api"><summary id="get-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/user/<u>{ID}</u></code> <span>Informations de la fiche d'un membre</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant unique du membre (différent du numéro).</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p><p>Plusieurs clés supplémentaires sont retournées, en plus des champs de la fiche membre :</p><ul> <li><code>has_password</code></li> <li><code>has_pgp_key</code></li> <li><code>has_otp</code></li> </ul><p>Exemple de réponse :</p><pre><code class="language-response">{ "has_password": true, "has_otp": false, "has_pgp_key": false, "id": 1, "id_category": 8, "date_login": "2021-06-06 09:17:39", "date_updated": null, "id_parent": null, "is_parent": false, "preferences": null, "numero": 1, "nom": "Ada Lovelace", "notes": null, "groupe_information": true, "groupe_benevoles": false, "email": "ada@lovelace.org", "telephone": "010101010101", "adresse": null, "code_postal": "21000", "ville": "DIJON", "pays": "FR", "date_inscription": "2012-02-25" }</code></pre></details><details class="api"><summary id="delete-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/user/<u>{ID}</u></code> <span>Supprime un membre</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant unique du membre (différent du numéro).</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p><p>Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres.</p><p>Note : il n'est pas possible de supprimer via l'API un membre appartenant à une catégorie ayant accès à la configuration.</p></details><details class="api"><summary id="post-user-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/<u>{ID}</u></code> <span>Modifie les infos de la fiche d'un membre</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant unique du membre (différent du numéro).</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p><p>Notes :</p><ul> <li>il n'est pas possible de modifier la catégorie d'un membre</li> <li>il n'est pas possible de modifier un membre appartenant à une catégorie ayant accès à la configuration.</li> <li>il n'est pas possible de modifier le mot de passe, l'OTP ou la clé PGP du membre créé</li> <li>il n'est pas possible de modifier des membres ayant accès à la configuration</li> <li>seul un identifiant d'API ayant l'accès en "Administration" pourra modifier un membre administrateur</li> </ul></details><details class="api"><summary id="post-user-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/import</code> <span>Importer un fichier de tableur de la liste des membres</span></summary><p>Formats de fichiers acceptés : CSV, ODS, XLSX.</p><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>mode</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Mode d'import du fichier. Voir ci-dessous pour les détails. <em>(Depuis la version 1.2.8)</em></td> </tr> <tr> <td style="text-align: left;"><code>skip_lines</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Nombre de lignes à ignorer. Défaut : <code>1</code>.</td> </tr> <tr> <td style="text-align: left;"><code>column</code></td> <td style="text-align: left;"><code>array</code></td> <td style="text-align: left;">Correspondance entre la colonne (clé, commence à zéro) et le champ de la fiche membre (valeur).</td> </tr> </tbody> </table><p>Cette route nécessite une clé d'API ayant les droits d'administration, car importer un fichier peut permettre de modifier l'identifiant de connexion d'un administrateur et donc potentiellement d'obtenir l'accès à l'interface d'administration.</p><p>Le paramètre <code>mode</code> permet d'utiliser une de ces options pour spécifier le mode d'import :</p><ul> <li><code>auto</code> (défaut si le mode n'est pas spécifié) : met à jour la fiche d'un membre si son numéro existe, sinon crée un membre si le numéro de membre indiqué n'existe pas ou n'est pas renseigné</li> <li><code>create</code> : ne fait que créer de nouvelles fiches de membre, si le numéro de membre existe déjà une erreur sera produite</li> <li><code>update</code> : ne fait que mettre à jour les fiches de membre en utilisant le numéro de membre comme référence, si le numéro de membre n'existe pas une erreur sera produite</li> </ul><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import \ -F mode=create \ -F 'column[0]=nom_prenom' \ -F 'column[1]=code_postal' \ -F skip_lines=0 \ -F file=@membres.csv</code></pre><p>Si aucun paramètre <code>column</code> n'est fourni, Paheko s'attend alors à ce que la première est ligne du tableau contienne le nom des colonnes, et que le nom des colonnes correspond au nom des champs de la fiche membre (ou à leur nom unique). Par exemple si votre fiche membre contient les champs <em>Nom et prénom</em> et <em>Adresse postale</em>, alors le fichier fourni devra ressembler à ceci :</p><table> <thead> <tr> <th style="text-align: left;">Nom et prénom</th> <th style="text-align: left;">Adresse postale</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;">Ada Lovelace</td> <td style="text-align: left;">42 rue du binaire, 21000 DIJON</td> </tr> </tbody> </table><p>Ou à ceci :</p><table> <thead> <tr> <th style="text-align: left;">nom_prenom</th> <th style="text-align: left;">adresse_postale</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;">Ada Lovelace</td> <td style="text-align: left;">42 rue du binaire, 21000 DIJON</td> </tr> </tbody> </table><p>La méthode renvoie un code HTTP <code>200 OK</code> si l'import s'est bien passé, sinon un code 400 et un message d'erreur JSON dans le corps de la réponse.</p><p>Utilisez la route <code>user/import/preview</code> avant pour vérifier que l'import correspond à ce que vous attendez.</p><p>Exemple pour modifier le nom du membre n°42 :</p><pre><code>echo 'numero,nom' > membres.csv echo '42,"Nouveau nom"' >> membres.csv curl -u test:abcd https://monpaheko.tld/api/user/import -F file=@membres.csv</code></pre></details><details class="api"><summary id="put-user-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/user/import</code> <span>Importer un fichier de tableur de la liste des membres</span></summary><p>Formats de fichiers acceptés : CSV, ODS, XLSX.</p><p>Identique à la même méthode en <code>POST</code>, mais les paramètres sont passés dans l'URL, et le fichier en contenu de la requête.</p><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&skip_lines=0 \ -T membres.csv</code></pre></details><details class="api"><summary id="post-user-import-preview" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/user/import/preview</code> <span>Prévisualise un import de membres, sans modifier les membres</span></summary><p>Identique à <code>user/import</code>, mais l'import n'est pas enregistré. À la place l'API indique les modifications qui seraient apportées.</p><p>Renvoie un objet JSON comme ceci :</p><ul> <li><code>errors</code> : liste des erreurs d'import</li> <li><code>created</code> : liste des membres ajoutés, chaque objet contenant tous les champs de la fiche membre qui serait créée</li> <li><code>modified</code> : liste des membres modifiés, chaque membre aura une clé <code>id</code> et une clé <code>name</code>, ainsi qu'un objet <code>changed</code> contenant la liste des champs modifiés. Chaque champ modifié aura 2 propriétés <code>old</code> et <code>new</code>, contenant respectivement l'ancienne valeur du champ et la nouvelle.</li> <li><code>unchanged</code> : liste des membres mentionnés dans l'import, mais qui ne seront pas affectés. Pour chaque membre une clé <code>name</code> et une clé <code>id</code> indiquant le nom et l'identifiant unique numérique du membre</li> </ul><p>Note : si <code>errors</code> n'est pas vide, alors il sera impossible d'importer le fichier avec <code>user/import</code>.</p><p>Exemple de requête :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import/preview -F mode=update -F file=@/tmp/membres.csv</code></pre><p>Exemple de réponse :</p><pre><code class="language-response">{ "created": [ { "numero": 3434351, "nom": "Bla Bli Blu" } ], "modified": [ |
︙ | ︙ | |||
309 310 311 312 313 314 315 | ], "unchanged": [ { "id": 2, "name": "Paul Muad'Dib" } ] | | < < < < < < < < | | | | < | < < | | > | > > | | | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | | | < < < < < | > > > > > | > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > | | > > > > > > > > > > > > > > > > > > > | > > > | > > | < | > > > > > | | < > > > > | > | < | | | | | | | > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | | | | | | | > > > > > > > > > > > > > > > | > > > > > > | | | | | | > > | < < | | > > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > | < | | | < | < > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > | < > > > > > > > > > > > > > > | > | > > > > | > > > > > > > | > > > > > > > > > > > > > | | > > > | | > > > > > > > > > > > > > > | < < < > > > | > > | > > > > > > > | > > > > > > > > > | | 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 | ], "unchanged": [ { "id": 2, "name": "Paul Muad'Dib" } ] }</code></pre></details><details class="api"><summary id="put-user-import-preview" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/user/import/preview</code> <span>Prévisualise un import de membres, sans modifier les membres</span></summary><p>Idem quel la méthode en <code>POST</code> mais les paramètres doivent être passés dans l'URL, et le fichier dans le corps de la requête.</p></details><h2 id="activites">Activités</h2><details class="api"><summary id="put-services-subscriptions-import" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-PUT">PUT</b> <code>/services/subscriptions/import</code> <span>Importer les inscriptions des membres aux activités</span></summary><p>Fichiers acceptés : CSV, XLSX, ODS.</p><p><em>(Depuis Paheko 1.3.2)</em></p><p>Les activités et tarifs doivent déjà exister avant l'import.</p><p>Les colonnes suivantes peuvent être utilisées :</p><ul> <li>Numéro de membre<code>**</code></li> <li>Activité<code>**</code></li> <li>Tarif</li> <li>Date d'inscription<code>**</code></li> <li>Date d'expiration</li> <li>Montant à régler</li> <li>Payé ?</li> </ul><p>Les colonnes suivies de deux astérisques (<code>**</code>) sont obligatoires.</p><p>Exemple :</p><pre><code>echo '"Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?"' > /tmp/inscriptions.csv echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' >> /tmp/inscriptions.csv curl -u test:abcd https://monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv</code></pre></details><h2 id="erreurs">Erreurs</h2><p>Paheko dispose d'un système dédié à la gestion des erreurs internes, compatible avec les formats des logiciels AirBrake et errbit.</p><details class="api"><summary id="post-errors-report" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/errors/report</code> <span>Ajouter un rapport d'erreur au log</span></summary><p>Cette route permet d'ajouter une erreur au log de l'instance. Utile pour centraliser les erreurs de plusieurs instances.</p><p>Paheko utilise le format d'erreurs de <a href="https://docs.airbrake.io/docs/devops-tools/api/#post-data-schema-v3" rel="noreferrer noopener external" target="_blank">AirBrake</a> et errbit.</p></details><details class="api"><summary id="get-errors-log" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/errors/log</code> <span>Log d'erreurs de l'instance</span></summary></details><h2 id="comptabilite">Comptabilité</h2><details class="api"><summary id="get-accounting-years" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years</code> <span>Liste des exercices</span></summary><p>Exemple de réponse :</p><pre><code class="language-response">[ { "id": 1, "label": "Premier exercice", "start_date": "2011-11-01", "end_date": "2013-01-31", "closed": 1, "id_chart": 1, "nb_transactions": 1194, "chart_name": "Plan comptable associatif 1999" }, … ]</code></pre></details><details class="api"><summary id="get-accounting-charts" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/charts</code> <span>Liste des plans comptables</span></summary><p>Exemple de réponse :</p><pre><code class="language-response">[ { "id": 2, "label": "Plan comptable associatif 2018", "country": "FR", "code": "PCA_2018", "archived": false } ]</code></pre></details><details class="api"><summary id="get-accounting-charts-id_chart-accounts" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/charts/<u>{ID_CHART}</u>/accounts</code> <span>Liste des comptes pour le plan comptable indiqué</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_CHART</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID du plan comptable.</td> </tr> </tbody> </table><p>Exemple de réponse :</p><pre><code class="language-response">[ { "id": 312, "id_chart": 2, "code": "1", "label": "Classe 1 \u2014 Comptes de capitaux (Fonds propres, emprunts et dettes assimil\u00e9s)", "description": null, "position": 2, "type": 0, "user": false, "bookmark": false }, … ]</code></pre></details><details class="api"><summary id="get-accounting-years-id_year-journal" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal</code> <span>Journal général des écritures de l'exercice indiqué</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_YEAR</code></td> <td style="text-align: left;"><code>int|string</code></td> <td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td> </tr> </tbody> </table><p>Note : il est possible d'utiliser <code>current</code> comme paramètre pour <code>{ID_YEAR}</code> pour désigner l'exercice ouvert en cours. S'il y a plusieurs exercices ouverts, alors celui qui est le plus proche de la date actuelle sera utilisé.</p></details><details class="api"><summary id="get-accounting-years-id_year-export-type-format" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/export/<u>{TYPE}</u>.<u>{FORMAT}</u></code> <span>Export d'un exercice</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_YEAR</code></td> <td style="text-align: left;"><code>int|string</code></td> <td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td> </tr> <tr> <td style="text-align: left;"><code>TYPE</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Type d'export : <code>full</code>, <code>grouped</code>, <code>simple</code> ou <code>fec</code>. <code>simple</code> ne contient pas les écritures avancées.</td> </tr> <tr> <td style="text-align: left;"><code>FORMAT</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Format d'export : <code>json</code>, <code>csv</code>, <code>ods</code> ou <code>xlsx</code></td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="get-accounting-years-id_year-journal-code" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal/<u>{CODE}</u></code> <span>Journal des écritures d'un compte</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_YEAR</code></td> <td style="text-align: left;"><code>int|string</code></td> <td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td> </tr> <tr> <td style="text-align: left;"><code>CODE</code></td> <td style="text-align: left;"><code>int|string</code></td> <td style="text-align: left;">Code du compte.</td> </tr> </tbody> </table><p>Exemple de réponse :</p><pre><code class="language-response">[ { "id": 9297, "id_line": 22401, "date": "2022-02-08", "debit": 0, "credit": 850, "change": 850, "sum": 850, "reference": "POS-SESSION-434", "type": 0, "label": "Session de caisse n\u00b0434", "line_label": null, "line_reference": null, "id_project": null, "project_code": null, "files": 1, "status": 0 }, … ]</code></pre></details><details class="api"><summary id="get-accounting-years-id_year-journal-id" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/years/<u>{ID_YEAR}</u>/journal/=<u>{ID}</u></code> <span>Journal des écritures d'un compte</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_YEAR</code></td> <td style="text-align: left;"><code>int|string</code></td> <td style="text-align: left;">ID de l'exercice, ou <code>current</code>.</td> </tr> <tr> <td style="text-align: left;"><code>ID</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID du compte.</td> </tr> </tbody> </table></details><details class="api"><summary id="post-accounting-transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction</code> <span>Créer une nouvelle écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>id_year</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant de l'exercice.</td> </tr> <tr> <td style="text-align: left;"><code>date</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Date au format <code>YYYY-MM-DD</code> ou <code>DD/MM/YYYY</code></td> </tr> <tr> <td style="text-align: left;"><code>type</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Type d'écriture.</td> </tr> <tr> <td style="text-align: left;"><code>reference</code></td> <td style="text-align: left;"><code>string|null</code></td> <td style="text-align: left;">Numéro de pièce comptable</td> </tr> <tr> <td style="text-align: left;"><code>notes</code></td> <td style="text-align: left;"><code>string|null</code></td> <td style="text-align: left;">Remarques (texte multi ligne)</td> </tr> <tr> <td style="text-align: left;"><code>linked_transactions</code></td> <td style="text-align: left;"><code>array(int, …)|null</code></td> <td style="text-align: left;">Tableau des IDs des écritures à lier à l'écriture <em>(depuis 1.3.5)</em></td> </tr> <tr> <td style="text-align: left;"><code>linked_users</code></td> <td style="text-align: left;"><code>array(int, …)|null</code></td> <td style="text-align: left;">Tableau des IDs des membres à lier à l'écriture <em>(depuis 1.3.3)</em></td> </tr> <tr> <td style="text-align: left;"><code>linked_subscriptions</code></td> <td style="text-align: left;"><code>array(int, …)|null</code></td> <td style="text-align: left;">Tableau des IDs des inscriptions à lier à l'écriture <em>(depuis 1.4.0)</em></td> </tr> </tbody> </table><h4 id="types-d-ecriture">Types d'écriture</h4><table> <thead> <tr> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>expense</code></td> <td style="text-align: left;">Dépense</td> </tr> <tr> <td style="text-align: left;"><code>revenue</code></td> <td style="text-align: left;">Recette</td> </tr> <tr> <td style="text-align: left;"><code>transfer</code></td> <td style="text-align: left;">Virement</td> </tr> <tr> <td style="text-align: left;"><code>debt</code></td> <td style="text-align: left;">Dette</td> </tr> <tr> <td style="text-align: left;"><code>credit</code></td> <td style="text-align: left;">Créance</td> </tr> <tr> <td style="text-align: left;"><code>advanced</code></td> <td style="text-align: left;">Saisie avancée</td> </tr> </tbody> </table><p>Les écritures avancées (multi-lignes) ont obligatoirement le type <code>advanced</code>. Les autres écritures sont appelées <em>"écritures simplifiées"</em> et ne peuvent comporter que deux lignes.</p><h4 id="parametres-des-ecritures-simplifiees">Paramètres des écritures simplifiées</h4><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>amount</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Montant de l'écriture, au format flottant (<code>53,34</code>)</td> </tr> <tr> <td style="text-align: left;"><code>credit</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Code du compte porté au crédit</td> </tr> <tr> <td style="text-align: left;"><code>debit</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Code du compte porté au débit</td> </tr> <tr> <td style="text-align: left;"><code>id_project</code></td> <td style="text-align: left;"><code>int|null</code></td> <td style="text-align: left;">ID du projet à affecter</td> </tr> <tr> <td style="text-align: left;"><code>payment_reference</code></td> <td style="text-align: left;"><code>int|null</code></td> <td style="text-align: left;">référence de paiement</td> </tr> </tbody> </table><h4 id="parametres-des-ecritures-avancees">Paramètres des écritures avancées</h4><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>lines</code></td> <td style="text-align: left;"><code>array(object, …)</code></td> <td style="text-align: left;">un tableau dont chaque élément est un objet correspondant à une ligne de l'écriture.</td> </tr> </tbody> </table><p>Structure de l'objet représentant une ligne de l'écriture :</p><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>account</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Code du compte</td> </tr> <tr> <td style="text-align: left;"><code>id_account</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">Identifiant du compte (ID). Peut être utilisé en alternative au code du compte.</td> </tr> <tr> <td style="text-align: left;"><code>credit</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">Montant à inscrire au crédit (doit être zéro ou non renseigné si <code>debit</code> est renseigné, et vice-versa)</td> </tr> <tr> <td style="text-align: left;"><code>debit</code></td> <td style="text-align: left;"><code>string</code></td> <td style="text-align: left;">montant à inscrire au débit</td> </tr> <tr> <td style="text-align: left;"><code>label</code></td> <td style="text-align: left;"><code>string|null</code></td> <td style="text-align: left;">libellé de la ligne</td> </tr> <tr> <td style="text-align: left;"><code>reference</code></td> <td style="text-align: left;"><code>string|null</code></td> <td style="text-align: left;">référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)</td> </tr> <tr> <td style="text-align: left;"><code>id_project</code></td> <td style="text-align: left;"><code>int|null</code></td> <td style="text-align: left;">ID du projet à affecter à cette ligne</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code class="language-request">curl -F 'id_year=12' \ -F 'label=Test' \ -F 'date=01/02/2022' \ -F 'type=expense' \ -F 'amount=42,45' \ -F 'debit=512A' \ -F 'credit=601'</code></pre></details><details class="api"><summary id="get-accounting-transaction-id_transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u></code> <span>Détails de l'écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table><p>Exemple de réponse :</p><pre><code class="language-response">{ "id": 9302, "type": 0, "status": 0, "label": "Session de caisse n\u00b0439", "notes": null, "reference": "POS-SESSION-439", "date": "2022-02-12", "hash": null, "prev_id": null, "prev_hash": null, "id_year": 12, "id_creator": 5883, "url": "http:\/\/dev.paheko.localhost\/admin\/acc\/transactions\/details.php?id=9302", "lines": [ { "id": 22421, "id_transaction": 9302, "id_account": 542, "credit": 0, "debit": 3000, "reference": "CE342", "label": null, "reconciled": false, "id_project": null, "account_code": "5112", "account_label": "Ch\u00e8ques \u00e0 encaisser", "account_position": 3, "project_name": null, "account_selector": { "542": "5112 \u2014 Ch\u00e8ques \u00e0 encaisser" } }, … ] }</code></pre></details><details class="api"><summary id="post-accounting-transaction-id_transaction" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u></code> <span>Modifier l'écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table><p>Si l'écriture est verrouillée, ou dans un exercice clôturé, la modification sera impossible.</p><p>Voir la route <code>POST accounting/transaction</code> (création d'une écriture) pour la liste des paramètres.</p></details><details class="api"><summary id="get-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Liste des membres liés à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table></details><details class="api"><summary id="post-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Met à jour la liste des membres liés à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> <tr> <td style="text-align: left;"><code>users</code></td> <td style="text-align: left;"><code>array(int, …)</code></td> <td style="text-align: left;">ID des membres.</td> </tr> </tbody> </table><p>Exemple de requête :</p><pre><code> curl -v "https://…/api/accounting/transaction/9337/users" -F 'users[]=2'</code></pre></details><details class="api"><summary id="delete-accounting-transaction-id_transaction-users" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/users</code> <span>Efface la liste des membres liés à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table></details><details class="api"><summary id="get-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-GET">GET</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Liste des inscriptions (aux activités) liées à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p></details><details class="api"><summary id="post-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-POST">POST</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Met à jour la liste des inscriptions liées à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> <tr> <td style="text-align: left;"><code>subscriptions</code></td> <td style="text-align: left;"><code>array(int, …)</code></td> <td style="text-align: left;">ID des inscriptions.</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p><p>Exemple de requête :</p><pre><code> curl -v "https://…/api/accounting/transaction/9337/subscriptions" -F 'subscriptions[]=2'</code></pre></details><details class="api"><summary id="delete-accounting-transaction-id_transaction-subscriptions" onclick="if (!this.parentNode.open) window.history.replaceState(null, '', '#' + this.id); return true;"><b class="method-DELETE">DELETE</b> <code>/accounting/transaction/<u>{ID_TRANSACTION}</u>/subscriptions</code> <span>Efface la liste des inscriptions liées à une écriture</span></summary><table> <thead> <tr> <th style="text-align: left;">Paramètre</th> <th style="text-align: left;">Type</th> <th style="text-align: left;">Description</th> </tr> </thead> <tbody> <tr> <td style="text-align: left;"><code>ID_TRANSACTION</code></td> <td style="text-align: left;"><code>int</code></td> <td style="text-align: left;">ID de l'écriture.</td> </tr> </tbody> </table><p><em>(Depuis la version 1.4.0)</em></p></details> </div></body></html> |
Modified src/www/admin/static/doc/brindille.html from [175273e804] to [c516582898].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Documentation du langage Brindille dans Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/brindille_functions.html from [c705f71df0] to [b17568f66f].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence des fonctions Brindille</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/brindille_modifiers.html from [e69ee88290] to [ba0150af03].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence des filtres Brindille</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/brindille_sections.html from [a6db77203f] to [9e7ca46886].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence des sections Brindille</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/keyboard.html from [55dd27ea5d] to [4e8ee2ed49].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Raccourcis claviers dans l'édition de texte — Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/markdown.html from [450ad76210] to [4ac111b36a].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence complète MarkDown — Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/markdown_quickref.html from [a03b76328e] to [8f7105212d].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence rapide MarkDown — Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/modules.html from [c143ed67d2] to [bd8e02b75f].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Développer des modules pour Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/skriv.html from [1fdf3f7ccd] to [86819c22d1].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Référence rapide SkrivML - Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/sql.html from [07fb557ea8] to [7e1714ce22].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>/home/bohwaz/fossil/paheko/tools/../doc/admin/sql.md</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/doc/web.html from [fe4a7a6059] to [4858b076a5].
1 2 3 | <!DOCTYPE html> <html> <head> | < > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 | <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Squelettes du site web dans Paheko</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |
Modified src/www/admin/static/styles/02-common.css from [e5d5ad0c0a] to [c21c2abd52].
︙ | ︙ | |||
901 902 903 904 905 906 907 | pre code { background: var(--gLightBackgroundColor); display: block; padding: .5em; border-radius: .3em; } | < < < < | 901 902 903 904 905 906 907 908 909 910 911 912 913 914 | pre code { background: var(--gLightBackgroundColor); display: block; padding: .5em; border-radius: .3em; } img.broken { font-size: 1.2rem; text-align: center; background: #977; border-radius: .3rem; color: #fff; text-decoration: none; |
︙ | ︙ | |||
933 934 935 936 937 938 939 | -ms-hyphens: auto; -moz-hyphens: auto; -webkit-hyphens: auto; hyphens: auto; white-space: pre-wrap; } | > > > > > > > > > | 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 | -ms-hyphens: auto; -moz-hyphens: auto; -webkit-hyphens: auto; hyphens: auto; white-space: pre-wrap; } span.tag { display: inline-block; color: #fff; text-shadow: 0px 0px 5px #000; padding: .2rem .4rem; background: var(--tag-color); border-radius: .3rem; } |
Modified src/www/admin/users/action.php from [ee26f54d10] to [37a560db21].
︙ | ︙ | |||
19 20 21 22 23 24 25 | $csrf_key = 'users_actions'; if ($action === 'ods' || $action === 'csv' || $action === 'xlsx') { Users::exportSelected($action, $list); return; } elseif ($action === 'subscribe') { | | | 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | $csrf_key = 'users_actions'; if ($action === 'ods' || $action === 'csv' || $action === 'xlsx') { Users::exportSelected($action, $list); return; } elseif ($action === 'subscribe') { Utils::redirect('!services/subscription/new.php?users=' . implode(',', $list)); } elseif ($action === 'move' || $action === 'delete' || $action === 'delete_files') { $logged_user_id = Session::getUserId(); // Don't allow to change or delete the currently logged-in user // to avoid shooting yourself in the foot $list = array_filter($list, fn ($a) => $a != $logged_user_id); |
︙ | ︙ |
Modified src/www/admin/users/details.php from [c1d42dd32c] to [b26703703f].
1 2 3 4 | <?php namespace Paheko; use Paheko\Accounting\Transactions; | | | 1 2 3 4 5 6 7 8 9 10 11 12 | <?php namespace Paheko; use Paheko\Accounting\Transactions; use Paheko\Services\Subscriptions; use Paheko\Users\Categories; use Paheko\Users\Users; use Paheko\UserTemplate\Modules; require_once __DIR__ . '/_inc.php'; |
︙ | ︙ | |||
38 39 40 41 42 43 44 | $session->logout(); $session->forceLogin($user->id); Log::add(Log::LOGIN_AS, ['admin' => $logged_user->name()]); }, $csrf_key, '!?login_as=1'); | | | 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | $session->logout(); $session->forceLogin($user->id); Log::add(Log::LOGIN_AS, ['admin' => $logged_user->name()]); }, $csrf_key, '!?login_as=1'); $services = Subscriptions::listDistinctForUser($user->id); $variables = []; if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) { $variables['transactions_linked'] = Transactions::countForUser($user->id); $variables['transactions_created'] = Transactions::countForCreator($user->id); } |
︙ | ︙ |
Added src/www/admin/users/email/address.php version [1de4509d63].
> > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php namespace Paheko; use Paheko\Email\Addresses; use Paheko\Entities\Email\Address; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_READ); $address = Addresses::getByID((int)qg('id')); if (!$address) { throw new UserException('Adresse e-mail inconnue'); } $limit_date = Addresses::getVerificationLimitDate(); $max_fail_count = Address::FAIL_LIMIT; $tpl->assign(compact('address', 'max_fail_count', 'limit_date')); $tpl->display('users/email/address.tpl'); |
Added src/www/admin/users/email/block.php version [47036cc98f].
> > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php namespace Paheko; use Paheko\Email\Addresses; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $address = qg('address'); $email = Addresses::get($address); if (!$email) { throw new UserException('Adresse invalide ou inconnue'); } $csrf_key = 'block_email'; $form->runIf('send', function () use ($email) { $email->setOptout('Désinscription manuelle par un administrateur'); $email->save(); }, $csrf_key, '!users/'); $tpl->assign(compact('csrf_key', 'email', 'address')); $tpl->display('users/email/block.tpl'); |
Added src/www/admin/users/email/mailing/_inc.php version [1f2037bdd9].
> > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 | <?php namespace Paheko; use Paheko\Users\Session; require_once __DIR__ . '/../../_inc.php'; $session = Session::getInstance(); $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); |
Added src/www/admin/users/email/mailing/delete.php version [9488cf7f5d].
> > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; $mailing = Mailings::get((int)qg('id')); if (!$mailing) { throw new UserException('Invalid mailing ID'); } $csrf_key = 'mailing_delete'; $form->runIf('delete', function () use ($mailing) { $mailing->delete(); }, $csrf_key, '!users/email/mailing/?msg=DELETE'); $tpl->assign(compact('mailing', 'csrf_key')); $tpl->display('users/email/mailing/delete.tpl'); |
Added src/www/admin/users/email/mailing/details.php version [5286ebd205].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; $mailing = Mailings::get((int)qg('id')); if (!$mailing) { throw new UserException('Invalid mailing ID'); } if (qg('preview') !== null) { echo $mailing->getHTMLPreview((int)qg('preview') ?: null, true); return; } $csrf_key = 'mailing_details'; $form->runIf('send', function() use ($mailing) { $mailing->send(); }, $csrf_key, '!users/email/mailing/details.php?sent&id=' . $mailing->id); $count = $mailing->countRecipients(); $tpl->assign(compact('mailing', 'csrf_key', 'count')); $tpl->assign('custom_css', [BASE_URL . 'content.css']); $tpl->assign('sent', null !== qg('sent')); $tpl->display('users/email/mailing/details.tpl'); |
Added src/www/admin/users/email/mailing/edit.php version [9b79a6b403].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; use Paheko\Entities\Email\Mailing; require_once __DIR__ . '/_inc.php'; $id = intval($_GET['id'] ?? 0); if ($id !== 0) { $mailing = Mailings::get($id); if (!$mailing) { throw new UserException('Invalid mailing ID'); } } else { $mailing = new Mailing; } $csrf_key = 'mailing_edit'; $form->runIf('save', function () use ($mailing) { $mailing->importForm(); $mailing->set('body', trim(f('content') ?? '')); $mailing->save(); $js = false !== strpos($_SERVER['HTTP_ACCEPT'] ?? '', '/json'); $url = '!users/email/mailing/details.php?id=' . $mailing->id; $url = Utils::getLocalURL($url); if ($js) { die(json_encode(['success' => true, 'modified' => time(), 'redirect' => $url])); } Utils::redirect($url); }, $csrf_key); if (!$form->hasErrors()) { $form->runIf('content', function() use ($mailing) { $mailing->set('body', trim(f('content') ?? '')); echo $mailing->getHTMLPreview(null, true); exit; }); } $tpl->assign(compact('mailing', 'csrf_key')); $tpl->assign('custom_js', ['web_editor.js']); $tpl->assign('custom_css', ['web.css', BASE_URL . 'content.css']); $tpl->display('users/email/mailing/edit.tpl'); |
Added src/www/admin/users/email/mailing/index.php version [8814739c31].
> > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <?php namespace Paheko; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; Mailings::anonymize(); $list = Mailings::getList(); $list->loadFromQueryString(); $tpl->assign(compact('list')); $tpl->display('users/email/mailing/index.tpl'); |
Added src/www/admin/users/email/mailing/populate.php version [5258f173ec].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; $csrf_key = 'create_mailing'; $target_type = f('target_type'); $form->runIf($target_type == 'all' || f('step3'), function () { $target_type = f('target_type'); $target_value = f('target_value'); $target_label = $_POST['labels'][$target_value] ?? null; if ($target_type !== 'all' && empty($target_value)) { throw new UserException('Aucune cible n\'a été sélectionnée.'); } $m = Mailings::create(f('subject'), $target_type, $target_value, $target_label); Utils::redirectDialog('!users/email/mailing/write.php?id=' . $m->id()); }, $csrf_key); $list = null; if ($target_type) { $list = Mailings::listTargets($target_type); } $tpl->assign(compact('csrf_key', 'target_type', 'list')); $tpl->display('users/email/mailing/new.tpl'); |
Added src/www/admin/users/email/mailing/recipient_data.php version [baac793801].
> > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; $mailing = Mailings::get((int)qg('id')); if (!$mailing) { throw new UserException('Invalid mailing ID'); } $data = $mailing->getRecipientExtraData((int)qg('r')); if (!$data) { throw new UserException('Ce destinataire n\'a aucune donnée.'); } $tpl->assign(compact('mailing', 'data')); $tpl->display('users/email/mailing/recipient_data.tpl'); |
Added src/www/admin/users/email/mailing/recipients.php version [1e8f8312fa].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php namespace Paheko; use Paheko\Users\Session; use Paheko\Email\Mailings; require_once __DIR__ . '/_inc.php'; $mailing = Mailings::get((int)qg('id')); if (!$mailing) { throw new UserException('Invalid mailing ID'); } $csrf_key = 'mailing'; if (!$mailing->sent) { $form->runIf('delete', function () use ($mailing) { $mailing->deleteRecipient((int)f('delete')); }, $csrf_key, '!users/email/mailing/recipients.php?id=' . $mailing->id); } $list = $mailing->getRecipientsList(); $list->loadFromQueryString(); $tpl->assign(compact('mailing', 'list', 'csrf_key')); $tpl->display('users/email/mailing/recipients.tpl'); |
Added src/www/admin/users/email/optout.php version [715868cbde].
> > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php namespace Paheko; use Paheko\Email\Mailings; use Paheko\Email\Addresses; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $limit_date = Addresses::getVerificationLimitDate(); $list = Mailings::getOptoutUsersList(); $list->loadFromQueryString(); $tpl->assign(compact('list', 'limit_date')); $tpl->display('users/email/optout.tpl'); |
Added src/www/admin/users/email/queue.php version [ea8b44d27f].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <?php namespace Paheko; use Paheko\Email\Queue; use Paheko\Email\Emails; use Paheko\Entities\Email\Message; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN); $form->runIf(qg('run') && !USE_CRON, function () use ($session) { $i = (int) qg('run'); Queue::run(100); $i++; // Continue sending by batch as long as there is something in the queue if ($i < 20 && Queue::count()) { Utils::redirect('!users/email/queue.php?run=' . $i); } }, null, '!users/email/queue.php?msg=EMPTY'); $count = Queue::count(); $list = Queue::getList(); $statuses = Message::STATUS_LIST; $statuses_colors = Message::STATUS_COLORS; $contexts = Message::CONTEXT_LIST; $tpl->assign(compact('list', 'count', 'statuses', 'statuses_colors', 'contexts')); $tpl->display('users/email/queue.tpl'); |
Added src/www/admin/users/email/rejected.php version [5ac573a167].
> > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <?php namespace Paheko; use Paheko\Email\Addresses; use Paheko\Entities\Email\Address; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $list = Addresses::listRejectedUsers(); $list->loadFromQueryString(); $labels = Address::STATUS_LIST; $colors = Address::STATUS_COLORS; $tpl->assign(compact('list', 'labels', 'colors')); $tpl->display('users/email/rejected.tpl'); |
Added src/www/admin/users/email/verify.php version [116b91acd0].
> > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | <?php namespace Paheko; use Paheko\Email\Addresses; require_once __DIR__ . '/../_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE); $raw_address = qg('address'); Addresses::validate($raw_address); $address = Addresses::getOrCreate($raw_address); if (!$address->canSendVerificationAfterFail()) { $message = sprintf('Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre %d jours.', $address->getVerificationDelay()); throw new UserException($message); } $csrf_key = 'send_verification'; $form->runIf('send', function () use ($address, $raw_address) { $address->sendVerification($raw_address); }, $csrf_key, '!users/email/address.php?msg=VERIFICATION_SENT', true); $tpl->assign(compact('csrf_key', 'address')); $tpl->display('users/email/verify.tpl'); |
Deleted src/www/admin/users/mailing/_inc.php version [57578032ce].
|
| < < < < < < < < < < |
Deleted src/www/admin/users/mailing/block.php version [0a3c768560].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/delete.php version [829abccebc].
|
| < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/details.php version [4d90651838].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/index.php version [1da23b3440].
|
| < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/new.php version [0ae0ecf4fb].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/recipient_data.php version [7eb9cf0ebd].
|
| < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/recipients.php version [0eab354cfc].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/rejected.php version [65ed7e261c].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/verify.php version [0d8c005324].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/www/admin/users/mailing/write.php version [70659ff0aa].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/www/admin/users/subscriptions.php version [d508c9b779].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <?php namespace Paheko; use Paheko\Services\Services; use Paheko\Services\Subscriptions; use Paheko\Users\Users; require_once __DIR__ . '/_inc.php'; $session->requireAccess($session::SECTION_USERS, $session::ACCESS_READ); $user_id = (int) qg('id'); $user_name = Users::getName($user_id); if (!$user_name) { throw new UserException("Ce membre est introuvable"); } $form->runIf($session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && null !== qg('paid') && qg('su_id'), function () { $su = Subscriptions::get((int) qg('su_id')); if (!$su) { throw new UserException("Cette inscription est introuvable"); } $su->paid = (bool)qg('paid'); $su->save(); }, null, '!users/subscriptions.php?id=' . $user_id); $only = (int)qg('only') ?: null; if ($after = qg('after')) { $after = \DateTime::createFromFormat('!Y-m-d', $after) ?: null; } $only_service = !$only ? null : Services::get($only); $list = Subscriptions::perUserList($user_id, $only, $after); $list->setTitle(sprintf('Inscriptions — %s', $user_name)); $list->loadFromQueryString(); $tpl->assign('services', Subscriptions::listDistinctForUser($user_id)); $tpl->assign(compact('list', 'user_id', 'user_name', 'only', 'only_service', 'after')); $tpl->display('users/subscriptions.tpl'); |
Modified tools/doc_md_to_html.php from [72736f48da] to [df2497a62a].
1 2 3 4 5 6 7 8 9 10 11 12 | <?php use KD2\HTML\Markdown; use KD2\HTML\Markdown_Extensions; require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown.php'; require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown_Extensions.php'; $md = new Markdown; // Allow extra tags for Markdown quickref $extra_tags = [ | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?php use KD2\HTML\Markdown; use KD2\HTMLDocument; use KD2\HTML\Markdown_Extensions; require_once __DIR__ . '/../src/include/lib/KD2/HTMLDocument.php'; require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown.php'; require_once __DIR__ . '/../src/include/lib/KD2/HTML/Markdown_Extensions.php'; $md = new Markdown; // Allow extra tags for Markdown quickref $extra_tags = [ |
︙ | ︙ | |||
46 47 48 49 50 51 52 53 54 55 56 57 58 | if (preg_match('/^Title: (.*)/', $t, $match)) { $t = substr($t, strlen($match[0])); } $t = $md->text($t); $t = preg_replace('!(<a\s+[^>]+external[^>]+)>!', '$1 target="_blank">', $t); $title = $match[1] ?? $file; $out = '<!DOCTYPE html> <html> <head> | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > < > > | 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | if (preg_match('/^Title: (.*)/', $t, $match)) { $t = substr($t, strlen($match[0])); } $t = $md->text($t); $t = preg_replace('!(<a\s+[^>]+external[^>]+)>!', '$1 target="_blank">', $t); // rewrite API HTML to make it better if (basename($file) === 'api.md') { $dom = new HTMLDocument; $dom->loadHTML($t); foreach ($dom->querySelectorAll('h3') as $route) { $label = null; $content = []; $next = $route; while ($next = $next->nextElementSibling) { if ($next->tagName === 'h3' || $next->tagName === 'h2' || $next->tagName === 'h1') { break; } if ($next->tagName === 'p' && null === $label) { $label = $next; } else { $content[] = $next; } } foreach ($content as $key => $elm) { $content[$key] = $elm->cloneNode(true); $elm->parentNode->removeChild($elm); } $parent = $dom->createElement('details'); $parent->setAttribute('class', 'api'); $title = $route->cloneNode(true); $method = strtok($title->textContent, ' '); $path = strtok(''); $summary = $dom->createElement('summary'); $summary->setAttribute('id', $route->getAttribute('id')); $summary->setAttribute('onclick', 'if (!this.parentNode.open) window.history.replaceState(null, \'\', \'#\' + this.id); return true;'); if (in_array($method, ['GET', 'POST', 'PUT', 'DELETE'])) { $label->parentNode->removeChild($label); $path = '/' . trim($path, '/'); $path = preg_replace('/(\{[^}]+\})/', '<u>$1</u>', $path); $fragment = $dom->createDocumentFragment(); $fragment->appendXML($path); $path = $dom->createElement('code'); $path->appendChild($fragment); $m = $dom->createElement('b', $method); $m->setAttribute('class', 'method-' . $method); $summary->replaceChildren($m, ' ', $path, ' ', $dom->createElement('span', $label->textContent)); } else { array_unshift($content, $label); $summary->replaceChildren($dom->createElement($title->tagName, $title->textContent)); } $parent->appendChild($summary); foreach ($content as $elm) { $parent->appendChild($elm); } $route->replaceWith($parent); } $t = '<style type="text/css"> details.api { clear: both; list-style: none; padding: 0.2em 0.5em; transition: background-color .2s; background: #fff; padding: 0; border: 1px solid #ccc; margin-bottom: .7em; border-radius: .5rem; } details.api summary { cursor: pointer; display: flex; align-items: center; gap: .8rem; font-size: 1.2em; position: relative; padding: .5rem; padding-right: 2em; flex-wrap: wrap; } details.api summary::after { content: "⌄"; position: absolute; right: .5rem; bottom: .5em; font-size: 2em; line-height: .5em; transition: top .2s, transform .4s, color .2s; } details.api summary:hover::after { color: darkred; text-shadow: 0px 0px 5px orange; } details.api:not([open]):hover { background: #eee; box-shadow: 0px 0px 5px orange; } details.api[open] summary::after { transform: rotate(180deg); top: .75em; right: 0; } details.api[open] { padding: .5rem; } details.api[open] summary { margin-bottom: 1em; padding: 0; padding-right: 2em; } details.api summary b { display: block; border-radius: .3em; background: #333; padding: .1rem .4rem; color: #fff; width: 8ch; text-align: center; } details.api summary code { background: none; font-weight: bold; word-break: keep-all; } details.api summary code u { text-decoration: none; border: 1px dashed #999; color: darkblue; border-radius: .5rem; padding: .2rem; } details.api summary span { font-size: 1rem; } details.api summary b.method-GET { background: #8fbc8f; } details.api summary b.method-POST { background: #4682b4; } details.api summary b.method-PUT { background: #9370db; } details.api summary b.method-DELETE { background: #cd5c5c; } details.api summary h3 { margin: 0; } details.api.all { float: right; } details.api.all summary { margin: 0; font-size: .9rem; } @media screen and (max-width: 800px) { details.api summary { flex-direction: column; align-items: start; } details.api.all { float: none; } } </style> <details class="api all"><summary onclick="var open = !this.parentNode.hasAttribute(\'open\'); document.querySelectorAll(\'details\').forEach(elm => elm.open = open); return false;">Tout déplier / replier</summary></details>'; $t .= $dom->saveHTML(); } $title = $match[1] ?? $file; $out = '<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>' . htmlspecialchars($title) . '</title> <style type="text/css"> body, form, p, div, hr, fieldset, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; } body { font-family: "Trebuchet MS", Arial, Helvetica, Sans-serif; |
︙ | ︙ |