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
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




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/{chemin}/`.



Remplacer =={chemin}== par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin.


Pour les requêtes de type `POST`, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (`application/x-www-form-urlencoded`) ou sous forme d'objet JSON. Dans ce cas le `Content-Type` doit être positionné sur `application/json`.



Les réponses sont faites en JSON par défaut.








<<toc level=3>>




# 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 https://test:coucou@[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:coucou@[identifiant_association].paheko.cloud/api/download  --auth-no-challenge -O association.sqlite


```

Exemple pour créer une écriture sous forme de formulaire :

```

curl -v "http://test:test@[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 "http://test:test@[identifiant_association].paheko.cloud/api/accounting/transaction" -H 'Content-Type: application/json' -d '{"id_year":1, "label": "Test écriture", "date": "01/02/2023"}'
```


# Authentification

Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP :

```
curl http://test:abcd@paheko.monasso.tld/api/download/
```

# 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.


# Chemins



## sql (POST)









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`. Le résultat est retourné dans la clé `results` de l'objet JSON.

S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.



```
curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;'

```


**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.


Depuis la version 1.2.8, il est possible d'utiliser le paramètre `format` pour choisir le format renvoyé :



* `json` (défaut) : renvoie un objet JSON, dont la clé est `"results"` et contient un tableau de la liste des membres trouvés
* `csv` : renvoie un fichier CSV
* `ods` : renvoie un tableau LibreOffice Calc (ODS)
* `xlsx` : renvoie un tableau Excel (XLSX)

Exemple :









```
curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv
```


## Téléchargements

### download (GET)



Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données.

Exemple :

```
curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite
```

### download/files (GET)



_(Depuis la version 1.3.4)_


Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.).









Exemple :

```
curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip
```

## Site web


### web/list (GET)


Renvoie la liste des pages du site web.


### web/attachment/{PAGE_URI}/{FILENAME} (GET)






Renvoie le fichier joint correspondant à la page et nom de fichier indiqués.













































































### web/page/{PAGE_URI} (GET)


Renvoie un objet JSON avec toutes les infos de la page donnée.





Rajouter le paramètre `?html` à l'URL pour obtenir en plus une clé `html` dans l'objet JSON qui contiendra la page au format HTML.




### web/html/{PAGE_URI} (GET)


Renvoie uniquement le contenu de la page au format HTML.



























































## Membres

### user/categories (GET)



_(Depuis la version 1.3.6)_

Renvoie la liste des catégories de membres, triée par nom, et incluant le nombre de membres de la catégorie (dans la clé `count`).


### user/category/{ID}.{FORMAT} (GET)







_(Depuis la version 1.3.6)_







Exporte la liste des membres d'une catégorie correspondant à l'ID demandé, au format indiqué :

* `json`
* `csv`

* `ods`
* `xlsx`


### user/new (POST)





_(Depuis la version 1.3.6)_



Permet de créer un nouveau membre.









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 leur clé unique, ainsi que les clés suivantes :

* `id_category` : indique l'ID d'une catégorie, si absent la catégorie par défaut sera utilisée
* `password` : mot de passe du membre
* `force_duplicate=1` : ne pas renvoyer une erreur si le nom du membre à ajouter est identique au nom d'un membre existant.

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.



```
curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new
```

### user/{ID} (GET)







_(Depuis la version 1.3.6)_

Renvoie les infos de la fiche d'un membre à partir de son ID, ainsi que 3 autres clés :

* `has_password`
* `has_pgp_key`
* `has_otp`


### user/{ID} (DELETE)


























_(Depuis la version 1.3.6)_


Supprime un membre à partir de son ID.







Seuls les identifiants d'API ayant le droit "Administration" pourront supprimer des membres.

Note : il n'est pas possible de supprimer un membre appartenant à une catégorie ayant accès à la configuration.

### user/{ID} (POST)

_(Depuis la version 1.3.6)_




Modifie les infos de la fiche d'un membre à partir de son ID.


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 "Administartion" pourra modifier un membre administrateur

### user/import (PUT)


Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko.









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.


















Paheko s'attend à 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 https://test:abcd@monpaheko.tld/api/user/import -T membres.csv
```

#### Paramètres

Les paramètres sont à spécifier dans l'URL, dans la query string.

Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire `mode` contenant 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

_Depuis la version 1.3.0 il est possible de spécifier :_

* le nombre de lignes à ignorer avec le paramètre `skip_lines=X` : elles ne seront pas importées. Par défaut la première ligne est ignorée.
* la correspondance des colonnes avec des paramètres `column[x]` ou `x` est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre.

Exemple :

```
curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&column[0]=nom_prenom&column[1]=code_postal&skip_lines=0 -T membres.csv

```

### user/import (POST)

Identique à la même méthode en `PUT`, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors `file`.

```
curl https://test:abcd@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
```

### user/import/preview (PUT)

Identique à `user/import`, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier :


* `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 retour :



```




{
    "created": [
        {
            "numero": 3434351,
            "nom": "Bla Bli Blu"
        }
    ],
>
>
>
>




|

>
>
|

>
|
>

>
|
>
>
>
>
>
>

>
|
>

>
>
|






|





|
>
>





>
|
>
>
>
>





>
>
>
|


<
|

<
|
<
<
<

|



>
|
>

>
|
>

>
>
>
>
>
>
>
|



>
>
|
|
>


>
|
>
|
<
>
>
|
<
<
<
<
|
<
>
>
>
|
>
>
>
>
>

|
<
>



|

>
>
|

|

|
|


|
>
>



>
|
>
>
>
>
>
>
>
>

|

|
|




>
|
>

|

>
|
>

>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
|

>
|
>
>
>

>
|
>
>
>

|

>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



|

>
>
|

|

>
|
>
|
>
>
>
>
>
|
>
>
>
>
>
>
|
<
|
|
|
>
|
<
>

<
>
>
>
>

|

>
>
|
>
>
>
>
>
>
>
>







|
<
<
<
<





>
>
|



|

>
>
>
>
>
>
|

|





>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>

|
>
>
>
>
>
>



|

|

|

>
>
>
|
>







|

|

>
|
>

>
>
>
>
>
>
>


>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|




















|


|

|

<
|
<
<
<

<
|
<
<

|

|
|
>


|

<
|
<
<
<
<
<
<
<
<

|

<
>








|

>
>

>
>
>
>







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
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










            "id": 2,
            "name": "Paul Muad'Dib"
        }
    ]
}
```



### user/import/preview (POST)

Idem quel la méthode en `PUT` mais accepte les paramètres dans le corps de la requête (voir ci-dessus).

## Activités

### services/subscriptions/import (PUT)





_(Depuis Paheko 1.3.2)_

Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. 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 https://test:abcd@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.

### errors/report (POST)


Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale.




### errors/log (GET)

Renvoie le log d'erreurs système, au format airbrake/errbit ([voir la doc AirBrake pour un exemple du format](https://airbrake.io/docs/api/#create-notice-v3))

## Comptabilité

### accounting/years (GET)

Renvoie la liste des exercices.



















### accounting/charts (GET)









Renvoie la liste des plans comptables.







### accounting/charts/{ID_CHART}/accounts (GET)

Renvoie la liste des comptes pour le plan comptable indiqué (voir `id_chart` dans la liste des exercices).




### accounting/years/{ID_YEAR}/journal (GET)


Renvoie le journal général des écritures de l'exercice indiqué. 


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é.

### accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)













_(Depuis la version 1.3.6)_



Exporte l'exercice indiqué au format indiqué. Les formats suivants sont disponibles :





* `full` : complet

* `grouped` : complet groupé
* `simple` : simple (ne comporte pas les saisies avancées)
* `fec` : format FEC (Fichier des Écritures Comptables)






L'extension indique le type de fichier :


* `csv` : Tableur CSV

* `ods` : LibreOffice Calc
* `xlsx` : Microsoft OOXML (Excel) - seulement disponible si l'instance le permet

* `json` : Texte JSON




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é.


### accounting/years/{ID_YEAR}/account/journal (GET)

Renvoie le journal des écritures d'un compte pour l'exercice indiqué.



















Le compte est spécifié soit via le paramètre `code`, soit via le paramètre `id`. Exemple :  `/accounting/years/4/account/journal?code=512A`


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é.


### accounting/transaction/{ID_TRANSACTION} (GET)


Renvoie les détails de l'écriture indiquée.





### accounting/transaction/{ID_TRANSACTION} (POST)

Modifie l'écriture indiquée. Voir plus bas le format attendu.








### accounting/transaction/{ID_TRANSACTION}/users (GET)



Renvoie la liste des membres liés à une écriture.





### accounting/transaction/{ID_TRANSACTION}/users (POST)




Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé `users`.


```
 curl -v "http://…/api/accounting/transaction/9337/users"  -F 'users[]=2'
```


### accounting/transaction/{ID_TRANSACTION}/users (DELETE)






Efface la liste des membres liés à une écriture.

### accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)




_(Depuis la version 1.3.6)_











Renvoie la liste des inscriptions (aux activités) liées à une écriture.










### accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)

_(Depuis la version 1.3.6)_

Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé `subscriptions`.

```
 curl -v "http://…/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'
```

### accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)


_(Depuis la version 1.3.6)_



Efface la liste des inscriptions liées à une écriture.













### accounting/transaction (POST)














Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu.



#### Structure pour créer / modifier une écriture


Les champs à spécifier pour créer ou modifier une écriture sont les suivants :



* `id_year`



* `date` (format YYYY-MM-DD)

* `type` peut être un type d'écriture simplifié (2 lignes) : `EXPENSE` (dépense), `REVENUE` (recette), `TRANSFER` (virement), `DEBT` (dette), `CREDIT` (créance), ou `ADVANCED` pour une écriture multi-ligne

* `amount` (uniquement pour les écritures simplifiées) : contient le montant de l'écriture

* `credit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit

* `debit` (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit


* `lines` (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :
  * `account` (numéro du compte) ou `id_account` (ID unique du compte)

  * `credit` : montant à inscrire au crédit (doit être zéro ou non renseigné si `debit` est renseigné, et vice-versa)

  * `debit` : montant à inscrire au débit




  * `label` (facultatif) : libellé de la ligne

  * `reference` (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)



  * `id_project` : ID unique du projet à affecter



Champs optionnels :





* `reference` : numéro de pièce comptable

* `notes` : remarques (texte multi ligne)



* `id_project` : ID unique du projet à affecter (pour les écritures simplifiées uniquement)

* `payment_reference` (uniquement pour les écritures simplifiées) : référence de paiement

* `linked_users` : Tableau des IDs des membres à lier à l'écriture *(depuis 1.3.3)*

* `linked_transactions` : Tableau des IDs des écritures à lier à l'écriture *(depuis 1.3.5)*




* `linked_subscriptions` : Tableau des IDs des inscriptions à lier à l'écriture *(depuis 1.3.6)*


Exemple :

```
curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …

```

















>

|

|



|
>
>
>
>



|


















|






|

>
|
>

>
>
|

|



|

|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
|
>
>
|
>
>
>
|
>
>
>
|

|

>
>
>
|
>

<
>
|
<
|
<
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>

>
|
>
>
>

>
|
>
|
|
|
>
>
>
>
>

|

>
|
>
|
<
>
|
>
>

>
|
>
|
<
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>

<
>

<
>

<
>
>
>
>

|

|

>
>
>
>
>
>
>
|
>
>

|

>
>
>
>
|
>
>
>

<
>

<
|
<

>
|
>
>
>
>
>

|

|
>
>

>
|
>
>
>
>
>
>
>
>
>

>
|
>
>
>
>
>
>
>
>
>

|

|

|
|
|
<
<

<
>

|
|
>
>
|
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>
>
|
<
|
>
|
>

>
|
>
>
>
|
>
|
>
|
>
|
>
|
>
>
|
|
>
|
>
|
>
>
>
>
|
>
|
>
>
>
|
>

>
|
>
>
>

>
|
>
|
>
>
>
|
>
|
>
|
>
|
>
>
>
>
|
>

|


<
>

>
>
>
>
>
>
>
>
>
>
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.3.5
|
1
1.4.0

Deleted src/include/data/schema.sql version [57116110a2].

1
../migrations/1.3/schema.sql
<


Modified src/include/init.php from [87388ff3ec] to [8af4bbd2c7].

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/data/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,







|







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
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
<?php

namespace Paheko;

use Paheko\Backup;
use Paheko\Users\Session;
use Paheko\Web\Web;
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\Search;
use Paheko\Services\Services_User;
use Paheko\Users\Categories;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;
use Paheko\Files\Files;

use KD2\ErrorManager;

class API
{




	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;

	protected array $allowed_methods = ['GET', 'POST', 'PUT', 'DELETE'];





	public function __construct(string $method, string $path, array $params)
	{
		if (!in_array($method, $this->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();
		}
	}








	public function setAllowedFilesRoot(?string $root): void
	{
		$this->allowed_files_root = rtrim($root, '/') . '/';
	}

	public function isPathAllowed(string $path): bool






<
<
<
<
<
<
<
<

|
<
<
<






>
>
>
>








|
>
>
>
>



|














>
>
>
>
>
>
>







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
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
		}
	}

	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;
	}

	protected function sql()
	{
		if ($this->method != 'POST') {
			throw new APIException('Wrong request method', 400);


		}



		$body = $this->params['sql'] ?? self::getRequestInput();

		if ($body === '') {
			throw new APIException('Missing SQL statement', 400);
		}

		try {
			$s = Search::fromSQL($body);
			$result = $s->iterateResults();
			$header = $s->getHeader();

			if (isset($this->params['format']) && in_array($this->params['format'], ['xlsx', 'ods', 'csv'])) {
				$s->export($this->params['format']);
				return null;
			}
			elseif (!$this->is_http_client) {
				return ['count' => $s->countResults, 'results' => iterator_to_array($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) {







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




















|

|
|
>
>
|
>
>
|
<










|
|



|







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
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
			}
		}
		catch (DB_Exception $e) {
			throw new APIException('Error in SQL statement: ' . $e->getMessage(), 400);
		}
	}

	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 ['success' => true];
			}

			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);
		}
	}

	protected function web(string $uri): ?array
	{
		if ($this->method != 'GET') {
			throw new APIException('Wrong request method', 400);
		}

		$fn = strtok($uri, '/');
		$param = strtok('');

		switch ($fn) {
			case 'list':
				return [
					'categories' => array_map(fn($p) => $p->asArray(true), Web::listCategories($param)),
					'pages' => array_map(fn($p) => $p->asArray(true), Web::listPages($param)),
				];
			case 'attachment':
				$attachment = Files::getFromURI($param);

				if (!$attachment) {
					throw new APIException('Page not found', 404);
				}

				$attachment->serve();
				return null;
			case 'html':
			case 'page':
				$page = Web::getByURI($param);

				if (!$page) {
					throw new APIException('Page not found', 404);
				}

				if ($fn == 'page') {
					$out = $page->asArray(true);

					if ($this->hasParam('html')) {
						$out['html'] = $page->render();
					}

					return $out;
				}

				// HTML render
				echo $page->render();
				return null;
			default:
				throw new APIException('Unknown web action', 404);
		}
	}

	protected function accounting(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$p1 = strtok('/');
		$p2 = strtok('');

		if ($fn == 'transaction') {
			if (!$p1) {
				if ($this->method != 'POST') {
					throw new APIException('Wrong request method', 400);
				}

				$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 ['success' => true];
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->updateLinkedUsers([]);
					return ['success' => true];
				}
				elseif ($this->method === 'GET') {
					return $transaction->listLinkedUsers();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			// 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 ['success' => true];
				}
				elseif ($this->method === 'DELETE') {
					$this->requireAccess(Session::ACCESS_WRITE);
					$transaction->deleteAllSubscriptionLinks([]);
					return ['success' => true];
				}
				elseif ($this->method === 'GET') {
					return $transaction->listSubscriptionLinks();
				}
				else {
					throw new APIException('Wrong request method', 400);
				}
			}
			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') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			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') {
			if ($this->method != 'GET') {
				throw new APIException('Wrong request method', 400);
			}

			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 iterator_to_array(Reports::getJournal(['year' => $id_year]));
				}
				catch (\LogicException $e) {
					throw new APIException('Missing parameter for journal: ' . $e->getMessage(), 400, $e);
				}
			}
			elseif (0 === strpos($p2, 'export/')) {
				strtok($p2, '/');
				$type = strtok('.');
				$format = strtok('');
				Export::export($year, $format, $type);
				return null;
			}
			elseif ($p2 === 'account/journal') {
				$a = $year->chart()->accounts();

				if (!empty($this->params['code'])) {
					$account = $a->getWithCode($this->params['code']);
				}
				else {
					$account = $a->get((int)$this->params['code'] ?? null);
				}

				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 iterator_to_array($list->iterate());
			}
			else {
				throw new APIException('Unknown years action', 404);
			}
		}
		else {
			throw new APIException('Unknown accounting action', 404);
		}
	}

	protected function services(string $uri): ?array
	{
		$fn = strtok($uri, '/');
		$fn2 = strtok('/');
		strtok('');

		// CSV import







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







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
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713

			try {
				if (!filesize($path)) {
					throw new APIException('Invalid upload', 400);
				}

				$csv = new CSV_Custom;
				$csv->setColumns(Services_User::listImportColumns());
				$csv->setMandatoryColumns(Services_User::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(Services_User::listMandatoryImportColumns()), 400);
				}

				Services_User::import($csv);
				return null;
			}
			finally {
				Utils::safe_unlink($path);
			}
		}
		else {







|
|





|


|







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
791
792
793
794
795
796
797
798
799

		$this->access = $access;
	}

	public function route()
	{
		$uri = $this->path;





		$fn = strtok($uri, '/');
		$uri = strtok('');

		switch ($fn) {
			case 'sql':
				return $this->sql();
			case 'download':
				return $this->download($uri);
			case 'web':
				return $this->web($uri);
			case 'user':
				return $this->user($uri);
			case 'errors':







>
>
>
>
>




<
<







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
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 id_service_user = %d)', $criterias['subscription']);
		}

		if (!empty($criterias['project'])) {
			$where[] = sprintf($lines_alias . 'id_project = %d', $criterias['project']);
		}

		if (!empty($criterias['account'])) {







|







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
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\Entities\Email\Email;

class DB extends SQLite3
{
	/**
	 * Application ID pour SQLite
	 * @link https://www.sqlite.org/pragma.html#pragma_application_id
	 */









|







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
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', [Email::class, 'getHash']);
		$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);








|







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].

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
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
<?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\Email;
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 const Paheko\{USE_CRON, MAIL_SENDER, MAIL_RETURN_PATH, DISABLE_EMAIL};
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;
use KD2\DB\EntityManager as EM;

class Emails
{
	const RENDER_FORMATS = [
		null => 'Texte brut',
		Render::FORMAT_MARKDOWN => 'MarkDown',
	];

	/**
	 * Email sending contexts
	 */
	const CONTEXT_BULK = 1;
	const CONTEXT_PRIVATE = 2;
	const CONTEXT_SYSTEM = 0;
	const CONTEXT_NOTIFICATION = 3;

	/**
	 * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification.
	 */
	const FAIL_LIMIT = 5;

	/**
	 * 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 === self::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 = Email::getHash($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::runQueue();
		}
		// Always send system emails right away
		elseif ($is_system) {
			self::runQueue(self::CONTEXT_SYSTEM);
		}
	}

	/**
	 * Return an Email entity from the optout code
	 */
	static public function getEmailFromOptout(string $code): ?Email
	{
		$hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT));

		if (!$hash) {
			return null;
		}

		$hash = bin2hex($hash);
		return EM::findOne(Email::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::getEmail($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 getEmail(string $address): ?Email
	{
		return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address)));
	}

	/**
	 * Return or create a new email entity
	 */
	static public function getOrCreateEmail(string $address): ?Email
	{
		$address = strtolower($address);

		if (!Email::isAddressValid($address, false)) {
			return null;
		}

		$e = self::getEmail($address);

		if (!$e) {
			$e = self::createEmail($address);
		}

		return $e;
	}

	static public function createEmail(string $address): Email
	{
		$e = new Email;
		$e->added = new \DateTime;
		$e->hash = $e::getHash($address);
		$e->validate($address);
		$e->save();
		return $e;
	}

	/**
	 * 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 sending = 2 WHERE %s;', $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 >= self::FAIL_LIMIT) {
				if ($row->context != self::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) {
					self::deleteFromQueue($row->id);
					continue;
				}
			}

			// Create email address in database
			if (!$row->email_hash) {
				$email = self::getOrCreateEmail($row->recipient);

				if (!$email || !$email->canSend()) {
					// Email address is invalid, skip
					self::deleteFromQueue($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 sending = 2 WHERE %s;
			INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2;
			UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime()
				WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2);
			DELETE FROM emails_queue WHERE sending = 2;',
			$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::listQueue($context);

		if (!count($queue)) {
			return $queue;
		}

		$ids = [];

		foreach ($queue as $row) {
			$ids[] = $row->id;
		}

		$db = DB::getInstance();
		$db->update('emails_queue', ['sending' => 1, '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 listQueue(?int $context = null): array
	{
		// Clean-up the queue from reject emails
		self::purgeQueueFromRejected();

		// 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.*, e.optout, e.verified, e.hash AS email_hash,
				e.invalid, e.fail_count, strftime(\'%%s\', e.last_sent) AS last_sent
			FROM emails_queue q
			LEFT JOIN emails e ON e.hash = q.recipient_hash
			WHERE q.sending = 0 %s;', $condition));
	}

	static public function countQueue(): int
	{
		return DB::getInstance()->count('emails_queue');
	}

	/**
	 * Supprime de la queue les messages liés à des adresses invalides
	 * ou qui ne souhaitent plus recevoir de message
	 * @return boolean
	 */
	static protected function purgeQueueFromRejected(): void
	{
		DB::getInstance()->delete('emails_queue',
			'recipient_hash IN (SELECT hash FROM emails 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 sending = 0, sending_started = NULL
			WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');';
		DB::getInstance()->exec($sql);
	}

	/**
	 * Supprime un message de la queue d'envoi
	 * @param  integer $id
	 * @return boolean
	 */
	static protected function deleteFromQueue($id)
	{
		return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id);
	}

	static public function getRejectionStatusClause(string $prefix): string
	{
		$prefix .= '.';

		return sprintf('CASE
			WHEN %1$soptout = 1 THEN \'Désinscription\'
			WHEN %1$sinvalid = 1 THEN \'Invalide\'
			WHEN %1$sfail_count >= %2$d THEN \'Trop d\'\'erreurs\'
			ELSE \'\'
		END', $prefix, self::FAIL_LIMIT);
	}

	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();
		$email_field = 'u.' . $db->quoteIdentifier(DynamicFields::getFirstEmailField());

		$columns = [
			'id' => [
				'select' => 'e.id',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => DynamicFields::getNameFieldsSQL('u'),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => $email_field,
			],
			'user_id' => [
				'select' => 'u.id',
			],
			'hash' => [
			],
			'status' => [
				'label' => 'Statut',
				'select' => self::getRejectionStatusClause('e'),
			],
			'sent_count' => [
				'label' => 'Messages envoyés',
			],
			'fail_log' => [
				'label' => 'Journal d\'erreurs',
			],
			'last_sent' => [
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = sprintf('emails e INNER JOIN users u ON %s IS NOT NULL AND %1$s != \'\' AND e.hash = email_hash(%1$s)', $email_field);

		$conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT);

		$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;
	}

	static public function getOptoutText(): string
	{
		return "Vous recevez ce message car vous êtes dans nos contacts.\n"
			. "Pour ne plus jamais recevoir de message de notre part cliquez ici :\n";
	}

	static public function appendHTMLOptoutFooter(string $html, string $url): string
	{
		$footer = '<p style="color: #666; background: #fff; padding: 10px; text-align: center; font-size: 9pt">' . nl2br(htmlspecialchars(self::getOptoutText()));
		$footer .= sprintf('<br /><a href="%s" style="color: #009; text-decoration: underline; padding: 5px 10px; border-radius: 5px; background: #eee; border: 1px outset #ccc;">Me désinscrire</a></p>', $url);

		if (stripos($html, '</body>') !== false) {
			$html = str_ireplace('</body>', $footer . '</body>', $html);
		}
		else {
			$html .= $footer;
		}

		return $html;
	}

	static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html, ?string $pgp_key = null, array $attachments = []): bool
	{
		$config = Config::getInstance();
		$message = new Mail_Message;
		$message->setHeaders($headers);

		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();

		// Append unsubscribe, except for password reminders
		if ($context != self::CONTEXT_SYSTEM) {
			$url = Email::getOptoutURL($recipient_hash);

			// RFC 8058
			$message->setHeader('List-Unsubscribe', sprintf('<%s>', $url));
			$message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes');

			$content .= sprintf("\n\n-- \n%s\n\n%s\n%s", $config->org_name, self::getOptoutText(), $url);

			if (null !== $content_html) {
				$content_html = self::appendHTMLOptoutFooter($content_html, $url);
			}
		}

		$message->setBody($content);

		if (null !== $content_html) {
			$message->addPart('text/html', $content_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 ($pgp_key && $can_use_encryption) {
			$message->encrypt($pgp_key);
		}

		return self::sendMessage($context, $message);
	}

	static public function sendMessage(int $context, Mail_Message $message): bool
	{
		if (DISABLE_EMAIL) {
			return false;
		}

		$signal = Plugins::fire('email.send.before', true, compact('context', 'message'), ['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 {
			$message->send();
		}

		Plugins::fire('email.send.after', false, compact('context', 'message'));
		return true;
	}

	/**
	 * 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::getOrCreateEmail($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);
	}

}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































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
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









































































































	}

	static public function get(int $id): ?Mailing
	{
		return EntityManager::findOneById(Mailing::class, $id);
	}

	static public function create(string $subject, string $target, ?string $target_id): Mailing
	{
		$db = DB::getInstance();
		$db->begin();

		$m = new Mailing;
		$m->set('subject', $subject);

		$m->save();
		$m->populate($target, $target_id);

		$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();
	}
}
















































































































|






>

|



















|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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],
						'direction' => 'credit',
						'defaults' => [
							self::TYPE_EXPENSE => 'credit',
							self::TYPE_REVENUE => 'debit',
						],
					],
					[
						'label' => 'Vers',
						'targets' => [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING],
						'direction' => 'debit',
					],
				],
				'label' => self::TYPES_NAMES[self::TYPE_TRANSFER],
				'help' => 'Dépôt en banque, virement interne, etc.',
			],
			self::TYPE_DEBT => [







|








|







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
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
 */
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_user, id_service_user)
			SELECT ?, id_user, id FROM services_users 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_service_user 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_service_user = ?', $this->id(), $id);
	}

	public function listSubscriptionLinks(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$number_column = DynamicFields::getNumberFieldSQL('u');
		$sql = sprintf('SELECT s.*, %s AS user_identity, %s AS user_number, l.id_service_user AS id_subscription
			FROM users u


			INNER JOIN acc_transactions_users l ON l.id_user = u.id
			INNER JOIN services_users s ON s.id = l.id_service_user
			WHERE l.id_transaction = ? AND l.id_service_user IS NOT NULL;', $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_user, id_service_user)
				SELECT ?, id_user, id FROM services_users WHERE id = ?;',
				$this->id(),
				(int)$id
			);
		}

		$db->commit();
	}
}







|
|








|





|


|




|

>
>
|
<
|



















|
|








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
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_service_user 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_service_user) 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_user = u.id
			WHERE l.id_transaction = ? AND l.id_service_user IS NULL
			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_user = u.id
			WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}
}












|


















|












|
|










|
|



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].

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
<?php
declare(strict_types=1);

namespace Paheko\Entities\Email;

use Paheko\Entity;
use Paheko\UserException;
use Paheko\Email\Emails;
use Paheko\Email\Templates as EmailsTemplates;

use KD2\SMTP;

use const Paheko\{WWW_URL, SECRET_KEY};

class Email extends Entity
{
	const TABLE = 'emails';

	const RESEND_VERIFICATION_DELAY = '1 month ago';
	const RESEND_VERIFICATION_DELAY_OPTOUT = '3 days ago';

	/**
	 * 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'];

	protected int $id;
	protected string $hash;
	protected bool $verified = false;
	protected bool $optout = false;
	protected bool $invalid = false;
	protected int $sent_count = 0;
	protected int $fail_count = 0;
	protected ?string $fail_log;
	protected \DateTime $added;
	protected ?\DateTime $last_sent;

	/**
	 * Normalize email address and create a hash from this
	 */
	static public function getHash(string $email): string
	{
		$email = strtolower(trim($email));

		$host = substr($email, strrpos($email, '@')+1);
		$host = idn_to_ascii($host);

		$email = substr($email, 0, strrpos($email, '@')+1) . $host;

		return sha1($email);
	}

	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 (self::getHash($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 = new \DateTime($this->optout ? self::RESEND_VERIFICATION_DELAY_OPTOUT : self::RESEND_VERIFICATION_DELAY);
		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('verified', true);
		$this->set('optout', false);
		$this->set('invalid', false);
		$this->set('fail_count', 0);
		$this->set('fail_log', null);
		return true;
	}

	public function validate(string $email): bool
	{
		if (!$this->canSend()) {
			return false;
		}

		try {
			self::validateAddress($email);
		}
		catch (UserException $e) {
			$this->setFailedValidation($e->getMessage());
			return false;
		}

		return true;
	}

	public function setFailedValidation(string $message): void
	{
		$this->hasFailed(['type' => 'permanent', 'message' => $message]);
	}

	static public function isAddressValid(string $email, bool $mx_check = true): bool
	{
		try {
			self::validateAddress($email);
			return true;
		}
		catch (UserException $e) {
			return false;
		}
	}

	static public function validateAddress(string $email, bool $mx_check = true): void
	{
		$pos = strrpos($email, '@');

		if ($pos === false) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		$user = substr($email, 0, $pos);
		$host = substr($email, $pos+1);

		// Ce domaine n'existe pas (MX inexistant), erreur de saisie courante
		if ($host == 'gmail.fr') {
			throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"');
		}

		if (preg_match('![/@]!', $user)) {
			throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.');
		}

		if (!SMTP::checkEmailIsValid($email, false)) {
			if (!trim($host)) {
				throw new UserException('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) {
					throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain));
				}
			}

			throw new UserException('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;
		}

		self::checkMX($host);
	}

	static public function checkMX(string $host)
	{
		if (PHP_OS_FAMILY == 'Windows') {
			return;
		}

		getmxrr($host, $mx_list);

		if (empty($mx_list)) {
			throw new UserException('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)) {
				throw new UserException('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.');
			}
		}
	}

	public function canSend(): bool
	{
		if (!empty($this->optout)) {
			return false;
		}

		if (!empty($this->invalid)) {
			return false;
		}

		if ($this->hasReachedFailLimit()) {
			return false;
		}

		return true;
	}

	public function hasReachedFailLimit(): bool
	{
		return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT);
	}

	public function incrementSentCount(): void
	{
		$this->set('sent_count', $this->sent_count+1);
	}

	public function setOptout(string $message = null): void
	{
		$this->set('optout', true);
		$this->appendFailLog($message ?? 'Demande de désinscription');
	}

	public function appendFailLog(string $message): void
	{
		$log = $this->fail_log ?? '';

		if ($log) {
			$log .= "\n";
		}

		$log .= date('d/m/Y H:i:s - ') . trim($message);
		$this->set('fail_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('optout', true);
			$this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']);
		}
		elseif ($return['type'] == 'permanent') {
			$this->set('invalid', true);
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
		elseif ($return['type'] == 'temporary') {
			$this->set('fail_count', $this->fail_count+1);
			$this->appendFailLog($return['message']);
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































Modified src/include/lib/Paheko/Entities/Email/Mailing.php from [a4f1806841] to [e57761a85e].

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
<?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\Emails;
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/mailing/details.php?id=%d';









	protected ?int $id = null;
	protected string $subject;
	protected ?string $body;








	/**
	 * Leave sender name and email NULL to use org name + email
	 */
	protected ?string $sender_name;
	protected ?string $sender_email;

	/**











|














|
>
>
>
>
>
>
>
>





>
>
>
>
>
>
>







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


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

		$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.');


			$this->assert(Email::isAddressValid($this->sender_email), 'L\'adresse e-mail de l\'expéditeur est invalide.');
		}
	}






	public function populate(string $target, ?int $target_id = null): void
	{
		if ($target !== 'all' && empty($target_id)) {
			throw new \InvalidArgumentException('Missing target ID');
		}




		if ($target == 'all') {
			$recipients = Users::iterateEmailsByCategory(null);
		}
		elseif ($target == 'category') {
			$recipients = Users::iterateEmailsByCategory($target_id);
		}
		elseif ($target == 'search') {
			$recipients = Users::iterateEmailsBySearch($target_id);
		}
		elseif ($target == 'service') {
			$recipients = Users::iterateEmailsByActiveService($target_id);
		}
		else {
			throw new \InvalidArgumentException('Invalid target');
		}

		$db = DB::getInstance();
		$db->begin();







>
>
|



>
>
>
>
>
|

|



>
>
>
|


|
|

|
|

|
|







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
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
		}

		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));
		$e = Emails::getEmail($email);

		if ($e && !$e->canSend()) {
			$data = null;
		}
		else {
			try {
				// Validate e-mail address, but not MX (quick check)
				Email::validateAddress($email, false);
			}
			catch (UserException $ex) {
				$e = Emails::createEmail($email);
				$e->setFailedValidation($ex->getMessage());
				$data = null;
			}
		}

		$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,
		]);







>
>

>
>
>
>
>
>
>
>









|

|



<
<
<
<
<
<
<
|
|
<
<
<







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
226
227
228
229
230
231
232
233


234
235
236


237
238
239
240
241
242
243
			],
			'name' => [
				'label' => 'Nom',
				'select' => $this->getNameFieldsSQL('r'),
			],
			'status' => [
				'label' => 'Erreur',
				'select' => Emails::getRejectionStatusClause('e'),
			],
			'has_extra_data' => [
				'select' => 'r.extra_data IS NOT NULL',
			],
		];

		$tables = 'mailings_recipients AS r LEFT JOIN emails e ON e.id = r.id_email';


		$conditions = 'id_mailing = ' . $this->id;

		$list = new DynamicList($columns, $tables, $conditions);


		$list->orderBy('email', false);
		$list->setTitle('Liste des destinataires');
		return $list;
	}

	public function countRecipients(): int
	{







|






|
>
>



>
>







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
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_users' => 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' => 'su.id_user',
			],
			'service_label' => [
				'select' => 's.label',
				'label' => 'Activité',
				'export' => true,
			],
			'fee_label' => [







|


















|







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
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' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
			],
			'paid_amount' => [
				'label' => 'Montant payé',
				'select' => 'CASE WHEN tu.id_service_user IS NOT NULL THEN SUM(l.credit) ELSE NULL END',
			],
			'date' => [
				'label' => 'Date',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN users u ON u.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN services s ON s.id = sf.id_service
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.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('su.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.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('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.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('su.id_fee = %d AND su.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('su.id_fee = %d AND su.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 su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where);
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function hasSubscriptions(): bool
	{
		return DB::getInstance()->test('services_users', 'id_fee = ?', $this->id());
	}
}







|
|



|



|



|
|
|

|
|
|
|






|

|











|
|












|












|














|





|


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
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







	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;
			}
		}









		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,
			],
			'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('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',
			],



		];

		$conditions = sprintf('su.id_service = %d AND sr.id = %d', $this->id_service, $this->id);
		$tables = '(' . Reminders::getPendingSQL($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($conditions);
		$db = DB::getInstance();

		foreach ($db->iterate($sql) as $reminder) {
			$m = Reminders::createMessage($reminder);
			return $m->getMessage($reminder);
		}

		return null;
	}
}













<











>
>
>
>
>
>
>
>

















|











|

















>
>
>



|









|









|
>
>
>
>
>
>
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
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;
	protected Date $sent_date;
	protected Date $due_date;

	protected ?Reminder $_reminder = null;

	/**
	 * @return UserTemplate|string







|







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
79
80




81
82
83
84
85
86
87
		$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
	{




		$this->_reminder ??= Reminders::get($this->id_reminder);
		return $this->_reminder;
	}

	public function send(stdClass $reminder, $body = null)
	{
		$body ??= $this->getBody($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
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
			],
			'identity' => [
				'label' => 'Membre',
				'select' => $id_field,
			],
			'status' => [
				'label' => 'Statut',
				'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END',
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
				'order' => 'su.paid %s, su.date %1$s',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(su.expiry_date)',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],





			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN users u ON u.id = su.id_user
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id';
		$conditions = sprintf('su.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('su.id_user');
		$list->orderBy('paid', true);
		$list->setCount('COUNT(DISTINCT su.id_user)');

		$list->setExportCallback(function (&$row) {
			$row->status = $row->status == -1 ? 'En retard' : ($row->status == 1 ? 'En cours' : '');
			$row->paid = $row->paid ? 'Oui' : 'Non';

		});

		return $list;
	}

	public function activeUsersList(bool $include_hidden_categories = false): DynamicList
	{
		$list = $this->allUsersList();
		$conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND su.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('su.id_service = %d AND su.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('su.id_service = %d AND su.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_users', '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 su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.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);







|



|
|



|





>
>
>
>
>


|



|
|
|
|
|
|






|

|




>








|
|












|












|











|





>
|
>
>
>
>







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].

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 Service_User extends Entity
{
	const TABLE = 'services_users';

	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;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































































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
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\Emails;
use Paheko\Email\Templates as EmailTemplates;
use Paheko\Users\DynamicFields;
use Paheko\Users\Session;
use Paheko\Users\Users;
use Paheko\Services\Services_User;

use Paheko\Entities\Files\File;

use KD2\SMTP;
use KD2\DB\EntityManager as EM;
use KD2\DB\Date;
use KD2\ZipWriter;







|




|







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
560
561

562

563
564
565



566
567



568
569
570
571
572
573
574
575

		$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)
	{
		$config = Config::getInstance();
		$email_field = DynamicFields::getFirstEmailField();



		$from = $from ? $from->getNameAndEmail() : null;

		Emails::queue(Emails::CONTEXT_PRIVATE, [$this->{$email_field} => ['pgp_key' => $this->pgp_key]], $from, $subject, $message);




		if ($send_copy) {



			Emails::queue(Emails::CONTEXT_PRIVATE, [$config->org_email], null, $subject, $message);
		}
	}

	public function checkLoginFieldForUserEdit()
	{
		$session = Session::getInstance();








<
|
>
|
>
|

|
>
>
>

|
>
>
>
|







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
677
678
679
680
681
682
683
684
		}

		return $out;
	}

	public function downloadExport(): void
	{
		$services_list = Services_User::perUserList($this->id);
		$services_list->setPageSize(null);

		$export_data = [
			'user'     => $this,
			'services' => $services_list->asArray(true),
		];








|







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
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 (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
				paid
			FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) su2 ON su2.id = su.id
			INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
			$db->where('id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);

		$columns = [
			'id' => [],
			'formula' => [],







|
|

|
|
|







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
47
48
49
50
51
52
53
54
			'date' => [
				'label' => 'Date d\'envoi du message',
				'select' => 'srs.sent_date',
			],
		];

		$tables = 'services_reminders_sent srs
			INNER 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;
	}







|







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
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
	}

	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(string $conditions = '1')
	{
		$db = DB::getInstance();

		$sql = 'SELECT
			u.*, %s AS identity,
			u.id AS id_user,
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(su.expiry_date)) AS nb_days,
			MAX(sr.delay) AS delay, sr.subject, sr.body, s.label, s.description,
			su.expiry_date, sr.id AS id_reminder, su.id_service, su.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
			-- Select latest subscription to a service (MAX) only
			INNER JOIN (SELECT MAX(su2.expiry_date) AS expiry_date, su2.id_user, su2.id_service, su2.id_fee FROM services_users AS su2 GROUP BY id_user, id_service) AS su ON s.id = su.id_service
			-- Select fee
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			-- Join with users, but not ones part of a hidden category
			INNER JOIN users u ON su.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 su.id_user = srs.id_user AND srs.id_reminder = sr.id
			WHERE
				date() > date(su.expiry_date, sr.delay || \' days\')
				AND (srs.id IS NULL OR srs.due_date < date(su.expiry_date, (sr.delay - 1) || \' days\'))
				AND %s

			GROUP BY su.id_user, sr.id_service
			ORDER BY su.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, $conditions);





		return $sql;
	}

	static public function createMessage(stdClass $reminder): ReminderMessage
	{
		$m = new ReminderMessage;







|






|
|

|


|

|

|

|



|

|
|

>
|
|





>
|
>
>
>
>







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
130
131
132
133
134
135
136
137
	/**
	 * 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();

		$date = new \DateTime;

		$db->begin();
		$body = null;

		foreach ($db->iterate($sql) as $row) {







|







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
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
	}

	static public function count()
	{
		return DB::getInstance()->count(Service::TABLE, 1);
	}

	static public function listGroupedWithFees(?int $user_id = null, int $current = 1)
	{
		if ($current === 1) {
			$where = 'WHERE end_date IS NULL OR end_date >= date()';
		}
		elseif ($current === 2) {
			$where = '';
		}
		else {
			$where = 'WHERE end_date IS NOT NULL AND end_date < date()';
		}

		$sql = sprintf('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 %s ORDER BY label COLLATE U_NOCASE;', $where);

		$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 listWithStats(bool $current_only = true): 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 (su.expiry_date IS NULL OR su.expiry_date >= date()) AND su.paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN su.expiry_date < date() THEN 1 ELSE 0 END,
				paid
			FROM services_users su
			INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) su2 ON su2.id = su.id
			INNER JOIN users u ON u.id = su.id_user WHERE u.%s',
			$db->where('id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);


		$columns = [
			'id' => [],







|

<
<
<
<
<
<
<
<
<
<
|


>
|



















>
>
>
>
>
>
>
|








|
|

|
|
|







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
131
132
133
134
135
136
137
138
139
140
141
142
143
			'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)',
			],
		];

		$current_condition = $current_only ? '(end_date IS NULL OR end_date >= datetime())' : '(end_date IS NOT NULL AND end_date < datetime())';

		$list = new DynamicList($columns, 'services', $current_condition);
		$list->setPageSize(null);
		$list->orderBy('label', false);
		return $list;
	}

	static public function countOldServices(): int
	{
		return DB::getInstance()->count(Service::TABLE, 'end_date IS NOT NULL AND end_date < datetime()');
	}
}







<
<
|





|

|


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].

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
<?php

namespace Paheko\Services;

use Paheko\CSV_Custom;
use Paheko\DB;
use Paheko\DynamicList;
use Paheko\Utils;
use Paheko\UserException;
use Paheko\Entities\Services\Service_User;
use Paheko\Users\DynamicFields;
use Paheko\Users\Users;

use KD2\DB\EntityManager;

class Services_User
{
	static public function get(int $id)
	{
		return EntityManager::findOneById(Service_User::class, $id);
	}

	static public function countForUser(int $user_id)
	{
		return DB::getInstance()->count(Service_User::TABLE, 'id_user = ?', $user_id);
	}

	static public function listDistinctForUser(int $user_id)
	{
		return DB::getInstance()->get('SELECT
			s.label, MAX(su.date) AS last_date, su.expiry_date AS expiry_date, sf.label AS fee_label, su.paid, s.end_date,
			CASE WHEN su.expiry_date < date() THEN -1 WHEN su.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_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			WHERE su.id_user = ?
			GROUP BY su.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 = [
			'id' => [
				'select' => 'su.id',
			],
			'id_account' => [
				'select' => 'sf.id_account',
			],
			'id_year' => [
				'select' => 'sf.id_year',
			],
			'account_code' => [
				'select' => 'a.code',
			],
			'has_transactions' => [
				'select' => 'tu.id_user',
			],
			'label' => [
				'select' => 's.label',
				'label' => 'Activité',
			],
			'fee' => [
				'label' => 'Tarif',
				'select' => 'sf.label',
			],
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
			],
			'expiry' => [
				'label' => 'Date d\'expiration',
				'select' => 'MAX(su.expiry_date)',
			],
			'paid' => [
				'label' => 'Payé',
				'select' => 'su.paid',
			],
			'amount' => [
				'label' => 'Reste à régler',
				'select' => 'CASE WHEN su.paid = 1 AND COUNT(tl.debit) = 0 THEN NULL
					ELSE MAX(0, expected_amount - IFNULL(SUM(tl.debit), 0)) END',
			],
			'expected_amount' => [],
		];

		$tables = 'services_users su
			INNER JOIN services s ON s.id = su.id_service
			LEFT JOIN services_fees sf ON sf.id = su.id_fee
			LEFT JOIN acc_accounts a ON sf.id_account = a.id
			LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id
			LEFT JOIN acc_transactions_lines tl ON tl.id_transaction = tu.id_transaction';
		$conditions = sprintf('su.id_user = %d', $user_id);

		if ($only_id) {
			$conditions .= sprintf(' AND su.id = %d', $only_id);
		}

		if ($after) {
			$conditions .= sprintf(' AND su.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('su.id');
		$list->setCount('COUNT(DISTINCT su.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));
					}
				}

				$su = new Service_User;
				$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);

				if (empty($row->paid) || strtolower(trim($row->paid)) === 'non') {
					$row->paid = false;
				}
				else {
					$row->paid = true;
				}

				$su->import((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 listImportColumns(): array
	{
		$number_field = DynamicFields::getNumberField();

		return [
			$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',
		];
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































































































































































































































































































































































































































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
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', '<')) {
				require ROOT . '/include/migrations/1.3/1.3.0-rc5.php';
			}

			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', '<')) {







|







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
41
42
43
44
45
46
47
48
		'icon',
		'linkbutton',
		'linkmenu',
		'exportmenu',
		'delete_form',
		'edit_user_field',
		'user_field',

	];

	static public function input(array $params)
	{
		static $params_list = ['value', 'default', 'type', 'help', 'label', 'name', 'options', 'source', 'no_size_limit', 'copy', 'suffix', 'prefix_title', '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)) {







>




|







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
246
247
248
249
250
251
252
253

		$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>' : ''
			);

			unset($help, $label);
		}
		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'] ?? '');
			}







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


















|







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
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
			if (!array_key_exists('label', $params) && ($type == 'radio' || $type == 'checkbox')) {
				$input .= sprintf('<label for="%s"></label>', $attributes['id']);
			}

			return $input;
		}

		$out = '';

		if (!empty($params['prefix_title'])) {
			$out .= sprintf('<dt><label for="%s">%s</label>%s</dt>',
				$attributes['id'],
				htmlspecialchars($params['prefix_title']),
				$required_label
			);
		}

		if (!empty($params['prefix_help'])) {
			$out .= sprintf('<dd class="help">%s</dd>',
				htmlspecialchars($params['prefix_help'])
			);
		}

		$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)) {







|
<
<
<
<
<
<
<
<
<
<
<
<
<
<







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
909






		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
21

22
23
24
25
26
27
28
29
30
31
32
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\Emails;

use Paheko\Files\Files;
use Paheko\Entities\Files\File;
use Paheko\Entities\Module;
use Paheko\Entities\Email\Email;
use Paheko\Users\DynamicFields;
use Paheko\Users\Session;

use Paheko\Entities\Accounting\Transaction;

use const Paheko\{ROOT, WWW_URL, BASE_URL, SECRET_KEY};








|
>



|







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
450
451
452
453
454
455
456
457

		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);
			Email::validateAddress($to);
		}

		unset($to);

		// Restrict sending recipients
		if (!$ut->isTrusted()) {
			$db = DB::getInstance();







|







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
494
495


496
497
498
499
500
501
502
					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 ? Emails::CONTEXT_PRIVATE : Emails::CONTEXT_BULK;
		Emails::queue($context, $params['to'], null, $params['subject'], $params['body'], $attachments);



		if (!$ut->isTrusted()) {
			$internal += $internal_count;
			$external_count += $external_count;
		}
	}








|
|
>
>







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
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\Entities\Email\Email;

use KD2\SMTP;

use KD2\Brindille;
use KD2\Brindille_Exception;

class Modifiers









|







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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
	static public function match($str, $pattern)
	{
		return (int) (stripos($str, $pattern) !== false);
	}

	static public function check_email($str)
	{
		if (!trim((string)$str)) {
			return false;
		}

		try {
			Email::validateAddress((string)$str);
		}
		catch (UserException $e) {
			return false;
		}

		return true;
	}

	/**
	 * 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







|
<
<
<
<
<
<
<
<
<
<
<







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
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('su.expiry_date, su.date, s.label, su.paid, su.expected_amount');
		$params['tables'] = 'services_users su INNER JOIN services s ON s.id = su.id_service';

		if (isset($params['user'])) {
			$params['where'] .= ' AND su.id_user = :id_user';
			$params[':id_user'] = (int) $params['user'];
			unset($params['user']);
		}

		if (isset($params['id_service'])) {
			$params['where'] .= ' AND su.id_service = :id_service';
			$params[':id_service'] = (int) $params['id_service'];
			unset($params['id_service']);
		}

		if (!empty($params['active'])) {
			$params['having'] = 'MAX(su.expiry_date) >= date()';
			unset($params['active']);
		}

		if (isset($params['active']) && empty($params['active'])) {
			$params['having'] = 'MAX(su.expiry_date) < date()';
			unset($params['active']);
		}

		if (empty($params['order'])) {
			$params['order'] = 'su.id';
		}

		$params['group'] = 'su.id_user, su.id_service';

		return self::sql($params, $tpl, $line);
	}

	static public function transactions(array $params, UserTemplate $tpl, int $line): \Generator
	{
		$db = DB::getInstance();







|
|


|





|





|




|




|


|







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
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_users 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_users 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_users 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_users 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_users' => 'Inscriptions aux activités',
		];
	}

	public function tables(): array
	{
		return array_merge(array_keys($this->schemaTables()), [
			'users_search',







|








|








|








|


















|







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
95
96
97
98
99
100
101
102
		$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_users
					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();







|







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].

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
<?php

namespace Paheko;

use Paheko\Files\Files;

/**
 * This file *only* applies to setups having updated to 1.3.0 RC1-4
 * as the filesystem handling was not working correctly
 */

$db->beginSchemaUpdate();
$db->dropIndexes();

$db->import(ROOT . '/include/migrations/1.3/1.3.0-rc5.sql');

$db->commitSchemaUpdate();

if (FILE_STORAGE_BACKEND == 'FileSystem' && file_exists(FILE_STORAGE_CONFIG)) {
	// Trash works differently now
	$db->exec('UPDATE files SET trash = NULL WHERE trash IS NOT NULL;');

	rename(FILE_STORAGE_CONFIG, FILE_STORAGE_CONFIG . '.deprecated');
	Files::disableVersioning();

	foreach (Files::all() as $file) {
		if ($file->isDir()) {
			continue;
		}

		// Copy files from old location to new
		$path = sprintf(FILE_STORAGE_CONFIG . '.deprecated/%.2s/%1$s', md5($file->id()));

		if (!file_exists($path)) {
			$file->delete();
			continue;
		}

		$file->store(compact('path'));
		Utils::safe_unlink($path);
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




















































































Deleted src/include/migrations/1.3/1.3.0-rc5.sql version [870ceef216].

1
2
3
4
5
6
7
8
ALTER TABLE web_pages RENAME TO web_pages_old;

.read schema.sql

-- Drop foreign key constant between web_pages and files, as files can just be a cache,
-- with missing web pages directories
INSERT INTO web_pages SELECT * FROM web_pages_old;
DROP TABLE web_pages_old;
<
<
<
<
<
<
<
<
















Deleted src/include/migrations/1.3/schema.sql version [65f1fda055].

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
---
--- Main stuff
---

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 (
-- 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,
	verified INTEGER NOT NULL DEFAULT 0,
	optout INTEGER NOT NULL DEFAULT 0,
	invalid INTEGER NOT NULL DEFAULT 0,
	fail_count INTEGER NOT NULL DEFAULT 0,
	sent_count INTEGER NOT NULL DEFAULT 0,
	fail_log TEXT NULL,
	last_sent TEXT NULL,
	added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS emails_hash ON emails (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,
	content TEXT NOT NULL,
	content_html TEXT NULL,
	sending INTEGER NOT NULL DEFAULT 0, -- Will be changed to 1 when the queue run will start
	sending_started TEXT NULL, -- Will be filled with the datetime when the email sending was started
	context INTEGER NOT NULL
);

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,
	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 (id) ON DELETE CASCADE,
	extra_data TEXT NULL
);

CREATE INDEX IF NOT EXISTS mailings_recipients_id ON mailings_recipients (id);

---
--- 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_users
-- 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 su_unique ON services_users (id_user, id_service, id_fee, date);

CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
CREATE INDEX IF NOT EXISTS su_expiry ON services_users (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
);

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 NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

	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_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
	id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
	id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,

	PRIMARY KEY (id_user, id_transaction, id_service_user)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);

---------- 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
);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































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
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 &amp; 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/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>







|







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 &amp; 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

227
228
229
230
231
232
233
234
		<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 class="actions">{linkbutton href="!services/user/?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)}







>
|







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].

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/user/payment.php?id=%d"|args:$service_user_id label="Nouveau règlement" shape="plus" target="_dialog"}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
	{linkbutton href="!services/user/link.php?id=%d"|args:$service_user_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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































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
13
14
15
16
17
18
19
20
21
22
23




24
25
26
27
28
29
30
{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 &amp; droits des membres</a></li>
		</ul>
	{elseif $current == 'advanced'}




		<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}












|
|









>
>
>
>







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 &amp; 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
51
52
53
54
55
56
57
58
59
</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 l'API, pour modifier ou récupérer les informations de votre association.<br />
			{linkbutton shape="help" label="Documentation de l'API" href="%swiki?name=API"|args:$website}
		</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>







|
<







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
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
{if !$dialog}
<nav class="tabs">
	<aside>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE) && $current != 'reminders'}
			{linkbutton href="!services/user/add.php" label="Inscrire à une activité" shape="plus"}



		{elseif $current == 'reminders'}
			{linkbutton href="!services/reminders/new.php" label="Nouveau rappel automatique" shape="plus" target="_dialog"}


		{/if}
	</aside>

	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}services/">Activités et cotisations</a></li>

		{if !DISABLE_EMAIL && $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			<li{if $current == 'import'} class="current"{/if}><a href="{$admin_url}services/import.php">Import</a></li>
			<li{if $current == 'reminders'} class="current"{/if}><a href="{$admin_url}services/reminders/">Gestion des rappels automatiques</a></li>
		{/if}
	</ul>

	{if !empty($has_old_services)}
	<ul class="sub">
		<li{if !$show_old_services} class="current"{/if}>{link href="!services/" label="Activités courantes"}</li>
		<li{if $show_old_services} class="current"{/if}>{link href="!services/?old=1" label="Activités passées"}</li>
	</ul>
	{/if}

	{if isset($current_service)}
	<ul class="sub">
		<li class="title">
			{$current_service->long_label()}



|
|
>
>
>
|
|
>
>





>

<
|



|

|
|







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
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="!services/user/?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}







|







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
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="!services/user/?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>








|







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é «&nbsp;{$service.label}&nbsp;» 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é «&nbsp;{$service.label}&nbsp;» 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"}

{include file="services/_nav.tpl" current="import" service=null fee=null}

{form_errors}

{if $_GET.msg == 'OK'}
	<p class="block confirm">
|







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
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}
		</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"}







|













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
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_old_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"}







|




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
9
10
11
12
13
14
15
16
17
18
19
20
21
22















23
24
25
26
27
28
29
{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}
			{input type="text" name="subject" required=1 source=$reminder label="Sujet du message envoyé"}

			<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>















			{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&nbsp;:
				{literal}
				<table class="list auto">
					<tr>
						<th>{{$label}}</th>








<













>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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&nbsp;:
				{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
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
	alert="Attention, cela supprimera également l'historique des emails envoyés par ce rappel."}

{include file="_foot.tpl"}







|


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
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
			{$list->count()}
		</dd>
	{elseif $current_list === 'pending'}
		<dt>Nombre de rappels à envoyer</dt>
		<dd>
			{$list->count()}
		</dd>
		{if USE_CRON && $list->count()}
			<dd class="help">Ces rappels seront envoyés dans les prochaines 24 heures.</dd>
		{/if}
	{/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 $row.expiry_date}
					<td>{$row.expiry_date|date_short}</td>
				{else}
					<td>{$row.date|date_short}</td>
				{/if}

				<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}







<
<
<













|

<
<

>







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
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="!services/user/?id=%d&after=%s"|args:$user_id,$row.date}
				</td>
			</tr>
		{/foreach}

		</tbody>
	</table>








|







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].

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}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































































































Deleted src/templates/services/user/_service_user_form.tpl version [3477f32009].

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
<?php
assert(isset($create) && is_bool($create));
assert(isset($has_past_services) && is_bool($has_past_services));
assert(isset($current_only) && is_bool($current_only));
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>

			{if $has_past_services}
			<dd>
				{* We can't use a button type="submit" here because it would trigger when user presses Enter, instead of the true submit button *}
				<input type="hidden" name="past_services" value="{$current_only}" />
				{if $current_only}
					Seules les activités courantes sont affichées.
					{button value="1" shape="reset" type="button" onclick="this.form.past_services=this.value; this.form.submit();" label="Inscrire à une activité passée"}
				{else}
					Seules les activités passées sont affichées.
					{button value="0" shape="left" type="button"  onclick="this.form.past_services=this.value; this.form.submit();" label="Inscrire à une activité courante"}
				{/if}
			</dd>
			{/if}


			{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=$service_user}
					<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=$service_user }
				<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=$service_user label="Date d'inscription"}
			{input type="date" name="expiry_date" source=$service_user label="Date d'expiration de l'inscription"}
			{input type="checkbox" name="paid" value="1" source=$service_user 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>
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






















































































































































































































































































































































Deleted src/templates/services/user/add.tpl version [a9b93751c5].

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="subscribe.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/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"}
<
<
<
<
<
<
<
<
<


















Deleted src/templates/services/user/edit.tpl version [3f7ef103e9].

1
2
3
4
5
6
7
{include file="_head.tpl" title="Modifier une inscription" current="users/services"}

{form_errors}

{include file="services/user/_service_user_form.tpl" create=false}

{include file="_foot.tpl"}
<
<
<
<
<
<
<














Deleted src/templates/services/user/index.tpl version [65f01862e2].

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&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est 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>
			<th>{$row.label}</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="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/service_user.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="edit.php?id=%d"|args:$row.id}
				{linkbutton shape="delete" label="Supprimer" href="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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































































































































































Deleted src/templates/services/user/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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































Deleted src/templates/services/user/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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































































Deleted src/templates/services/user/subscribe.tpl version [956f4af913].

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/user/_service_user_form.tpl" create=true}

{include file="_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<






















Modified src/templates/users/_details.tpl from [b3fb59fa84] to [53a6d4782f].

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
			?>
			{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"}
			{/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\Emails::getEmail($value); ?>


		<dt>Statut e-mail</dt>
		<dd>
			{if $email.optout}
				<b class="alert">{icon shape="alert"}</b> Ne souhaite plus recevoir de messages
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					<?php $value = rawurlencode($value); ?>
					<br/>{linkbutton target="_dialog" label="Rétablir les envois à cette adresse" href="!users/mailing/verify.php?address=%s"|args:$value shape="check"}
				{/if}
			{elseif $email.invalid}
				<b class="error">{icon shape="alert"} Adresse invalide</b>
				{linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
			{elseif $email && $email->hasReachedFailLimit()}
				<b class="error">{icon shape="alert"} Trop d'erreurs</b>
				{linkbutton href="!users/mailing/rejected.php?hl=%d#e_%1\$d"|args:$email.id label="Détails de l'erreur" shape="help"}
			{elseif $email.verified}
				<b class="confirm">{icon shape="check" class="confirm"}</b> Adresse vérifiée
			{else}
				{* Adresse non vérifiée *}
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
					{linkbutton target="_dialog" label="Désinscrire de tous les envois" href="!users/mailing/block.php?address=%s"|args:$value shape="delete"}
				{/if}
			{/if}
		</dd>
		{/if}
	{/foreach}
</dl>







|
















>
|
>
>


|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
<
<




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
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/user/subscribe.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="!services/user/?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>









|





|



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
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&nbsp;!</b>{/if}
	</dd>
	{foreachelse}
	<dd>
		Ce membre n'est inscrit à aucune activité ou cotisation.
	</dd>
	{/foreach}
	<dd>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
			{linkbutton href="!services/user/subscribe.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}







|




|







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&nbsp;!</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&nbsp;:</strong> {$config.org_name} &lt;{$config.org_email}&gt;
				{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"} &nbsp;
				{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&nbsp;?</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&nbsp;?</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&nbsp;?</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é&nbsp;?</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&nbsp;?</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&nbsp;:
	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].

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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































Deleted src/templates/users/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"}
<
<
<
<
<
<
<
<
















Deleted src/templates/users/mailing/details.tpl version [8eb4612382].

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
{include file="_head.tpl" title="Message collectif : %s"|args:$mailing.subject current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{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 />
				{linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$mailing.id}
				{linkbutton shape="delete" label="Supprimer" href="delete.php?id=%d"|args:$mailing.id}
				{if $mailing.body}
				{button shape="right" label="Envoyer" class="main" name="send" type="submit"}
				{/if}
			</dd>
			<dt>Expéditeur</dt>
			<dd>
				{$mailing->getFrom()}<br/>
			</dd>
		{/if}
		<dt>Destinataires</dt>
		<dd>
			{{%n destinataire}{%n destinataires} n=$mailing->countRecipients()}<br />
			{linkbutton shape="users" label="Voir la liste des destinataires" href="recipients.php?id=%d"|args:$mailing.id}
		</dd>
		<dt>Sujet</dt>
		<dd><strong>{$mailing.subject}</strong></dd>
		<dt>Message</dt>
		<dd><pre class="preview"><code>{$mailing.body}</code></pre></dd>
		<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>
	</dl>
	{csrf_field key=$csrf_key}
</form>

{include file="_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































Deleted src/templates/users/mailing/index.tpl version [a078781ae8].

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
{include file="_head.tpl" title="Messages collectifs" current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li class="current"><a href="{$self_url}">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{if $_GET.msg == 'DELETE'}
	<p class="confirm block">Le message a bien été supprimé.</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}
				{if !$row.sent}
					{linkbutton shape="edit" label="Modifier" href="write.php?id=%d"|args:$row.id}
				{/if}
				{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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


























































































Deleted src/templates/users/mailing/new.tpl version [aff296b479].

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
{include file="_head.tpl" title="Nouveau message collectif" current="users/mailing"}

{form_errors}

<form method="post" action="" data-focus=1>
{if !$target}
	<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&nbsp;?</legend>
		<dl>
			{input type="radio-btn" name="target" value="all" label="Tous les membres (sauf ceux appartenant à une catégorie cachée)" required=true}
			{input type="radio-btn" name="target" value="category" label="Les membres d'une seule catégorie" required=true}
			{input type="radio-btn" name="target" value="service" label="Les 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" value="search" label="Les 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 == 'category'}
	<fieldset>
		<legend>Quelle catégorie&nbsp;?</legend>
		<dl>
			{foreach from=$categories item="cat"}
				{input type="radio" name="target_id" value=$cat.id label=$cat.name help="%d membres"|args:$cat.count}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target == 'service'}
	<fieldset>
		<legend>Quelle activité&nbsp;?</legend>
		<dl>
			{foreach from=$services->iterate() item="service"}
				{input type="radio" name="target_id" value=$service.id label=$service.label help="%d membres"|args:$service.nb_users_ok}
			{/foreach}
		</dl>
	</fieldset>
{elseif $target == 'search'}
	<fieldset>
		<legend>Quelle recherche utiliser&nbsp;?</legend>
		<dl>
			{foreach from=$search_list item="search"}
				{input type="radio" name="target_id" value=$search.id label=$search.label help="%d membres"|args:$search.count}
			{/foreach}
		</dl>
	</fieldset>
{/if}
{if $target}
	<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"}
		{csrf_field key=$csrf_key}
		{button type="submit" name="step3" label="Créer" shape="right" class="main"}
	</p>
{/if}
</form>

{include file="_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































































































































Deleted src/templates/users/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&nbsp;:
	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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































Deleted src/templates/users/mailing/recipients.tpl version [4b637ba142].

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="Destinataires du message collectif : %s"|args:$mailing.subject current="users/mailing"}

<nav class="tabs">
	<aside>
		{linkbutton shape="plus" label="Nouveau message" href="new.php" target="_dialog"}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

<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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































































































Deleted src/templates/users/mailing/rejected.tpl version [6aee1d93eb].

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
{include file="_head.tpl" title="Adresses rejetées" current="users/mailing"}

<nav class="tabs">
	<aside>
		{exportmenu}
	</aside>
	<ul>
		<li><a href="./">Messages collectifs</a></li>
		<li class="current"><a href="rejected.php">Adresses rejetées</a></li>
	</ul>
</nav>

{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>
{elseif isset($_GET['forced'])}
<p class="confirm block">
	La file d'attente a été envoyée.
</p>
{/if}

<form method="post" action="">
<p class="help">
	{if !$queue_count}
		Il n'y a aucun message en attente d'envoi.
	{else}
		Il y a {$queue_count} messages dans la file d'attente, ils seront envoyés dans quelques instants.
		{if !USE_CRON && $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
			{button shape="right" label="Forcer l'envoi des messages en attente" type="submit" name="force_queue"}
		{/if}
	{/if}
</p>
</form>

{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>{$row.status}</td>
			<td class="num">{$row.sent_count}</td>
			<td>{$row.fail_log|escape|nl2br}</td>
			<td>{$row.last_sent|date}</td>
			<td>
				{if $row.email && ($row.optout || $row.last_sent < $limit_date)}
					<?php $email = rawurlencode($row->email); ?>
					{linkbutton target="_dialog" label="Rétablir" href="!users/mailing/verify.php?address=%s"|args:$email shape="check"}
				{/if}
			</td>
		</tr>

		{/foreach}
	</tbody>
	</table>

	{$list->getHTMLPagination()|raw}

	<div class="block help">
		<h3>Statuts possibles d'une adresse e-mail&nbsp;:</h3>
		<dl class="cotisation">
			{*
			<dt>Vérifiée</dt>
			<dd>L'adresse a déjà reçu un message et a été vérifiée manuellement par le destinataire.</dd>
			*}
			<dt>Désinscription</dt>
			<dd>Le destinataire a demandé à être désinscrit et ne recevra plus de messages.</dd>
			<dt>Invalide</dt>
			<dd>L'adresse n'existe pas ou plus. Il n'est pas possible de lui envoyer des messages.</dd>
			<dt>Trop d'erreurs</dt>
			<dd>Le service destinataire a renvoyé une erreur temporaire plus de {$max_fail_count} fois.<br />Cela arrive par exemple si vos messages sont vus comme du spam trop souvent, ou si la boîte mail destinataire est pleine. Cette adresse ne recevra plus de message.</dd>
		</dl>
		<p class="help">
			Il est possible de rétablir la réception de messages pour les adresses invalides après un délai d'un mois, et les adresses désinscrites immédiatement, en cliquant sur le bouton "Rétablir" qui enverra un message de validation à la personne.
		</p>
	</div>

{/if}

{include file="_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








































































































































































Deleted src/templates/users/mailing/verify.tpl version [3380d3fc0b].

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 $email.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>
		{elseif $email->hasReachedFailLimit()}
		<p class="help">
			Si l'adresse du membre a rencontré trop d'erreurs (boîte mail pleine par exemple), 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/write.tpl version [a5ee5946c0].

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
{include file="_head.tpl" title="Message collectif" current="users/mailing" hide_title=true}

{form_errors}

<form method="post" action="{$self_url}">

	<fieldset class="header">
		<legend>Modifier le message collectif</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&nbsp;:</strong> {$config.org_name} &lt;{$config.org_email}&gt;
				{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"} &nbsp;
				{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/mailing/write.php?id=%s&preview"|local_url|args:$mailing.id data-format="markdown" placeholder="Contenu du message…" default=$mailing.body}
	</fieldset>

	<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"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































































































Modified src/templates/users/message.tpl from [68d6edcd71] to [a5fc52d86b].

1
2
3
4
5
6
7
8
9
10
11
12
13


14
15
16
17
18
19
20
{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>Expéditeur</dt>
			{input type="radio" name="sender" value="self" default="self" required=true label="Membre : %s"|args:$self->getNameAndEmail()}
			{input type="radio" name="sender" value="org" default="self" required=true label='Association : "%s" <%s>'|args:$config.org_name:$config.org_email}
			<dt>Destinataire</dt>
			<dd>{$recipient->getNameAndEmail()}</dd>


			{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">








<
<
<


>
>







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&nbsp;!</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
6
7
8
9
10
11
12
13
<?php

namespace Paheko;

use Paheko\Web\Router;
use Paheko\Email\Emails;

if (empty($_SERVER['REQUEST_URI'])) {
	http_response_code(500);
	die('Appel non supporté');
}

$uri = $_SERVER['REQUEST_URI'];





|







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
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 = Emails::getEmailFromOptout($params['un']);

		if (!$email) {
			throw new UserException('Adresse email introuvable.');
		}

		$email->setOptout();
		$email->save();







|







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
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->listSubscriptionLinks(),
];

$tpl->assign($variables);
$tpl->assign('snippets', Modules::snippetsAsString(Modules::SNIPPET_TRANSACTION, $variables));

$tpl->display('acc/transactions/details.tpl');







|






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].

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/service_user.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('service_user_id', $id);
$tpl->assign(compact('action'));

$tpl->display('acc/transactions/service_user.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































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
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('website', WEBSITE);
$tpl->assign(compact('list', 'csrf_key', 'default_key', 'secret', 'access_levels'));

$tpl->display('config/advanced/api.tpl');







|



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
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\Services_User;
use Paheko\Accounting\Reports;
use Paheko\Entities\Accounting\Account;
use Paheko\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';

$tpl->assign('membre', $user);

$list = Services_User::perUserList($user->id);
$list->loadFromQueryString();

$tpl->assign(compact('list'));

$services = Services_User::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');



|








|




|







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
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\Emails;

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 = Emails::getEmailFromOptout($code);
$verify = null;

if (!$email) {
	throw new UserException('Adresse email introuvable.');
}

if (!empty($_GET['v'])) {



|










|







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
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\Services_User;

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(Services_User::listImportColumns());
$csv->setMandatoryColumns(Services_User::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');
		}

		Services_User::import($csv);
	}
	finally {
		$csv->clear();
	}
}, $csrf_key, '!services/import.php?msg=OK');

$tpl->assign(compact('csv', 'csrf_key'));

$tpl->display('services/import.tpl');







|









|
|



















|









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
18
19
20




21


22
23
24
25
26
$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_old_services = Services::countOldServices();
$show_old_services = $_GET['old'] ?? false;





$list = Services::listWithStats(!$show_old_services);


$list->loadFromQueryString();

$tpl->assign(compact('csrf_key', 'has_old_services', 'show_old_services', 'list'));

$tpl->display('services/index.tpl');







|
|

>
>
>
>
|
>
>


|


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].

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
<?php

namespace Paheko;

use Paheko\Accounting\Projects;
use Paheko\Services\Services;


if (!defined('\Paheko\ROOT')) {
	die();
}

assert(isset($tpl, $form_url, $create));

$current_only = !f('past_services');

// 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, (int)$current_only);

if (!count($grouped_services)) {
	$current_only = false;
	$grouped_services = Services::listGroupedWithFees($single_user_id, (int)$current_only);
}

if (!isset($count_all)) {
	$count_all = Services::count();
}

$has_past_services = count($grouped_services) != $count_all;

$today = new \DateTime;

$tpl->assign([
	'custom_js' => ['service_form.js'],
]);

$tpl->assign(compact('form_url', 'today', 'grouped_services', 'current_only', 'has_past_services',
	'create', 'copy_service', 'copy_service_only_paid'));

$tpl->assign_by_ref('users', $users);

$tpl->assign('projects', Projects::listAssoc());
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































































Deleted src/www/admin/services/user/add.php version [f211c6b3cd].

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 Service_User 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/user/add.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






















































Deleted src/www/admin/services/user/delete.php version [258ad809c7].

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\Services_User;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Services_User::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 . 'services/user/?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/user/delete.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































Deleted src/www/admin/services/user/edit.php version [ac51e77e6c].

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
<?php
namespace Paheko;

use Paheko\Services\Services_User;
use Paheko\Users\Users;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$su = Services_User::get((int) qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'su_edit_' . $su->id();
$users = [$su->id_user => Users::getName($su->id_user)];
$form_url = sprintf('edit.php?id=%d&', $su->id());
$create = false;

require __DIR__ . '/_form.php';

$form->runIf('save', function () use ($su) {
	$su->importForm();
	$su->importForm(['paid' => (bool)f('paid')]);
	$su->updateExpectedAmount();
	$su->save();
}, $csrf_key, ADMIN_URL . 'services/user/?id=' . $su->id_user);

$service_user = $su;

$tpl->assign(compact('csrf_key', 'service_user'));

$tpl->display('services/user/edit.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































Deleted src/www/admin/services/user/index.php version [f8e9112ec8].

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\Services_User;
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 = Services_User::get((int) qg('su_id'));

	if (!$su) {
		throw new UserException("Cette inscription est introuvable");
	}

	$su->paid = (bool)qg('paid');
	$su->save();
}, null, ADMIN_URL . 'services/user/?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 = Services_User::perUserList($user_id, $only, $after);
$list->setTitle(sprintf('Inscriptions — %s', $user_name));
$list->loadFromQueryString();

$tpl->assign('services', Services_User::listDistinctForUser($user_id));
$tpl->assign(compact('list', 'user_id', 'user_name', 'only', 'only_service', 'after'));

$tpl->display('services/user/index.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































































Deleted src/www/admin/services/user/link.php version [fadf825ef4].

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\Services_User;
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);

$su = Services_User::get((int)qg('id'));

if (!$su) {
	throw new UserException("Cette inscription n'existe pas");
}

$csrf_key = 'service_link';

$form->runIf('save', function () use ($su) {
	$id = (int)f('id_transaction');
	$transaction = Transactions::get($id);

	if (!$transaction) {
		throw new UserException('Impossible de trouver l\'écriture #' . $id);
	}

	$transaction->linkToSubscription($su->id);
}, $csrf_key, '!acc/transactions/service_user.php?id=' . $su->id . '&user=' . $su->id_user);

$tpl->assign(compact('csrf_key'));

$tpl->display('services/user/link.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


































































Deleted src/www/admin/services/user/payment.php version [ffae6c1087].

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\Services_User;
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 = Services_User::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, '!services/user/?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/user/payment.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































































Deleted src/www/admin/services/user/subscribe.php version [f757166bd5].

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\Service_User;
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 Service_User 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 = Service_User::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 . 'services/user/?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;

$service_user = null;

$tpl->assign(compact('csrf_key', 'users', 'account_targets', 'service_user', 'allow_users_edit', 'copy_service', 'copy_fee', 'copy_only_paid'));
$tpl->assign('projects', Projects::listAssoc());

$tpl->display('services/user/subscribe.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































Modified src/www/admin/static/doc/api.html from [d4bb706a70] to [3d95d98e16].

1
2
3
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/api.md</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
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
		.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"><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/{chemin}/</code>.</p>
<p>Remplacer <mark>{chemin}</mark> par un des chemins de l'API (voir ci-dessous). La méthode HTTP à utiliser est spécifiée pour chaque chemin.</p>
<p>Pour les requêtes de type <code>POST</code>, les paramètres peuvent être envoyés par le client sous forme de formulaire HTTP classique (<code>application/x-www-form-urlencoded</code>) ou sous forme d'objet JSON. Dans ce cas le <code>Content-Type</code> doit être positionné sur <code>application/json</code>.</p>
<p>Les réponses sont faites en JSON par défaut.</p><div class="toc">


	<ol>



		<li><a href="#utiliser-l-api">Utiliser l'API</a></li>






		<li><a href="#authentification">Authentification</a></li>




		<li><a href="#erreurs">Erreurs</a></li>









		<li><a href="#chemins">Chemins</a>

		<ol>


			<li><a href="#sql-post">sql (POST)</a></li>




			<li><a href="#telechargements">Téléchargements</a>


			<ol>


				<li><a href="#download-get">download (GET)</a></li>



				<li><a href="#download-files-get">download/files (GET)</a>


			</ol></li>


			<li><a href="#site-web">Site web</a>

			<ol>
				<li><a href="#web-list-get">web/list (GET)</a></li>
				<li><a href="#web-attachment-page_uri-filename-get">web/attachment/{PAGE_URI}/{FILENAME} (GET)</a></li>
				<li><a href="#web-page-page_uri-get">web/page/{PAGE_URI} (GET)</a></li>
				<li><a href="#web-html-page_uri-get">web/html/{PAGE_URI} (GET)</a>
			</ol></li>



			<li><a href="#membres">Membres</a>


			<ol>


				<li><a href="#user-categories-get">user/categories (GET)</a></li>







				<li><a href="#user-category-id-format-get">user/category/{ID}.{FORMAT} (GET)</a></li>



				<li><a href="#user-new-post">user/new (POST)</a></li>
				<li><a href="#user-id-get">user/{ID} (GET)</a></li>









				<li><a href="#user-id-delete">user/{ID} (DELETE)</a></li>


				<li><a href="#user-id-post">user/{ID} (POST)</a></li>



				<li><a href="#user-import-put">user/import (PUT)</a></li>



				<li><a href="#user-import-post">user/import (POST)</a></li>




				<li><a href="#user-import-preview-put">user/import/preview (PUT)</a></li>





				<li><a href="#user-import-preview-post">user/import/preview (POST)</a>











			</ol></li>

			<li><a href="#activites">Activités</a>
			<ol>
				<li><a href="#services-subscriptions-import-put">services/subscriptions/import (PUT)</a>
			</ol></li>
















			<li><a href="#erreurs">Erreurs</a>


			<ol>
				<li><a href="#errors-report-post">errors/report (POST)</a></li>
				<li><a href="#errors-log-get">errors/log (GET)</a>



			</ol></li>
			<li><a href="#comptabilite">Comptabilité</a>
			<ol>







				<li><a href="#accounting-years-get">accounting/years (GET)</a></li>
				<li><a href="#accounting-charts-get">accounting/charts (GET)</a></li>
				<li><a href="#accounting-charts-id_chart-accounts-get">accounting/charts/{ID_CHART}/accounts (GET)</a></li>
				<li><a href="#accounting-years-id_year-journal-get">accounting/years/{ID_YEAR}/journal (GET)</a></li>
				<li><a href="#accounting-years-id_year-export-format-extension-get">accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)</a></li>
				<li><a href="#accounting-years-id_year-account-journal-get">accounting/years/{ID_YEAR}/account/journal (GET)</a></li>

















				<li><a href="#accounting-transaction-id_transaction-get">accounting/transaction/{ID_TRANSACTION} (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-post">accounting/transaction/{ID_TRANSACTION} (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-get">accounting/transaction/{ID_TRANSACTION}/users (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-post">accounting/transaction/{ID_TRANSACTION}/users (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-users-delete">accounting/transaction/{ID_TRANSACTION}/users (DELETE)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-get">accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-post">accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)</a></li>
				<li><a href="#accounting-transaction-id_transaction-subscriptions-delete">accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)</a></li>
				<li><a href="#accounting-transaction-post">accounting/transaction (POST)</a>
</li></ol></li></ol></li></ol></div><h1 id="utiliser-l-api">Utiliser l'API</h1>
<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 https://test:coucou@[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:coucou@[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 "http://test:test@[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 "http://test:test@[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>













<h1 id="authentification">Authentification</h1>
<p>Il ne faut pas oublier de fournir le nom d'utilisateur et mot de passe en HTTP :</p>



<pre><code>curl http://test:abcd@paheko.monasso.tld/api/download/</code></pre>


<h1 id="erreurs">Erreurs</h1>
<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>




<h1 id="chemins">Chemins</h1>




<h2 id="sql-post">sql (POST)</h2>
<p>Permet d'exécuter une requête SQL <code>SELECT</code> (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 <code>sql</code>. Le résultat est retourné dans la clé <code>results</code> de l'objet JSON.</p>
<p>S'il n'y a pas de limite à la requête, une limite à 1000 résultats sera ajoutée obligatoirement.</p>

<pre><code>curl https://test:abcd@paheko.monasso.tld/api/sql/ -d 'SELECT * FROM membres LIMIT 5;'</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>














<p>Depuis la version 1.2.8, il est possible d'utiliser le paramètre <code>format</code> pour choisir le format renvoyé :</p>


<ul>
<li><code>json</code> (défaut) : renvoie un objet JSON, dont la clé est <code>"results"</code> et contient un tableau de la liste des membres trouvés</li>

<li><code>csv</code> : renvoie un fichier CSV</li>
<li><code>ods</code> : renvoie un tableau LibreOffice Calc (ODS)</li>



<li><code>xlsx</code> : renvoie un tableau Excel (XLSX)</li>
















</ul>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/sql/ -F sql='SELECT * FROM membres LIMIT 5;' -F format=csv</code></pre>
<h2 id="telechargements">Téléchargements</h2>
<h3 id="download-get">download (GET)</h3>
<p>Télécharger la base de données complète. Renvoie directement le fichier SQLite de la base de données.</p>
<p>Exemple :</p>

<pre><code>curl https://test:abcd@paheko.monasso.tld/api/download -o db.sqlite</code></pre>
<h3 id="download-files-get">download/files (GET)</h3>
<p><em>(Depuis la version 1.3.4)</em></p>
<p>Télécharger un fichier ZIP contenant tous les fichiers (documents, fichiers des écritures, des membres, modules modifiés, etc.).</p>





















<p>Exemple :</p>
<pre><code>curl https://test:abcd@paheko.monasso.tld/api/download/files -o backup_files.zip</code></pre>
<h2 id="site-web">Site web</h2>
<h3 id="web-list-get">web/list (GET)</h3>
<p>Renvoie la liste des pages du site web.</p>
<h3 id="web-attachment-page_uri-filename-get">web/attachment/{PAGE_URI}/{FILENAME} (GET)</h3>
<p>Renvoie le fichier joint correspondant à la page et nom de fichier indiqués.</p>
<h3 id="web-page-page_uri-get">web/page/{PAGE_URI} (GET)</h3>

<p>Renvoie un objet JSON avec toutes les infos de la page donnée.</p>






















<p>Rajouter le paramètre <code>?html</code> à l'URL pour obtenir en plus une clé <code>html</code> dans l'objet JSON qui contiendra la page au format HTML.</p>

<h3 id="web-html-page_uri-get">web/html/{PAGE_URI} (GET)</h3>

<p>Renvoie uniquement le contenu de la page au format HTML.</p>
<h2 id="membres">Membres</h2>
<h3 id="user-categories-get">user/categories (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Renvoie la liste des catégories de membres, triée par nom, et incluant le nombre de membres de la catégorie (dans la clé <code>count</code>).</p>
<h3 id="user-category-id-format-get">user/category/{ID}.{FORMAT} (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Exporte la liste des membres d'une catégorie correspondant à l'ID demandé, au format indiqué :</p>























<ul>



<li><code>json</code></li>
<li><code>csv</code></li>



<li><code>ods</code></li>
<li><code>xlsx</code></li>

</ul>







<h3 id="user-new-post">user/new (POST)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Permet de créer un nouveau membre.</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 leur clé unique, ainsi que les clés suivantes :</p>

<ul>
<li><code>id_category</code> : indique l'ID d'une catégorie, si absent la catégorie par défaut sera utilisée</li>







<li><code>password</code> : mot de passe du membre</li>
<li><code>force_duplicate=1</code> : ne pas renvoyer une erreur si le nom du membre à ajouter est identique au nom d'un membre existant.</li>


</ul>
<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>
<pre><code>curl -F nom="Bla bla" -F id_category=3 -F password=abcdef123456 https://test:abcd@monpaheko.tld/api/user/new</code></pre>
<h3 id="user-id-get">user/{ID} (GET)</h3>

<p><em>(Depuis la version 1.3.6)</em></p>
<p>Renvoie les infos de la fiche d'un membre à partir de son ID, ainsi que 3 autres clés :</p>
<ul>
<li><code>has_password</code></li>
<li><code>has_pgp_key</code></li>
<li><code>has_otp</code></li>





























</ul>
<h3 id="user-id-delete">user/{ID} (DELETE)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>





<p>Supprime un membre à partir de son ID.</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 un membre appartenant à une catégorie ayant accès à la configuration.</p>
<h3 id="user-id-post">user/{ID} (POST)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Modifie les infos de la fiche d'un membre à partir de son ID.</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 "Administartion" pourra modifier un membre administrateur</li>






</ul>
<h3 id="user-import-put">user/import (PUT)</h3>
<p>Permet d'importer un fichier de tableur (CSV/XLSX/ODS) de la liste des membres, comme si c'était fait depuis l'interface de Paheko.</p>


















<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>Paheko s'attend à 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' &gt; membres.csv
echo '42,"Nouveau nom"' &gt;&gt; membres.csv
curl https://test:abcd@monpaheko.tld/api/user/import -T membres.csv</code></pre>
<h4 id="parametres">Paramètres</h4>
<p>Les paramètres sont à spécifier dans l'URL, dans la query string.</p>
<p>Depuis la version 1.2.8 il est possible d'utiliser un paramètre supplémentaire <code>mode</code> contenant 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><em>Depuis la version 1.3.0 il est possible de spécifier :</em></p>
<ul>
<li>le nombre de lignes à ignorer avec le paramètre <code>skip_lines=X</code> : elles ne seront pas importées. Par défaut la première ligne est ignorée.</li>
<li>la correspondance des colonnes avec des paramètres <code>column[x]</code> ou <code>x</code> est le numéro de la colonne (la numérotation commence à zéro), et la valeur contient le nom unique du champ de la fiche membre.</li>
</ul>
<p>Exemple :</p>
<pre><code>curl https://test:abcd@monpaheko.tld/api/user/import?mode=create&amp;column[0]=nom_prenom&amp;column[1]=code_postal&amp;skip_lines=0 -T membres.csv</code></pre>
<h3 id="user-import-post">user/import (POST)</h3>
<p>Identique à la même méthode en <code>PUT</code>, mais les paramètres sont passés dans le corps de la requête, avec le fichier, dont le nom sera alors <code>file</code>.</p>
<pre><code>curl https://test:abcd@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>
<h3 id="user-import-preview-put">user/import/preview (PUT)</h3>
<p>Identique à <code>user/import</code>, mais l'import n'est pas enregistré, et la route renvoie les modifications qui seraient effectuées en important le fichier :</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 retour :</p>
<pre><code>{
    "created": [
        {
            "numero": 3434351,
            "nom": "Bla Bli Blu"
        }
    ],
    "modified": [







|
|
|
|
|
|
>
>
|
>
>
>
|
>
>
>
>
>
>
|
>
>
>
>
|
>
>
>
>
>
>
>
>
>
|
>
|
>
>
|
>
>
>
>
|
>
>
|
>
>
|
>
>
>
|
>
>
|
>
>
|
>
|
|
|
|
<
|
>
>
>
|
>
>
|
>
>
|
>
>
>
>
>
>
>
|
>
>
>
|
<
>
>
>
>
>
>
>
>
>
|
>
>
|
>
>
>
|
>
>
>
|
>
>
>
>
|
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
|
>
|
|
<
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
|
<
<
>
>
>
|
<
|
>
>
>
>
>
>
>
|
<
<
<
<
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
>
>
>
|
>
>
|
<
>
>
>
>
|
>
>
>
>
|
<
<
>
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
|
<
>
|
|
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
|
|
|
|
>
|
<
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
<
|
|
|
|
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
|
|
<
<
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
|
|
>
>
>
|
|
>
|
>
>
>
>
>
>
>
|
<
|
<
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
<
|
>
|
<
>
>
>
>
>
>
>
|
<
>
>
|
<
<
<
<
>
|
<
<



>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
<
>
>
>
>
>
|
>
>
|
<
<
<
<
>
>
>
>
>
|
>
>
|
>
>
>
>
>
>
|
|
|
|
|
>
>
>
>
>
>
|
<
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>
>
>
>
>
>
|
>
>


|









|
<
<












<
|
<
<
<

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
<
<
<
<
|
<
<
<

|
|
|
<
|
<
<







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&eacute;buter</h3></summary><p>Une API de type REST est disponible dans Paheko.</p><p>Pour acc&eacute;der &agrave; l'API il faut un identifiant et un mot de passe, &agrave; cr&eacute;er dans le menu <mark>Configuration</mark>, onglet <mark>Fonctions avanc&eacute;es</mark>, puis <mark>API</mark>.</p><p>L'API peut ensuite recevoir des requ&ecirc;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&eacute;thode HTTP (<code>GET</code>, <code>POST</code>, etc.) &agrave; utiliser est sp&eacute;cifi&eacute;e pour chaque route.</p><p>Des exemples sont donn&eacute;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&ecirc;tes et r&eacute;ponses</h3></summary><p>Les param&egrave;tres peuvent &ecirc;tre fournis sous les formes suivantes :</p><ul>
<li>dans les param&egrave;tres de l'URL (query string) : pour toutes les m&eacute;thodes</li>
<li>formulaire HTTP classique pour les requ&ecirc;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&ecirc;tes POST :<ul>
<li><code>Content-Type: application/json</code></li>
</ul>

</li>
</ul><p>Les r&eacute;ponses sont renvoy&eacute;es en JSON par d&eacute;faut, sauf quand la route permet de choisir un autre format.</p><p>Les formats ODS et XLSX ne sont disponibles &agrave; l'import que si le serveur est configur&eacute; pour convertir ces formats.</p><p>De la m&ecirc;me mani&egrave;re, le format XLSX n'est disponible que si le serveur est configur&eacute; pour g&eacute;n&eacute;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&eacute;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&eacute;l&eacute;charger la base de donn&eacute;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&eacute;er une &eacute;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"
  &hellip;</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 &eacute;criture", "date": "01/02/2023", &hellip;}'</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&eacute; <code>error</code> contenant le message d'erreur.</p></details><h1 id="routes">Routes</h1><h2 id="requetes-sql">Requ&ecirc;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&eacute;cute une requ&ecirc;te SQL en lecture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&ecirc;te SQL &agrave; ex&eacute;cuter.</td>
</tr>
</tbody>
</table><p>Si aucun format n'est pass&eacute; (exemple : <code>&hellip;/api/sql</code>, sans point ni extension), <code>json</code> sera utilis&eacute;.</p><p>Permet d'ex&eacute;cuter une requ&ecirc;te SQL <code>SELECT</code> (uniquement, pas de requ&ecirc;te <code>UPDATE</code>, <code>DELETE</code>, <code>INSERT</code>, etc.) sur la base de donn&eacute;es. La requ&ecirc;te SQL doit &ecirc;tre pass&eacute;e dans le corps de la requ&ecirc;te HTTP, ou dans le param&egrave;tre <code>sql</code>.</p><p>S'il n'y a pas de limite &agrave; la requ&ecirc;te, une limite &agrave; 1000 r&eacute;sultats sera ajout&eacute;e obligatoirement.</p><p>Exemple de requ&ecirc;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&eacute;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&ecirc;tes en &eacute;criture (<code>INSERT, DELETE, UPDATE, CREATE TABLE</code>, etc.) ne sont pas accept&eacute;es, il n'est pas possible de modifier la base de donn&eacute;es directement via Paheko, afin d'&eacute;viter les soucis de donn&eacute;es corrompues.</p></details><h2 id="telechargements">T&eacute;l&eacute;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&eacute;l&eacute;charger la base de donn&eacute;es</span></summary><p>Renvoie directement le fichier SQLite de la base de donn&eacute;es.</p><p>Exemple de requ&ecirc;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&eacute;l&eacute;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&eacute;s aux &eacute;critures,</li>
<li>fichiers li&eacute;s des membres,</li>
<li>fichiers joints aux pages du site web</li>
<li>code des modules modifi&eacute;s</li>
<li>corbeille</li>
<li>configuration : logo, ic&ocirc;nes, etc.</li>
<li>anciennes versions des fichiers</li>
</ul><p>Exemple de requ&ecirc;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&eacute;tadonn&eacute;es de la page du site web</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute; <code>html</code> sera ajout&eacute;e &agrave; la r&eacute;ponse avec le contenu de la page au format HTML.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;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"
    },
    &hellip;
]</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&egrave;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&ecirc;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&eacute;tadonn&eacute;es de la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;ro de la cat&eacute;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&eacute;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&ecirc;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&eacute;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&egrave;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&ecirc;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&egrave;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&ecirc;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&eacute;gories dans cette cat&eacute;gorie</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&ecirc;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&eacute;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": "&hellip;"
        },
        &hellip;
    ]
}</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 &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;cup&eacute;rer le fichier joint &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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 &agrave; la page</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;gories de membres</span></summary><p><em>(Depuis la version 1.4.0)</em></p><p>La liste est tri&eacute;e par nom, et inclue le nombre de membres de la cat&eacute;gorie dans la cl&eacute; <code>count</code>.</p><p>Exemple de r&eacute;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&eacute;gorie</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;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&eacute;er un nouveau membre</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;gorie. Si absent, la cat&eacute;gorie par d&eacute;faut sera utilis&eacute;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&eacute;e si le nom du membre correspond &agrave; un membre d&eacute;j&agrave; existant.</td>
</tr>
</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Attention, cette m&eacute;thode comporte des restrictions :</p><ul>
<li>il n'est pas possible de cr&eacute;er un membre dans une cat&eacute;gorie ayant acc&egrave;s &agrave; la configuration</li>
<li>il n'est pas possible de d&eacute;finir l'OTP ou la cl&eacute; PGP du membre cr&eacute;&eacute;</li>
<li>seul un identifiant API ayant le droit "Administration" pourra cr&eacute;er des membres administrateurs</li>

</ul><p>Il est possible d'utiliser tous les champs de la fiche membre en utilisant la cl&eacute; unique du champ.</p><p>Sera renvoy&eacute;e la liste des infos de la fiche membre.</p><p>Si un membre avec le m&ecirc;me nom existe d&eacute;j&agrave; (et que <code>force_duplicate</code> n'est pas utilis&eacute;), une erreur <code>409</code> sera renvoy&eacute;e.</p><p>Exemple de requ&ecirc;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&egrave;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&eacute;rent du num&eacute;ro).</td>
</tr>




</tbody>
</table><p><em>(Depuis la version 1.4.0)</em></p><p>Plusieurs cl&eacute;s suppl&eacute;mentaires sont retourn&eacute;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&eacute;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&egrave;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&eacute;rent du num&eacute;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 &agrave; une cat&eacute;gorie ayant acc&egrave;s &agrave; 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&egrave;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&eacute;rent du num&eacute;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&eacute;gorie d'un membre</li>
<li>il n'est pas possible de modifier un membre appartenant &agrave; une cat&eacute;gorie ayant acc&egrave;s &agrave; la configuration.</li>
<li>il n'est pas possible de modifier le mot de passe, l'OTP ou la cl&eacute; PGP du membre cr&eacute;&eacute;</li>
<li>il n'est pas possible de modifier des membres ayant acc&egrave;s &agrave; la configuration</li>
<li>seul un identifiant d'API ayant l'acc&egrave;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&eacute;s : CSV, ODS, XLSX.</p><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;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 &agrave; ignorer. D&eacute;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&eacute;, commence &agrave; z&eacute;ro) et le champ de la fiche membre (valeur).</td>
</tr>
</tbody>
</table><p>Cette route n&eacute;cessite une cl&eacute; 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&egrave;s &agrave; l'interface d'administration.</p><p>Le param&egrave;tre <code>mode</code> permet d'utiliser une de ces options pour sp&eacute;cifier le mode d'import :</p><ul>

<li><code>auto</code> (d&eacute;faut si le mode n'est pas sp&eacute;cifi&eacute;) : met &agrave; jour la fiche d'un membre si son num&eacute;ro existe, sinon cr&eacute;e un membre si le num&eacute;ro de membre indiqu&eacute; n'existe pas ou n'est pas renseign&eacute;</li>
<li><code>create</code> : ne fait que cr&eacute;er de nouvelles fiches de membre, si le num&eacute;ro de membre existe d&eacute;j&agrave; une erreur sera produite</li>
<li><code>update</code> : ne fait que mettre &agrave; jour les fiches de membre en utilisant le num&eacute;ro de membre comme r&eacute;f&eacute;rence, si le num&eacute;ro de membre n'existe pas une erreur sera produite</li>
</ul><p>Exemple de requ&ecirc;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&egrave;tre <code>column</code> n'est fourni, Paheko s'attend alors &agrave; ce que la premi&egrave;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 &agrave; leur nom unique). Par exemple si votre fiche membre contient les champs <em>Nom et pr&eacute;nom</em> et <em>Adresse postale</em>, alors le fichier fourni devra ressembler &agrave; ceci :</p><table>
<thead>
<tr>
<th style="text-align: left;">Nom et pr&eacute;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 &agrave; 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&eacute;thode renvoie un code HTTP <code>200 OK</code> si l'import s'est bien pass&eacute;, sinon un code 400 et un message d'erreur JSON dans le corps de la r&eacute;ponse.</p><p>Utilisez la route <code>user/import/preview</code> avant pour v&eacute;rifier que l'import correspond &agrave; ce que vous attendez.</p><p>Exemple pour modifier le nom du membre n&deg;42 :</p><pre><code>echo 'numero,nom' &gt; membres.csv



echo '42,"Nouveau nom"' &gt;&gt; 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&eacute;s : CSV, ODS, XLSX.</p><p>Identique &agrave; la m&ecirc;me m&eacute;thode en <code>POST</code>, mais les param&egrave;tres sont pass&eacute;s dans l'URL, et le fichier en contenu de la requ&ecirc;te.</p><p>Exemple de requ&ecirc;te :</p><pre><code class="language-request">curl -u test:abcd https://monpaheko.tld/api/user/import?mode=create&amp;column[0]=nom_prenom&amp;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&eacute;visualise un import de membres, sans modifier les membres</span></summary><p>Identique &agrave; <code>user/import</code>, mais l'import n'est pas enregistr&eacute;. &Agrave; la place l'API indique les modifications qui seraient apport&eacute;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&eacute;s, chaque objet contenant tous les champs de la fiche membre qui serait cr&eacute;&eacute;e</li>
<li><code>modified</code> : liste des membres modifi&eacute;s, chaque membre aura une cl&eacute; <code>id</code> et une cl&eacute; <code>name</code>, ainsi qu'un objet <code>changed</code> contenant la liste des champs modifi&eacute;s. Chaque champ modifi&eacute; aura 2 propri&eacute;t&eacute;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&eacute;s dans l'import, mais qui ne seront pas affect&eacute;s. Pour chaque membre une cl&eacute; <code>name</code> et une cl&eacute; <code>id</code> indiquant le nom et l'identifiant unique num&eacute;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&ecirc;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&eacute;ponse :</p><pre><code class="language-response">{


    "created": [
        {
            "numero": 3434351,
            "nom": "Bla Bli Blu"
        }
    ],
    "modified": [
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
    ],
    "unchanged": [
        {
            "id": 2,
            "name": "Paul Muad'Dib"
        }
    ]
}</code></pre>
<h3 id="user-import-preview-post">user/import/preview (POST)</h3>
<p>Idem quel la méthode en <code>PUT</code> mais accepte les paramètres dans le corps de la requête (voir ci-dessus).</p>
<h2 id="activites">Activités</h2>
<h3 id="services-subscriptions-import-put">services/subscriptions/import (PUT)</h3>
<p><em>(Depuis Paheko 1.3.2)</em></p>
<p>Permet d'importer les inscriptions des membres aux activités à partir d'un fichier CSV. 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é ?"' &gt; /tmp/inscriptions.csv
echo '42,"Cours de théâtre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' &gt;&gt; /tmp/inscriptions.csv
curl https://test:abcd@monpaheko.tld/api/services/subscriptions/import -T /tmp/inscriptions.csv</code></pre>

<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>
<h3 id="errors-report-post">errors/report (POST)</h3>
<p>Permet d'envoyer un rapport d'erreur (au format airbrake/errbit/Paheko), comme si c'était une erreur locale.</p>
<h3 id="errors-log-get">errors/log (GET)</h3>

















































<p>Renvoie le log d'erreurs système, au format airbrake/errbit (<a href="https://airbrake.io/docs/api/#create-notice-v3" rel="noreferrer noopener external" target="_blank">voir la doc AirBrake pour un exemple du format</a>)</p>
<h2 id="comptabilite">Comptabilité</h2>
<h3 id="accounting-years-get">accounting/years (GET)</h3>
<p>Renvoie la liste des exercices.</p>
<h3 id="accounting-charts-get">accounting/charts (GET)</h3>
<p>Renvoie la liste des plans comptables.</p>
<h3 id="accounting-charts-id_chart-accounts-get">accounting/charts/{ID_CHART}/accounts (GET)</h3>
<p>Renvoie la liste des comptes pour le plan comptable indiqué (voir <code>id_chart</code> dans la liste des exercices).</p>
<h3 id="accounting-years-id_year-journal-get">accounting/years/{ID_YEAR}/journal (GET)</h3>
<p>Renvoie le journal général des écritures de l'exercice indiqué. </p>
<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>





<h3 id="accounting-years-id_year-export-format-extension-get">accounting/years/{ID_YEAR}/export/{FORMAT}.{EXTENSION} (GET)</h3>


















<p><em>(Depuis la version 1.3.6)</em></p>











<p>Exporte l'exercice indiqué au format indiqué. Les formats suivants sont disponibles :</p>





























<ul>












<li><code>full</code> : complet</li>
<li><code>grouped</code> : complet groupé</li>



















<li><code>simple</code> : simple (ne comporte pas les saisies avancées)</li>



<li><code>fec</code> : format FEC (Fichier des Écritures Comptables)</li>


</ul>
<p>L'extension indique le type de fichier :</p>
<ul>





<li><code>csv</code> : Tableur CSV</li>
<li><code>ods</code> : LibreOffice Calc</li>
<li><code>xlsx</code> : Microsoft OOXML (Excel) - seulement disponible si l'instance le permet</li>




<li><code>json</code> : Texte JSON</li>

</ul>
<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>
<h3 id="accounting-years-id_year-account-journal-get">accounting/years/{ID_YEAR}/account/journal (GET)</h3>
<p>Renvoie le journal des écritures d'un compte pour l'exercice indiqué.</p>
<p>Le compte est spécifié soit via le paramètre <code>code</code>, soit via le paramètre <code>id</code>. Exemple :  <code>/accounting/years/4/account/journal?code=512A</code></p>
<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>
<h3 id="accounting-transaction-id_transaction-get">accounting/transaction/{ID_TRANSACTION} (GET)</h3>
<p>Renvoie les détails de l'écriture indiquée.</p>
<h3 id="accounting-transaction-id_transaction-post">accounting/transaction/{ID_TRANSACTION} (POST)</h3>




<p>Modifie l'écriture indiquée. Voir plus bas le format attendu.</p>
















































<h3 id="accounting-transaction-id_transaction-users-get">accounting/transaction/{ID_TRANSACTION}/users (GET)</h3>
<p>Renvoie la liste des membres liés à une écriture.</p>
<h3 id="accounting-transaction-id_transaction-users-post">accounting/transaction/{ID_TRANSACTION}/users (POST)</h3>
<p>Met à jour la liste des membres liés à une écriture, en utilisant les ID de membres passés dans un tableau nommé <code>users</code>.</p>
<pre><code> curl -v "http://…/api/accounting/transaction/9337/users"  -F 'users[]=2'</code></pre>
<h3 id="accounting-transaction-id_transaction-users-delete">accounting/transaction/{ID_TRANSACTION}/users (DELETE)</h3>
<p>Efface la liste des membres liés à une écriture.</p>
<h3 id="accounting-transaction-id_transaction-subscriptions-get">accounting/transaction/{ID_TRANSACTION}/subscriptions (GET)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>















<p>Renvoie la liste des inscriptions (aux activités) liées à une écriture.</p>






<h3 id="accounting-transaction-id_transaction-subscriptions-post">accounting/transaction/{ID_TRANSACTION}/subscriptions (POST)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>
<p>Met à jour la liste des inscriptions liées à une écriture, en utilisant les ID d'inscriptions passés dans un tableau nommé <code>subscriptions</code>.</p>
<pre><code> curl -v "http://…/api/accounting/transaction/9337/subscriptions"  -F 'subscriptions[]=2'</code></pre>
<h3 id="accounting-transaction-id_transaction-subscriptions-delete">accounting/transaction/{ID_TRANSACTION}/subscriptions (DELETE)</h3>
<p><em>(Depuis la version 1.3.6)</em></p>


<p>Efface la liste des inscriptions liées à une écriture.</p>
<h3 id="accounting-transaction-post">accounting/transaction (POST)</h3>
<p>Crée une nouvelle écriture, renvoie les détails si l'écriture a été créée. Voir plus bas le format attendu.</p>
<h4 id="structure-pour-creer-modifier-une-ecriture">Structure pour créer / modifier une écriture</h4>
<p>Les champs à spécifier pour créer ou modifier une écriture sont les suivants :</p>





















<ul>















































<li><code>id_year</code></li>













<li><code>date</code> (format YYYY-MM-DD)</li>
<li><code>type</code> peut être un type d'écriture simplifié (2 lignes) : <code>EXPENSE</code> (dépense), <code>REVENUE</code> (recette), <code>TRANSFER</code> (virement), <code>DEBT</code> (dette), <code>CREDIT</code> (créance), ou <code>ADVANCED</code> pour une écriture multi-ligne</li>
<li><code>amount</code> (uniquement pour les écritures simplifiées) : contient le montant de l'écriture</li>
<li><code>credit</code> (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au crédit</li>
<li><code>debit</code> (uniquement pour les écritures simplifiées) : contient le numéro du compte porté au débit</li>
<li><code>lines</code> (pour les écritures multi-lignes) : un tableau dont chaque ligne doit contenir :<ul>
<li><code>account</code> (numéro du compte) ou <code>id_account</code> (ID unique du compte)</li>
<li><code>credit</code> : montant à inscrire au crédit (doit être zéro ou non renseigné si <code>debit</code> est renseigné, et vice-versa)</li>
















<li><code>debit</code> : montant à inscrire au débit</li>














<li><code>label</code> (facultatif) : libellé de la ligne</li>
<li><code>reference</code> (facultatif) : référence de la ligne (aussi appelé référence du paiement pour les écritures simplifiées)</li>














<li><code>id_project</code> : ID unique du projet à affecter</li>

</ul>




</li>







</ul>













<p>Champs optionnels :</p>
<ul>



<li><code>reference</code> : numéro de pièce comptable</li>
<li><code>notes</code> : remarques (texte multi ligne)</li>














<li><code>id_project</code> : ID unique du projet à affecter (pour les écritures simplifiées uniquement)</li>
<li><code>payment_reference</code> (uniquement pour les écritures simplifiées) : référence de paiement</li>
<li><code>linked_users</code> : Tableau des IDs des membres à lier à l'écriture <em>(depuis 1.3.3)</em></li>
<li><code>linked_transactions</code> : Tableau des IDs des écritures à lier à l'écriture <em>(depuis 1.3.5)</em></li>



<li><code>linked_subscriptions</code> : Tableau des IDs des inscriptions à lier à l'écriture <em>(depuis 1.3.6)</em></li>


</ul>







<p>Exemple :</p>









<pre><code>curl -F 'id_year=12' -F 'label=Test' -F 'date=01/02/2022' -F 'type=EXPENSE' -F 'amount=42' -F 'debit=512A' -F 'credit=601' …</code></pre></div></body></html>







|
<
<
<
<
<
<
<
<
|
|



|
|
<
|
<
<
|
|
>
|
>
>
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
|
|
<
<
<
<
<
|
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
|
>
>
|
<
|
>
>
>
>
>
|
|
<
>
>
>
>
|
>
|
<
|
|
|
|
|
|
|
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
|
|
|
|
|
|
>
>
|
<
<
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
|
|
|
<
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
|
>
>
>
>
|
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
>
>
>
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
<
<
<
>
>
>
|
>
>
|
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
|
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&eacute;visualise un import de membres, sans modifier les membres</span></summary><p>Idem quel la m&eacute;thode en <code>POST</code> mais les param&egrave;tres doivent &ecirc;tre pass&eacute;s dans l'URL, et le fichier dans le corps de la requ&ecirc;te.</p></details><h2 id="activites">Activit&eacute;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&eacute;s</span></summary><p>Fichiers accept&eacute;s : CSV, XLSX, ODS.</p><p><em>(Depuis Paheko 1.3.2)</em></p><p>Les activit&eacute;s et tarifs doivent d&eacute;j&agrave; exister avant l'import.</p><p>Les colonnes suivantes peuvent &ecirc;tre utilis&eacute;es :</p><ul>








<li>Num&eacute;ro de membre<code>**</code></li>
<li>Activit&eacute;<code>**</code></li>
<li>Tarif</li>
<li>Date d'inscription<code>**</code></li>
<li>Date d'expiration</li>
<li>Montant &agrave; r&eacute;gler</li>
<li>Pay&eacute; ?</li>

</ul><p>Les colonnes suivies de deux ast&eacute;risques (<code>**</code>) sont obligatoires.</p><p>Exemple :</p><pre><code>echo '"Num&eacute;ro de membre","Activit&eacute;","Tarif","Date d'inscription","Date d'expiration","Montant &agrave; r&eacute;gler","Pay&eacute; ?"' &gt; /tmp/inscriptions.csv


echo '42,"Cours de th&eacute;&acirc;tre","Tarif adulte","01/09/2023","01/07/2023","123,50","Non"' &gt;&gt; /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&egrave;me d&eacute;di&eacute; &agrave; 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&eacute;</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&eacute;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"
    },
    &hellip;
]</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&eacute;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&eacute;</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;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
    },
    &hellip;
]</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&eacute;n&eacute;ral des &eacute;critures de l'exercice indiqu&eacute;</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&egrave;tre pour <code>{ID_YEAR}</code> pour d&eacute;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&eacute;.</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&egrave;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 &eacute;critures avanc&eacute;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 &eacute;critures d'un compte</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;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
    },
    &hellip;
]</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 &eacute;critures d'un compte</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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&eacute;er une nouvelle &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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&eacute;ro de pi&egrave;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, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des &eacute;critures &agrave; lier &agrave; l'&eacute;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, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des membres &agrave; lier &agrave; l'&eacute;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, &hellip;)|null</code></td>
<td style="text-align: left;">Tableau des IDs des inscriptions &agrave; lier &agrave; l'&eacute;criture <em>(depuis 1.4.0)</em></td>
</tr>
</tbody>
</table><h4 id="types-d-ecriture">Types d'&eacute;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&eacute;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&eacute;ance</td>
</tr>
<tr>
<td style="text-align: left;"><code>advanced</code></td>
<td style="text-align: left;">Saisie avanc&eacute;e</td>
</tr>
</tbody>
</table><p>Les &eacute;critures avanc&eacute;es (multi-lignes) ont obligatoirement le type <code>advanced</code>. Les autres &eacute;critures sont appel&eacute;es <em>"&eacute;critures simplifi&eacute;es"</em> et ne peuvent comporter que deux lignes.</p><h4 id="parametres-des-ecritures-simplifiees">Param&egrave;tres des &eacute;critures simplifi&eacute;es</h4><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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&eacute; au cr&eacute;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&eacute; au d&eacute;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 &agrave; 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&eacute;f&eacute;rence de paiement</td>
</tr>
</tbody>
</table><h4 id="parametres-des-ecritures-avancees">Param&egrave;tres des &eacute;critures avanc&eacute;es</h4><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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, &hellip;)</code></td>
<td style="text-align: left;">un tableau dont chaque &eacute;l&eacute;ment est un objet correspondant &agrave; une ligne de l'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Structure de l'objet repr&eacute;sentant une ligne de l'&eacute;criture :</p><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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 &ecirc;tre utilis&eacute; 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 &agrave; inscrire au cr&eacute;dit (doit &ecirc;tre z&eacute;ro ou non renseign&eacute; si <code>debit</code> est renseign&eacute;, 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 &agrave; inscrire au d&eacute;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&eacute; 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&eacute;f&eacute;rence de la ligne (aussi appel&eacute; r&eacute;f&eacute;rence du paiement pour les &eacute;critures simplifi&eacute;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 &agrave; affecter &agrave; cette ligne</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;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&eacute;tails de l'&eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Exemple de r&eacute;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"
      }
    },
    &hellip;
  ]
}</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'&eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;criture.</td>
</tr>
</tbody>
</table><p>Si l'&eacute;criture est verrouill&eacute;e, ou dans un exercice cl&ocirc;tur&eacute;, la modification sera impossible.</p><p>Voir la route <code>POST accounting/transaction</code> (cr&eacute;ation d'une &eacute;criture) pour la liste des param&egrave;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&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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 &agrave; jour la liste des membres li&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;criture.</td>
</tr>
<tr>
<td style="text-align: left;"><code>users</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)</code></td>
<td style="text-align: left;">ID des membres.</td>
</tr>
</tbody>
</table><p>Exemple de requ&ecirc;te :</p><pre><code> curl -v "https://&hellip;/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&eacute;s &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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&eacute;s) li&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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 &agrave; jour la liste des inscriptions li&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;criture.</td>
</tr>
<tr>
<td style="text-align: left;"><code>subscriptions</code></td>
<td style="text-align: left;"><code>array(int, &hellip;)</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&ecirc;te :</p><pre><code> curl -v "https://&hellip;/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&eacute;es &agrave; une &eacute;criture</span></summary><table>
<thead>
<tr>
<th style="text-align: left;">Param&egrave;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'&eacute;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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Documentation du langage Brindille dans Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des fonctions Brindille</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des filtres Brindille</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence des sections Brindille</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Raccourcis claviers dans l&#039;édition de texte — Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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&#039;é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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence complète MarkDown — Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence rapide MarkDown — Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Développer des modules pour Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Référence rapide SkrivML - Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>/home/bohwaz/fossil/paheko/tools/../doc/admin/sql.md</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
4
5


6
7
8
9
10
11
12
<!DOCTYPE html>
	<html>
	<head>
		<title>Squelettes du site web dans Paheko</title>
		<meta charset="utf-8" />


		<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;



<

>
>







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
908
909
910
911
912
913
914
915
916
917
918
pre code {
    background: var(--gLightBackgroundColor);
    display: block;
    padding: .5em;
    border-radius: .3em;
}

fieldset.message {
    max-width: 40em;
}

img.broken {
    font-size: 1.2rem;
    text-align: center;
    background: #977;
    border-radius: .3rem;
    color: #fff;
    text-decoration: none;







<
<
<
<







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
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/user/subscribe.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);







|







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
5
6
7
8
9
10
11
12
<?php
namespace Paheko;

use Paheko\Accounting\Transactions;
use Paheko\Services\Services_User;
use Paheko\Users\Categories;
use Paheko\Users\Users;

use Paheko\UserTemplate\Modules;

require_once __DIR__ . '/_inc.php';





|







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
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 = Services_User::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);
}







|







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].

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);
<
<
<
<
<
<
<
<
<
<




















Deleted src/www/admin/users/mailing/block.php version [0a3c768560].

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\Emails;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$address = qg('address');
$email = Emails::getOrCreateEmail($address);

if (!$email) {
    throw new UserException('Adresse invalide');
}

$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/mailing/block.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































Deleted src/www/admin/users/mailing/delete.php version [829abccebc].

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/mailing/?msg=DELETE');

$tpl->assign(compact('mailing', 'csrf_key'));
$tpl->display('users/mailing/delete.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































Deleted src/www/admin/users/mailing/details.php version [4d90651838].

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\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/mailing/details.php?sent&id=' . $mailing->id);

$tpl->assign(compact('mailing', 'csrf_key'));

$tpl->assign('custom_css', [BASE_URL . 'content.css']);
$tpl->assign('sent', null !== qg('sent'));

$tpl->display('users/mailing/details.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































Deleted src/www/admin/users/mailing/index.php version [1da23b3440].

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/mailing/index.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































Deleted src/www/admin/users/mailing/new.php version [0ae0ecf4fb].

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\Users\Categories;
use Paheko\Users\Session;
use Paheko\Search;
use Paheko\UserException;
use Paheko\Services\Services;
use Paheko\Entities\Search as SearchEntity;
use Paheko\Email\Mailings;

require_once __DIR__ . '/_inc.php';

$csrf_key = 'create_mailing';

$target = f('target');

$form->runIf($target == 'all' || f('step3'), function () {
	$target = f('target');
	$target_id = f('target_id');

	if ($target !== 'all' && empty($target_id)) {
		throw new UserException('Aucune cible n\'a été sélectionnée.');
	}

	$m = Mailings::create(f('subject'), $target, $target_id);
	Utils::redirectDialog('!users/mailing/write.php?id=' . $m->id());
}, $csrf_key);

if ($target == 'category') {
	$tpl->assign('categories', Categories::listWithStats(Categories::WITHOUT_HIDDEN));
}
elseif ($target == 'service') {
	$tpl->assign('services', Services::listWithStats(true));
}
elseif ($target == 'search') {
	$search_list = Search::list(SearchEntity::TARGET_USERS, Session::getUserId());
	$search_list = array_filter($search_list, fn($s) => $s->hasUserId());
	array_walk($search_list, function (&$s) {
		$s = (object) ['label' => $s->label, 'id' => $s->id, 'count' => $s->countResults()];
	});

	$tpl->assign(compact('search_list'));
}

$tpl->assign(compact('csrf_key', 'target'));

$tpl->display('users/mailing/new.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































Deleted src/www/admin/users/mailing/recipient_data.php version [7eb9cf0ebd].

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/mailing/recipient_data.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































Deleted src/www/admin/users/mailing/recipients.php version [0eab354cfc].

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/mailing/recipients.php?id=' . $mailing->id);
}

$list = $mailing->getRecipientsList();
$list->loadFromQueryString();

$tpl->assign(compact('mailing', 'list', 'csrf_key'));

$tpl->display('users/mailing/recipients.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































Deleted src/www/admin/users/mailing/rejected.php version [65ed7e261c].

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
<?php
namespace Paheko;

use Paheko\Email\Emails;
use Paheko\Entities\Email\Email;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$limit_date = new \DateTime(Email::RESEND_VERIFICATION_DELAY);

$form->runIf(f('force_queue') && !USE_CRON, function () use ($session) {
	$session->requireAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);

	Emails::runQueue();
}, null, '!membres/emails.php?forced');

$list = Emails::listRejectedUsers();
$list->loadFromQueryString();

$max_fail_count = Emails::FAIL_LIMIT;
$queue_count = Emails::countQueue();
$tpl->assign(compact('list', 'max_fail_count', 'queue_count', 'limit_date'));

$tpl->display('users/mailing/rejected.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




















































Deleted src/www/admin/users/mailing/verify.php version [0d8c005324].

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
<?php
namespace Paheko;

use Paheko\Email\Emails;

require_once __DIR__ . '/../_inc.php';

$session->requireAccess($session::SECTION_USERS, $session::ACCESS_WRITE);

$address = qg('address');
$email = Emails::getOrCreateEmail($address);

if (!$email) {
    throw new UserException('Adresse invalide');
}

if (!$email->canSendVerificationAfterFail()) {
	if ($email->optout) {
		$message = 'Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre 3 jours.';
	}
	else {
		$message = 'Il n\'est pas possible de renvoyer une vérification à cette adresse pour le moment, il faut attendre un mois.';
	}

    throw new UserException($message);
}

$csrf_key = 'send_verification';

$form->runIf('send', function () use ($email, $address) {
    $email->sendVerification($address);
}, $csrf_key, '!users/mailing/rejected.php?sent', true);

$tpl->assign(compact('csrf_key', 'email'));
$tpl->display('users/mailing/verify.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































Deleted src/www/admin/users/mailing/write.php version [70659ff0aa].

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
<?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_write';

$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/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/mailing/write.tpl');
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































































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
59
60


61
62
63
64
65
66
67

	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>
		<title>' . htmlspecialchars($title) . '</title>
		<meta charset="utf-8" />


		<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;







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>






<

>
>







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;