Changes In Branch dev Excluding Merge-Ins

This is equivalent to a diff from b82a63e4c3 to 8e3dffbfb1

2022-06-29
00:29
Change page title check-in: f364b467c1 user: bohwaz tags: trunk, stable
2022-06-27
23:21
Merge trunk into dev Leaf check-in: 8e3dffbfb1 user: bohwaz tags: dev
23:09
Add help text for GDPR compliance check-in: e063e6c329 user: bohwaz tags: dev
22:50
Add missing mandatory mention in service form check-in: b82a63e4c3 user: bohwaz tags: trunk, stable
22:29
Add user name to mailing recipients export check-in: 9be5f8f930 user: bohwaz tags: trunk, stable

Modified src/VERSION from [70b0e3e1dd] to [f213ed5661].

1
1.1.26
|
1
1.2.0

Modified src/config.dist.php from [f1c00bfd4c] to [b45c4504c6].

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
 * la recopiant à la place du fichier association.sqlite
 *
 * Défaut : true
 */

//const ALLOW_MODIFIED_IMPORT = true;

/**
 * Doit-on suggérer à l'utilisateur d'utiliser la version chiffrée du site ?
 *
 * 1 ou true = affiche un message de suggestion sur l'écran de connexion invitant à utiliser le site chiffré
 * (conseillé si vous avez un certificat auto-signé ou peu connu type CACert)
 * 2 = rediriger automatiquement sur la version chiffrée pour l'administration (mais pas le site public)
 * 3 = rediriger automatiquement sur la version chiffrée pour administration et site public
 * false ou 0 = aucune version chiffrée disponible, donc ne rien proposer ni rediriger
 *
 * Défaut : false
 */

//const PREFER_HTTPS = false;

/**
 * Répertoire où se situe le code source de Garradin
 *
 * Défaut : répertoire racine de Garradin (__DIR__)
 */

//const ROOT = __DIR__;







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







54
55
56
57
58
59
60














61
62
63
64
65
66
67
 * la recopiant à la place du fichier association.sqlite
 *
 * Défaut : true
 */

//const ALLOW_MODIFIED_IMPORT = true;















/**
 * Répertoire où se situe le code source de Garradin
 *
 * Défaut : répertoire racine de Garradin (__DIR__)
 */

//const ROOT = __DIR__;
520
521
522
523
524
525
526







527
528
529
530
531
532
533
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint. Si aucune solution n'est disponible,
 * une erreur sera levée.
 *
 * %1$s sera remplacé par le chemin du fichier HTML, et %2$s par le chemin du fichier PDF.
 *
 * Exemple : chromium --headless --print-to-pdf=%2$s %1$s
 *







 * Défaut : null
 */
//const PDF_COMMAND = 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s';

/**
 * CALC_CONVERT_COMMAND
 * Outil de conversion de formats de tableur vers un format propriétaire







>
>
>
>
>
>
>







506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
 * PrinceXML, Chromium, wkhtmltopdf ou weasyprint. Si aucune solution n'est disponible,
 * une erreur sera levée.
 *
 * %1$s sera remplacé par le chemin du fichier HTML, et %2$s par le chemin du fichier PDF.
 *
 * Exemple : chromium --headless --print-to-pdf=%2$s %1$s
 *
 * Il est aussi possible de simplement spécifier le nom du programme à utiliser,
 * et Garradin placera les arguments automatiquement : prince, chromium, wkhtmltopdf ou weasyprint.
 *
 * Si vous utilisez Prince, un message mentionnant l'utilisation de Prince
 * sera joint aux e-mails utilisant des fichiers PDF, conformément à la licence :
 * https://www.princexml.com/purchase/license_faq/#non-commercial
 *
 * Défaut : null
 */
//const PDF_COMMAND = 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s';

/**
 * CALC_CONVERT_COMMAND
 * Outil de conversion de formats de tableur vers un format propriétaire
584
585
586
587
588
589
590











 * Il faut recopier cette clé dans le fichier config.local.php
 * dans la constante CONTRIBUTOR_LICENSE.
 *
 * Merci de ne pas essayer de contourner cette licence et de contribuer au
 * financement de notre travail :-)
 */
//const CONTRIBUTOR_LICENSE = 'XXXXX';


















>
>
>
>
>
>
>
>
>
>
>
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
 * Il faut recopier cette clé dans le fichier config.local.php
 * dans la constante CONTRIBUTOR_LICENSE.
 *
 * Merci de ne pas essayer de contourner cette licence et de contribuer au
 * financement de notre travail :-)
 */
//const CONTRIBUTOR_LICENSE = 'XXXXX';

/**
 * Ligne légale sur le pied de page du site public
 *
 * Ce texte (HTML) est affiché en bas des pages du site public.
 * Utile pour indiquer les mentions légales obligatoires
 * Le %1$s est remplacé par le nom de l'association, %2$s par son adresse.
 *
 * Défaut : "Hébergé par nom_association, adresse_association | Propulsé par Garradin"
 */
//const LEGAL_LINE = 'Propulsé par <a href="https://garradin.eu/" id="garradin" target="_blank">Garradin</a> — logiciel libre de gestion d\'association | Hébergé par <strong>%1$s</strong>, %2$s';

Deleted src/include/data/1.0.0-beta6_migration.sql version [13c95a32fe].

1
2
3
4
5
6
7
8
9
10
11
12
13
ALTER TABLE fichiers_membres RENAME TO fichiers_membres_old;
ALTER TABLE fichiers_wiki_pages RENAME TO fichiers_wiki_pages_old;
ALTER TABLE fichiers_acc_transactions RENAME TO fichiers_acc_transactions_old;

.read 1.0.0_schema.sql

INSERT INTO fichiers_membres SELECT * FROM fichiers_membres_old;
INSERT INTO fichiers_wiki_pages SELECT * FROM fichiers_wiki_pages_old;
INSERT INTO fichiers_acc_transactions SELECT * FROM fichiers_acc_transactions_old;

DROP TABLE fichiers_membres_old;
DROP TABLE fichiers_wiki_pages_old;
DROP TABLE fichiers_acc_transactions_old;
<
<
<
<
<
<
<
<
<
<
<
<
<


























Deleted src/include/data/1.0.0-beta8_migration.sql version [ff1b70a076].

1
2
UPDATE acc_accounts SET type = 11 WHERE code = '120';
UPDATE acc_accounts SET type = 12 WHERE code = '129';
<
<




Deleted src/include/data/1.0.0-rc10_migration.sql version [ab6262425c].

1
2
3
4
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%');
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%');

UPDATE acc_accounts SET position = 3 WHERE code IN ('5112', '5115', '530') AND code IS NOT NULL;
<
<
<
<








Deleted src/include/data/1.0.0-rc14_migration.sql version [2be7aec0e4].

1
2
3
4
-- Put 890, 891 accounts in balance sheet, though it's not really correct...
UPDATE acc_accounts SET position = 3 WHERE
	code IN (890, 891)
	AND id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL);
<
<
<
<








Deleted src/include/data/1.0.0-rc16_migration.sql version [2408d33901].

1
2
UPDATE acc_accounts SET position = 1 WHERE code = '486' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
UPDATE acc_accounts SET position = 2 WHERE code = '487' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
<
<




Deleted src/include/data/1.0.0-rc3_migration.sql version [46b4d521e5].

1
UPDATE acc_transactions SET type = 0 WHERE type = 6;
<


Deleted src/include/data/1.0.0_migration.sql version [5b1e5033eb].

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
ALTER TABLE membres_operations RENAME TO membres_operations_old;
ALTER TABLE membres_categories RENAME TO membres_categories_old;

DROP TABLE fichiers_compta_journal; -- Inutilisé à ce jour

-- Fix: comptes de clôture et fermeture
UPDATE compta_comptes SET libelle = 'Bilan d''ouverture' WHERE id = '890' AND libelle = 'Bilan de clôture';
INSERT OR REPLACE INTO compta_comptes (id, parent, libelle, position) VALUES ('891', '89', 'Bilan de clôture', 0);

-- N'est pas utilisé
DELETE FROM config WHERE cle = 'categorie_dons' OR cle = 'categorie_cotisations';

.read 1.0.0_schema.sql

-------- MIGRATION COMPTA ---------
INSERT INTO acc_charts (id, country, code, label) VALUES (1, 'FR', 'PCGA1999', 'Plan comptable associatif 1999');

-- Migration comptes de code comme identifiant à ID unique
-- Inversement valeurs actif/passif et produit/charge
INSERT INTO acc_accounts (id, id_chart, code, label, position, user)
	SELECT NULL, 1, id, libelle,
	CASE
		WHEN position = 1 THEN 2
		WHEN position = 2 THEN 1
		WHEN position = 3 THEN 3
		WHEN position = 4 THEN 5
		WHEN position = 8 THEN 4
		-- Suppression de la position "charge ou produit" qui n'a aucun sens
		WHEN position = 12 AND id LIKE '6%' THEN 4
		WHEN position = 12 AND id LIKE '7%' THEN 5
		WHEN position = 12 THEN 0
		ELSE 0
	END,
	CASE WHEN plan_comptable = 1 THEN 0 ELSE 1 END
	FROM compta_comptes;

-- Migrations projets vers comptes analytiques
INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	VALUES (1, '99', 'Projets', 0, 1, 0);

INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	SELECT 1, '99' || substr('0000' || id, -4), libelle, 0, 1, 7 FROM compta_projets;

-- Mise à jour de la position pour les comptes de tiers qui peuvent varier actif ou passif
UPDATE acc_accounts SET position = 3 WHERE code IN (4010, 4110, 4210, 428, 438);

-- Mise à jour position comptes bancaires, qui peuvent être à découvert et donc changer de côté au bilan
UPDATE acc_accounts SET position = 3 WHERE code LIKE '512%';

-- Migration comptes bancaires
UPDATE acc_accounts SET type = 1 WHERE code IN (SELECT id FROM compta_comptes_bancaires);

-- Caisse
UPDATE acc_accounts SET type = 2 WHERE code = '530';

-- Chèques et carte à encaisser
UPDATE acc_accounts SET type = 3 WHERE code = '5112' OR code = '5113';

-- Comptes d'ouverture et de clôture
UPDATE acc_accounts SET type = 9, position = 0 WHERE code = '890';
UPDATE acc_accounts SET type = 10, position = 0 WHERE code = '891';

-- Comptes de tiers
UPDATE acc_accounts SET type = 4 WHERE code IN (SELECT id FROM compta_comptes WHERE id LIKE '4%' AND plan_comptable = 0 AND desactive = 0);

-- Recopie des mouvements
INSERT INTO acc_transactions (id, label, notes, reference, date, id_year, id_creator)
	SELECT id, libelle, remarques, numero_piece, date, id_exercice, id_auteur
	FROM compta_journal;

-- Recettes
UPDATE acc_transactions SET type = 1 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = 1));

-- Dépenses
UPDATE acc_transactions SET type = 2 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = -1));

-- Virements
UPDATE acc_transactions SET type = 3 WHERE id IN (SELECT id FROM compta_journal
	WHERE (compte_credit IN ('530', '5112', '5115') OR compte_credit LIKE '512%')
	AND (compte_debit IN ('530', '5112', '5115') OR compte_debit LIKE '512%'));

-- Dettes
UPDATE acc_transactions SET type = 4 WHERE id IN (SELECT id FROM compta_journal WHERE compte_debit LIKE '6%' AND compte_credit LIKE '4%');

-- Créances
UPDATE acc_transactions SET type = 5 WHERE id IN (SELECT id FROM compta_journal WHERE compte_credit LIKE '7%' AND compte_debit LIKE '4%');

-- Création des lignes associées aux mouvements
INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_credit), 0, CAST(REPLACE(montant * 100, '.0', '') AS INT), numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_debit), CAST(REPLACE(montant * 100, '.0', '') AS INT), 0, numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

-- Recopie des descriptions de catégories dans la table des comptes, et mise des comptes en signets
-- +Fix éventuels types qui ne correspondent pas à leur type… (@Fred C.) (... a.position = X)
-- Revenus
UPDATE acc_accounts SET type = 6, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = 1 AND a.position = 5);

-- Dépenses
UPDATE acc_accounts SET type = 5, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte NOT LIKE '4%' AND a.position = 4);

-- Tiers
UPDATE acc_accounts SET type = 4, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte LIKE '4%');

-- Recopie des exercices, mais la date de fin ne peut être nulle
INSERT INTO acc_years (id, label, start_date, end_date, closed, id_chart)
	SELECT id, libelle, debut, CASE WHEN fin IS NULL THEN date(debut, '+1 year') ELSE fin END, cloture, 1 FROM compta_exercices;

-- Recopie des catégories, on supprime la colonne id_cotisation_obligatoire
INSERT INTO membres_categories
	SELECT id, nom, droit_wiki, droit_membres, droit_compta, droit_inscription, droit_connexion, droit_config, cacher FROM membres_categories_old;

DROP TABLE membres_categories_old;

-- Transfert des rapprochements
UPDATE acc_transactions_lines SET reconciled = 1 WHERE id_transaction IN (SELECT id_operation FROM compta_rapprochement);

--------- MIGRATION COTISATIONS ----------

-- A edge-case where the end date is after the start date, let's fix it…
UPDATE cotisations SET fin = debut WHERE fin < debut;
UPDATE cotisations SET duree = NULL WHERE duree = 0;

INSERT INTO services SELECT id, intitule, description, duree, debut, fin FROM cotisations;

INSERT INTO services_fees (id, label, amount, id_service, id_account, id_year)
	SELECT id, intitule, CASE WHEN montant IS NOT NULL THEN CAST(montant*100 AS integer) ELSE NULL END, id,
		(SELECT id FROM acc_accounts WHERE code = (SELECT compte FROM compta_categories WHERE id = id_categorie_compta)),
		(SELECT MAX(id) FROM acc_years GROUP BY closed ORDER BY closed LIMIT 1)
	FROM cotisations;

INSERT INTO services_users SELECT cm.id, cm.id_membre, cm.id_cotisation,
	cm.id_cotisation,
	1,
	NULL,
	cm.date,
	CASE
		WHEN c.duree IS NOT NULL THEN date(cm.date, '+'||c.duree||' days')
		WHEN c.fin IS NOT NULL THEN c.fin
		ELSE NULL
	END
	FROM cotisations_membres cm
	INNER JOIN cotisations c ON c.id = cm.id_cotisation;

INSERT INTO services_reminders SELECT * FROM rappels;
INSERT INTO services_reminders_sent SELECT id, id_membre, id_cotisation,
	CASE WHEN id_rappel IS NULL THEN (SELECT MAX(id) FROM rappels) ELSE id_rappel END, date
	FROM rappels_envoyes
	WHERE id_rappel IS NOT NULL
	GROUP BY id_membre, id_cotisation, id_rappel;

-- Recopie des opérations par membre, mais le nom a changé pour acc_transactions_users, et il faut valider l'existence du membre ET du service
INSERT INTO acc_transactions_users
	SELECT a.* FROM membres_operations_old a
	INNER JOIN membres b ON b.id = a.id_membre
	INNER JOIN services_users c ON c.id = a.id_cotisation;

DROP TABLE cotisations;
DROP TABLE cotisations_membres;
DROP TABLE rappels;
DROP TABLE rappels_envoyes;

-- Suppression inutilisées
DROP TABLE compta_rapprochement;
DROP TABLE compta_journal;
DROP TABLE compta_categories;
DROP TABLE compta_comptes;
DROP TABLE compta_exercices;
DROP TABLE membres_operations_old;

DROP TABLE compta_projets;
DROP TABLE compta_comptes_bancaires;
DROP TABLE compta_moyens_paiement;

INSERT INTO acc_charts (country, code, label) VALUES ('FR', 'PCA2018', 'Plan comptable associatif 2018');

CREATE TEMP TABLE tmp_accounts (code,label,description,position,type);

.import charts/fr_pca_2018.csv tmp_accounts

INSERT INTO acc_accounts (id_chart, code, label, description, position, type) SELECT
	(SELECT id FROM acc_charts WHERE code = 'PCA2018'),
	code, label, description,
	CASE position
		WHEN 'Actif' THEN 1
		WHEN 'Passif' THEN 2
		WHEN 'Actif ou passif' THEN 3
		WHEN 'Charge' THEN 4
		WHEN 'Produit' THEN 5
		ELSE 0
	END,
	CASE type
		WHEN 'Banque' THEN 1
		WHEN 'Caisse' THEN 2
		WHEN 'Attente d''encaissement' THEN 3
		WHEN 'Tiers' THEN 4
		WHEN 'Dépenses' THEN 5
		WHEN 'Recettes' THEN 6
		WHEN 'Analytique' THEN 7
		WHEN 'Bénévolat' THEN 8
		WHEN 'Ouverture' THEN 9
		WHEN 'Clôture' THEN 10
		WHEN 'Résultat excédentaire' THEN 11
		WHEN 'Résultat déficitaire' THEN 12
		ELSE 0
	END
	FROM tmp_accounts;

DROP TABLE tmp_accounts;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































































































































































































































































































































































Deleted src/include/data/1.0.0_schema.sql version [292ae06778].

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
CREATE TABLE IF NOT EXISTS config (
-- Configuration de Garradin
    cle TEXT PRIMARY KEY NOT NULL,
    valeur TEXT
);

CREATE TABLE IF NOT EXISTS membres_categories
-- Catégories de membres
(
    id INTEGER PRIMARY KEY NOT NULL,
    nom TEXT NOT NULL,

    droit_wiki INTEGER NOT NULL DEFAULT 1,
    droit_membres INTEGER NOT NULL DEFAULT 1,
    droit_compta INTEGER NOT NULL DEFAULT 1,
    droit_inscription INTEGER NOT NULL DEFAULT 0,
    droit_connexion INTEGER NOT NULL DEFAULT 1,
    droit_config INTEGER NOT NULL DEFAULT 0,
    cacher INTEGER NOT NULL DEFAULT 0
);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
(
    selecteur TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selecteur, id_membre)
);

CREATE TABLE IF NOT EXISTS services
-- Types de services (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
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    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 si le type n'est pas associé automatiquement à la compta
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (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,

    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, 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
-- Rappels de devoir renouveller une cotisation
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

    delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel

    subject TEXT NOT NULL,
    body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (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,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, 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);

--
-- WIKI
--

CREATE TABLE IF NOT EXISTS wiki_pages
-- Pages du wiki
(
    id INTEGER PRIMARY KEY NOT NULL,
    uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
    titre TEXT NOT NULL,
    date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
    date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
    parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
    revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
    droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
    droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
);

CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);

CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
-- Table dupliquée pour chercher une page
(
    id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
    titre TEXT NOT NULL,
    contenu TEXT NULL, -- Contenu de la dernière révision
    FOREIGN KEY (id) REFERENCES wiki_pages(id)
);

CREATE TABLE IF NOT EXISTS wiki_revisions
-- Révisions du contenu des pages
(
    id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    revision INTEGER NULL,

    id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,

    contenu TEXT NOT NULL,
    modification TEXT NULL, -- Description des modifications effectuées
    chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
    date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),

    PRIMARY KEY(id_page, revision)
);

CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);

-- Triggers pour synchro avec table wiki_pages
CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
    BEGIN
        DELETE FROM wiki_recherche WHERE id = old.id;
    END;

CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
    BEGIN
        UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
    END;

-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
    BEGIN
        UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
    END;

-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
    BEGIN
        UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
    END;

--
-- COMPTA
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Plans comptables : il peut y en avoir plusieurs
(
    id INTEGER NOT NULL PRIMARY KEY,
    country TEXT NOT NULL,
    code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
    label TEXT NOT NULL,
    archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Comptes des plans comptables
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,

    code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.

    label TEXT NOT NULL,
    description TEXT NULL,

    position INTEGER NOT NULL, -- position actif/passif/charge/produit
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 1 = fait partie du plan comptable original, 0 = a été ajouté par l'utilisateur
);

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 TABLE IF NOT EXISTS acc_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,

    id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

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 INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

CREATE TABLE IF NOT EXISTS acc_transactions
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
    status INTEGER NOT NULL DEFAULT 0, -- Statut (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),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable

    hash TEXT NULL,
    prev_hash TEXT NULL,

    id_year INTEGER NOT NULL REFERENCES acc_years(id),
    id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
);

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_related ON acc_transactions (id_related);
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 TABLE IF NOT EXISTS acc_transactions_lines
-- Lignes d'écritures d'une opération
(
    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), -- N° du compte dans le plan comptable

    credit INTEGER NOT NULL,
    debit INTEGER NOT NULL,

    reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
    label TEXT NULL,

    reconciled INTEGER NOT NULL DEFAULT 0,

    id_analytical INTEGER NULL REFERENCES acc_accounts(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_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Liaison des écritures et des membres
(
    id_user INTEGER NOT NULL REFERENCES membres (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)
);

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

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    officiel INTEGER NOT NULL DEFAULT 0,
    nom TEXT NOT NULL,
    description TEXT NULL,
    auteur TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0,
    menu_condition TEXT NULL,
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signaux
-- Association entre plugins et signaux (hooks)
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS fichiers
-- Données sur les fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
    type TEXT NULL, -- Type MIME
    image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
    datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
    id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);

CREATE TABLE IF NOT EXISTS fichiers_contenu
-- Contenu des fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
    taille INTEGER NOT NULL, -- Taille en octets
    contenu BLOB NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);

CREATE TABLE IF NOT EXISTS fichiers_membres
-- Associations entre fichiers et membres (photo de profil par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
-- Associations entre fichiers et pages du wiki
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_acc_transactions
-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
    intitule TEXT NOT NULL,
    creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
    cible TEXT NOT NULL, -- "membres" ou "compta"
    type TEXT NOT NULL, -- "json" ou "sql"
    contenu 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
);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/data/1.0.1_migration.sql version [d72d553917].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UPDATE acc_accounts SET position = 3 WHERE code = '445' OR code = '444' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCGA1999');

UPDATE acc_transactions SET label = '[ERREUR ! À corriger !] ' || label, status = 8 WHERE id IN (
	SELECT DISTINCT t.id
		FROM acc_transactions t
		INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
		INNER JOIN acc_accounts a ON l.id_account = a.id
		INNER JOIN acc_years y ON y.id = t.id_year AND y.closed = 0 AND y.id_chart != a.id_chart
);

UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_account IN (
	SELECT sf.id_account FROM services_fees sf
		INNER JOIN acc_accounts a ON a.id = sf.id_account
		INNER JOIN acc_years y ON y.id = sf.id_year
	WHERE a.id_chart != y.id_chart);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























Deleted src/include/data/1.0.3_migration.sql version [9ded2b030d].

1
2
-- Fix services connected to old closed year
UPDATE services_fees SET id_year = NULL, id_account = NULL WHERE id_year IN (SELECT id FROM acc_years WHERE closed = 1);
<
<




Deleted src/include/data/1.0.6_migration.sql version [6e98c5399a].

1
2
-- Fix credit/debt payment types
UPDATE acc_transactions SET type = 0 WHERE id_related IS NOT NULL AND type IN (4,5);
<
<




Deleted src/include/data/1.0.7_migration.sql version [f53124e2db].

1
2
3
4
5
-- Add indexes
DROP INDEX IF EXISTS acc_transactions_type;
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
<
<
<
<
<










Deleted src/include/data/1.1.0_migration.sql version [9f5de55bec].

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
-- Remove triggers in case they interact with the migration
DROP TRIGGER IF EXISTS wiki_recherche_delete;
DROP TRIGGER IF EXISTS wiki_recherche_update;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_insert;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_chiffre;

-- Fix some rare edge cases where date_inscription is incorrect
UPDATE membres SET date_inscription = date() WHERE date(date_inscription) IS NULL;

-- Uh, force another login id if email is not correct
UPDATE config SET valeur = 'numero' WHERE cle = 'champ_identifiant' AND valeur = 'email'
	AND (SELECT COUNT(*) FROM membres WHERE email IS NOT NULL GROUP BY LOWER(email) HAVING COUNT(*) > 1 LIMIT 1);

-- Other weird things to fix before migration
UPDATE wiki_pages SET uri = 'page_' || id WHERE uri = '' OR uri IS NULL;
DELETE FROM config WHERE cle = 'connexion' OR cle = 'wiki';

ALTER TABLE membres_categories RENAME TO membres_categories_old;

INSERT OR IGNORE INTO config (cle, valeur) VALUES ('desactiver_site', '0');
ALTER TABLE config RENAME TO config_old;

.read 1.1.0_schema.sql

INSERT INTO config SELECT * FROM config_old;
DROP TABLE config_old;

-- This is not used anymore
DELETE FROM config WHERE key = 'version';
DELETE FROM config WHERE key = 'accueil_wiki';

-- New config key
INSERT INTO config (key, value) VALUES ('telephone_asso', NULL);

-- Create directories
INSERT INTO files (parent, name, path, type) VALUES ('', 'documents', 'documents', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'config', 'config', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'transaction', 'transaction', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'skel', 'skel', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'user', 'user', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'web', 'web', 2);

-- Copy droit_wiki value to droit_web and droit_documents
INSERT INTO users_categories
	SELECT id, nom,
		droit_wiki, -- perm_web
		droit_wiki, -- perm_documents
		droit_membres,
		droit_compta,
		droit_inscription,
		droit_connexion,
		droit_config,
		cacher
	FROM membres_categories_old;

DROP TABLE membres_categories_old;

UPDATE recherches SET contenu = REPLACE(contenu, 'id_categorie', 'id_category') WHERE cible = 'membres' AND contenu LIKE '%id_categorie%';

CREATE TEMP TABLE files_transactions (old_id, old_transaction, old_name, new_path, new_id, same_name);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_transactions
	SELECT f.id, t.id, f.nom, NULL, NULL, NULL
	FROM fichiers f
		INNER JOIN fichiers_acc_transactions t ON t.fichier = f.id;

UPDATE files_transactions SET same_name = old_id || '_'
	WHERE old_id IN (SELECT old_id FROM files_transactions GROUP BY old_transaction, old_name HAVING COUNT(*) > 1);

-- Make file name is unique!
UPDATE files_transactions SET new_path = 'transaction/' || old_transaction || '/' || COALESCE(old_id || '_', '') || old_name;

-- Copy existing files for transactions
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		ft.new_path,
		dirname(ft.new_path),
		basename(ft.new_path),
		1, f.type, f.datetime, c.taille, f.image
	FROM files_transactions ft
		INNER JOIN fichiers f ON f.id = ft.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

UPDATE files_transactions SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT ft.new_id, 0, c.contenu
	FROM fichiers f
		INNER JOIN files_transactions ft ON ft.old_id = f.id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Copy wiki pages content
CREATE TEMP TABLE wiki_as_files (old_id, new_id, path, content, title, uri,
	old_parent, new_parent, created, modified, author_id, encrypted, type, public);

INSERT INTO wiki_as_files
	SELECT
		id, NULL, '', CASE WHEN contenu IS NULL THEN '' ELSE contenu END, titre, uri,
		parent, parent, date_creation, date_modification, id_auteur, chiffrement,
		CASE WHEN (SELECT 1 FROM wiki_pages pp WHERE pp.parent = p.id LIMIT 1) THEN 1 ELSE 2 END, -- Type, 1 = category, 2 = page
		CASE WHEN droit_lecture = -1 THEN 1 ELSE 0 END -- public
	FROM wiki_pages p
	LEFT JOIN wiki_revisions r ON r.id_page = p.id AND r.revision = p.revision;

-- Build path
WITH RECURSIVE path(level, uri, parent, id) AS (
	SELECT 0, uri, old_parent, old_id
	FROM wiki_as_files
	UNION ALL
	SELECT path.level + 1,
	wiki_as_files.uri,
	wiki_as_files.old_parent,
	path.id
	FROM wiki_as_files
	JOIN path ON wiki_as_files.old_id = path.parent
	WHERE level <= 8 -- max level = 8 to avoid recursion
),
path_from_root AS (
	SELECT group_concat(uri, '/') AS path, id
	FROM (SELECT id, uri FROM path ORDER BY level DESC)
	GROUP BY id
)
UPDATE wiki_as_files SET path = (SELECT path FROM path_from_root WHERE id = wiki_as_files.old_id);

-- remove recursion
UPDATE wiki_as_files SET path = uri WHERE path IS NULL OR LENGTH(path) - LENGTH(REPLACE(path, '/', '')) >= 8;

-- Copy into files
INSERT INTO files (path, parent, name, type, mime, modified, size)
	SELECT
		'web/' || path || '/index.txt',
		'web/' || path,
		'index.txt',
		1,
		'text/plain',
		modified,
		0 -- size will be set after
	FROM wiki_as_files;

UPDATE wiki_as_files SET new_id = (SELECT id FROM files WHERE files.path = 'web/' || (CASE WHEN wiki_as_files.path IS NOT NULL THEN wiki_as_files.path || '/' ELSE '' END) || wiki_as_files.uri || '/index.txt');

-- x'0a' == \n
INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0,
		'Title: ' || title || x'0a' || 'Published: ' || created || x'0a' || 'Status: '
		|| (CASE WHEN public THEN 'Online' ELSE 'Draft' END)
		|| x'0a' || 'Format: ' || (CASE WHEN encrypted THEN 'Skriv/Encrypted' ELSE 'Skriv' END)
		|| x'0a' || x'0a' || '----' || x'0a' || x'0a' || content
	FROM wiki_as_files waf
	INNER JOIN files f ON f.path = 'web/' || waf.path || '/index.txt';

-- Copy to search
INSERT INTO files_search (path, title, content)
	SELECT
		'web/' || path || '/index.txt',
		title,
		CASE WHEN encrypted THEN NULL ELSE content END
	FROM wiki_as_files WHERE encrypted = 0;

-- Copy to web_pages
INSERT INTO web_pages (id, parent, path, uri, file_path, type, status, title, published, modified, format, content)
	SELECT new_id,
	CASE WHEN dirname(path) = '.' THEN '' ELSE dirname(path) END,
	path,
	uri,
	'web/' || path || '/index.txt',
	type,
	CASE WHEN public THEN 'online' ELSE 'draft' END,
	title, created, modified,
	CASE WHEN encrypted THEN 'skriv/encrypted' ELSE 'skriv' END,
	content
	FROM wiki_as_files;

CREATE TEMP TABLE files_wiki (old_id, wiki_id, web_path, old_name, new_path, new_id);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_wiki
	SELECT f.id, w.id, waf.path, f.nom, 'web/' || waf.path || '/' || f.id || '_' || f.nom, NULL
	FROM fichiers f
		INNER JOIN fichiers_wiki_pages w ON w.fichier = f.id
		INNER JOIN wiki_as_files waf ON w.id = waf.old_id;

-- Copy files linked to wiki pages
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		fw.new_path,
		dirname(fw.new_path),
		basename(fw.new_path),
		1,
		f.type,
		f.datetime,
		c.taille,
		f.image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		INNER JOIN files_wiki fw ON fw.old_id = f.id;

UPDATE files_wiki SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT
		fw.new_id, 0, c.contenu
	FROM files_wiki fw
		INNER JOIN fichiers f ON f.id = fw.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Create parent directories
INSERT INTO files (type, path, parent, name, modified)
	SELECT 2,
		'web/' || waf.path,
		dirname('web/' || waf.path),
		waf.uri,
		modified
	FROM wiki_as_files waf;

INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;

-- Copy existing config files
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_bg.png', 'config', 'admin_bg.png', 1, type, datetime, c.taille, image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
	WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

INSERT INTO files_contents (id, compressed, content)
	SELECT f2.id, 0, c.contenu
	FROM files AS f2
		INNER JOIN fichiers f ON f2.path = 'config/admin_bg.png'
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

-- Rename
UPDATE config SET key = 'admin_background', value = 'config/admin_bg.png' WHERE key = 'image_fond';

-- Copy connection page as a single file
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_homepage.skriv', 'config', 'admin_homepage.skriv', 1, 'text/plain', datetime(), LENGTH(content), 0
	FROM wiki_as_files
	WHERE uri = (SELECT value FROM config WHERE key = 'accueil_connexion');

INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0, waf.content
	FROM files f
		INNER JOIN wiki_as_files waf ON waf.uri = (SELECT value FROM config WHERE key = 'accueil_connexion')
	WHERE f.path = 'config/admin_homepage.skriv';

-- Rename
UPDATE config SET key = 'admin_homepage', value = 'config/admin_homepage.skriv' WHERE key = 'accueil_connexion';
UPDATE config SET key = 'site_disabled' WHERE key = 'desactiver_site';

-- Create transaction directories
INSERT INTO files (path, parent, name, type) SELECT 'transaction/' || id, 'transaction', id, 2 FROM fichiers_acc_transactions GROUP BY id;

-- Set file size
UPDATE files SET size = (SELECT LENGTH(content) FROM files_contents WHERE id = files.id) WHERE type = 1;

DELETE FROM plugins_signaux WHERE signal LIKE 'boucle.%';

DROP TABLE wiki_recherche;

DROP TABLE wiki_pages;
DROP TABLE wiki_revisions;

DROP TABLE fichiers_wiki_pages;
DROP TABLE fichiers_acc_transactions;
DROP TABLE fichiers_membres;

DROP TABLE fichiers;
DROP TABLE fichiers_contenu;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































































































Deleted src/include/data/1.1.15_migration.sql version [f806b6152c].

1
2
3
4
5
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%') AND user = 0;
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%') AND user = 0;

-- Cohérence avec plan 2018
UPDATE acc_charts SET code = 'PCA1999' WHERE code = 'PCGA1999';
<
<
<
<
<










Deleted src/include/data/1.1.19_migration.sql version [a29354b289].

1
2
3
UPDATE acc_accounts SET type = 13 WHERE type = 0 AND code = '1068';
UPDATE acc_accounts SET type = 14 WHERE type = 0 AND code = '110';
UPDATE acc_accounts SET type = 15 WHERE type = 0 AND code = '119';
<
<
<






Deleted src/include/data/1.1.3_migration.sql version [18eac8e4eb].

1
2
3
4
-- 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;
<
<
<
<








Deleted src/include/data/1.1.7_migration.sql version [1dab4145d3].

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
ALTER TABLE services_reminders_sent RENAME TO srs_old;

-- Add new column in services_reminders_sent
CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (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);

INSERT INTO services_reminders_sent SELECT id, id_user, id_service, id_reminder, date, date FROM srs_old;
DROP TABLE srs_old;

-- Missing acc_years_delete trigger, again, because of missing symlink in previous release
-- 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;

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




























































Deleted src/include/data/1.1.8_migration.sql version [cc434f178d].

1
2
3
4
5
-- Remove any leftover duplicates
DELETE FROM web_pages WHERE id IN (SELECT id FROM web_pages GROUP BY uri HAVING COUNT(*) > 1);

-- Add unique index
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
<
<
<
<
<










Added src/include/data/1.2.0_migration.sql version [30a98b321f].















































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
-- Already created before, so we need to drop it to migrate
DROP TABLE plugins_signals;

-- The new users table has already been created and copied
ALTER TABLE plugins RENAME TO plugins_old;
ALTER TABLE plugins_signaux RENAME TO plugins_signaux_old;

-- References old membres table
ALTER TABLE services_users RENAME TO services_users_old;
ALTER TABLE services_reminders_sent RENAME TO services_reminders_sent_old;
ALTER TABLE acc_transactions RENAME TO acc_transactions_old;
ALTER TABLE acc_transactions_users RENAME TO acc_transactions_users_old;

DROP VIEW acc_accounts_balances;

.read 1.2.0_schema.sql

INSERT INTO users_sessions SELECT * FROM membres_sessions;
DROP TABLE membres_sessions;

INSERT INTO services_users SELECT * FROM services_users_old;

INSERT INTO services_reminders_sent SELECT * FROM services_reminders_sent_old;

INSERT INTO acc_transactions SELECT * FROM acc_transactions_old;

INSERT INTO acc_transactions_users SELECT * FROM acc_transactions_users_old;

DROP TABLE services_reminders_sent_old;
DROP TABLE acc_transactions_users_old;
DROP TABLE acc_transactions_old;
DROP TABLE services_users_old;

INSERT INTO plugins SELECT * FROM plugins_old;
INSERT INTO plugins_signals SELECT * FROM plugins_signaux_old;

DROP TABLE plugins_signaux_old;
DROP TABLE plugins_old;

INSERT INTO searches SELECT * FROM recherches;
UPDATE searches SET target = 'accounting' WHERE target = 'compta';
UPDATE searches SET target = 'users' WHERE target = 'membres';

DROP TABLE recherches;

INSERT INTO config VALUES ('log_retention', 720);
INSERT INTO config VALUES ('log_anonymize', 365);

-- Rename config keys to english
UPDATE config SET key = 'default_category' WHERE key = 'categorie_membres';
UPDATE config SET key = 'color1' WHERE key = 'couleur1';
UPDATE config SET key = 'color2' WHERE key = 'couleur2';
UPDATE config SET key = 'country' WHERE key = 'pays';
UPDATE config SET key = 'currency' WHERE key = 'monnaie';
UPDATE config SET key = 'backup_frequency' WHERE key = 'frequence_sauvegardes';
UPDATE config SET key = 'backup_limit' WHERE key = 'nombre_sauvegardes';

UPDATE config SET key = 'org_name' WHERE key = 'nom_asso';
UPDATE config SET key = 'org_address' WHERE key = 'adresse_asso';
UPDATE config SET key = 'org_email' WHERE key = 'email_asso';
UPDATE config SET key = 'org_phone' WHERE key = 'telephone_asso';
UPDATE config SET key = 'org_web' WHERE key = 'site_asso';

-- This is now part of the config_users_fields table
DELETE FROM config WHERE key IN ('champs_membres', 'champ_identite', 'champ_identifiant');

-- Seems that some installations had this leftover? Let's just drop it.
DROP TABLE IF EXISTS srs_old;

-- Drop membres
DROP TABLE IF EXISTS membres;

Added src/include/data/1.2.0_schema.sql version [bbc725b503].

































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
---
--- 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,
    read_access INTEGER NOT NULL DEFAULT 0,
    write_access INTEGER NOT NULL DEFAULT 1,
    list_table INTEGER NOT NULL DEFAULT 0,
    options TEXT NULL,
    default_value 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 TEXT NOT NULL PRIMARY KEY,
    official INTEGER NOT NULL DEFAULT 0, -- 1 if plugin is official
    name TEXT NOT NULL,
    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0, -- 1 if plugin should be shown in sidebar menu
    menu_condition TEXT NULL, -- Brindille condition to know if item should be shown in menu
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

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

---
--- 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 TABLE IF NOT EXISTS users_sessions
-- Permanent sessions for logged-in users
(
    selector TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selector, id_user)
);

CREATE TABLE IF NOT EXISTS logs
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NULL REFERENCES users (id),
    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, created);
CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, 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
);

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,

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

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

-- Balance des comptes par exercice
CREATE VIEW IF NOT EXISTS acc_accounts_balances
AS
    SELECT id_year, id, label, code, type, debit, credit,
        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,
            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_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),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 means transaction is locked

    hash TEXT 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,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- linked transaction (eg. payment of a debt)
);

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_related ON acc_transactions (id_related);
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 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_analytical INTEGER NULL REFERENCES acc_accounts(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_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

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

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 NOT NULL,
    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 (ddatetime(modified) IS NOT NULL AND atetime(modified) = modified),
    image INT NOT NULL DEFAULT 0,

    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_name ON files (name);
CREATE INDEX IF NOT EXISTS files_modified ON files (modified);

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,
    compressed INT NOT NULL DEFAULT 0,
    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 NULL,
    content TEXT NOT NULL, -- Text content
    notindexed=path
);

CREATE TABLE IF NOT EXISTS web_pages
(
    id INTEGER NOT NULL PRIMARY KEY,
    parent TEXT NOT NULL, -- Parent path, empty = web root
    path TEXT NOT NULL, -- Full page directory name
    uri TEXT NOT NULL, -- Page identifier
    file_path TEXT NOT NULL, -- Full file path for contents
    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_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (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);

Deleted src/include/data/champs_membres.ini version [1af4967b02].

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
;	Ce fichier contient la configuration par défaut des champs des fiches membres.
;	La configuration est ensuite enregistrée au format INI dans la table 
;	config de la base de données.
;
;	Syntaxe :
;
;	[nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas
;	type = text
;	title = "Super champ trop cool"
;	mandatory = true
;	editable = false
;
;	Description des options possibles pour chaque champ :
;
;	type: (défaut: text) OBLIGATOIRE
;		certains types gérés par <input type> de HTML5 :
;		text, number, date, datetime, url, email, checkbox, file, password, tel
;		champs spécifiques :
;		- country = sélecteur de pays
;		- textarea = texte multi lignes
;		- multiple = multiples cases à cocher (jusqu'à 32, binaire)
;		- select = un choix parmis plusieurs
;	title: OBLIGATOIRE
;		Titre du champ
;	help:
;		Texte d'aide sur les fiches membres
;	options[]:
;		pour définir les options d'un champ de type select ou multiple
;	editable:
;		true = modifiable par le membre
;		false = modifiable uniquement par un admin (défaut)
;	mandatory:
;		true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
;		false = facultatif (défaut)
;	private:
;		true = non visible par le membre lui-même
;		false = visible par le membre (défaut)
;	list_row:
;		Si absent ou zéro ('0') ou false, ce champ n'apparaîtra pas dans la liste des membres
;		Si présent et un chiffre supérieur à 0, alors le champ apparaîtra dans la liste des membres
;		dans l'ordre défini par le chiffre (si nom est à 2 et email à 1, alors email sera
;		la première colonne et nom la seconde)
;	install:
;		true = sera ajouté aux fiches membres à l'installation
;		false = sera seulement présent dans les champs supplémentaires possibles (défaut)

[numero]
type = number
title = "Numéro de membre"
help = "Doit être unique, laisser vide pour que le numéro soit attribué automatiquement"
mandatory = false
install = true
editable = false
list_row = 1

[nom]
type = text
title = "Nom & prénom"
mandatory = true
install = true
editable = true
list_row = 2

[email]
; ce champ est facultatif et de type 'email'
type = email
title = "Adresse E-Mail"
mandatory = false
install = true
editable = true

[passe]
; ce champ est obligatoirement présent et de type 'password'
; le titre ne peut être modifié
type = password
mandatory = false
install = true
editable = true

[adresse]
type = textarea
title = "Adresse postale"
help = "Indiquer ici le numéro, le type de voie, etc."
install = true
editable = true

[code_postal]
type = text
title = "Code postal"
install = true
editable = true
list_row = 3

[ville]
type = text
title = "Ville"
install = true
editable = true
list_row = 4

[pays]
type = country
title = "Pays"
install = true
editable = true

[telephone]
type = tel
title = "Numéro de téléphone"
install = true
editable = true

[lettre_infos]
type = checkbox
title = "Inscription à la lettre d'information"
install = true
editable = true

[groupe_travail]
type = multiple
title = "Groupes de travail"
editable = false
options[] = "Télécoms"
options[] = "Trésorerie"
options[] = "Relations publiques"
options[] = "Communication presse"
options[] = "Organisation d'événements"

[date_naissance]
type = date
title = "Date de naissance"
editable = true

[notes]
type = textarea
title = "Notes"
editable = false
private = true

[photo]
type = file
title = "Photo"
editable = false
private = false
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































































































Modified src/include/data/schema.sql from [0a44b3a9cc] to [e7bdc826ab].





1

2
3
4
5










































































6
7
8
9
10
11
12




CREATE TABLE IF NOT EXISTS config (

    key TEXT PRIMARY KEY NOT NULL,
    value TEXT NULL
);











































































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

>




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







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
---
--- 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,
    read_access INTEGER NOT NULL DEFAULT 0,
    write_access INTEGER NOT NULL DEFAULT 1,
    list_table INTEGER NOT NULL DEFAULT 0,
    options TEXT NULL,
    default_value 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 TEXT NOT NULL PRIMARY KEY,
    official INTEGER NOT NULL DEFAULT 0, -- 1 if plugin is official
    name TEXT NOT NULL,
    description TEXT NULL,
    author TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0, -- 1 if plugin should be shown in sidebar menu
    menu_condition TEXT NULL, -- Brindille condition to know if item should be shown in menu
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signals
-- Link between plugins and signals
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

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

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

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
(
    selecteur TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selecteur, id_membre)
);



















CREATE TABLE IF NOT EXISTS services
-- Types de services (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

(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    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_analytical INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (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, 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
-- Rappels de devoir renouveller une cotisation
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

    delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel

    subject TEXT NOT NULL,
    body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (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);

--
-- COMPTA
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Plans comptables : il peut y en avoir plusieurs
(
    id INTEGER NOT NULL PRIMARY KEY,
    country TEXT NOT NULL,
    code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
    label TEXT NOT NULL,
    archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Comptes des plans comptables
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,

    code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.

    label TEXT NOT NULL,
    description TEXT NULL,

    position INTEGER NOT NULL, -- position actif/passif/charge/produit
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 0 = fait partie du plan comptable original, 1 = a été ajouté par l'utilisateur
);

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

-- Balance des comptes par exercice







<
<
<
<
|
|

|

|


|


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

|












>







|



|
<



|


|

|
















|




|






|



|













|



|



|

|



|




|




|
|
|







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
    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 TABLE IF NOT EXISTS users_sessions
-- Permanent sessions for logged-in users
(
    selector TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selector, id_user)
);

CREATE TABLE IF NOT EXISTS logs
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NULL REFERENCES users (id),
    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, created);
CREATE INDEX IF NOT EXISTS logs_user ON logs (id_user, 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

);

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,

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

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

-- Balance des comptes par exercice
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
        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_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,

    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
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
    status INTEGER NOT NULL DEFAULT 0, -- Statut (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),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable

    hash TEXT NULL,
    prev_hash TEXT NULL,

    id_year INTEGER NOT NULL REFERENCES acc_years(id),
    id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
);

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_related ON acc_transactions (id_related);
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 TABLE IF NOT EXISTS acc_transactions_lines
-- Lignes d'écritures d'une opération
(
    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), -- N° du compte dans le plan comptable

    credit INTEGER NOT NULL,
    debit INTEGER NOT NULL,

    reference TEXT NULL, -- férence de paiement, eg. numéro de chèque
    label TEXT NULL,

    reconciled INTEGER NOT NULL DEFAULT 0,

    id_analytical INTEGER NULL REFERENCES acc_accounts(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_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Liaison des écritures et des membres
(
    id_user INTEGER NOT NULL REFERENCES membres (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)
);

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

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    officiel INTEGER NOT NULL DEFAULT 0,
    nom TEXT NOT NULL,
    description TEXT NULL,
    auteur TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0,
    menu_condition TEXT NULL,
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signaux
-- Association entre plugins et signaux (hooks)
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

---------- FILES ----------------

CREATE TABLE IF NOT EXISTS files
-- Files metadata
(
    id INTEGER NOT NULL PRIMARY KEY,
    path TEXT NOT NULL,
    parent TEXT NOT NULL,
    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) = modified),
    image INT NOT NULL DEFAULT 0,

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







|








|












|



|
|







|





|
|









|




|




|
















|

|








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












|







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
        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_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),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 means transaction is locked

    hash TEXT 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,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- linked transaction (eg. payment of a debt)
);

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_related ON acc_transactions (id_related);
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 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_analytical INTEGER NULL REFERENCES acc_accounts(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_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

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

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 NOT NULL,
    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 (ddatetime(modified) IS NOT NULL AND atetime(modified) = modified),
    image INT NOT NULL DEFAULT 0,

    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);
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
    parent TEXT NOT NULL, -- Parent path, empty = web root
    path TEXT NOT NULL, -- Full page directory name
    uri TEXT NOT NULL, -- Page identifier
    file_path TEXT NOT NULL, -- Full file path for contents
    type INTEGER NOT NULL, -- 1 = Category, 2 = Page
    status TEXT NOT NULL,
    format TEXT NOT NULL,
    published TEXT NOT NULL CHECK (datetime(published) = published),
    modified TEXT NOT NULL CHECK (datetime(modified) = modified),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS web_pages_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (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);

-- FIXME: rename to english
CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
    intitule TEXT NOT NULL,
    creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
    cible TEXT NOT NULL, -- "membres" ou "compta"
    type TEXT NOT NULL, -- "json" ou "sql"
    contenu 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,







|
|











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







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
    parent TEXT NOT NULL, -- Parent path, empty = web root
    path TEXT NOT NULL, -- Full page directory name
    uri TEXT NOT NULL, -- Page identifier
    file_path TEXT NOT NULL, -- Full file path for contents
    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_path ON web_pages (path);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_file_path ON web_pages (file_path);
CREATE INDEX IF NOT EXISTS web_pages_parent ON web_pages (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 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,

Added src/include/data/users_fields_presets.ini version [d719964ae2].















































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
;	Ce fichier contient la configuration par défaut des champs des fiches membres.
;	La configuration est ensuite enregistrée au format INI dans la table 
;	config de la base de données.
;
;	Syntaxe :
;
;	[nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas
;	type = text
;	label = "Super champ trop cool"
;	required = true
;	write_access = 0
;
;	Description des options possibles pour chaque champ :
;
;	type: (défaut: text) OBLIGATOIRE
;		certains types gérés par <input type> de HTML5 :
;		text, number, date, datetime, url, email, checkbox, file, password, tel
;		champs spécifiques :
;		- country = sélecteur de pays
;		- textarea = texte multi lignes
;		- multiple = multiples cases à cocher (jusqu'à 32, binaire)
;		- select = un choix parmis plusieurs
;	label: OBLIGATOIRE
;		Titre du champ
;	help:
;		Texte d'aide sur les fiches membres
;	options[]:
;		pour définir les options d'un champ de type select ou multiple
;	write_access:
;		1 = modifiable par le membre
;		0 = modifiable uniquement par un admin (défaut)
;	required:
;		true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
;		false = facultatif (défaut)
;	read_access:
;		1 = visible par le membre (défaut)
;		0 = visible uniquement par un admin
;	list_row:
;		Si absent ou zéro ('0') ou false, ce champ n'apparaîtra pas dans la liste des membres
;		Si présent et un chiffre supérieur à 0, alors le champ apparaîtra dans la liste des membres
;		dans l'ordre défini par le chiffre (si nom est à 2 et email à 1, alors email sera
;		la première colonne et nom la seconde)

[numero]
type = number
label = "Numéro de membre"
required = true
write_access = 0
list_table = true

[nom]
type = text
label = "Nom & prénom"
required = true
write_access = 1
list_table = true

[email]
; ce champ est facultatif et de type 'email'
type = email
label = "Adresse E-Mail"
required = false
write_access = 1

[password]
; ce champ est obligatoirement présent et de type 'password'
; le titre ne peut être modifié
label = "Mot de passe"
type = password
required = false
write_access = 1

[adresse]
type = textarea
label = "Adresse postale"
help = "Indiquer ici le numéro, le type de voie, etc."
write_access = 1

[code_postal]
type = text
label = "Code postal"
write_access = 1

[ville]
type = text
label = "Ville"
write_access = 1
list_table = true

[pays]
type = country
label = "Pays"
write_access = 1

[telephone]
type = tel
label = "Numéro de téléphone"
write_access = 1

[lettre_infos]
type = checkbox
label = "Inscription à la lettre d'information"
write_access = 1

Modified src/include/init.php from [083a63a102] to [f6dffaceb7].

182
183
184
185
186
187
188
189
190
191
192
193
194
195
196

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',
	'PREFER_HTTPS'          => false,
	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'ENABLE_TECH_DETAILS'   => true,
	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,







<







182
183
184
185
186
187
188

189
190
191
192
193
194
195

static $default_config = [
	'CACHE_ROOT'            => DATA_ROOT . '/cache',
	'SHARED_CACHE_ROOT'     => DATA_ROOT . '/cache/shared',
	'DB_FILE'               => DATA_ROOT . '/association.sqlite',
	'DB_SCHEMA'             => ROOT . '/include/data/schema.sql',
	'PLUGINS_ROOT'          => DATA_ROOT . '/plugins',

	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'ENABLE_TECH_DETAILS'   => true,
	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
212
213
214
215
216
217
218

219
220
221
222
223
224
225
	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => null,
	'CALC_CONVERT_COMMAND'  => null,
	'CONTRIBUTOR_LICENSE'   => null,
	'SQL_DEBUG'             => null,
	'SYSTEM_SIGNALS'        => [],

];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Garradin\\%s', $const);

	if (!defined($const))







>







211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
	'API_USER'              => null,
	'API_PASSWORD'          => null,
	'PDF_COMMAND'           => null,
	'CALC_CONVERT_COMMAND'  => null,
	'CONTRIBUTOR_LICENSE'   => null,
	'SQL_DEBUG'             => null,
	'SYSTEM_SIGNALS'        => [],
	'LEGAL_LINE'            => 'Propulsé par <a href="https://garradin.eu/" id="garradin" target="_blank">Garradin</a> — logiciel libre de gestion d\'association<br />Hébergé par <strong>%1$s</strong>, %2$s',
];

foreach ($default_config as $const => $value)
{
	$const = sprintf('Garradin\\%s', $const);

	if (!defined($const))
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
if (ERRORS_REPORT_URL)
{
	ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true);
}

ErrorManager::setProductionErrorTemplate(defined('Garradin\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title>
	<style type="text/css">
	body {font-family: sans-serif; }
	code, p, h1 { max-width: 400px; margin: 1em auto; display: block; }
	code { text-align: right; color: #666; }
	a { color: blue; }
	form { text-align: center; }
	</style></head><body><h1>Erreur interne</h1><p>Désolé mais le serveur a rencontré une erreur interne
	et ne peut répondre à votre requête. Merci de ré-essayer plus tard.</p>
	<p>Si vous suspectez un bug dans Garradin, vous pouvez suivre 
	<a href="https://fossil.kd2.org/garradin/wiki?name=Rapporter+un+bug&p">ces instructions</a>
	pour le rapporter.</p>
	<if(sent)><p>Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.</p></if>
	<if(logged)><code>L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : <b>{$ref}</b></code></if>
	<p><a href="' . WWW_URL . '">&larr; Retour à la page d\'accueil</a></p>
	</body></html>');

ErrorManager::setHtmlHeader('<!DOCTYPE html><meta charset="utf-8" /><style type="text/css">
	body { font-family: sans-serif; } * { margin: 0; padding: 0; }
	u, code b, i, h3 { font-style: normal; font-weight: normal; text-decoration: none; }
	#icn { color: #fff; font-size: 2em; float: right; margin: 1em; padding: 1em; background: #900; border-radius: 50%; }
	section header { background: #fdd; padding: 1em; }
	section article { margin: 1em; }
	section article h3, section article h4 { font-size: 1em; font-family: mono; }
	code { border: 1px dotted #ccc; display: block; }
	code b { margin-right: 1em; color: #999; }







|















|







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
if (ERRORS_REPORT_URL)
{
	ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true);
}

ErrorManager::setProductionErrorTemplate(defined('Garradin\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title>
	<style type="text/css">
	body {font-family: sans-serif; background: #fff; }
	code, p, h1 { max-width: 400px; margin: 1em auto; display: block; }
	code { text-align: right; color: #666; }
	a { color: blue; }
	form { text-align: center; }
	</style></head><body><h1>Erreur interne</h1><p>Désolé mais le serveur a rencontré une erreur interne
	et ne peut répondre à votre requête. Merci de ré-essayer plus tard.</p>
	<p>Si vous suspectez un bug dans Garradin, vous pouvez suivre 
	<a href="https://fossil.kd2.org/garradin/wiki?name=Rapporter+un+bug&p">ces instructions</a>
	pour le rapporter.</p>
	<if(sent)><p>Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.</p></if>
	<if(logged)><code>L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : <b>{$ref}</b></code></if>
	<p><a href="' . WWW_URL . '">&larr; Retour à la page d\'accueil</a></p>
	</body></html>');

ErrorManager::setHtmlHeader('<!DOCTYPE html><meta charset="utf-8" /><style type="text/css">
	body { font-family: sans-serif; background: #fff; } * { margin: 0; padding: 0; }
	u, code b, i, h3 { font-style: normal; font-weight: normal; text-decoration: none; }
	#icn { color: #fff; font-size: 2em; float: right; margin: 1em; padding: 1em; background: #900; border-radius: 50%; }
	section header { background: #fdd; padding: 1em; }
	section article { margin: 1em; }
	section article h3, section article h4 { font-size: 1em; font-family: mono; }
	code { border: 1px dotted #ccc; display: block; }
	code b { margin-right: 1em; color: #999; }
340
341
342
343
344
345
346

347

348
349
350
351
352
353
354
355
356
357
358
359
{
	if (PHP_SAPI == 'cli')
	{
		echo $e->getMessage();
	}
	else
	{

		$tpl = Template::getInstance();


		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display('error.tpl');
	}

	exit;
}

// Message d'erreur simple pour les erreurs de l'utilisateur
ErrorManager::setCustomExceptionHandler('\Garradin\UserException', '\Garradin\user_error');







>
|
>




|







340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
{
	if (PHP_SAPI == 'cli')
	{
		echo $e->getMessage();
	}
	else
	{
		// Don't use Template class as there might be an error there due do the context (eg. install/upgrade)
		$tpl = new \KD2\Smartyer(ROOT . '/templates/error.tpl');
		$tpl->setCompiledDir(SMARTYER_CACHE_ROOT);

		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display();
	}

	exit;
}

// Message d'erreur simple pour les erreurs de l'utilisateur
ErrorManager::setCustomExceptionHandler('\Garradin\UserException', '\Garradin\user_error');

Modified src/include/lib/Garradin/Accounting/Accounts.php from [fe703b65c6] to [a8763a39d4].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Config;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Accounting;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Line;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Year;
use Garradin\Users\DynamicFields;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;
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
	public function getClosingAccountId()
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_CLOSING, $this->chart_id);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$id_field = Config::getInstance()->champ_identite;

		$columns = [
			'id' => [
				'select' => 'u.id',
			],
			'user_number' => [
				'select' => 'u.numero',
				'label' => 'N° membre',
			],
			'user_identity' => [
				'select' => 'u.' . $id_field,
				'label' => 'Membre',
			],
			'balance' => [
				'select' => 'SUM(l.debit - l.credit)',
				'label'  => 'Solde',
				//'order'  => 'balance != 0 %s, balance < 0 %1$s',
			],
			'status' => [
				'select' => null,
				'label' => 'Statut',
			],
		];

		$tables = 'acc_transactions_users tu
			INNER JOIN membres u ON u.id = tu.id_user
			INNER JOIN acc_transactions t ON tu.id_transaction = t.id
			INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account';

		$conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id;

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







<
<









|














|







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
	public function getClosingAccountId()
	{
		return DB::getInstance()->firstColumn('SELECT id FROM acc_accounts WHERE type = ? AND id_chart = ?;', Account::TYPE_CLOSING, $this->chart_id);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{


		$columns = [
			'id' => [
				'select' => 'u.id',
			],
			'user_number' => [
				'select' => 'u.numero',
				'label' => 'N° membre',
			],
			'user_identity' => [
				'select' => DynamicFields::getNameFieldsSQL('u'),
				'label' => 'Membre',
			],
			'balance' => [
				'select' => 'SUM(l.debit - l.credit)',
				'label'  => 'Solde',
				//'order'  => 'balance != 0 %s, balance < 0 %1$s',
			],
			'status' => [
				'select' => null,
				'label' => 'Statut',
			],
		];

		$tables = 'acc_transactions_users tu
			INNER JOIN users u ON u.id = tu.id_user
			INNER JOIN acc_transactions t ON tu.id_transaction = t.id
			INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction
			INNER JOIN acc_accounts a ON a.id = l.id_account';

		$conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id;

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

Added src/include/lib/Garradin/Accounting/AdvancedSearch.php version [604badc8f4].













































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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 Garradin\Accounting;

use Garradin\DynamicList;
use Garradin\Users\DynamicFields;
use Garradin\AdvancedSearch as A_S;
use Garradin\DB;
use Garradin\Accounting\Years;
use Garradin\Entities\Accounting\Transaction;

use function Garradin\qg;

class AdvancedSearch extends A_S
{
	/**
	 * Returns list of columns for search
	 * @return array
	 */
	public function columns(): array
	{
		$db = DB::getInstance();

		$types = 'CASE t.type ';

		foreach (Transaction::TYPES_NAMES as $num => $name) {
			$types .= sprintf('WHEN %d THEN %s ', $num, $db->quote($name));
		}

		$types .= 'END';

		return [
			'id' => [
				'label'    => 'Numéro écriture',
				'type'     => 'integer',
				'null'     => false,
				'select'   => 't.id',
			],
			'date' => [
				'label'    => 'Date',
				'type'     => 'date',
				'null'     => false,
				'select'   => 't.date',
			],
			'label' => [
				'label'    => 'Libellé écriture',
				'type'     => 'text',
				'null'     => false,
				'select'   => 't.label',
				'order'    => 't.label COLLATE U_NOCASE %s',
			],
			'reference' => [
				'label'    => 'Numéro pièce comptable',
				'type'     => 'text',
				'null'     => true,
				'select'   => 't.reference',
				'order'    => 't.reference COLLATE U_NOCASE %s',
			],
			'notes' => [
				'label'    => 'Remarques',
				'type'     => 'text',
				'null'     => true,
				'select'   => 't.notes',
				'order'    => 't.notes COLLATE U_NOCASE %s',
			],
			'account_code' => [
				'textMatch'=> true,
				'label'    => 'Numéro de compte',
				'type'     => 'text',
				'null'     => false,
				'select'   => 'a.code',
			],
			'debit' => [
				'label'    => 'Débit',
				'type'     => 'text',
				'null'     => false,
				'select'   => 'l.debit',
				'normalize' => 'money',
			],
			'credit' => [
				'label'    => 'Crédit',
				'type'     => 'text',
				'null'     => false,
				'select'   => 'l.credit',
				'normalize' => 'money',
			],
			'line_label' => [
				'label'    => 'Libellé ligne',
				'type'     => 'text',
				'null'     => true,
				'select'   => 'l.label',
				'order'    => 'l.label COLLATE U_NOCASE %s',
			],
			'line_reference' => [
				'textMatch'=> true,
				'label'    => 'Référence ligne écriture',
				'type'     => 'text',
				'null'     => true,
				'select'   => 'l.reference',
			],
			'type' => [
				'textMatch'=> false,
				'label'    => 'Type d\'écriture',
				'type'     => 'enum',
				'null'     => false,
				'values'   => Transaction::TYPES_NAMES,
				'select'   => $types,
				'where'    => 't.type',
			],
			'id_year' => [
				'textMatch'=> false,
				'label'    => 'Exercice',
				'type'     => 'enum',
				'null'     => false,
				'values'   => $db->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;'),
				'select'   => 'y.label',
				'where'    => 't.id_year',
			],
			'project_code' => [
				'textMatch'=> true,
				'label'    => 'N° de compte projet',
				'type'     => 'text',
				'null'     => true,
				'select'   => 'a2.code',
			],
		];
	}

	public function simple(string $text, ?int $id_year = null): \stdClass
	{
		$query = [];

		$text = trim($text);

		if ($id_year) {
			$query[] = [
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'id_year',
						'operator' => '= ?',
						'values'   => [$id_year],
					],
				],
			];
		}

		// Match number: find transactions per credit or debit
		if (preg_match('/^=\s*\d+([.,]\d+)?$/', $text))
		{
			$text = ltrim($text, "\n\t =");
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 'debit',
						'operator' => '= ?',
						'values'   => [$text],
					],
					[
						'column'   => 'credit',
						'operator' => '= ?',
						'values'   => [$text],
					],
				],
			];
		}
		// Match account number
		elseif ($id_year && preg_match('/^[0-9]+[A-Z]*$/', $text)
			&& ($year = Years::get($id_year))
			&& ($id = (new Accounts($year->id_chart))->getIdFromCode($text))) {
			Utils::redirect(sprintf('!acc/accounts/journal.php?id=%d&year=%d', $id, $id_year));
		}
		// Match date
		elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $text) && ($d = Utils::get_datetime($text)))
		{
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 'date',
						'operator' => '= ?',
						'values'   => [$d->format('Y-m-d')],
					],
				],
			];
		}
		// Match transaction ID
		elseif (preg_match('/^#[0-9]+$/', $text)) {
			return sprintf('!acc/transactions/details.php?id=%d', (int)substr($text, 1));
		}
		// Or search in label or reference
		else
		{
			$operator = 'LIKE %?%';
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 'label',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 'reference',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 'reference',
						'operator' => $operator,
						'values'   => [$text],
					],
				],
			];
		}

		return (object) [
			'groups' => $query,
			'order' => 'id',
			'desc' => true,
		];
	}

	public function schema(): array
	{
		$db = DB::getInstance();
		$sql = sprintf('SELECT name, sql FROM sqlite_master WHERE %s ORDER BY name;', $db->where('name', ['acc_transactions', 'acc_transactions_lines', 'acc_accounts', 'acc_years']));
		return $db->getAssoc($sql);
	}

	public function make(string $query): DynamicList
	{
		$tables = 'acc_transactions AS t
			INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
			INNER JOIN acc_accounts AS a ON l.id_account = a.id
			INNER JOIN acc_years AS y ON t.id_year = y.id
			LEFT JOIN acc_accounts AS a2 ON l.id_analytical = a2.id';
		return $this->makeList($query, $tables, 'id', true, ['id', 'account_code', 'debit', 'credit']);
	}

	public function defaults(): \stdClass
	{
		$group = [
			'operator' => 'AND',
			'conditions' => [
				[
					'column'   => 'id_year',
					'operator' => '= ?',
					'values'   => [(int)qg('year') ?: Years::getCurrentOpenYearId()],
				],
				[
					'column'   => 'label',
					'operator' => 'LIKE %?%',
					'values'   => [''],
				],
			],
		];

		if (null !== qg('type')) {
			$group['conditions'][] = [
				'column' => 'type',
				'operator' => '= ?',
				'values' => [(int)qg('type')],
			];
		}

		if (null !== qg('account')) {
			$group['conditions'][] = [
				'column' => 'account_code',
				'operator' => '= ?',
				'values' => [qg('account')],
			];
		}

		return (object) ['groups' => [$group]];
	}
}

Modified src/include/lib/Garradin/Accounting/Charts.php from [1afa92c251] to [92d19977a5].

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
		$code = strtoupper(substr($chart_code, 3));

		if (DB::getInstance()->test(Chart::TABLE, 'country = ? AND code = ?', $country, $code)) {
			throw new \RuntimeException('Ce plan comptable est déjà installé');
		}

		$chart = new Chart;
        $chart->label = self::BUNDLED_CHARTS[$chart_code];
        $chart->country = $country;
        $chart->code = $code;
        $chart->save();
        $chart->accounts()->importCSV($file);
        return $chart;
	}

	static public function listInstallable(): array
	{
		$installed = DB::getInstance()->getAssoc('SELECT id, LOWER(country || \'_\' || code) FROM acc_charts;');
		$out = [];








|
|
|
|
|
|







36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
		$code = strtoupper(substr($chart_code, 3));

		if (DB::getInstance()->test(Chart::TABLE, 'country = ? AND code = ?', $country, $code)) {
			throw new \RuntimeException('Ce plan comptable est déjà installé');
		}

		$chart = new Chart;
		$chart->label = self::BUNDLED_CHARTS[$chart_code];
		$chart->country = $country;
		$chart->code = $code;
		$chart->save();
		$chart->accounts()->importCSV($file);
		return $chart;
	}

	static public function listInstallable(): array
	{
		$installed = DB::getInstance()->getAssoc('SELECT id, LOWER(country || \'_\' || code) FROM acc_charts;');
		$out = [];

Modified src/include/lib/Garradin/Accounting/Graph.php from [dabf37ba31] to [3599db72d6].

219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234

		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('couleur1') ?: ADMIN_COLOR1;
		$c2 = $config->get('couleur2') ?: ADMIN_COLOR2;
		list($h, $s, $v) = Utils::rgbToHsv($c1);
		list($h1, $s, $v) = Utils::rgbToHsv($c2);

		$colors = [];

		for ($i = 0; $i < 6; $i++) {
			if ($i % 2 == 0) {







|
|







219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234

		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('color1') ?: ADMIN_COLOR1;
		$c2 = $config->get('color2') ?: ADMIN_COLOR2;
		list($h, $s, $v) = Utils::rgbToHsv($c1);
		list($h1, $s, $v) = Utils::rgbToHsv($c2);

		$colors = [];

		for ($i = 0; $i < 6; $i++) {
			if ($i % 2 == 0) {

Modified src/include/lib/Garradin/Accounting/Transactions.php from [7f3d458a38] to [399a2403c8].

173
174
175
176
177
178
179
180
181
182
183
184
185
186
187

		if (!array_key_exists($type, self::EXPORT_COLUMNS)) {
			throw new \InvalidArgumentException('Unknown type: ' . $type);
		}

		CSV::export(
			$format,
			sprintf('Export comptable %s - %s - %s', strtolower(self::EXPORT_NAMES[$type]), Config::getInstance()->get('nom_asso'), $year->label),
			self::iterateExport($year->id(), $type),
			array_values(self::EXPORT_COLUMNS[$type])
		);
	}

	static public function getExportExamples(Year $year)
	{







|







173
174
175
176
177
178
179
180
181
182
183
184
185
186
187

		if (!array_key_exists($type, self::EXPORT_COLUMNS)) {
			throw new \InvalidArgumentException('Unknown type: ' . $type);
		}

		CSV::export(
			$format,
			sprintf('Export comptable %s - %s - %s', strtolower(self::EXPORT_NAMES[$type]), Config::getInstance()->get('org_name'), $year->label),
			self::iterateExport($year->id(), $type),
			array_values(self::EXPORT_COLUMNS[$type])
		);
	}

	static public function getExportExamples(Year $year)
	{
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
		}

		return $out;
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$id_field = Config::getInstance()->get('champ_identite');

		if (self::EXPORT_SIMPLE == $type) {
			$sql =  'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l1.reference AS p_reference,
				a1.code AS debit_account,
				a2.code AS credit_account,
				l1.debit AS amount,
				a3.code AS analytical,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0
				INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0
				INNER JOIN acc_accounts a1 ON a1.id = l1.id_account
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				LEFT JOIN acc_accounts a3 ON a3.id = l1.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
					AND t.type != %d
				GROUP BY t.id
				ORDER BY t.date, t.id;';

			$sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED);
		}
		else {
			$sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l.id AS line_id, a.code AS account, l.debit AS debit, l.credit AS credit,
				l.reference AS line_reference, l.label AS line_label, l.reconciled,
				a2.code AS analytical,
				GROUP_CONCAT(u.%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
				INNER JOIN acc_accounts a ON a.id = l.id_account
				LEFT JOIN acc_accounts a2 ON a2.id = l.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN membres u ON u.id = tu.id_user
				WHERE t.id_year = ?
				GROUP BY t.id, l.id
				ORDER BY t.date, t.id, l.id;';

			$sql = sprintf($sql, $id_field);
		}








|








|







|












|





|







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
		}

		return $out;
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$id_field = DynamicFields::getNameFieldsSQL('u');

		if (self::EXPORT_SIMPLE == $type) {
			$sql =  'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l1.reference AS p_reference,
				a1.code AS debit_account,
				a2.code AS credit_account,
				l1.debit AS amount,
				a3.code AS analytical,
				GROUP_CONCAT(%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0
				INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0
				INNER JOIN acc_accounts a1 ON a1.id = l1.id_account
				INNER JOIN acc_accounts a2 ON a2.id = l2.id_account
				LEFT JOIN acc_accounts a3 ON a3.id = l1.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN users u ON u.id = tu.id_user
				WHERE t.id_year = ?
					AND t.type != %d
				GROUP BY t.id
				ORDER BY t.date, t.id;';

			$sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED);
		}
		else {
			$sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference,
				l.id AS line_id, a.code AS account, l.debit AS debit, l.credit AS credit,
				l.reference AS line_reference, l.label AS line_label, l.reconciled,
				a2.code AS analytical,
				GROUP_CONCAT(%s) AS linked_users
				FROM acc_transactions t
				INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
				INNER JOIN acc_accounts a ON a.id = l.id_account
				LEFT JOIN acc_accounts a2 ON a2.id = l.id_analytical
				LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id
				LEFT JOIN users u ON u.id = tu.id_user
				WHERE t.id_year = ?
				GROUP BY t.id, l.id
				ORDER BY t.date, t.id, l.id;';

			$sql = sprintf($sql, $id_field);
		}

Added src/include/lib/Garradin/AdvancedSearch.php version [2ab0976559].





































































































































































































































































































































































































































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

namespace Garradin;

abstract class AdvancedSearch
{
	/**
	 * From a single search string, returns a search object (stdClass) containing 3 properties:
	 * - query (array, list of search conditions)
	 * - order
	 * - desc
	 */
	abstract public function simple(string $query): \stdClass;

	/**
	 * Return list of columns. The format is similar to the one accepted in DynamicList.
	 *
	 * Those specific keys are also supported:
	 * - 'normalize' (string) will normalize the user entry to a specific format (accepted: tel, money)
	 * - 'null' (bool) if true, the user will be able to search for NULL values
	 * - 'type' (string) type of HTML input
	 */
	abstract public function columns(): array;

	/**
	 * Returns schema of supported tables
	 */
	abstract public function schema(): array;

	/**
	 * Builds a DynamicList object from the supplied search groups
	 */
	abstract public function make(string $query): DynamicList;

	/**
	 * Returns default empty search groups
	 */
	abstract public function defaults(): \stdClass;

	public function makeList(string $query, string $tables, string $default_order, string $default_desc, array $mandatory_columns = ['id']): DynamicList
	{
		$query = json_decode($query, true);

		if (null === $query) {
			throw new \InvalidArgumentException('Invalid JSON search object');
		}

		$query = (object) $query;

		if (!isset($query->groups) || !is_array($query->groups)) {
			throw new \InvalidArgumentException('Invalid JSON search object: missing groups');
		}

		$conditions = $this->build($query->groups);
		array_unshift($conditions->select, $default_order); // Always include default order

		foreach ($mandatory_columns as $c) {
			array_unshift($conditions->select, $c); // Always include
		}

		// Only select columns that we want
		$select_columns = array_intersect_key($this->columns(), array_flip($conditions->select));

		$list = new DynamicList($select_columns, $tables, $conditions->where);

		$list->orderBy($query->order ?? $default_order, $query->desc ?? $default_desc);
		return $list;
	}

	/**
	 * Redirects to a URL if only one result is found for a simple search
	 */
	public function redirect(DynamicList $list): void
	{
		if ($list->count() != 1) {
			return;
		}

		$item = $list->iterate()->current();
		Utils::redirect($item->id);
	}

	public function build(array $groups): \stdClass
	{
		$db = DB::getInstance();
		$columns = $this->columns();

		$select_columns = [];
		$query_columns = [];
		$query_groups = [];

		foreach ($groups as $group)
		{
			if (!isset($group['conditions'], $group['operator'])
				|| !is_array($group['conditions'])
				|| ($group['operator'] != 'AND' && $group['operator'] != 'OR'))
			{
				// Ignorer les groupes de conditions invalides
				continue;
			}

			$query_group_conditions = [];

			foreach ($group['conditions'] as $condition)
			{
				if (!isset($condition['column'], $condition['operator'])
					|| (isset($condition['values']) && !is_array($condition['values'])))
				{
					// Ignorer les conditions invalides
					continue;
				}

				if (!array_key_exists($condition['column'], $columns))
				{
					// Ignorer une condition qui se rapporte à une colonne
					// qui n'existe pas, cas possible si on reprend une recherche
					// après avoir modifié les fiches de membres
					continue;
				}

				$select_columns[] = $condition['column'];

				// Just append the column to the select
				if ($condition['operator'] == '1') {
					continue;
				}

				$query_columns[] = $condition['column'];
				$column = $columns[$condition['column']];

				if (isset($column['where'])) {
					$query = sprintf($column['where'], $condition['operator']);
				}
				else {
					$name = $column['select'] ?? $condition['column'];
					$query = sprintf('%s %s', $name, $condition['operator']);
				}

				$values = isset($condition['values']) ? $condition['values'] : [];

				if (!empty($column->normalize)) {
					if ($column->normalize == 'tel') {
						// Normaliser le numéro de téléphone
						$values = array_map(['Garradin\Utils', 'normalizePhoneNumber'], $values);
					}
					elseif ($column->normalize == 'money') {
						$values = array_map(['Garradin\Utils', 'moneyToInteger'], $values);
					}
				}

				// L'opérateur binaire est un peu spécial
				if ($condition['operator'] == '&')
				{
					$new_query = [];

					foreach ($values as $value)
					{
						$new_query[] = sprintf('%s (1 << %d)', $query, (int) $value);
					}

					$query = '(' . implode(' AND ', $new_query) . ')';
				}
				// Remplacement de liste
				elseif (strpos($query, '??') !== false)
				{
					$values = array_map([$db, 'quote'], $values);
					$query = str_replace('??', implode(', ', $values), $query);
				}
				// Remplacement de recherche LIKE
				elseif (preg_match('/%\?%|%\?|\?%/', $query, $match))
				{
					$value = str_replace(['%', '_'], ['\\%', '\\_'], reset($values));
					$value = str_replace('?', $value, $match[0]);
					$query = str_replace($match[0], sprintf('%s ESCAPE \'\\\'', $db->quote($value)), $query);
				}
				// Remplacement de paramètre
				elseif (strpos($query, '?') !== false)
				{
					$expected = substr_count($query, '?');
					$found = count($values);

					if ($expected != $found)
					{
						throw new \RuntimeException(sprintf('Operator %s expects at least %d parameters, only %d supplied', $condition['operator'], $expected, $found));
					}

					for ($i = 0; $i < $expected; $i++)
					{
						$pos = strpos($query, '?');
						$query = substr_replace($query, $db->quote(array_shift($values)), $pos, 1);
					}
				}

				$query_group_conditions[] = $query;
			}

			if (count($query_group_conditions))
			{
				$query_groups[] = implode(' ' . $group['operator'] . ' ', $query_group_conditions);
			}
		}

		if (!count($query_groups))
		{
			throw new UserException('Aucune clause de recherche trouvée dans la recherche : elle contenait peut-être des clauses qui correspondent à des champs qui ont été supprimés ?');
		}

		return (object) ['select' => $select_columns, 'where' => '(' . implode(') AND (', $query_groups) . ')'];
	}
}

Modified src/include/lib/Garradin/Config.php from [422db2352d] to [370d3e052d].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Membres\Champs;

use KD2\SMTP;
use KD2\Graphics\Image;

class Config extends Entity
{
	const FILES = [






<







1
2
3
4
5
6

7
8
9
10
11
12
13
<?php

namespace Garradin;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;


use KD2\SMTP;
use KD2\Graphics\Image;

class Config extends Entity
{
	const FILES = [
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
		'admin_css'        => 'code',
		'admin_homepage'   => 'web',
		'logo'             => 'image',
		'icon'             => 'image',
		'favicon'          => 'image',
	];

	protected $nom_asso;
	protected $adresse_asso;
	protected $email_asso;
	protected $telephone_asso;
	protected $site_asso;

	protected $monnaie;
	protected $pays;

	protected $champs_membres;
	protected $categorie_membres;

	protected $frequence_sauvegardes;
	protected $nombre_sauvegardes;

	protected $champ_identifiant;
	protected $champ_identite;

	protected $last_chart_change;
	protected $last_version_check;

	protected $couleur1;
	protected $couleur2;

	protected $files = [];

	protected $site_disabled;




	protected $_types = [
		'nom_asso'              => 'string',
		'adresse_asso'          => '?string',
		'email_asso'            => 'string',
		'telephone_asso'        => '?string',
		'site_asso'             => '?string',

		'monnaie'               => 'string',
		'pays'                  => 'string',

		'champs_membres'        => Champs::class,

		'categorie_membres'     => 'int',

		'frequence_sauvegardes' => '?int',
		'nombre_sauvegardes'    => '?int',

		'champ_identifiant'     => 'string',
		'champ_identite'        => 'string',

		'last_chart_change'     => '?int',
		'last_version_check'    => '?string',

		'couleur1'              => '?string',
		'couleur2'              => '?string',

		'files'                 => 'array',

		'site_disabled'         => 'bool',



	];

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;







|
|
|
|
|

|
|

|
<

<
<
<
|
|




|
|





>
>
>

|
|
|
|
|

|
|

<
|
<

<
|
<
<
|




|
|




>
>
>







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
		'admin_css'        => 'code',
		'admin_homepage'   => 'web',
		'logo'             => 'image',
		'icon'             => 'image',
		'favicon'          => 'image',
	];

	protected $org_name;
	protected $org_address;
	protected $org_email;
	protected $org_phone;
	protected $org_web;

	protected $currency;
	protected $country;

	protected $default_category;





	protected $backup_frequency;
	protected $backup_limit;

	protected $last_chart_change;
	protected $last_version_check;

	protected $color1;
	protected $color2;

	protected $files = [];

	protected $site_disabled;

	protected $log_retention;
	protected $log_anonymize;

	protected $_types = [
		'org_name'              => 'string',
		'org_address'           => '?string',
		'org_email'             => 'string',
		'org_phone'             => '?string',
		'org_web'               => '?string',

		'currency'              => 'string',
		'country'               => 'string',


		'default_category'      => 'int',



		'backup_frequency'      => '?int',


		'backup_limit'          => '?int',

		'last_chart_change'     => '?int',
		'last_version_check'    => '?string',

		'color1'                => '?string',
		'color2'                => '?string',

		'files'                 => 'array',

		'site_disabled'         => 'bool',

		'log_retention'         => 'int',
		'log_anonymize'         => 'int',
	];

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
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
		if (empty($config)) {
			return;
		}

		$default = array_fill_keys(array_keys($this->_types), null);
		$config = array_merge($default, $config);

		$config['champs_membres'] = new Champs($config['champs_membres']);

		foreach ($this->_types as $key => $type) {
			$value = $config[$key];

			if ($type[0] == '?' && $value === null) {
				continue;
			}
		}

		$this->load($config);

		$this->champs_membres = new Membres\Champs((string)$this->champs_membres);
	}

	public function save(): bool
	{
		if (!count($this->_modified)) {
			return true;
		}

		$this->selfCheck();

		$values = $this->modifiedProperties(true);

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

		foreach ($values as $key => $value)
		{
			$db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value);
		}

		if (!empty($values['champ_identifiant'])) {
			// Regenerate login index
			$db->exec('DROP INDEX IF EXISTS users_id_field;');
			$config = Config::getInstance();
			$champs = $config->get('champs_membres');
			$champs->createIndexes();
		}

		$db->commit();

		if (isset($values['couleur1']) || isset($values['couleur2'])) {
			// Reset graph cache
			Static_Cache::clean(0);
		}

		$this->_modified = [];

		return true;
	}

	public function delete(): bool
	{
		throw new \LogicException('Cannot delete config');
	}

	public function importForm($source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		// N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
		if (isset($source['couleur1'], $source['couleur2'])
			&& ($source['couleur1'] == ADMIN_COLOR1 && $source['couleur2'] == ADMIN_COLOR2))
		{
			$source['couleur1'] = null;
			$source['couleur2'] = null;
		}

		parent::importForm($source);
	}

	protected function _filterType(string $key, $value)
	{
		switch ($this->_types[$key]) {
			case 'int':
				return (int) $value;
			case 'bool':
				return (bool) $value;
			case 'string':
				return (string) $value;
			case Champs::class:
				if (!is_object($value) || !($value instanceof $this->_types[$key])) {
					throw new \InvalidArgumentException(sprintf('"%s" is not of type "%s"', $key, $this->_types[$key]));
				}
				return $value;
			default:
				throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key]));
		}
	}

	public function selfCheck(): void
	{
		$this->assert(trim($this->nom_asso) != '', 'Le nom de l\'association ne peut rester vide.');
		$this->assert(trim($this->monnaie) != '', 'La monnaie ne peut rester vide.');
		$this->assert(trim($this->pays) != '' && Utils::getCountryName($this->pays), 'Le pays ne peut rester vide.');
		$this->assert(null === $this->site_asso || filter_var($this->site_asso, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.');
		$this->assert(trim($this->email_asso) != '' && SMTP::checkEmailIsValid($this->email_asso, false), 'L\'adresse e-mail de l\'association est  invalide.');
		$this->assert($this->champs_membres instanceof Champs, 'Objet champs membres invalide');

		// Files
		$this->assert(count($this->files) == count(self::FILES));

		foreach ($this->files as $key => $value) {
			$this->assert(array_key_exists($key, self::FILES));
			$this->assert(is_int($value) || is_null($value));
		}

		$champs = $this->champs_membres;

		$this->assert(!empty($champs->get($this->champ_identite)), sprintf('Le champ spécifié pour identité, "%s" n\'existe pas', $this->champ_identite));
		$this->assert(!empty($champs->get($this->champ_identifiant)), sprintf('Le champ spécifié pour identifiant, "%s" n\'existe pas', $this->champ_identifiant));

		$db = DB::getInstance();

		// Check that this field is actually unique
		if (isset($this->_modified['champ_identifiant'])) {
			$sql = sprintf('SELECT (COUNT(DISTINCT %s COLLATE U_NOCASE) = COUNT(*)) FROM membres WHERE %1$s IS NOT NULL AND %1$s != \'\';', $this->champ_identifiant);
			$is_unique = (bool) $db->firstColumn($sql);

			$this->assert($is_unique, sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->champ_identifiant));
		}

		$this->assert($db->test('users_categories', 'id = ?', $this->categorie_membres), 'Catégorie de membres inconnue');
	}

	public function file(string $key): ?File
	{
		if (!isset(self::FILES[$key])) {
			throw new \InvalidArgumentException('Invalid file key: ' . $key);
		}







<
<









<
<




















<
<
<
<
<
<
<
<


<
<
<
<
<

















|
|

|
|














<
<
<
<
<







|
|
|
|
|
<









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







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
		if (empty($config)) {
			return;
		}

		$default = array_fill_keys(array_keys($this->_types), null);
		$config = array_merge($default, $config);



		foreach ($this->_types as $key => $type) {
			$value = $config[$key];

			if ($type[0] == '?' && $value === null) {
				continue;
			}
		}

		$this->load($config);


	}

	public function save(): bool
	{
		if (!count($this->_modified)) {
			return true;
		}

		$this->selfCheck();

		$values = $this->modifiedProperties(true);

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

		foreach ($values as $key => $value)
		{
			$db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value);
		}









		$db->commit();






		$this->_modified = [];

		return true;
	}

	public function delete(): bool
	{
		throw new \LogicException('Cannot delete config');
	}

	public function importForm($source = null): void
	{
		if (null === $source) {
			$source = $_POST;
		}

		// N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut
		if (isset($source['color1'], $source['color2'])
			&& ($source['color1'] == ADMIN_COLOR1 && $source['color2'] == ADMIN_COLOR2))
		{
			$source['color1'] = null;
			$source['color2'] = null;
		}

		parent::importForm($source);
	}

	protected function _filterType(string $key, $value)
	{
		switch ($this->_types[$key]) {
			case 'int':
				return (int) $value;
			case 'bool':
				return (bool) $value;
			case 'string':
				return (string) $value;





			default:
				throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key]));
		}
	}

	public function selfCheck(): void
	{
		$this->assert(trim($this->org_name) != '', 'Le nom de l\'association ne peut rester vide.');
		$this->assert(trim($this->currency) != '', 'La monnaie ne peut rester vide.');
		$this->assert(trim($this->country) != '' && Utils::getCountryName($this->country), 'Le pays ne peut rester vide.');
		$this->assert(null === $this->org_web || filter_var($this->org_web, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.');
		$this->assert(trim($this->org_email) != '' && SMTP::checkEmailIsValid($this->org_email, false), 'L\'adresse e-mail de l\'association est  invalide.');


		// Files
		$this->assert(count($this->files) == count(self::FILES));

		foreach ($this->files as $key => $value) {
			$this->assert(array_key_exists($key, self::FILES));
			$this->assert(is_int($value) || is_null($value));
		}
















		$this->assert($db->test('users_categories', 'id = ?', $this->default_category), 'Catégorie de membres inconnue');
	}

	public function file(string $key): ?File
	{
		if (!isset(self::FILES[$key])) {
			throw new \InvalidArgumentException('Invalid file key: ' . $key);
		}

Modified src/include/lib/Garradin/DB.php from [e7b3d0adf7] to [5aab2bab44].

217
218
219
220
221
222
223
224

225
226
227
228
229
230
231
        self::registerCustomFunctions($this->db);
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);

        $db->createFunction('email_hash', [Entities\Users\Email::class, 'getHash']);
        $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {







|
>







217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
        self::registerCustomFunctions($this->db);
    }

    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', [Entities\Users\Email::class, 'getHash']);
        $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }

    public function version(): ?string
    {
        if (-1 === $this->_version) {

Modified src/include/lib/Garradin/DynamicList.php from [69e24b02bd] to [b54872f56e].

149
150
151
152
153
154
155

















156
157
158
159
160
161
162
			$columns[$alias] = $label_only ? $properties['label'] : $properties;
		}

		return $columns;
	}

	public function iterate(bool $include_hidden = true)

















	{
		$start = ($this->page - 1) * $this->per_page;
		$columns = [];

		foreach ($this->columns as $alias => $properties) {
			// Skip columns that require a certain order (eg. calculating a running sum)
			if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order && !$this->desc)) {







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







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
			$columns[$alias] = $label_only ? $properties['label'] : $properties;
		}

		return $columns;
	}

	public function iterate(bool $include_hidden = true)
	{
		foreach (DB::getInstance()->iterate($this->SQL()) as $row) {
			if ($this->modifier) {
				call_user_func_array($this->modifier, [&$row]);
			}

			foreach ($this->columns as $key => $config) {
				if (empty($config['label']) && !$include_hidden) {
					unset($row->$key);
				}
			}

			yield $row;
		}
	}

	public function SQL()
	{
		$start = ($this->page - 1) * $this->per_page;
		$columns = [];

		foreach ($this->columns as $alias => $properties) {
			// Skip columns that require a certain order (eg. calculating a running sum)
			if (isset($properties['only_with_order']) && !($properties['only_with_order'] == $this->order && !$this->desc)) {
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
		$sql = sprintf('SELECT %s FROM %s WHERE %s %s ORDER BY %s',
			$columns, $this->tables, $this->conditions, $group, $order);

		if (null !== $this->per_page) {
			$sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page);
		}

		foreach (DB::getInstance()->iterate($sql) as $row) {
			if ($this->modifier) {
				call_user_func_array($this->modifier, [&$row]);
			}

			foreach ($this->columns as $key => $config) {
				if (empty($config['label']) && !$include_hidden) {
					unset($row->$key);
				}
			}

			yield $row;
		}
	}

	public function loadFromQueryString()
	{









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



			$this->export($this->title, $_GET['export']);
			exit;
		}

		if (!empty($_GET['o'])) {
			$this->orderBy($_GET['o'], !empty($_GET['d']));
		}

		if (!empty($_GET['p'])) {
			$this->page = (int)$_GET['p'];
		}
	}
}







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




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



|
|


|
|



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
		$sql = sprintf('SELECT %s FROM %s WHERE %s %s ORDER BY %s',
			$columns, $this->tables, $this->conditions, $group, $order);

		if (null !== $this->per_page) {
			$sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page);
		}





		return $sql;








	}

	public function loadFromQueryString()
	{
		$export = $_POST['_dl_export'] ?? ($_GET['export'] ?? null);
		$page = $_POST['_dl_page'] ?? ($_GET['p'] ?? null);

		if (!empty($_POST['_dl_order'])) {
			$order = substr($_POST['_dl_order'], 1);
			$desc = substr($_POST['_dl_order'], 0, 1) == '>' ? true : false;
		}
		else {
			$order = $_GET['o'] ?? null;
			$desc = !empty($_GET['d']);
		}

		if ($export) {
			$this->export($this->title, $export);
			exit;
		}

		if ($order) {
			$this->orderBy($order, $desc);
		}

		if ($page) {
			$this->page = (int) $page;
		}
	}
}

Modified src/include/lib/Garradin/Entities/Accounting/Account.php from [283323d3dd] to [88520a7e00].

327
328
329
330
331
332
333



















































































































334
335
336
337
338
339
340
			yield $row;
		}

		if (!$only_non_reconciled) {
			yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
		}
	}




















































































































	public function getDepositJournal(int $year_id, array $checked = []): \Generator
	{
		$res = DB::getInstance()->iterate('SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line, l.id_account
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)







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







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

		if (!$only_non_reconciled) {
			yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date];
		}
	}

	public function mergeReconcileJournalAndCSV(\Generator $journal, CSV_Custom $csv)
	{
		$lines = [];

		$csv = iterator_to_array($csv->iterate());
		$journal = iterator_to_array($journal);
		$i = 0;
		$sum = 0;

		foreach ($csv as $k => &$line) {
			try {
				$date = \DateTime::createFromFormat('!d/m/Y', $line->date);
				$line->amount = (substr($line->amount, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->amount);

				if (!$date) {
					throw new UserException('Date invalide : ' . $line->date);
				}

				$line->date = $date;
			}
			catch (UserException $e) {
				throw new UserException(sprintf('Ligne %d : %s', $k, $e->getMessage()));
			}
		}
		unset($line);

		foreach ($journal as $j) {
			$id = $j->date->format('Ymd') . '.' . $i++;

			$row = (object) ['csv' => null, 'journal' => $j];

			if (isset($j->debit)) {
				foreach ($csv as &$line) {
					if (!isset($line->date)) {
						 continue;
					}

					// Match date, amount and label
					if ($j->date->format('Ymd') == $line->date->format('Ymd')
						&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)
						&& strtolower($j->label) == strtolower($line->label)) {
						$row->csv = $line;
						$line = null;
						break;
					}
				}
			}

			$lines[$id] = $row;
		}

		unset($line, $row, $j);

		// Second round to match only amount and label
		foreach ($lines as $row) {
			if ($row->csv || !isset($row->journal->debit)) {
				continue;
			}

			$j = $row->journal;

			foreach ($csv as &$line) {
				if (!isset($line->date)) {
					 continue;
				}

				if ($j->date->format('Ymd') == $line->date->format('Ymd')
					&& ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) {
					$row->csv = $line;
					$line = null;
					break;
				}
			}
		}

		unset($j);

		// Then add CSV lines on the right
		foreach ($csv as $line) {
			if (null == $line) {
				continue;
			}

			$id = $line->date->format('Ymd') . '.' . ($i++);
			$lines[$id] = (object) ['csv' => $line, 'journal' => null];
		}

		ksort($lines);
		$prev = null;

		foreach ($lines as &$line) {
			$line->add = false;

			if (isset($line->csv)) {
				$sum += $line->csv->amount;
				$line->csv->running_sum = $sum;

				if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) {
					$prev = null;
				}
			}

			if (isset($line->csv) && isset($line->journal)) {
				$prev = null;
			}

			if (isset($line->csv) && !isset($line->journal) && !$prev) {
				$line->add = true;
				$prev = $line->csv;
			}
		}

		return $lines;
	}

	public function getDepositJournal(int $year_id, array $checked = []): \Generator
	{
		$res = DB::getInstance()->iterate('SELECT l.debit, l.credit, t.id, t.date, t.reference, l.reference AS line_reference, t.label, l.label AS line_label, l.reconciled, l.id AS id_line, l.id_account
			FROM acc_transactions_lines l
			INNER JOIN acc_transactions t ON t.id = l.id_transaction
			WHERE t.id_year = ? AND l.id_account = ? AND l.credit = 0 AND NOT (t.status & ?)

Modified src/include/lib/Garradin/Entities/Accounting/Transaction.php from [01e23b1c36] to [a113110702].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Entities\Accounting;

use KD2\DB\EntityManager;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Config;
use Garradin\Utils;
use Garradin\UserException;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use Garradin\Accounting\Accounts;








|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace Garradin\Entities\Accounting;

use KD2\DB\EntityManager;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Users\DynamicFields;
use Garradin\Utils;
use Garradin\UserException;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use Garradin\Accounting\Accounts;
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309

		return [
			$type->accounts[0]->position == 'credit' ? $credit : $debit,
			$type->accounts[1]->position == 'credit' ? $credit : $debit,
		];
	}

	/**duplic
	 * Creates a new Transaction entity (not saved) from an existing one,
	 * trying to adapt to a different chart if possible
	 * @param  int    $id
	 * @param  Year   $year Target year
	 * @return Transaction
	 */
	public function duplicate(Year $year): Transaction







|







295
296
297
298
299
300
301
302
303
304
305
306
307
308
309

		return [
			$type->accounts[0]->position == 'credit' ? $credit : $debit,
			$type->accounts[1]->position == 'credit' ? $credit : $debit,
		];
	}

	/**
	 * Creates a new Transaction entity (not saved) from an existing one,
	 * trying to adapt to a different chart if possible
	 * @param  int    $id
	 * @param  Year   $year Target year
	 * @return Transaction
	 */
	public function duplicate(Year $year): Transaction
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
		}

		// Only set date if valid
		if ($this->date >= $year->start_date && $this->date <= $year->end_date) {
			$new->date = clone $this->date;
		}

		$new->status = 0;

		return $new;
	}

	public function payment_reference(): ?string
	{
		$line = current($this->getLines());








<
<







345
346
347
348
349
350
351


352
353
354
355
356
357
358
		}

		// Only set date if valid
		if ($this->date >= $year->start_date && $this->date <= $year->end_date) {
			$new->date = clone $this->date;
		}



		return $new;
	}

	public function payment_reference(): ?string
	{
		$line = current($this->getLines());

486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
	public function selfCheck(): void
	{
		parent::selfCheck();
		$db = DB::getInstance();

		$this->assert(null !== $this->id_year, 'Aucun exercice spécifié.');
		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type);
		$this->assert(null === $this->id_creator || $db->test('membres', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus');

		$is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));

		$this->assert($is_in_year, 'La date ne correspond pas à l\'exercice sélectionné : ' . $this->date->format('d/m/Y'));

		$total = 0;








|







484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
	public function selfCheck(): void
	{
		parent::selfCheck();
		$db = DB::getInstance();

		$this->assert(null !== $this->id_year, 'Aucun exercice spécifié.');
		$this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type);
		$this->assert(null === $this->id_creator || $db->test('users', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus');

		$is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d'));

		$this->assert($is_in_year, 'La date ne correspond pas à l\'exercice sélectionné : ' . $this->date->format('d/m/Y'));

		$total = 0;

509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
			$accounts_ids = [$line->id_account];
			$total += $line->credit;
			$total -= $line->debit;
		}

		$this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));

		$this->assert($db->test('acc_years', 'id = ?', $this->id_year), 'L\'exercice sélectionné n\'existe pas');
		$this->assert($this->id_creator === null || $db->test('membres', 'id = ?', $this->id_creator), 'Le compte membre créateur de l\'écriture n\'existe pas');

		$found_accounts = $db->getAssoc(sprintf('SELECT id, id FROM acc_accounts WHERE %s AND id_chart = (SELECT id_chart FROM acc_years WHERE id = %d);', $db->where('id', $accounts_ids), $this->id_year));

		$diff = array_diff($accounts_ids, $found_accounts);
		$this->assert(count($diff) == 0, sprintf('Certains comptes (%s) ne sont pas liés au bon plan comptable', implode(', ', $diff)));

		$this->assert(!$this->id_related || $db->test('acc_transactions', 'id = ?', $this->id_related), 'L\'écriture liée indiquée n\'existe pas');







|
<







507
508
509
510
511
512
513
514

515
516
517
518
519
520
521
			$accounts_ids = [$line->id_account];
			$total += $line->credit;
			$total -= $line->debit;
		}

		$this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total)));

		// Foreign keys constraints will check for validity of id_creator and id_year


		$found_accounts = $db->getAssoc(sprintf('SELECT id, id FROM acc_accounts WHERE %s AND id_chart = (SELECT id_chart FROM acc_years WHERE id = %d);', $db->where('id', $accounts_ids), $this->id_year));

		$diff = array_diff($accounts_ids, $found_accounts);
		$this->assert(count($diff) == 0, sprintf('Certains comptes (%s) ne sont pas liés au bon plan comptable', implode(', ', $diff)));

		$this->assert(!$this->id_related || $db->test('acc_transactions', 'id = ?', $this->id_related), 'L\'écriture liée indiquée n\'existe pas');
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
		}

		if ($debit != $credit) {
			// Add final balance line
			$line = new Line;

			if ($debit > $credit) {
				$line->credit = $debit - $credit;
			}
			else {
				$line->debit = $credit - $debit;
			}

			$open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);

			if (!$open_account) {
				throw new ValidationException('Aucun compte favori de bilan d\'ouverture n\'existe dans le plan comptable');
			}







|


|







707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
		}

		if ($debit != $credit) {
			// Add final balance line
			$line = new Line;

			if ($debit > $credit) {
				$line->debit = $debit - $credit;
			}
			else {
				$line->credit = $credit - $debit;
			}

			$open_account = EntityManager::findOne(Account::class, 'SELECT * FROM @TABLE WHERE id_chart = ? AND type = ? LIMIT 1;', $year->id_chart, Account::TYPE_OPENING);

			if (!$open_account) {
				throw new ValidationException('Aucun compte favori de bilan d\'ouverture n\'existe dans le plan comptable');
			}
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

		$db->commit();
	}

	public function listLinkedUsers()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user FROM membres m INNER JOIN acc_transactions_users l ON l.id_user = m.id WHERE l.id_transaction = ?;', $identity_column);
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user
			FROM membres m
			INNER JOIN acc_transactions_users l ON l.id_user = m.id
			WHERE l.id_transaction = ?;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}

	public function listRelatedTransactions()
	{
		return EntityManager::getInstance(self::class)->all('SELECT * FROM @TABLE WHERE id_related = ?;', $this->id);







|
|






|
|
|
|







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

		$db->commit();
	}

	public function listLinkedUsers()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$sql = sprintf('SELECT u.id, %s AS identity, l.id_service_user FROM users u INNER JOIN acc_transactions_users l ON l.id_user = u.id WHERE l.id_transaction = ?;', $identity_column);
		return $db->get($sql, $this->id());
	}

	public function listLinkedUsersAssoc()
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = DynamicFields::getNameFieldsSQL('u');
		$sql = sprintf('SELECT u.id, %s AS identity, l.id_service_user
			FROM users u
			INNER JOIN acc_transactions_users l ON l.id_user = u.id
			WHERE l.id_transaction = ?;', $identity_column);
		return $db->getAssoc($sql, $this->id());
	}

	public function listRelatedTransactions()
	{
		return EntityManager::getInstance(self::class)->all('SELECT * FROM @TABLE WHERE id_related = ?;', $this->id);

Modified src/include/lib/Garradin/Entities/Files/File.php from [a96bc06098] to [859f306a73].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;
use KD2\DB\EntityManager as EM;

use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;













|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;
use KD2\DB\EntityManager as EM;

use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugin;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;

use Garradin\Files\Files;

Added src/include/lib/Garradin/Entities/Search.php version [218cbdca8e].





























































































































































































































































































































































































































































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

namespace Garradin\Entities;

use Garradin\AdvancedSearch;
use Garradin\CSV;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\UserException;

use Garradin\Accounting\AdvancedSearch as Accounting_AdvancedSearch;
use Garradin\Users\AdvancedSearch as Users_AdvancedSearch;

use KD2\DB\DB_Exception;

class Search extends Entity
{
	const TABLE = 'searches';

	const TYPE_JSON = 'json';
	const TYPE_SQL = 'sql';
	const TYPE_SQL_UNPROTECTED = 'sql_unprotected';

	const TYPES = [
		self::TYPE_JSON => 'Recherche avancée',
		self::TYPE_SQL => 'Recherche SQL',
		self::TYPE_SQL_UNPROTECTED => 'Recherche SQL non protégée',
	];

	const TARGET_USERS = 'users';
	const TARGET_ACCOUNTING = 'accounting';

	const TARGETS = [
		self::TARGET_USERS => 'Membres',
		self::TARGET_ACCOUNTING => 'Comptabilité',
	];

	protected ?int $id;
	protected ?int $id_user = null;
	protected string $label;
	protected \DateTime $created;
	protected string $target;
	protected string $type;
	protected string $content;

	protected $_result = null;
	protected $_as = null;

	public function selfCheck(): void
	{
		parent::selfCheck();

		$this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné');
		$this->assert(strlen('label') <= 500, 'Le champ libellé est trop long');

		$db = DB::getInstance();

		if ($this->id_user !== null) {
			$this->assert($db->test('users', 'id = ?', $data['id_user']), 'Numéro de membre inconnu');
		}

		$this->assert(array_key_exists($this->type, self::TYPES));
		$this->assert(array_key_exists($this->target, self::TARGETS));

		$this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide');

		if ($this->type === self::TYPE_JSON) {
			$this->assert(json_decode($this->content) !== null, 'Recherche invalide pour le type JSON');
		}
	}

	public function getDynamicList(): DynamicList
	{
		if ($this->type == self::TYPE_JSON) {
			return $this->getAdvancedSearch()->make($this->content);
		}
		else {
			throw new \LogicException('SQL search cannot be used as dynamic list');
		}
	}

	public function getAdvancedSearch(): AdvancedSearch
	{
		if ($this->target == self::TARGET_ACCOUNTING) {
			$class = 'Garradin\Accounting\AdvancedSearch';
		}
		else {
			$class = 'Garradin\Users\AdvancedSearch';
		}

		if (null === $this->_as || !is_a($this->_as, $class)) {
			$this->_as = new $class;
		}

		return $this->_as;
	}

	public function transformToSQL()
	{
		if ($this->type != self::TYPE_JSON) {
			throw new \LogicException('Cannot transform a non-JSON search to SQL');
		}

		$sql = $this->getDynamicList()->SQL();

		// Remove indentation
		$sql = preg_replace('/^\s*/m', '', $sql);

		$this->set('content', $sql);
		$this->set('type', self::TYPE_SQL);
	}

	public function SQL(?int $force_limit, ?string $force_select = null): string
	{
		if ($this->type == self::TYPE_JSON) {
			$sql = $this->getDynamicList()->SQL();
		}
		else {
			$sql = $this->content;
		}

		$has_limit = preg_match('/LIMIT\s+\d+/i', $sql);

		// force LIMIT
		if ($force_limit && !$has_limit) {
			$sql = preg_replace('/;?\s*$/', '', $sql);
			$sql .= ' LIMIT ' . (int) $force_limit;
		}
		elseif (!$force_limit && $has_limit) {
			$sql = preg_replace('/LIMIT\s+.*;?\s*$/', '', $sql);
		}

		if ($force_select) {
			$query = preg_replace('/^\s*SELECT\s+(.*)\s+FROM\s+/Uis', 'SELECT $1, ' . implode(', ', $force_select) . ' FROM ', $query);
		}

		$sql = trim($sql, "\n\r\t; ");

		return $sql;
	}

	/**
	 * Returns a SQLite3Result for the current search
	 */
	protected function query(?int $force_limit = 100, ?string $force_select = null): \SQLite3Result
	{
		if (null !== $this->_result) {
			return $this->_result;
		}

		$sql = $this->SQL($force_limit, $force_select);

		$allowed_tables = $this->getProtectedTables();

		try {
			$st = DB::getInstance()->protectSelect($allowed_tables, $sql);

			$this->_result = $st->execute();
			return $this->_result;
		}
		catch (DB_Exception $e) {
			$message = 'Erreur dans la requête : ' . $e->getMessage();

			if (count($columns))
			{
				$message .= "\nVérifiez que votre requête sélectionne bien les colonnes suivantes : " . implode(', ', $columns);
			}

			throw new UserException($message);
		}
	}

	public function getHeader(): array
	{
		$r = $this->query();
		$columns = [];

		for ($i = 0; $i < $r->numColumns(); $i++) {
			$columns[] = $r->columnName($i);
		}

		return $columns;
	}

	public function iterateResults(): iterable
	{
		$r = $this->query();

		while ($row = $r->fetchArray(\SQLITE3_NUM)) {
			yield $row;
		}
	}

	public function export(string $format)
	{
		CSV::export($format, 'Recherche', $this->iterateResults(), $this->getHeader());
	}

	public function getProtectedTables(): ?array
	{
		if ($this->type != self::TYPE_SQL) {
			return null;
		}

		if ($this->target == self::TARGET_ACCOUNTING) {
			return ['acc_transactions' => null, 'acc_transactions_lines' => null, 'acc_accounts' => null, 'acc_charts' => null, 'acc_years' => null, 'acc_transactions_users' => null];
		}
		else {
			return ['users' => null, 'users_categories' => null];
		}
	}

	public function getGroups(): array
	{
		if ($this->type != self::TYPE_JSON) {
			throw new \LogicException('Only JSON searches can use this method');
		}

		return json_decode($this->content, true)['groups'];
	}
}

Modified src/include/lib/Garradin/Entities/Services/Fee.php from [cf78e1ab65] to [9a5936d56e].

1
2
3
4
5
6
7
8
9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;

class Fee extends Entity
{




<





>







1
2
3
4

5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\Entities\Services;


use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;
use Garradin\Users\DynamicFields;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;

class Fee extends Entity
{
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
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM membres WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['membres' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function activeUsersList(): DynamicList
	{

		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
			],
			'paid_amount' => [
				'label' => 'Montant payé',
				'select' => 'SUM(l.credit)',
			],
			'date' => [
				'label' => 'Date',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.id = su.id_user
			INNER JOIN services_fees sf ON sf.id = su.id_fee
			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 AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', 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 unpaidUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND m.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}
}







|







|














>
|






|
















|





|
















|







|




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
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM users WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['users' => null], $sql);
			return null;
		}
		catch (DB_Exception $e) {
			return $e->getMessage();
		}
	}

	public function service()
	{
		return EntityManager::findOneById(Service::class, $this->id_service);
	}

	public function activeUsersList(): DynamicList
	{
		$identity = DynamicFields::getNameFieldsSQL('u');

		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => $identity,
			],
			'paid' => [
				'label' => 'Payé ?',
				'select' => 'su.paid',
			],
			'paid_amount' => [
				'label' => 'Montant payé',
				'select' => 'SUM(l.credit)',
			],
			'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 (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 AND (su.expiry_date >= date() OR su.expiry_date IS NULL)
			AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->groupBy('su.id_user');
		$list->orderBy('date', 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 unpaidUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}

	public function expiredUsersList(): DynamicList
	{
		$list = $this->activeUsersList();
		$conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id());
		$list->setConditions($conditions);
		return $list;
	}
}

Modified src/include/lib/Garradin/Entities/Users/Category.php from [2e075cd1f0] to [9193aebf33].

1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin\Entities\Users;

use Garradin\Membres\Session;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Entity;

class Category extends Entity
{




|







1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin\Entities\Users;

use Garradin\Users\Session;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Entity;

class Category extends Entity
{
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
	}

	public function delete(): bool
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		if ($this->id() == $config->get('categorie_membres')) {
			throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
		}

		if ($db->test('membres', 'id_category = ?', $this->id())) {
			throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.');
		}

		return parent::delete();
	}

	public function setAllPermissions(int $access): void







|



|







111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
	}

	public function delete(): bool
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		if ($this->id() == $config->get('default_category')) {
			throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
		}

		if ($db->test('users', 'id_category = ?', $this->id())) {
			throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.');
		}

		return parent::delete();
	}

	public function setAllPermissions(int $access): void

Added src/include/lib/Garradin/Entities/Users/DynamicField.php version [0e64db5237].

























































































































































































































































































































































































































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

namespace Garradin\Entities\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\Entity;
use Garradin\Utils;
use Garradin\Users\DynamicFields;

class DynamicField extends Entity
{
	const TABLE = 'config_users_fields';

	protected int $id;
	protected string $name;

	/**
	 * Order of field in form
	 * @var int
	 */
	protected int $sort_order;

	protected string $type;
	protected string $label;
	protected ?string $help;

	/**
	 * TRUE if the field is required
	 */
	protected bool $required;

	/**
	 * 0 = only admins can read this field (private)
	 * 1 = admins + the user themselves can read it
	 */
	protected int $read_access;

	/**
	 * 0 = only admins can write this field
	 * 1 = admins + the user themselves can change it
	 */
	protected int $write_access;

	/**
	 * Use in users list table?
	 */
	protected bool $list_table;

	/**
	 * Multiple options (JSON) for select and multiple fields
	 */
	protected ?array $options = [];

	/**
	 * Default value
	 */
	protected ?string $default_value;

	/**
	 * System use
	 */
	protected int $system = 0;

	const PASSWORD = 0x01;
	const LOGIN = 0x02;
	const NUMBER = 0x04;
	const NAME = 0x08;
	const PRESET = 0x16;

	const ACCESS_ADMIN = 0;
	const ACCESS_USER = 1;

	const TYPES = [
		'email'		=>	'Adresse E-Mail',
		'url'		=>	'Adresse URL',
		'checkbox'	=>	'Case à cocher',
		'date'		=>	'Date',
		'datetime'	=>	'Date et heure',
		'month'     =>  'Mois et année',
		'file'      =>  'Fichier',
		'password'  =>  'Mot de passe',
		'number'	=>	'Nombre',
		'tel'		=>	'Numéro de téléphone',
		'select'	=>	'Sélecteur à choix unique',
		'multiple'  =>  'Sélecteur à choix multiple',
		'country'	=>	'Sélecteur de pays',
		'text'		=>	'Texte',
		'textarea'	=>	'Texte multi-lignes',
	];

	const PHP_TYPES = [
		'email'    => '?string',
		'url'      => '?string',
		'checkbox' => 'int',
		'date'     => '?date',
		'datetime' => '?DateTime',
		'file'     => '?string',
		'password' => '?string',
		'number'   => '?int|float',
		'tel'      => '?string',
		'select'   => '?string',
		'multiple' => 'int',
		'country'  => '?string',
		'text'     => '?string',
		'textarea' => '?string',
	];

	const SQL_TYPES = [
		'email'    => 'TEXT',
		'url'      => 'TEXT',
		'checkbox' => 'INTEGER NOT NULL DEFAULT 0',
		'date'     => 'TEXT',
		'datetime' => 'TEXT',
		'file'     => 'TEXT',
		'password' => 'TEXT',
		'number'   => 'INTEGER',
		'tel'      => 'TEXT',
		'select'   => 'TEXT',
		'multiple' => 'INTEGER NOT NULL DEFAULT 0',
		'country'  => 'TEXT',
		'text'     => 'TEXT',
		'textarea' => 'TEXT',
	];

	const SQL_CONSTRAINTS = [
		'checkbox' => '%1s = 1 OR %1s = 0',
		'date'     => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)',
		'datetime' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)',
		'month'    => '%1s IS NULL OR (date(%1s || \'-03\') = %1$s || \'-03\')', // Use 3rd day to avoid any potential issue with timezones
	];

	const SYSTEM_FIELDS = [
		'id'           => '?int',
		'id_category'  => 'int',
		'pgp_key'      => '?string',
		'otp_secret'   => '?string',
		'date_login'   => '?DateTime',
		'date_created' => '?date',
	];

	public function delete(): bool
	{
		if ($this->system) {
			throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer');
		}

		if ($this->type == 'file') {
			foreach (Files::glob(File::CONTEXT_USER . '/*/' . $this->name) as $file) {
				$file->delete();
			}
		}

		return parent::delete();
	}

	public function selfCheck(): void
	{
		$this->name = strtolower($this->name);

		$this->assert($this->read_access == self::ACCESS_ADMIN || $this->read_access == self::ACCESS_USER);
		$this->assert($this->write_access == self::ACCESS_ADMIN || $this->write_access == self::ACCESS_USER);

		$this->assert(!array_key_exists($this->name, self::SYSTEM_FIELDS), 'Ce nom de champ est déjà utilisé par un champ système, merci d\'en choisir un autre.');
		$this->assert(preg_match('!^[a-z][a-z0-9]*(_[a-z0-9]+)*$!', $this->name), 'Le nom du champ est invalide : ne sont acceptés que les lettres minuscules et les chiffres (éventuellement séparés par un underscore).');

		$this->assert(trim($this->label) != '', 'Le libellé est obligatoire.');
		$this->assert($this->type && array_key_exists($this->type, self::TYPES), 'Type de champ invalide.');

		if ($this->system & self::PASSWORD) {
			$this->assert($this->type == 'password', 'Le champ mot de passe ne peut être d\'un type différent de mot de passe.');
		}

		$this->assert(!($this->type == 'multiple' || $this->type == 'select') || !empty($this->options), 'Le champ nécessite de comporter au moins une option possible.');

		$db = DB::getInstance();

		if (!$this->exists()) {
			$this->assert(!$db->test(self::TABLE, 'name = ?', $this->name), 'Ce nom de champ est déjà utilisé par un autre champ: ' . $this->name);
		}
		else {
			$this->assert(!$db->test(self::TABLE, 'name = ? AND id != ?', $this->name, $this->id()), 'Ce nom de champ est déjà utilisé par un autre champ.');
		}

		$this->assert($this->exists() || $this->system & self::PRESET || !array_key_exists($this->name, DynamicFields::getInstance()->getPresets()), 'Ce nom de champ est déjà utilisé par un champ pré-défini.');

		if ($this->exists()) {
			$this->assert(!isset($this->_modified['type']));
			$this->assert(!isset($this->_modified['name']));
		}
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		$source['required'] = !empty($source['required']) ? true : false;
		$source['list_table'] = !empty($source['list_table']) ? true : false;

		return parent::importForm($source);
	}
}

Added src/include/lib/Garradin/Entities/Users/User.php version [760c00848f].



















































































































































































































































































































































































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

namespace Garradin\Entities\Users;

use KD2\DB\EntityManager;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Config;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;

use Garradin\Users\Categories;
use Garradin\Users\Emails;
use Garradin\Users\DynamicFields;
use Garradin\Users\Session;

use Garradin\Entities\Files\File;

use KD2\SMTP;

#[AllowDynamicProperties]
class User extends Entity
{
	const TABLE = 'users';

	protected bool $_construct = false;

	public function __construct()
	{
		$this->_construct = true;

		foreach (DynamicField::SYSTEM_FIELDS as $key => $type) {
			$this->_types[$key] = $type;
			$this->$key = null;
		}

		$fields = DynamicFields::getInstance()->all();

		foreach ($fields as $key => $config) {
			$this->_types[$key] = DynamicField::PHP_TYPES[$config->type];
			$this->$key = null;
		}

		$this->_construct = false;

		parent::__construct();
	}

	public function set(string $key, $value, bool $loose = false, bool $check_for_changes = true) {
		if ($this->_construct && $value === null) {
			$this->$key = $value;
			return;
		}

		return parent::set($key, $value, $loose, $check_for_changes);
	}

	public function selfCheck(): void
	{
		$df = DynamicFields::getInstance();

		foreach ($df->all() as $field) {
			if ($field->required) {
				$this->assert(null !== $this->{$field->name}, sprintf('"%s" : ce champ est requis', $field->label));
			}
		}

		// Check email addresses
		foreach (DynamicFields::getEmailFields() as $field) {
			$this->assert($this->$field === null || SMTP::checkEmailIsValid($this->$field, false), 'Cette adresse email n\'est pas valide.');
		}

		// check user number
		$field = DynamicFields::getNumberField();
		$this->assert($this->$field !== null && ctype_alnum($this->$field), 'Numéro de membre invalide : ne peut contenir que des chiffres et des lettres.');

		$db = DB::getInstance();

		if (!$this->exists()) {
			$number_exists = $db->test(self::TABLE, sprintf('%s = ?', $db->quoteIdentifier($field)), $this->$field);
		}
		else {
			$number_exists = $db->test(self::TABLE, sprintf('%s = ? AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id());
		}

		$this->assert(!$number_exists, 'Ce numéro de membre est déjà attribué à un autre membre.');

		$field = DynamicFields::getLoginField();
		if ($this->$field !== null) {
			if (!$this->exists()) {
				$login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE', $db->quoteIdentifier($field)), $this->$field);
			}
			else {
				$login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id());
			}

			$this->assert(!$login_exists, sprintf('Le champ "%s" (utilisé comme identifiant de connexion) est déjà utilisé par un autre membre. Il doit être unique pour chaque membre.', $df->fieldByKey($field)->label));
		}
	}

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

		if ($session->isLogged()) {
			$user = $session->getUser();

			if ($user->id == $this->id) {
				throw new UserException('Il n\'est pas possible de supprimer son propre compte. Merci de demander à un administrateur de le faire.');
			}
		}

		Files::delete($this->attachementsDirectory());

		return parent::delete();
	}

	public function category(): Category
	{
		return Categories::get($this->id_category);
	}

	public function attachementsDirectory(): string
	{
		return File::CONTEXT_USER . '/' . $this->id();
	}

	public function name(): string
	{
		$out = [];

		foreach (DynamicFields::getNameFields() as $key) {
			$out[] = $this->$key;
		}

		return implode(' ', $out);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['password'])) {
			$this->assert($source['password'] == ($source['password_confirmed'] ?? null), 'La confirmation de mot de passe doit être identique au mot de passe.');
		}

		return parent::importForm($source);
	}

	public function canEmail(): bool
	{
		foreach (DynamicFields::getEmailFields() as $f) {
			if (!empty($this->$f)) {
				return true;
			}
		}

		return false;
	}

	public function getNameAndEmail(): string
	{
		$email_field = DynamicFields::getFirstEmailField();

		return sprintf('"%s" <%s>', $this->name(), $this->{$email_field});
	}

	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} => null], $from, $subject, $message);

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

Modified src/include/lib/Garradin/Files/Files.php from [9196c1dfe3] to [736440620f].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\Files;

use Garradin\Static_Cache;
use Garradin\DB;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;

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

use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG};









|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\Files;

use Garradin\Static_Cache;
use Garradin\DB;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;

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

use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG};
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
			WHERE files_search MATCH ? %s
			ORDER BY points DESC
			LIMIT 0,50;', $where);

		return DB::getInstance()->get($query, ...$params);
	}






	static public function list(string $parent = ''): array
	{
		if ($parent !== '') {
			File::validatePath($parent);
		}

		// Update this path
		return self::callStorage('list', $parent);
	}
















	static public function zip(string $parent, ?Session $session)
	{
		$file = Files::get($parent);

		if (!$file) {
			throw new UserException('Ce répertoire n\'existe pas.');
		}








>
>
>
>
>










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







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
			WHERE files_search MATCH ? %s
			ORDER BY points DESC
			LIMIT 0,50;', $where);

		return DB::getInstance()->get($query, ...$params);
	}

	/**
	 * Returns a list of files and directories inside a parent path
	 * This is not recursive and will only return files and directories
	 * directly in the specified $parent path.
	 */
	static public function list(string $parent = ''): array
	{
		if ($parent !== '') {
			File::validatePath($parent);
		}

		// Update this path
		return self::callStorage('list', $parent);
	}

	/**
	 * Returns a list of files or directories matching a glob pattern
	 * only * and ? characters are supported in pattern
	 */
	static public function glob(string $pattern): array
	{
		return self::callStorage('glob', $parent);
	}

	/**
	 * Creates a ZIP file archive from a $parent path
	 * @param  string  $parent  Parent path
	 * @param  Session $session Logged-in user session, if set access rights to the path will be checked,
	 * if left NULL, then no check will be made (!).
	 */
	static public function zip(string $parent, ?Session $session): void
	{
		$file = Files::get($parent);

		if (!$file) {
			throw new UserException('Ce répertoire n\'existe pas.');
		}

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

		$add_file($parent);

		$zip->close();
	}




	static public function listForContext(string $context, ?string $ref = null)
	{
		$path = $context;

		if ($ref) {
			$path .= '/' . $ref;
		}

		return self::list($path);
	}




	static public function delete(string $path): void
	{
		$file = self::get($path);

		if (!$file) {
			return;
		}







>
>
>
|










>
>
>







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

		$add_file($parent);

		$zip->close();
	}

	/**
	 * List files and directories inside a context (first-level directory)
	 */
	static public function listForContext(string $context, ?string $ref = null): array
	{
		$path = $context;

		if ($ref) {
			$path .= '/' . $ref;
		}

		return self::list($path);
	}

	/**
	 * Delete a specified file/directory path
	 */
	static public function delete(string $path): void
	{
		$file = self::get($path);

		if (!$file) {
			return;
		}

Modified src/include/lib/Garradin/Files/Storage/FileSystem.php from [3a2659d851] to [4d44d78961].

210
211
212
213
214
215
216



















217
218
219
220
221
222
223
			}

			// Used to make sorting easier
			// directory_blabla
			// file_image.jpeg
			$files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file);
		}




















		return Utils::knatcasesort($files);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);







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







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
			}

			// Used to make sorting easier
			// directory_blabla
			// file_image.jpeg
			$files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file);
		}

		return Utils::knatcasesort($files);
	}

	static public function glob(string $path)
	{
		$fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);
		$fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR);

		if (!file_exists($fullpath)) {
			return [];
		}

		$files = [];

		foreach (glob($fullpath) as $file) {
			$file = new \SplFileInfo($file);
			$files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file);
		}

		return Utils::knatcasesort($files);
	}

	static public function listDirectoriesRecursively(string $path): array
	{
		$fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);

Modified src/include/lib/Garradin/Files/Storage/SQLite.php from [99e5f6c9f5] to [2fc130c8df].

115
116
117
118
119
120
121





122
123
124
125
126
127
128
	}

	static public function get(string $path): ?File
	{
		$sql = 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;';
		return EM::findOne(File::class, $sql, $path);
	}






	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $path);
	}

	static public function listDirectoriesRecursively(string $path): array







>
>
>
>
>







115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
	}

	static public function get(string $path): ?File
	{
		$sql = 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;';
		return EM::findOne(File::class, $sql, $path);
	}

	static public function glob(string $pattern): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE path GLOB ? AND path NOT GLOB ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $pattern, $pattern . '/*');
	}

	static public function list(string $path): array
	{
		return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $path);
	}

	static public function listDirectoriesRecursively(string $path): array

Modified src/include/lib/Garradin/Files/Transactions.php from [181762b574] to [7fc2e9d2f8].

40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

		$columns = self::LIST_COLUMNS;

		$tables = sprintf('%s f
			INNER JOIN acc_transactions t ON t.id = f.name
			INNER JOIN acc_years y ON t.id_year = y.id', Files::getVirtualTableName());

		$sum = 0;

		// Only fetch directories with an ID as the name
		$conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_TRANSACTION, File::TYPE_DIRECTORY);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('year', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
		});

		return $list;
	}
}







<
<













40
41
42
43
44
45
46


47
48
49
50
51
52
53
54
55
56
57
58
59

		$columns = self::LIST_COLUMNS;

		$tables = sprintf('%s f
			INNER JOIN acc_transactions t ON t.id = f.name
			INNER JOIN acc_years y ON t.id_year = y.id', Files::getVirtualTableName());



		// Only fetch directories with an ID as the name
		$conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_TRANSACTION, File::TYPE_DIRECTORY);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('year', true);
		$list->setCount('COUNT(DISTINCT t.id)');
		$list->setModifier(function (&$row) {
			$row->date = \DateTime::createFromFormat('!Y-m-d', $row->date);
		});

		return $list;
	}
}

Modified src/include/lib/Garradin/Files/Users.php from [55c1feeac2] to [30a79a779c].

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

namespace Garradin\Files;

use Garradin\Entities\Files\File;
use Garradin\DynamicList;
use Garradin\Config;

class Users
{
	const LIST_COLUMNS = [
		'number' => [
			'select' => 'm.numero',
			'label' => 'Numéro',
		],
		'identity' => [
			'select' => '',
			'label' => '',
		],
		'path' => [
		],
		'id' => [
			'label' => null,
			'select' => 'm.id',
		],
	];

	static public function list()
	{
		Files::syncVirtualTable(File::CONTEXT_USER);

		$config = Config::getInstance();
		$name_field = $config->get('champ_identite');
		$champs = $config->get('champs_membres');

		$columns = self::LIST_COLUMNS;
		$columns['identity']['select'] = 'm.' . $name_field;
		$columns['identity']['label'] = $champs->get($name_field)->title;


		$tables = sprintf('%s f INNER JOIN membres m ON m.id = f.name', Files::getVirtualTableName());

		$sum = 0;

		// Only fetch directories with an ID as the name
		$conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_USER, File::TYPE_DIRECTORY);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('number', false);
		$list->setCount('COUNT(DISTINCT m.id)');

		return $list;
	}
}






|





<














|



<
<
<
<

|
|
>

|













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

namespace Garradin\Files;

use Garradin\Entities\Files\File;
use Garradin\DynamicList;
use Garradin\Users\DynamicFields as DF;

class Users
{
	const LIST_COLUMNS = [
		'number' => [

			'label' => 'Numéro',
		],
		'identity' => [
			'select' => '',
			'label' => '',
		],
		'path' => [
		],
		'id' => [
			'label' => null,
			'select' => 'm.id',
		],
	];

	static public function list(): DynamicList
	{
		Files::syncVirtualTable(File::CONTEXT_USER);





		$columns = self::LIST_COLUMNS;
		$columns['identity']['select'] = DF::getNameFieldsSQL('u');
		$columns['identity']['label'] = DF::getNameLabel();
		$columns['number']['select'] = DF::getNumberField();

		$tables = sprintf('%s f INNER JOIN users u ON u.id = f.name', Files::getVirtualTableName());

		$sum = 0;

		// Only fetch directories with an ID as the name
		$conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_USER, File::TYPE_DIRECTORY);

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('number', false);
		$list->setCount('COUNT(DISTINCT m.id)');

		return $list;
	}
}

Modified src/include/lib/Garradin/Form.php from [b4e4fab8c9] to [025c058022].

50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
		}
		catch (UserException $e) {
			$this->addError($e);
			return false;
		}
	}

	public function runIf($condition, callable $fn, ?string $csrf_key = null, ?string $redirect = null): ?bool
	{
		if (is_string($condition) && empty($_POST[$condition])) {
			return null;
		}
		elseif (is_bool($condition) && !$condition) {
			return null;
		}

		return $this->run($fn, $csrf_key, $redirect);
	}

	public function check($token_action = '', Array $rules = null)
	{
		if (!\KD2\Form::tokenCheck($token_action))
		{
			$this->errors[] = 'Une erreur est survenue, merci de bien vouloir renvoyer le formulaire.';







|








|







50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
		}
		catch (UserException $e) {
			$this->addError($e);
			return false;
		}
	}

	public function runIf($condition, callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): ?bool
	{
		if (is_string($condition) && empty($_POST[$condition])) {
			return null;
		}
		elseif (is_bool($condition) && !$condition) {
			return null;
		}

		return $this->run($fn, $csrf_key, $redirect, $follow_redirect);
	}

	public function check($token_action = '', Array $rules = null)
	{
		if (!\KD2\Form::tokenCheck($token_action))
		{
			$this->errors[] = 'Une erreur est survenue, merci de bien vouloir renvoyer le formulaire.';

Modified src/include/lib/Garradin/Install.php from [68a0642e6b] to [7f2b788bfe].

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

namespace Garradin;

use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Files\File;
use Garradin\Membres\Session;

/**
 * Pour procéder à l'installation de l'instance Garradin
 * Utile pour automatiser l'installation sans passer par la page d'installation
 */
class Install
{
	/**
	 * Reset the database to empty and create a new user with the same password
	 */
	static public function reset(Session $session, string $password, array $options = [])
	{
		$config = (object) Config::getInstance()->asArray();
		$user = $session->getUser();

		if (!$session->checkPassword($password, $user->passe))
		{
			throw new UserException('Le mot de passe ne correspond pas.');
		}

		if (!trim($config->nom_asso)) {
			throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.');
		}

		if (!trim($user->identite)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.');
		}










|










|









|







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

namespace Garradin;

use Garradin\Accounting\Charts;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Year;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Files\File;
use Garradin\Users\Session;

/**
 * Pour procéder à l'installation de l'instance Garradin
 * Utile pour automatiser l'installation sans passer par la page d'installation
 */
class Install
{
	/**
	 * Reset the database to empty and create a new user with the same password
	 */
	static public function reset(Users\Session $session, string $password, array $options = [])
	{
		$config = (object) Config::getInstance()->asArray();
		$user = $session->getUser();

		if (!$session->checkPassword($password, $user->passe))
		{
			throw new UserException('Le mot de passe ne correspond pas.');
		}

		if (!trim($config->org_name)) {
			throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.');
		}

		if (!trim($user->identite)) {
			throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.');
		}

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
		DB::getInstance()->close();
		DB::deleteInstance();

		file_put_contents(CACHE_ROOT . '/reset', json_encode([
			'password'     => $session::hashPassword($password),
			'name'         => $user->identite,
			'email'        => $user->email,
			'organization' => $config->nom_asso,
		]));

		rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero'));

		self::showProgressSpinner('!install.php', 'Remise à zéro en cours…');
		exit;
	}







|







46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
		DB::getInstance()->close();
		DB::deleteInstance();

		file_put_contents(CACHE_ROOT . '/reset', json_encode([
			'password'     => $session::hashPassword($password),
			'name'         => $user->identite,
			'email'        => $user->email,
			'organization' => $config->org_name,
		]));

		rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero'));

		self::showProgressSpinner('!install.php', 'Remise à zéro en cours…');
		exit;
	}
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
			throw new \LogicException('Invalid reset data');
		}

		// We can't use the real password, as it might not be valid (too short or compromised)
		$ok = self::install($data->organization ?? 'Association', $data->name, $data->email, md5($data->password));

		// Restore password
		DB::getInstance()->preparedQuery('UPDATE membres SET passe = ? WHERE id = 1;', [$data->password]);

		if (defined('\Garradin\LOCAL_LOGIN') && \Garradin\LOCAL_LOGIN) {
			Session::getInstance()->refresh();
		}

		@unlink(CACHE_ROOT . '/reset');








|







74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
			throw new \LogicException('Invalid reset data');
		}

		// We can't use the real password, as it might not be valid (too short or compromised)
		$ok = self::install($data->organization ?? 'Association', $data->name, $data->email, md5($data->password));

		// Restore password
		DB::getInstance()->preparedQuery('UPDATE users SET password = ? WHERE id = 1;', [$data->password]);

		if (defined('\Garradin\LOCAL_LOGIN') && \Garradin\LOCAL_LOGIN) {
			Session::getInstance()->refresh();
		}

		@unlink(CACHE_ROOT . '/reset');

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

		file_put_contents(SHARED_CACHE_ROOT . '/version', garradin_version());

		// Configuration de base
		// c'est dans Config::set que sont vérifiées les données utilisateur (renvoie UserException)
		$config = Config::getInstance();
		$config->set('nom_asso', $name);
		$config->set('email_asso', $user_email);
		$config->set('monnaie', '€');
		$config->set('pays', 'FR');
		$config->set('site_disabled', true);

		$champs = Membres\Champs::importInstall();
		$champs->create(); // Pas de copie car pas de table membres existante
		$config->set('champs_membres', $champs);

		$config->set('champ_identifiant', 'email');
		$config->set('champ_identite', 'nom');



		// Create default category for common users
		$cat = new Category;
		$cat->setAllPermissions(Session::ACCESS_NONE);
		$cat->importForm([
			'name' => 'Membres actifs',
			'perm_connect' => Session::ACCESS_READ,
		]);
		$cat->save();

		$config->set('categorie_membres', $cat->id());

		// Create default category for ancient users
		$cat = new Category;
		$cat->importForm([
			'name' => 'Anciens membres',
			'hidden' => 1,
		]);







|
|
|
|

|
<
<
|

<
<
>
>










|







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

		file_put_contents(SHARED_CACHE_ROOT . '/version', garradin_version());

		// Configuration de base
		// c'est dans Config::set que sont vérifiées les données utilisateur (renvoie UserException)
		$config = Config::getInstance();
		$config->set('org_name', $name);
		$config->set('org_email', $user_email);
		$config->set('currency', '€');
		$config->set('country', 'FR');
		$config->set('site_disabled', true);
		$config->set('log_retention', 720);


		$config->set('log_anonymize', 365);



		$fields = DynamicFields::getInstance();
		$fields->install();

		// Create default category for common users
		$cat = new Category;
		$cat->setAllPermissions(Session::ACCESS_NONE);
		$cat->importForm([
			'name' => 'Membres actifs',
			'perm_connect' => Session::ACCESS_READ,
		]);
		$cat->save();

		$config->set('default_category', $cat->id());

		// Create default category for ancient users
		$cat = new Category;
		$cat->importForm([
			'name' => 'Anciens membres',
			'hidden' => 1,
		]);
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383

384

		}
		.spinner h2::after {
			display: block;
			content: " ";
			margin: 1rem auto;
			width: 50px;
			height: 50px;
			border: 5px solid #000;
			border-radius: 50%%;
			border-top-color: #999;
			animation: spin 1s ease-in-out infinite;
		}

		@keyframes spin { to { transform: rotate(360deg); } }
		</style>
		%s
		</head>
		<body>
		<div class="spinner">
			<h2>%s</h2>
		</div>', $next, htmlspecialchars($message));
	}

}








|

|











|
>
|
>
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
		}
		.spinner h2::after {
			display: block;
			content: " ";
			margin: 1rem auto;
			width: 50px;
			height: 50px;
			border: 5px solid #ccc;
			border-radius: 50%%;
			border-top-color: #000;
			animation: spin 1s ease-in-out infinite;
		}

		@keyframes spin { to { transform: rotate(360deg); } }
		</style>
		%s
		</head>
		<body>
		<div class="spinner">
			<h2>%s</h2>
		</div>', $next, htmlspecialchars($message));

		flush();
	}
}

Added src/include/lib/Garradin/Log.php version [7f7b3d84f8].

































































































































































































































































































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

namespace Garradin;

use Garradin\Config;
use Garradin\DB;

class Log
{
	/**
	 * How many seconds in the past should we look for failed attempts?
	 * @type int
	 */
	const LOCKOUT_DELAY = 5*60;

	/**
	 * Number of maximum login attempts per minute
	 * 1 = max. 1 attempt per minute
	 * 1.2 = max 6 attempts in 5 minutes
	 * @type float
	 */
	const LOCKOUT_RATE = 1.2;

	const LOGIN_FAIL = 1;
	const LOGIN_SUCCESS = 2;
	const LOGIN_REMIND = 3;
	const LOGIN_CHANGE = 4;

	const DELETE = 10;
	const CREATE = 11;
	const EDIT = 12;

	const ACTIONS = [
		self::LOGIN_FAIL => 'Connexion refusée',
		self::LOGIN_SUCCESS => 'Connexion réussie',
		self::LOGIN_REMIND => 'Rappel de mot de passe',
		self::LOGIN_CHANGE => 'Modification de mot de passe',
		self::DELETE => 'Suppression',
		self::CREATE => 'Création',
		self::EDIT => 'Modification',
	];

	static public function add(int $type, ?string $details = null): void
	{
		if ($type != LOGIN_FAIL) {
			$keep = Config::getInstance()->log_retention;

			// Don't log anything
			if ($keep == 0) {
				return;
			}
		}

		$ip = Utils::getIP();
		$session = Session::getInstance();
		$id_user = $session->isLogged() ? $session->getUser()->id : null;

		DB::getInstance()->insert('log', [
			'id_user'    => $user_id,
			'type'       => $type,
			'details'    => $details,
			'ip_address' => $ip,
		]);
	}

	static public function clean(): void
	{
		$config = Config::getInstance();
		$db = DB::getInstance();

		$days_delete = $config->log_retention;
		$days_anonymous = $config->log_anonymize;

		// Anonymize old logs according to configuration
		$db->exec(sprintf('UPDATE logs SET ip_address = NULL, id_user = NULL
			WHERE type != %d AND type != %d AND created < datetime(\'now\', \'-%d days\');',
			self::LOGIN_FAIL, self::LOGIN_REMIND, $days_anonymous));

		// Delete old logs according to configuration
		$db->exec(sprintf('DELETE FROM logs
			WHERE type != %d AND type != %d AND created < datetime(\'now\', \'-%d days\');',
			self::LOGIN_FAIL, self::LOGIN_REMIND, $days_delete));

		// Delete failed login attempts and reminders after 30 days
		$db->exec(sprintf('DELETE FROM logs WHERE type = %d OR type = %d AND created < datetime(\'now\', \'-%d days\');',
			self::LOGIN_FAIL, self::LOGIN_REMIND, 30));
	}

	/**
	 * Returns TRUE if the current IP address has done too many failed login attempts
	 * @return boolean
	 */
	static public function isLocked(): bool
	{
		$ip = Utils::getIP();

		// is IP locked out?
		$sql = sprintf('SELECT COUNT(*) FROM logs WHERE type = ? AND ip_address = ? AND created > datetime(\'now\', \'-%d seconds\');', self::LOCKOUT_DELAY);
		$count = $db->firstColumn($sql, self::LOGIN_FAIL, $ip);

		if (($count / self::LOCKOUT_DELAY) > self::LOCKOUT_RATE) {
			return true;
		}

		return false;
	}

	static public function list(): DynamicList
	{
		$config = Config::getInstance();

		$columns = [
			'id_user' => [
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'users.' . $config->champ_identite,
			],
			'created' => [
				'label' => 'Date'
			],
			'type' => [
				'label' => 'Action',
			],
			'ip_address' => [
				'label' => 'Adresse IP',
			],
			'details' => [
				'label' => 'Détails',
			],
		];

		$tables = 'logs LEFT JOIN users ON users.id = logs.id_user';

		$list = new DynamicList($columns, $tables);
		$list->orderBy('created', true);
		$list->setCount('COUNT(logs.id)');
		$list->setModifier(function (&$row) {
			$row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created);
		});

		return $list;
	}
}

Modified src/include/lib/Garradin/Membres.php from [081983cad8] to [9d392ca5eb].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Users\Email;

use Garradin\Users\Emails;
use Garradin\UserTemplate\UserTemplate;






|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Users\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Users\Email;

use Garradin\Users\Emails;
use Garradin\UserTemplate\UserTemplate;
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
        $config = Config::getInstance();

        unset($data['id']);

        $this->_checkFields($data, $check_editable, false);
        $champ_id = $config->get('champ_identifiant');

        if (!empty($data[$champ_id])
            && $db->firstColumn('SELECT 1 FROM membres WHERE '.$champ_id.' = ? COLLATE U_NOCASE AND id != ? LIMIT 1;', $data[$champ_id], (int)$id))
        {
            throw new UserException('La valeur du champ '.$champ_id.' est déjà utilisée par un autre membre, or ce champ doit être unique à chaque membre.');
        }

        if (isset($data['numero']))
        {
            if (!preg_match('/^\d+$/', $data['numero']))
            {
                throw new UserException('Le numéro de membre ne doit contenir que des chiffres.');
            }
            elseif ($data['numero'] == 0)
            {
                throw new UserException('Le numéro de membre ne peut être vide.');
            }

            if ($db->test('membres', 'numero = ? AND id != ?', (int)$data['numero'], $id))
            {
                throw new UserException('Ce numéro est déjà attribué à un autre membre.');
            }
        }

        if (isset($data['delete_password'])) {
            $data['passe'] = null;
            unset($data['delete_password']);
        }
        elseif (!empty($data['passe']) && trim($data['passe']))
        {
            Session::checkPasswordValidity($data['passe']);







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







204
205
206
207
208
209
210























211
212
213
214
215
216
217
        $config = Config::getInstance();

        unset($data['id']);

        $this->_checkFields($data, $check_editable, false);
        $champ_id = $config->get('champ_identifiant');
























        if (isset($data['delete_password'])) {
            $data['passe'] = null;
            unset($data['delete_password']);
        }
        elseif (!empty($data['passe']) && trim($data['passe']))
        {
            Session::checkPasswordValidity($data['passe']);
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
        return $db->first('SELECT *,
            '.$config->get('champ_identite').' AS identite,
            strftime(\'%s\', date_inscription) AS date_inscription,
            strftime(\'%s\', date_connexion) AS date_connexion
            FROM membres WHERE id = ? LIMIT 1;', (int)$id);
    }

    public function delete($ids)
    {
        if (!is_array($ids))
        {
            $ids = [(int)$ids];
        }

        $session = Session::getInstance();

        if ($session->isLogged())
        {
            $user = $session->getUser();

            foreach ($ids as $id)
            {
                if ($user->id == $id)
                {
                    throw new UserException('Il n\'est pas possible de supprimer son propre compte.');
                }
            }
        }

        return $this->_deleteMembres($ids);
    }

    public function getNom($id)
    {
        $db = DB::getInstance();
        $config = Config::getInstance();

        return $db->firstColumn('SELECT '.$config->get('champ_identite').' FROM membres WHERE id = ? LIMIT 1;', (int)$id);
    }







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







247
248
249
250
251
252
253

























254
255
256
257
258
259
260
        return $db->first('SELECT *,
            '.$config->get('champ_identite').' AS identite,
            strftime(\'%s\', date_inscription) AS date_inscription,
            strftime(\'%s\', date_connexion) AS date_connexion
            FROM membres WHERE id = ? LIMIT 1;', (int)$id);
    }


























    public function getNom($id)
    {
        $db = DB::getInstance();
        $config = Config::getInstance();

        return $db->firstColumn('SELECT '.$config->get('champ_identite').' FROM membres WHERE id = ? LIMIT 1;', (int)$id);
    }
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
    }

    public function countAllButHidden()
    {
        $db = DB::getInstance();
        return $db->firstColumn('SELECT COUNT(*) FROM membres WHERE id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1);');
    }

    public function getAttachementsDirectory(int $id)
    {
        return File::CONTEXT_USER . '/' . $id;
    }

    static public function changeCategorie($id_cat, $membres)
    {
        foreach ($membres as &$id)
        {
            $id = (int) $id;
        }

        $db = DB::getInstance();
        return $db->update('membres',
            ['id_category' => (int)$id_cat],
            sprintf('id IN (%s)', implode(',', $membres))
        );
    }

    protected function _deleteMembres(array $membres)
    {
        foreach ($membres as &$id)
        {
            $id = (int) $id;

            Files::delete($this->getAttachementsDirectory($id));
        }

        Plugin::fireSignal('membre.suppression', $membres);

        $db = DB::getInstance();

        // Suppression du membre
        return $db->delete('membres', $db->where('id', $membres));
    }
}







|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
370
371
372
373
374
375
376
377




































    }

    public function countAllButHidden()
    {
        $db = DB::getInstance();
        return $db->firstColumn('SELECT COUNT(*) FROM membres WHERE id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1);');
    }
}




































Deleted src/include/lib/Garradin/Membres/Champs.php version [c845f8077c].

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

namespace Garradin\Membres;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\UserException;

class Champs
{
    const TABLE = 'membres';

	protected $champs = null;

    protected $system_fields = [
        'date_connexion',
        'date_inscription',
        'clef_pgp',
        'secret_otp',
        'id',
        'id_category',
    ];

	protected $types = [
		'email'		=>	'Adresse E-Mail',
		'url'		=>	'Adresse URL',
		'checkbox'	=>	'Case à cocher',
		'date'		=>	'Date',
		'datetime'	=>	'Date et heure',
        'file'      =>  'Fichier',
        'password'  =>  'Mot de passe',
		'number'	=>	'Nombre',
		'tel'		=>	'Numéro de téléphone',
		'select'	=>	'Sélecteur à choix unique',
        'multiple'  =>  'Sélecteur à choix multiple',
		'country'	=>	'Sélecteur de pays',
		'text'		=>	'Texte',
		'textarea'	=>	'Texte multi-lignes',
	];

    protected $text_types = [
        'email',
        'text',
        'select',
        'textarea',
        'url',
        'password',
        'country'
    ];

    protected $config_fields = [
        'type',
        'title',
        'help',
        'editable',
        'list_row',
        'mandatory',
        'private',
        'options'
    ];

    static protected $presets = null;

	public function __toString()
	{
		return Utils::write_ini_string($this->champs);
	}

    public function toString()
    {
        return Utils::write_ini_string($this->champs);
    }

	static public function importInstall()
	{
		$champs = parse_ini_file(\Garradin\ROOT . '/include/data/champs_membres.ini', true);
        $champs = array_filter($champs, function ($row) { return !empty($row['install']); });
        return new \Garradin\Membres\Champs($champs, true);
	}

    static public function importPresets()
    {
        if (is_null(self::$presets))
        {
            self::$presets = parse_ini_file(\Garradin\ROOT . '/include/data/champs_membres.ini', true);
        }

        return self::$presets;
    }

    static public function listUnusedPresets(Champs $champs)
    {
        return array_diff_key(self::importPresets(), (array) $champs->getAll());
    }

	public function __construct($champs, $initial_setup = false)
	{
		if ($champs instanceOf Champs)
		{
			$this->champs = $champs->getAll();
		}
        elseif (is_array($champs))
        {
            $this->setAll($champs, $initial_setup);
        }
		else
		{
			$champs = parse_ini_string((string)$champs, true);

            foreach ($champs as $key=>&$config)
            {
                $config = (object) $config;
                $this->_checkField($key, $config);
            }

            $this->champs = (object) $champs;
		}
	}

	public function getTypes()
	{
		return $this->types;
	}

	public function get($champ, $key = null)
	{
        if (!property_exists($this->champs, $champ))
            return null;

        if ($key !== null)
        {
            if (property_exists($this->champs->$champ, $key))
                return $this->champs->$champ->$key;
            else
                return null;
        }

		return $this->champs->$champ;
	}

    public function isText($champ)
    {
        if (!property_exists($this->champs, $champ))
            return null;

        if (in_array($this->champs->$champ->type, $this->text_types))
            return true;
        else
            return false;
    }

    public function getKeys($all = false)
    {
        $keys = [];

        foreach ($this->champs as $key => $config)
        {
            if (!$all && $key == 'passe')
            {
                continue;
            }

            $keys[] = $key;
        }

        return $keys;
    }

	public function getAll()
	{
		return $this->champs;
	}

    public function getList()
    {
        $champs = clone $this->champs;
        unset($champs->passe);

        return $champs;
    }

    public function listAssocNames()
    {
        $out = [];

        foreach ($this->champs as $key => $config) {
            if ($key == 'passe') {
                continue;
            }

            $out[$key] = $config->title;
        }

        return $out;
    }

    public function getMultiples()
    {
        $out = [];

        foreach ($this->champs as $id => $champ) {
            if ($champ->type == 'multiple') {
                $out[$id] = $champ;
            }
        }

        return $out;
    }

    public function getListedFields()
    {
        $champs = (array) $this->champs;

        $champs = array_filter($champs, function ($a) {
            return empty($a->list_row) ? false : true;
        });

        uasort($champs, function ($a, $b) {
            if ($a->list_row == $b->list_row)
                return 0;

            return ($a->list_row > $b->list_row) ? 1 : -1;
        });

        return (object) $champs;
    }

    public function getFirstListed()
    {
        foreach ($this->champs as $key=>$config)
        {
            if (empty($config->list_row))
            {
                continue;
            }

            return $key;
        }
    }

    /**
     * Vérifie la cohérence et la présence des bons éléments pour un champ
     * @param  string $name     Nom du champ
     * @param  array $config    Configuration du champ
     * @return boolean true
     */
    protected function _checkField($name, \stdClass &$config)
    {
        if (!preg_match('!^\w+(_\w+)*$!', $name))
        {
            throw new UserException('Le nom du champ est invalide.');
        }

        foreach ($config as $key=>&$value)
        {
            // Champ install non pris en compte
            if ($key == 'install')
            {
                unset($config->$key);
                continue;
            }

            if (!in_array($key, $this->config_fields))
            {
                throw new \BadMethodCallException('Champ '.$key.' non valide.');
            }

            if ($key == 'editable' || $key == 'private' || $key == 'mandatory')
            {
                $value = (bool) (int) $value;
            }
            elseif ($key == 'list_row')
            {
                $value = (int) $value;
            }
            elseif ($key == 'help' || $key == 'title')
            {
                $value = trim((string) $value);
            }
            elseif ($key == 'options')
            {
                $value = (array) $value;

                foreach ($value as $option_key=>$option_value)
                {
                    if (trim($option_value) == '')
                    {
                        unset($value[$option_key]);
                    }
                }
            }
        }

        if (empty($config->title) && $name != 'passe')
        {
            throw new UserException('Champ "'.$name.'" : Le titre est obligatoire.');
        }

        if (empty($config->type) || !array_key_exists($config->type, $this->types))
        {
            throw new UserException('Champ "'.$name.'" : Le type est vide ou non valide.');
        }

        if ($name == 'email' && $config->type != 'email')
        {
            throw new UserException('Le champ email ne peut être d\'un type différent de email.');
        }

        if ($name == 'passe' && $config->type != 'password')
        {
            throw new UserException('Le champ mot de passe ne peut être d\'un type différent de mot de passe.');
        }

        if (($config->type == 'multiple' || $config->type == 'select') && empty($config->options))
        {
            throw new UserException('Le champ "'.$name.'" nécessite de comporter au moins une option possible.');
        }

        if (!property_exists($config, 'editable'))
        {
            $config->editable = false;
        }

        if (!property_exists($config, 'mandatory'))
        {
            $config->mandatory = false;
        }

        if (!property_exists($config, 'private'))
        {
            $config->private = false;
        }

        return true;
    }

    /**
     * Ajouter un nouveau champ
     * @param string $name Nom du champ
     * @param array $config Configuration du champ
     * @return boolean true
     */
    public function add($name, $config)
    {
        if (!preg_match('!^[a-z]!', $name))
        {
            throw new UserException('Le nom du champ est invalide : le premier caractère doit être une lettre.');
        }
        
        if (!preg_match('!^[a-z][a-z0-9]*(_[a-z0-9]+)*$!', $name))
        {
            throw new UserException('Le nom du champ est invalide : ne sont acceptés que les lettres minuscules et les chiffres (éventuellement séparés par un underscore).');
        }

        $config = (object) $config;
        
        $this->_checkField($name, $config);

        $this->champs->$name = $config;

        return true;
    }

    /**
     * Modifie un champ particulier
     * @param string $champ Nom du champ
     * @param string $key   Nom de la clé à modifier
     * @param mixed  $value Valeur à affecter
     * @return boolean true
     */
	public function set($champ, $key, $value)
	{
        if (!isset($this->champs->$champ))
        {
            throw new \LogicException('Champ "'.$champ.'" inconnu.');
        }

        // Vérification
        $config = clone $this->champs->$champ;
        $config->$key = $value;
        $this->_checkField($champ, $config);

		$this->champs->$champ = $config;
		return true;
	}

    public function checkCustomFieldName($name)
    {
        if (in_array($name, $this->system_fields))
        {
            throw new UserException('Ce nom unique de champ existe déjà dans les champs systèmes utilisés par Garradin.');
        }

        $presets = self::importPresets();

        if (array_key_exists($name, $presets))
        {
            throw new UserException('Le champ personnalisé ne peut avoir le même nom qu\'un champ pré-défini.');
        }

        if (isset($this->champs->$name))
        {
            throw new UserException('Ce nom est déjà utilisé par un autre champ.');
        }
    }

    /**
     * Modifie les champs en interne en vérifiant que tout va bien
     * @param array $champs Liste des champs
     * @return boolean true
     */
    public function setAll($champs, $initial_setup = false)
    {
        $presets = self::importPresets();
        $champs = (object) $champs;

        if (!isset($champs->passe))
        {
            $champs->passe = (object) ['type' => 'password'];
        }

        $config = null;

        foreach ($champs as $key=>&$config)
        {
            if (in_array($key, $this->system_fields))
            {
                throw new UserException('Ce nom unique de champ existe déjà dans les champs systèmes utilisés par Garradin.');
            }

            if (is_array($config))
            {
                $config = (object) $config;
            }

            if (isset($presets[$key]))
            {
                $config->type = $presets[$key]['type'];
            }

            $this->_checkField($key, $config);
        }

        unset($config);

        if ($initial_setup)
        {
            $this->champs = $champs;
            return true;
        }

        if (!property_exists($champs, 'email'))
        {
            throw new UserException('Le champ E-Mail ne peut être supprimé des fiches membres.');
        }

        if (!property_exists($champs, 'passe'))
        {
            throw new UserException('Le champ Mot de passe ne peut être supprimé des fiches membres.');
        }

        if (!property_exists($champs, 'numero'))
        {
            throw new UserException('Le champ numéro de membre ne peut être supprimé des fiches membres.');
        }

        $config = Config::getInstance();

        $identite = $config->get('champ_identite');

        if ($identite != 'id' && !property_exists($champs, $identite))
        {
            throw new UserException('Le champ '.$config->get('champ_identite')
                .' est défini comme identité des membres et ne peut donc être supprimé des fiches membres.');
        }

        $identifiant = $config->get('champ_identifiant');

        if ($identifiant != 'id' && !property_exists($champs, $identifiant))
        {
            throw new UserException('Le champ '.$config->get('champ_identifiant')
                .' est défini comme identifiant à la connexion et ne peut donc être supprimé des fiches membres.');
        }

        $this->champs = $champs;

        return true;
    }

    public function getSQLSchema(string $table_name = self::TABLE): string
    {
        $db = DB::getInstance();

        // Champs à créer
        $create = [
            'id INTEGER PRIMARY KEY, -- Numéro attribué automatiquement',
            'id_category INTEGER NOT NULL REFERENCES users_categories(id),',
            'date_connexion TEXT NULL CHECK (date_connexion IS NULL OR datetime(date_connexion) = date_connexion), -- Date de dernière connexion',
            'date_inscription TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date_inscription) IS NOT NULL AND date(date_inscription) = date_inscription), -- Date d\'inscription',
            'secret_otp TEXT NULL, -- Code secret pour TOTP',
            'clef_pgp TEXT NULL, -- Clé publique PGP'
        ];

        $last_one = array_key_last((array)$this->champs);

        foreach ($this->champs as $key=>$cfg)
        {
            if ($cfg->type == 'number' || $cfg->type == 'multiple' || $cfg->type == 'checkbox')
                $type = 'INTEGER';
            elseif ($cfg->type == 'file')
                $type = 'BLOB';
            else
                $type = 'TEXT COLLATE U_NOCASE';

            $line = sprintf('%s %s', $db->quoteIdentifier($key), $type);

            if ($last_one != $key) {
                $line .= ',';
            }

            if (!empty($cfg->title))
            {
                $line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->title);
            }

            $create[] = $line;
        }

        $sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create));
        return $sql;
    }

    public function getCopyFields(bool $same = false): array
    {
        // Champs à recopier
        $copy = [
            'id'               => 'id',
            'id_category'      => 'id_category',
            'date_connexion'   => 'date_connexion',
            'date_inscription' => 'date_inscription',
            'secret_otp'       => 'secret_otp',
            'clef_pgp'         => 'clef_pgp',
        ];

        $anciens_champs = $same ? null : Config::getInstance()->get('champs_membres');
        $anciens_champs = is_null($anciens_champs) ? $this->champs : $anciens_champs->getAll();

        foreach ($this->champs as $key=>$cfg)
        {
            if (property_exists($anciens_champs, $key)) {
                $copy[$key] = $key;
            }
        }

        return $copy;
    }

    public function getSQLCopy(string $old_table_name, string $new_table_name = self::TABLE, array $fields = null): string
    {
        if (null === $fields) {
            $fields = $this->getCopyFields();
        }

        $db = DB::getInstance();

        return sprintf('INSERT INTO %s (%s) SELECT %s FROM %s;',
            $new_table_name,
            implode(', ', array_map([$db, 'quoteIdentifier'], $fields)),
            implode(', ', array_map([$db, 'quoteIdentifier'], array_keys($fields))),
            $old_table_name
        );
    }

    public function copy(string $old_table_name, string $new_table_name = self::TABLE, array $fields = null): void
    {
        DB::getInstance()->exec($this->getSQLCopy($old_table_name, $new_table_name, $fields));
    }

    public function create(string $table_name = self::TABLE)
    {
        $db = DB::getInstance();
        $db->begin();
        $this->createTable($table_name);
        $this->createIndexes($table_name);
        $db->commit();
    }

    public function createTable(string $table_name = self::TABLE): void
    {
        DB::getInstance()->exec($this->getSQLSchema($table_name));
    }

    public function createIndexes(string $table_name = self::TABLE, string $id_field = null): void
    {
        $db = DB::getInstance();
        $id_field ??= Config::getInstance()->champ_identifiant;

        if ($id_field) {
            // Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
            $db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';',
                $table_name, $id_field));

            $collation = '';

            if ($this->isText($id_field)) {
                $collation = ' COLLATE U_NOCASE';
            }

            // Création de l'index unique
            $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation));
        }

        $db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS user_number ON %s (numero);', $table_name));
        $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name));

        // Create index on listed columns
        // FIXME: these indexes are currently unused by SQLite in the default user list
        // when there is more than one non-hidden category, as this makes SQLite merge multiple results
        // and so the index is not useful in that case sadly.
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX users_list_nom (id_category=?)
        // EXPLAIN QUERY PLAN SELECT * FROM membres WHERE "id_category" IN (3, 7) ORDER BY "nom" ASC LIMIT 0,100;
        // --> SEARCH TABLE membres USING INDEX user_category (id_category=?)
        // USE TEMP B-TREE FOR ORDER BY
        $listed_fields = array_keys((array) $this->getListedFields());
        foreach ($listed_fields as $field) {
            if ($field === $id_field) {
                // Il y a déjà un index
                continue;
            }

            $collation = '';

            if ($this->isText($field)) {
                $collation = ' COLLATE U_NOCASE';
            }

            $db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_list_%s ON %s (id_category, %s%s);', $field, $table_name, $db->quoteIdentifier($field), $collation));
        }
    }

    /**
     * Enregistre les changements de champs en base de données
     * @return boolean true
     */
    public function save()
    {
        $db = DB::getInstance();
        $config = Config::getInstance();

    	$db->exec('PRAGMA foreign_keys = OFF;');

        $db->begin();
        $this->createTable(self::TABLE . '_tmp');
        $this->copy(self::TABLE, self::TABLE . '_tmp');
        $db->exec(sprintf('DROP TABLE IF EXISTS %s;', self::TABLE));
    	$db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', self::TABLE));

        $this->createIndexes(self::TABLE);

    	$db->commit();
    	$db->exec('PRAGMA foreign_keys = ON;');

    	$config->set('champs_membres', $this);
    	$config->save();

    	return true;
    }
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/lib/Garradin/Membres/Session.php version [710cbbf9c2].

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

namespace Garradin\Membres;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\Membres;
use Garradin\UserException;
use Garradin\Plugin;
use Garradin\Users\Emails;

use const Garradin\SECRET_KEY;
use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

use KD2\Security;
use KD2\Security_OTP;
use KD2\Graphics\QRCode;
use KD2\HTTP;

class Session extends \KD2\UserSession
{
	const SECTION_WEB = 'web';
	const SECTION_DOCUMENTS = 'documents';
	const SECTION_USERS = 'users';
	const SECTION_ACCOUNTING = 'accounting';
	const SECTION_CONNECT = 'connect';
	const SECTION_CONFIG = 'config';
	const SECTION_SUBSCRIBE = 'subscribe';

	const ACCESS_NONE = 0;
	const ACCESS_READ = 1;
	const ACCESS_WRITE = 2;
	const ACCESS_ADMIN = 9;

	// Personalisation de la config de UserSession
	protected $cookie_name = 'gdin';
	protected $remember_me_cookie_name = 'gdinp';
	protected $remember_me_expiry = '+3 months';

	const MINIMUM_PASSWORD_LENGTH = 8;

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}

	public function __clone()
	{
		throw new \LogicException('Cannot clone');
	}

	public function __construct()
	{
		if (self::$_instance !== null) {
			throw new \LogicException('Wrong call, use getInstance');
		}

		$url = parse_url(ADMIN_URL);

		parent::__construct(DB::getInstance(), [
			'cookie_domain' => $url['host'],
			'cookie_path'   => preg_replace('!/admin/$!', '/', $url['path']),
			'cookie_secure' => (\Garradin\PREFER_HTTPS >= 2) ? true : false,
		]);
	}

	static public function checkPasswordValidity($password)
	{
		if (strlen($password) < self::MINIMUM_PASSWORD_LENGTH)
		{
			throw new UserException(sprintf('Le mot de passe doit faire au moins %d caractères.', self::MINIMUM_PASSWORD_LENGTH));
		}

		$session = self::getInstance();
		$session->http = new HTTP;

		if ($session->isPasswordCompromised($password)) {
			throw new UserException('Ce mot de passe figure dans une liste de mots de passe compromis, il ne peut donc être utilisé ici. Si vous l\'avez utilisé sur d\'autres sites il est recommandé de le changer sur ces autres sites également.');
		}
	}

	public function isPasswordCompromised($password)
	{
		// Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect
		// notamment en installation mutualisée c'est plus efficace
		$return = ['is_compromised' => null];
		$called = Plugin::fireSignal('motdepasse.compromis', ['password' => $password], $return);

		if ($called !== null) {
			return $return['is_compromised'];
		}

		return parent::isPasswordCompromised($password);
	}

	protected function getUserForLogin($login)
	{
		$champ_id = Config::getInstance()->get('champ_identifiant');

		// Ne renvoie un membre que si celui-ci a le droit de se connecter
		$query = 'SELECT m.id, m.%1$s AS login, m.passe AS password, m.secret_otp AS otp_secret
			FROM membres AS m
			INNER JOIN users_categories AS c ON c.id = m.id_category
			WHERE m.%1$s = ? COLLATE U_NOCASE AND c.perm_connect >= %2$d
			LIMIT 1;';

		$query = sprintf($query, $champ_id, self::ACCESS_READ);

		return $this->db->first($query, $login);
	}

	protected function getUserDataForSession($id)
	{
		// Mettre à jour la date de connexion
		$this->db->preparedQuery('UPDATE membres SET date_connexion = datetime() WHERE id = ?;', [$id]);
		$config = Config::getInstance();

		return $this->db->first('SELECT m.*, m.'.$config->get('champ_identite').' AS identite,
			c.perm_connect, c.perm_web, c.perm_users, c.perm_documents,
			c.perm_subscribe, c.perm_accounting, c.perm_config
			FROM membres AS m
			INNER JOIN users_categories AS c ON m.id_category = c.id
			WHERE m.id = ? LIMIT 1;', $id);
	}

	protected function storeRememberMeSelector($selector, $hash, $expiry, $user_id)
	{
		return $this->db->insert('membres_sessions', [
			'selecteur' => $selector,
			'hash'      => $hash,
			'expire'    => $expiry,
			'id_membre' => $user_id,
		]);
	}

	protected function expireRememberMeSelectors()
	{
		return $this->db->delete('membres_sessions', $this->db->where('expire', '<', time()));
	}

	protected function getRememberMeSelector($selector)
	{
		return $this->db->first('SELECT selecteur AS selector, hash,
			s.id_membre AS user_id, m.passe AS user_password, expire AS expiry
			FROM membres_sessions AS s
			INNER JOIN membres AS m ON m.id = s.id_membre
			WHERE s.selecteur = ? LIMIT 1;', $selector);
	}

	protected function deleteRememberMeSelector($selector)
	{
		return $this->db->delete('membres_sessions', $this->db->where('selecteur', $selector));
	}

	protected function deleteAllRememberMeSelectors($user_id)
	{
		return $this->db->delete('membres_sessions', $this->db->where('id_membre', $user_id));
	}

	public function isLogged(bool $disable_local_login = false)
	{
		$logged = parent::isLogged();

		// Ajout de la gestion de LOCAL_LOGIN
		if (!$disable_local_login && defined('\Garradin\LOCAL_LOGIN')) {
			$logged = $this->forceLogin(\Garradin\LOCAL_LOGIN);
		}

		return $logged;
	}

	public function forceLogin(int $id)
	{
		// On va chercher le premier membre avec le droit de gérer la config
		if (-1 === $id) {
			$id = $this->db->firstColumn('SELECT id FROM membres
				WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?)
				LIMIT 1', self::ACCESS_ADMIN);
		}

		$logged = parent::isLogged();

		// Only login if required
		if ($id > 0 && (!$logged || ($logged && $this->user->id != $id))) {
			return $this->create($id);
		}

		return $logged;
	}

	// Ici checkOTP utilise NTP en second recours
	public function checkOTP($secret, $code)
	{
		if (Security_OTP::TOTP($secret, $code))
		{
			return true;
		}

		// Vérifier encore, mais avec le temps NTP
		// au cas où l'horloge du serveur n'est pas à l'heure
		if (\Garradin\NTP_SERVER
			&& ($time = Security_OTP::getTimeFromNTP(\Garradin\NTP_SERVER))
			&& Security_OTP::TOTP($secret, $code, $time))
		{
			return true;
		}

		return false;
	}

	public function getOTPSecret($secret = null)
	{
		if (!$secret)
		{
			$secret = Security_OTP::getRandomSecret();
		}

		$out = [];
		$out['secret'] = $secret;
		$out['secret_display'] = implode(' ', str_split($secret, 4));
		$out['url'] = Security_OTP::getOTPAuthURL(Config::getInstance()->get('nom_asso'), $secret);

		$qrcode = new QRCode($out['url']);
		$out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG());

		return $out;
	}

	public function recoverPasswordSend($id)
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		$champ_id = $config->get('champ_identifiant');

		$membre = $db->first('SELECT id, email, passe, clef_pgp FROM membres WHERE '.$champ_id.' = ? COLLATE U_NOCASE LIMIT 1;', trim($id));

		if (!$membre || trim($membre->email) == '')
		{
			return false;
		}

		// valide pour 1 heure minimum
		$expire = ceil((time() - strtotime('2017-01-01')) / 3600) + 1;

		$hash = hash_hmac('sha256', $membre->email . $membre->id . $membre->passe . $expire, SECRET_KEY, true);
		$hash = substr(Security::base64_encode_url_safe($hash), 0, 16);

		$id = base_convert($membre->id, 10, 36);
		$expire = base_convert($expire, 10, 36);

		$query = sprintf('%s.%s.%s', $id, $expire, $hash);

		$message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n";
		$message.= "Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe.\n\n";
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";

		if ($membre->clef_pgp) {
			$content = Security::encryptWithPublicKey($membre->clef_pgp, $message);
		}

		Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe perdu ?', $message);
		return true;
	}

	public function recoverPasswordCheck($code, &$membre = null)
	{
		if (substr_count($code, '.') !== 2)
		{
			return false;
		}

		list($id, $expire, $email_hash) = explode('.', $code);

		$config = Config::getInstance();
		$db = DB::getInstance();

		$id = base_convert($id, 36, 10);
		$expire = base_convert($expire, 36, 10);

		$expire_timestamp = ($expire * 3600) + strtotime('2017-01-01');

		if (time() / 3600 > $expire_timestamp)
		{
			return false;
		}

		$membre = $db->first('SELECT id, email, passe, clef_pgp FROM membres WHERE id = ? LIMIT 1;', (int)$id);

		if (!$membre || trim($membre->email) == '')
		{
			return false;
		}

		$hash = hash_hmac('sha256', $membre->email . $membre->id . $membre->passe . $expire, SECRET_KEY, true);
		$hash = substr(Security::base64_encode_url_safe($hash), 0, 16);

		if (!hash_equals($hash, $email_hash))
		{
			return false;
		}

		return true;
	}

	public function recoverPasswordChange($code, $password, $password_confirm)
	{
		if (!$this->recoverPasswordCheck($code, $membre))
		{
			throw new UserException('Le code permettant de changer le mot de passe a expiré. Merci de bien vouloir recommencer la procédure.');
		}

		$password = trim($password);
		$password_confirm = trim($password_confirm);

		if (!hash_equals($password, $password_confirm))
		{
			throw new UserException('Le mot de passe et sa vérification ne sont pas identiques.');
		}

		self::checkPasswordValidity($password);

		$password = self::hashPassword($password);

		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$membre->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('membres', ['passe' => $password], 'id = :id', ['id' => (int)$membre->id]);

		return Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe changé', $message);
	}

	public function editUser($data)
	{
		(new Membres)->edit($this->user->id, $data, false);
		$this->refresh();

		return true;
	}

	public function getUser()
	{
		$user = parent::getUser();

		// Force refresh of session when it's too old (FIXME: remove at version 1.2+)
		if (!property_exists($this->user, 'perm_users')) {
			$this->refresh();
			$user = $this->getUser();
		}

		return $user;
	}

	public function canAccess($category, $permission)
	{
		if (!$this->getUser())
		{
			return false;
		}

		$perm_name = 'perm_' . $category;
		$perm = $this->getUser()->$perm_name;

		return ($perm >= $permission);
	}

	public function requireAccess($category, $permission)
	{
		if (!$this->canAccess($category, $permission))
		{
			throw new UserException('Vous n\'avez pas le droit d\'accéder à cette page.');
		}
	}

	public function getNewOTPSecret()
	{
		$out = [];
		$out['secret'] = Security_OTP::getRandomSecret();
		$out['secret_display'] = implode(' ', str_split($out['secret'], 4));
		$out['url'] = Security_OTP::getOTPAuthURL(Config::getInstance()->get('nom_asso'), $out['secret']);

		$qrcode = new QRCode($out['url']);
		$out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG());

		return $out;
	}

	public function sendMessage($dest, $sujet, $message, $copie = false)
	{
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;

		$dest = $copie ? [$dest => null, $user->email => null] : [$dest => null];

		return Emails::queue(Emails::CONTEXT_PRIVATE, $dest, null, $sujet, $content);
	}

	public function editSecurity(Array $data = [])
	{
		$allowed_fields = ['passe', 'clef_pgp', 'secret_otp'];

		foreach ($data as $key=>$value)
		{
			if (!in_array($key, $allowed_fields))
			{
				throw new \RuntimeException(sprintf('Le champ %s n\'est pas autorisé dans cette méthode.', $key));
			}
		}

		if (isset($data['passe']) && trim($data['passe']) !== '')
		{
			$data['passe'] = trim($data['passe']);

			self::checkPasswordValidity($data['passe']);

			$data['passe'] = self::hashPassword($data['passe']);
		}
		else
		{
			unset($data['passe']);
		}

		if (isset($data['clef_pgp']) && trim($data['clef_pgp']) !== '')
		{
			$data['clef_pgp'] = trim($data['clef_pgp']);

			if (!$this->getPGPFingerprint($data['clef_pgp']))
			{
				throw new UserException('Clé PGP invalide : impossible d\'extraire l\'empreinte.');
			}
		}

		$db = DB::getInstance();
		$db->update('membres', $data, $db->where('id', (int)$this->user->id));
		$this->refresh();

		return true;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































































































































































































































































































































































































































































































































































































































































































































































Modified src/include/lib/Garradin/Plugin.php from [14ad010455] to [cdc93fe561].

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

namespace Garradin;

use Garradin\Membres\Session;

class Plugin
{
	const PLUGIN_ID_SYNTAX = '[a-z]+(?:_[a-z]+)*';

	protected $id = null;
	protected $plugin = null;
	protected $config_changed = false;







	protected $mimes = [
		'css' => 'text/css',
		'gif' => 'image/gif',
		'htm' => 'text/html',
		'html' => 'text/html',
		'ico' => 'image/x-ico',
		'jpe' => 'image/jpeg',
		'jpg' => 'image/jpeg',
		'jpeg' => 'image/jpeg',
		'js' => 'application/x-javascript',
		'pdf' => 'application/pdf',
		'png' => 'image/png',
		'swf' => 'application/shockwave-flash',
		'xml' => 'text/xml',
		'svg' => 'image/svg+xml',
	];





	static public function getPath($id)
	{
		if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
		{
			return 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz';
		}




|









>
>
>
>
>
>
















>
>
>
>







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

use Garradin\Users\Session;

class Plugin
{
	const PLUGIN_ID_SYNTAX = '[a-z]+(?:_[a-z]+)*';

	protected $id = null;
	protected $plugin = null;
	protected $config_changed = false;

	/**
	 * Set to false to disable signal firing
	 * @var boolean
	 */
	static protected $signals = true;

	protected $mimes = [
		'css' => 'text/css',
		'gif' => 'image/gif',
		'htm' => 'text/html',
		'html' => 'text/html',
		'ico' => 'image/x-ico',
		'jpe' => 'image/jpeg',
		'jpg' => 'image/jpeg',
		'jpeg' => 'image/jpeg',
		'js' => 'application/x-javascript',
		'pdf' => 'application/pdf',
		'png' => 'image/png',
		'swf' => 'application/shockwave-flash',
		'xml' => 'text/xml',
		'svg' => 'image/svg+xml',
	];

	static public function toggleSignals(bool $enabled) {
		self::$signals = $enabled;
	}

	static public function getPath($id)
	{
		if (file_exists(PLUGINS_ROOT . '/' . $id . '.tar.gz'))
		{
			return 'phar://' . PLUGINS_ROOT . '/' . $id . '.tar.gz';
		}
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

		if (!$this->plugin)
		{
			throw new UserException(sprintf('Le plugin "%s" n\'existe pas ou n\'est pas installé correctement.', $id));
		}

		$this->plugin->config = json_decode($this->plugin->config ?? '');
		
		if (!is_object($this->plugin->config))
		{
			$this->plugin->config = new \stdClass;
		}

		// Juste pour vérifier que le fichier source du plugin existe bien
		self::getPath($id);







|







65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

		if (!$this->plugin)
		{
			throw new UserException(sprintf('Le plugin "%s" n\'existe pas ou n\'est pas installé correctement.', $id));
		}

		$this->plugin->config = json_decode($this->plugin->config ?? '');

		if (!is_object($this->plugin->config))
		{
			$this->plugin->config = new \stdClass;
		}

		// Juste pour vérifier que le fichier source du plugin existe bien
		self::getPath($id);
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
		if (file_exists($this->path() . '/uninstall.php'))
		{
			$plugin = $this;
			include $this->path() . '/uninstall.php';
		}

		$db = DB::getInstance();
		$db->delete('plugins_signaux', 'plugin = ?', $this->id);
		return $db->delete('plugins', 'id = ?', $this->id);
	}

	/**
	 * Renvoie TRUE si le plugin a besoin d'être mis à jour
	 * (si la version notée dans la DB est différente de la version notée dans garradin_plugin.ini)
	 * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon







|







267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
		if (file_exists($this->path() . '/uninstall.php'))
		{
			$plugin = $this;
			include $this->path() . '/uninstall.php';
		}

		$db = DB::getInstance();
		$db->delete('plugins_signals', 'plugin = ?', $this->id);
		return $db->delete('plugins', 'id = ?', $this->id);
	}

	/**
	 * Renvoie TRUE si le plugin a besoin d'être mis à jour
	 * (si la version notée dans la DB est différente de la version notée dans garradin_plugin.ini)
	 * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
			$plugin = $this;
			include $this->path() . '/upgrade.php';
		}

		$infos = (object) parse_ini_file($this->path() . '/garradin_plugin.ini', false);

		$data = [
			'nom'		=>	$infos->nom,
			'description'=>	$infos->description,
			'auteur'	=>	$infos->auteur,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
			'menu'		=>	(int)(bool)$infos->menu,
		];

		if ($infos->menu && !empty($infos->menu_condition))
		{







|

|







302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
			$plugin = $this;
			include $this->path() . '/upgrade.php';
		}

		$infos = (object) parse_ini_file($this->path() . '/garradin_plugin.ini', false);

		$data = [
			'name'		=>	$infos->name ?? $infos->nom,
			'description'=>	$infos->description,
			'author'	=>	$infos->author ?? $infos->auteur,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
			'menu'		=>	(int)(bool)$infos->menu,
		];

		if ($infos->menu && !empty($infos->menu_condition))
		{
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
		}

		$db = DB::getInstance();

		// Signaux exclusifs, qui ne peuvent être attribués qu'à un seul plugin
		if (strpos($signal, 'boucle.') === 0)
		{
			$registered = $db->firstColumn('SELECT plugin FROM plugins_signaux WHERE signal = ? AND plugin != ?;', $signal, $this->id);

			if ($registered)
			{
				throw new \LogicException('Le signal ' . $signal . ' est exclusif et déjà associé au plugin "'.$registered.'"');
			}
		}

		$callable_name = str_replace('Garradin\\Plugin\\', '', $callable_name);

		$st = $db->prepare('INSERT OR REPLACE INTO plugins_signaux VALUES (:signal, :plugin, :callback);');
		$st->bindValue(':signal', $signal);
		$st->bindValue(':plugin', $this->id);
		$st->bindValue(':callback', $callable_name);
		return $st->execute();
	}

	/**
	 * Liste des plugins installés (en DB)
	 * @return array Liste des plugins triés par nom
	 */
	static public function listInstalled()
	{
		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, * FROM plugins ORDER BY nom;');

		foreach ($plugins as &$row)
		{
			$row->disabled = !self::getPath($row->id, false);
		}

		return $plugins;







|









|













|







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
		}

		$db = DB::getInstance();

		// Signaux exclusifs, qui ne peuvent être attribués qu'à un seul plugin
		if (strpos($signal, 'boucle.') === 0)
		{
			$registered = $db->firstColumn('SELECT plugin FROM plugins_signals WHERE signal = ? AND plugin != ?;', $signal, $this->id);

			if ($registered)
			{
				throw new \LogicException('Le signal ' . $signal . ' est exclusif et déjà associé au plugin "'.$registered.'"');
			}
		}

		$callable_name = str_replace('Garradin\\Plugin\\', '', $callable_name);

		$st = $db->prepare('INSERT OR REPLACE INTO plugins_signals VALUES (:signal, :plugin, :callback);');
		$st->bindValue(':signal', $signal);
		$st->bindValue(':plugin', $this->id);
		$st->bindValue(':callback', $callable_name);
		return $st->execute();
	}

	/**
	 * Liste des plugins installés (en DB)
	 * @return array Liste des plugins triés par nom
	 */
	static public function listInstalled()
	{
		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, * FROM plugins ORDER BY name;');

		foreach ($plugins as &$row)
		{
			$row->disabled = !self::getPath($row->id, false);
		}

		return $plugins;
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
	 */
	static public function listMenu(Session $session)
	{
		$list = [];

		// First let plugins handle
		self::fireSignal('menu.item', compact('session'), $list);

		$db = DB::getInstance();
		$plugins = $db->getGrouped('SELECT id, nom, menu_condition FROM plugins WHERE menu = 1 ORDER BY nom;');

		// FIXME deprecated
		$fix_legacy = [
			'{Membres::DROIT_AUCUN}' => '{ACCESS_NONE}',
			'{Membres::DROIT_ACCES}' => '{ACCESS_READ}',
			'{Membres::DROIT_ECRITURE}' => '{ACCESS_WRITE}',
			'{Membres::DROIT_ADMIN}' => '{ACCESS_ADMIN}',
			'{$user.droit_compta}' => '{$user.perm_accounting}',
			'{$user.droit_membres}' => '{$user.perm_users}',
			'{$user.droit_config}' => '{$user.perm_config}',
			'{$user.droit_wiki}' => '{$user.perm_documents}',
		];

		$user = $session->getUser();
		$permissions = [
			'{ACCESS_NONE}'  => $session::ACCESS_NONE,
			'{ACCESS_READ}'  => $session::ACCESS_READ,
			'{ACCESS_WRITE}' => $session::ACCESS_WRITE,
			'{ACCESS_ADMIN}' => $session::ACCESS_ADMIN,
		];

		foreach ($plugins as $id => $row)
		{
			if (!self::getPath($row->id, false))
			{
				// Ne pas lister les plugins dont le code a disparu
				unset($list[$id]);
				continue;
			}

			if (!$row->menu_condition)
			{
				continue;
			}

			$new_condition = strtr($row->menu_condition, $fix_legacy);

			// FIXME: legacy
			if ($new_condition != $row->menu_condition) {
				$db->update('plugins', ['menu_condition' => $new_condition], 'id = :id', ['id' => $id]);
				$row->menu_condition = $new_condition;
			}

			$condition = strtr($row->menu_condition, $permissions);

			$condition = preg_replace_callback('/\{\$user\.(\w+)\}/', function ($m) use ($user, $db) {
				$prop = $m[1];
				if (!property_exists($user, $prop)) {
					return 'NULL';
				}

				if (substr($prop, 0, 5) == 'perm_') {
					return (int) $user->$prop;
				}

				return $db->quote($user->$prop);
			}, $condition);

			$query = 'SELECT 1 WHERE ' . $condition . ';';

			$res = $db->protectSelect(['membres' => []], $query);

			if (!$db->firstColumn($query))
			{
				unset($plugins[$id]);
				continue;
			}
		}

		foreach ($plugins as $id => $row) {
			$list['plugin_' . $id] = sprintf('<a href="%s%s/">%s</a>', Utils::getLocalURL('!plugin/'), $id, $row->nom);
		}

		ksort($list);

		return $list;
	}

	/**
	 * Liste les plugins téléchargés mais non installés







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







412
413
414
415
416
417
418












































































419
420
421
422
423
424
425
	 */
	static public function listMenu(Session $session)
	{
		$list = [];

		// First let plugins handle
		self::fireSignal('menu.item', compact('session'), $list);












































































		ksort($list);

		return $list;
	}

	/**
	 * Liste les plugins téléchargés mais non installés
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
		if (!file_exists($path . '/garradin_plugin.ini'))
		{
			throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Garradin : fichier garradin_plugin.ini manquant.', $id));
		}

		$infos = (object) parse_ini_file($path . '/garradin_plugin.ini', false);

		$required = ['nom', 'description', 'auteur', 'url', 'version', 'menu', 'config'];

		foreach ($required as $key)
		{
			if (!property_exists($infos, $key))
			{
				throw new \RuntimeException('Le fichier garradin_plugin.ini ne contient pas d\'entrée "'.$key.'".');
			}







|







614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
		if (!file_exists($path . '/garradin_plugin.ini'))
		{
			throw new UserException(sprintf('Le plugin "%s" n\'est pas une extension Garradin : fichier garradin_plugin.ini manquant.', $id));
		}

		$infos = (object) parse_ini_file($path . '/garradin_plugin.ini', false);

		$required = ['name', 'description', 'author', 'url', 'version', 'menu', 'config'];

		foreach ($required as $key)
		{
			if (!property_exists($infos, $key))
			{
				throw new \RuntimeException('Le fichier garradin_plugin.ini ne contient pas d\'entrée "'.$key.'".');
			}
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750

			$config = json_encode($config);
		}

		$data = [
			'id' 		=> 	$id,
			'officiel' 	=> 	(int)(bool)$official,
			'nom'		=>	$infos->nom,
			'description'=>	$infos->description,
			'auteur'	=>	$infos->auteur,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
			'menu'		=>	(int)(bool)$infos->menu,
			'config'	=>	$config,
		];

		if ($infos->menu && !empty($infos->menu_condition))







|

|







668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684

			$config = json_encode($config);
		}

		$data = [
			'id' 		=> 	$id,
			'officiel' 	=> 	(int)(bool)$official,
			'name'		=>	$infos->name,
			'description'=>	$infos->description,
			'author'	=>	$infos->author,
			'url'		=>	$infos->url,
			'version'	=>	$infos->version,
			'menu'		=>	(int)(bool)$infos->menu,
			'config'	=>	$config,
		];

		if ($infos->menu && !empty($infos->menu_condition))
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
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stoppé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{




		// Process SYSTEM_SIGNALS first
		foreach (SYSTEM_SIGNALS as $system_signal) {
			if (key($system_signal) != $signal) {
				continue;
			}

			if (!is_callable(current($system_signal))) {
				throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), key($system_signal)));
			}

			if (true === call_user_func_array(current($system_signal), [&$params, &$callback_return])) {
				return true;
			}
		}

		$list = DB::getInstance()->get('SELECT * FROM plugins_signaux WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];







>
>
>
>















|







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
	 * @param  array  $params Paramètres du callback (array ou null)
	 * @return NULL 		  NULL si aucun plugin n'a été appelé,
	 * TRUE si un plugin a été appelé et a arrêté l'exécution,
	 * FALSE si des plugins ont été appelés mais aucun n'a stoppé l'exécution
	 */
	static public function fireSignal($signal, $params = null, &$callback_return = null)
	{
		if (!self::$signals) {
			return null;
		}

		// Process SYSTEM_SIGNALS first
		foreach (SYSTEM_SIGNALS as $system_signal) {
			if (key($system_signal) != $signal) {
				continue;
			}

			if (!is_callable(current($system_signal))) {
				throw new \LogicException(sprintf('System signal: cannot call "%s" for signal "%s"', current($system_signal), key($system_signal)));
			}

			if (true === call_user_func_array(current($system_signal), [&$params, &$callback_return])) {
				return true;
			}
		}

		$list = DB::getInstance()->get('SELECT * FROM plugins_signals WHERE signal = ?;', $signal);

		if (!count($list)) {
			return null;
		}

		if (null === $params) {
			$params = [];

Deleted src/include/lib/Garradin/Recherche.php version [77d31061bf].

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

namespace Garradin;

use Garradin\Entities\Accounting\Transaction;
use Garradin\Accounting\Accounts;
use Garradin\Accounting\Years;

class Recherche
{
	const TYPE_JSON = 'json';
	const TYPE_SQL = 'sql';
	const TYPE_SQL_UNPROTECTED = 'sql_unprotected';

	const TARGETS = [
		'membres',
		'compta',
	];

	protected function _checkFields($data)
	{
		$db = DB::getInstance();

		if (array_key_exists('intitule', $data) && trim($data['intitule']) === '')
		{
			throw new UserException('Le champ intitulé ne peut être vide.');
		}

		if (array_key_exists('id_membre', $data) && null !== $data['id_membre'])
		{
			$data['id_membre'] = (int)$data['id_membre'];
		}

		if (array_key_exists('id_membre', $data) && null !== $data['id_membre'] && !$db->test('membres', 'id = ?', $data['id_membre']))
		{
			throw new \InvalidArgumentException('Numéro d\'utilisateur inconnu.');
		}

		static $types = [self::TYPE_SQL, self::TYPE_JSON, self::TYPE_SQL_UNPROTECTED];

		if (array_key_exists('type', $data) && !in_array($data['type'], $types))
		{
			throw new \InvalidArgumentException('Type de recherche inconnu.');
		}

		if (array_key_exists('cible', $data) && !in_array($data['cible'], self::TARGETS, true))
		{
			throw new \InvalidArgumentException('Cible de recherche invalide.');
		}

		$query = null;

		if (array_key_exists('type', $data))
		{
			if (empty($data['contenu']))
			{
				throw new UserException('Le contenu ne peut être vide.');
			}

			if ($data['type'] == self::TYPE_SQL && !is_string($data['contenu']))
			{
				throw new \InvalidArgumentException('Recherche invalide pour le type SQL');
			}

			$query = $data['contenu'];

			if ($data['type']  == self::TYPE_JSON)
			{
				if (!is_object($query))
				{
					throw new \InvalidArgumentException('Recherche invalide pour le type JSON');
				}

				$query = json_encode($query);

				if (!json_decode($query))
				{
					throw new \InvalidArgumentException('JSON invalide pour le type JSON');
				}
			}
		}

		return $query;
	}

	public function edit($id, $data)
	{
		$allowed = ['intitule', 'id_membre', 'type', 'cible', 'contenu'];

		// Supprimer les champs qui ne sont pas ceux de la BDD
		$data = array_intersect_key($data, array_flip($allowed));

		$query = $this->_checkFields($data);

		if (isset($data['contenu']))
		{
			$data['contenu'] = $query;
		}

		return DB::getInstance()->update('recherches', $data, 'id = ' . (int)$id);
	}

	public function add($intitule, $id_membre, $type, $cible, $contenu)
	{
		$data = compact('intitule', 'id_membre', 'type', 'cible', 'contenu');
		$data['contenu'] = $this->_checkFields($data);

		$db = DB::getInstance();

		$db->insert('recherches', $data);

		return $db->lastInsertRowId();
	}

	public function remove($id)
	{
		return DB::getInstance()->delete('recherches', 'id = ?', (int) $id);
	}

	public function get($id)
	{
		$r = DB::getInstance()->first('SELECT * FROM recherches WHERE id = ?;', (int) $id);

		if ($r && $r->type == self::TYPE_JSON) {
			$r->query = (object) json_decode($r->contenu, true);
		}

		return $r;
	}

	public function getList($id_membre, $cible)
	{
		return DB::getInstance()->get('SELECT id, type, intitule, type, id_membre FROM recherches 
			WHERE (id_membre IS NULL OR id_membre = ?) AND cible = ?
			ORDER BY intitule;', (int)$id_membre, $cible);
	}

	/**
	 * Lancer une recherche enregistrée
	 */
	public function search($id, array $force_select = null, $no_limit = false)
	{
		$search = $this->get($id);

		if (!$search)
		{
			return false;
		}

		if ($search->type == self::TYPE_JSON)
		{
			$query = $search->query;
			$search->contenu = $this->buildQuery($search->cible, $query->query, $query->order, $query->desc, $no_limit ? 10000 : $query->limit);
		}

		$unprotected = $search->type == self::TYPE_SQL_UNPROTECTED;

		return $this->searchSQL($search->cible, $search->contenu, $force_select, $no_limit, $unprotected);
	}

	public function getResultHeader(string $target, array $result)
	{
		if (!count($result)) {
			return [];
		}

		$out = [];
		$columns = $this->getColumns($target);

		foreach (reset($result) as $key => $v) {
			if (substr($key, 0, 1) == '_') {
				continue;
			}

			$label = null;

			foreach ($columns as $ckey => $config) {
				if ($ckey == $key) {
					$label = $config->label;
					break;
				}
				elseif (isset($config->alias) && $config->alias == $key) {
					$key = $config->alias;
					$label = $config->label;
					break;
				}
			}

			if (!$label) {
				$label = $key;
			}

			$out[$key] = $label;
		}

		return $out;
	}

	public function getDefaultOrder(string $target): string
	{
		if ($target == 'compta') {
			return 't.date';
		}
		else {
			return Config::getInstance()->get('champ_identite');
		}
	}

	public function getDefaultDesc(string $target): bool
	{
		if ($target == 'compta') {
			return true;
		}
		else {
			return false;
		}
	}

	/**
	 * Renvoie la liste des colonnes d'une cible
	 */
	public function getColumns($target)
	{
		$columns = [];
		$db = DB::getInstance();

		if ($target == 'membres')
		{
			$champs = Config::getInstance()->get('champs_membres');

			$columns['id_category'] = (object) [
					'textMatch'=> false,
					'label'    => 'Catégorie',
					'type'     => 'enum',
					'null'     => false,
					'values'   => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE U_NOCASE;'),
				];

			foreach ($champs->getList() as $champ => $config)
			{
				$column = (object) [
					'textMatch'=> $champs->isText($champ),
					'label'    => $config->title,
					'type'     => 'text',
					'null'     => true,
				];

				if ($config->type == 'checkbox')
				{
					$column->type = 'boolean';
				}
				elseif ($config->type == 'select')
				{
					$column->type = 'enum';
					$column->values = array_combine($config->options, $config->options);
				}
				elseif ($config->type == 'multiple')
				{
					$column->type = 'bitwise';
					$column->values = $config->options;
				}
				elseif ($config->type == 'date' || $config->type == 'datetime')
				{
					$column->type = $config->type;
				}
				elseif ($config->type == 'number' || $champ == 'numero')
				{
					$column->type = 'integer';
				}

				if ($config->type == 'tel') {
					$column->originalType = 'tel';
				}

				$columns[$champ] = $column;
			}
		}
		elseif ($target === 'compta') {
			$columns['t.id'] = (object) [
				'textMatch'=> false,
				'label'    => 'Numéro écriture',
				'type'     => 'integer',
				'null'     => false,
				'alias'    => 'transaction_id',
			];

			$columns['t.date'] = (object) [
				'textMatch'=> false,
				'label'    => 'Date',
				'type'     => 'date',
				'null'     => false,
				'alias'    => 'date',
			];

			$columns['t.label'] = (object) [
				'textMatch'=> true,
				'label'    => 'Libellé écriture',
				'type'     => 'text',
				'null'     => false,
				'alias'    => 'label',
			];

			$columns['t.reference'] = (object) [
				'textMatch'=> true,
				'label'    => 'Numéro pièce comptable',
				'type'     => 'text',
				'null'     => true,
				'alias'    => 'reference',
			];

			$columns['t.notes'] = (object) [
				'textMatch'=> true,
				'label'    => 'Remarques',
				'type'     => 'text',
				'null'     => true,
				'alias'    => 'notes',
			];

			$columns['l.label'] = (object) [
				'textMatch'=> true,
				'label'    => 'Libellé ligne',
				'type'     => 'text',
				'null'     => true,
				'alias'    => 'line_label',
			];

			$columns['l.debit'] = (object) [
				'textMatch'=> false,
				'label'    => 'Débit',
				'type'     => 'text',
				'null'     => false,
				'alias'    => 'debit',
				'originalType' => 'money',
			];

			$columns['l.credit'] = (object) [
				'textMatch'=> false,
				'label'    => 'Crédit',
				'type'     => 'text',
				'null'     => false,
				'alias'    => 'credit',
				'originalType' => 'money',
			];

			$columns['l.reference'] = (object) [
				'textMatch'=> true,
				'label'    => 'Référence ligne écriture',
				'type'     => 'text',
				'null'     => true,
				'alias'    => 'line_reference',
			];

			$columns['t.type'] = (object) [
				'textMatch'=> false,
				'label'    => 'Type d\'écriture',
				'type'     => 'enum',
				'null'     => false,
				'values'   => Transaction::TYPES_NAMES,
				'alias'    => 'type',
			];

			$columns['a.code'] = (object) [
				'textMatch'=> true,
				'label'    => 'Numéro de compte',
				'type'     => 'text',
				'null'     => false,
				'alias'    => 'code',
			];

			$columns['t.id_year'] = (object) [
				'textMatch'=> false,
				'label'    => 'Exercice',
				'type'     => 'enum',
				'null'     => false,
				'values'   => $db->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;'),
				'alias'    => 'id_year',
			];

			$columns['a2.code'] = (object) [
				'textMatch'=> true,
				'label'    => 'N° de compte projet',
				'type'     => 'text',
				'null'     => true,
				'alias'    => 'id_analytical',
			];
		}

		return $columns;
	}

	/**
	 * Construire une recherche SQL à partir d'un objet généré par QueryBuilder
	 * @param  string  $target Cible de la requête : membres, compta_journal, etc.
	 * @param  array   $groups Groupes de critères
	 * @param  string  $order  Ordre de tri
	 * @param  boolean $desc   Inverser le tri
	 * @param  integer $limit  Limite
	 * @return string Chaîne SQL
	 */
	public function buildQuery(string $target, array $groups, string $order, bool $desc = false, int $limit = 100)
	{
		if (!in_array($target, self::TARGETS, true))
		{
			throw new \InvalidArgumentException('Cible inconnue : ' . $target);
		}

		$config = Config::getInstance();

		if ($target == 'membres')
		{
			$champs = $config->get('champs_membres');
		}

		$db = DB::getInstance();
		$target_columns = $this->getColumns($target);

		if (!isset($target_columns[$order])) {
			throw new UserException('Colonne de tri inconnue : ' . $order);
		}

		$query_columns = [];

		$query_groups = [];

		static $no_transform_operators = ['IS NULL', 'IS NOT NULL', '= 0', '= 1', '&'];

		foreach ($groups as $group)
		{
			if (!isset($group['conditions'], $group['operator'])
				|| !is_array($group['conditions'])
				|| ($group['operator'] != 'AND' && $group['operator'] != 'OR'))
			{
				// Ignorer les groupes de conditions invalides
				continue;
			}

			$query_group_conditions = [];

			foreach ($group['conditions'] as $condition)
			{
				if (!isset($condition['column'], $condition['operator'])
					|| (isset($condition['values']) && !is_array($condition['values'])))
				{
					// Ignorer les conditions invalides
					continue;
				}

				if (!array_key_exists($condition['column'], $target_columns))
				{
					// Ignorer une condition qui se rapporte à une colonne
					// qui n'existe pas, cas possible si on reprend une recherche
					// après avoir modifié les fiches de membres
					continue;
				}

				$query_columns[] = $condition['column'];
				$column = $target_columns[$condition['column']];

				$query = sprintf('%s %s', $db->quoteIdentifier($condition['column']), $condition['operator']);

				$values = isset($condition['values']) ? $condition['values'] : [];

				if (!empty($column->originalType)) {
					if ($column->originalType == 'tel') {
						// Normaliser le numéro de téléphone
						$values = array_map(['Garradin\Utils', 'normalizePhoneNumber'], $values);
					}
					elseif ($column->originalType == 'money') {
						$values = array_map(['Garradin\Utils', 'moneyToInteger'], $values);
					}
				}

				// L'opérateur binaire est un peu spécial
				if ($condition['operator'] == '&')
				{
					$new_query = [];

					foreach ($values as $value)
					{
						$new_query[] = sprintf('%s (1 << %d)', $query, (int) $value);
					}

					$query = '(' . implode(' AND ', $new_query) . ')';
				}
				// Remplacement de liste
				elseif (strpos($query, '??') !== false)
				{
					$values = array_map([$db, 'quote'], $values);
					$query = str_replace('??', implode(', ', $values), $query);
				}
				// Remplacement de recherche LIKE
				elseif (preg_match('/%\?%|%\?|\?%/', $query, $match))
				{
					$value = str_replace(['%', '_'], ['\\%', '\\_'], reset($values));
					$value = str_replace('?', $value, $match[0]);
					$query = str_replace($match[0], sprintf('%s ESCAPE \'\\\'', $db->quote($value)), $query);
				}
				// Remplacement de paramètre
				elseif (strpos($query, '?') !== false)
				{
					$expected = substr_count($query, '?');
					$found = count($values);

					if ($expected != $found)
					{
						throw new \RuntimeException(sprintf('Operator %s expects at least %d parameters, only %d supplied', $condition['operator'], $expected, $found));
					}

					for ($i = 0; $i < $expected; $i++)
					{
						$pos = strpos($query, '?');
						$query = substr_replace($query, $db->quote(array_shift($values)), $pos, 1);
					}
				}

				$query_group_conditions[] = $query;
			}

			if (count($query_group_conditions))
			{
				$query_groups[] = implode(' ' . $group['operator'] . ' ', $query_group_conditions);
			}
		}

		if (!count($query_groups))
		{
			throw new UserException('Aucune clause trouvée dans la recherche : elle contenait peut-être des clauses qui correspondent à des champs qui ont été supprimés ?');
		}

		// Ajout du champ identité si pas présent
		if ($target == 'membres')
		{
			$query_columns = array_merge([$config->get('champ_identite')], $query_columns);
		}
		// Ajout de champs compta si pas présents
		elseif ($target == 'compta')
		{
			$query_columns = array_merge(['t.id', 't.date', 't.label', 'l.debit', 'l.credit', 'a.code'], $query_columns);
		}

		$query_columns[] = $order;

		if ($target_columns[$order]->textMatch)
		{
			$order = sprintf('%s COLLATE U_NOCASE', $db->quoteIdentifier($order));
		}
		else
		{
			$order = $db->quoteIdentifier($order);
		}

		$query_columns = array_unique($query_columns);
		$query_columns = array_map(function ($column) use ($target_columns, $db) {
			if (isset($target_columns[$column]->alias)) {
				return sprintf('%s AS %s', $db->quoteIdentifier($column), $db->quote($target_columns[$column]->alias));
			}
			return $db->quoteIdentifier($column);
		}, $query_columns);

		$query_columns = implode(', ', $query_columns);

		$query_groups = '(' . implode(') AND (', $query_groups) . ')';

		$desc = $desc ? 'DESC' : 'ASC';

		if ('compta' === $target) {
			$sql_query = sprintf('SELECT %s
				FROM acc_transactions AS t
				INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id
				INNER JOIN acc_accounts AS a ON l.id_account = a.id
				LEFT JOIN acc_accounts AS a2 ON l.id_analytical = a2.id
				WHERE %s GROUP BY t.id ORDER BY %s %s LIMIT %d;',
				$query_columns, $query_groups, $order, $desc, (int) $limit);
			$sql_query = preg_replace('/"(a|a2|l|t)\./', '"$1"."', $sql_query);
		}
		else if ('membres' === $target) {
			$sql_query = sprintf('SELECT id AS _user_id, %s FROM %s WHERE %s ORDER BY %s %s LIMIT %d;',
				$query_columns, $target, $query_groups, $order, $desc, (int) $limit);
		}
		else {
			$sql_query = sprintf('SELECT id, %s FROM %s WHERE %s ORDER BY %s %s LIMIT %d;',
				$query_columns, $target, $query_groups, $order, $desc, (int) $limit);
		}

		return $sql_query;
	}

	static public function rawSQL(string $query, array $allowed_tables = null, bool $no_limit = false): array
	{
		if (!$no_limit && !preg_match('/LIMIT\s+\d+/i', $query))
		{
			$query = preg_replace('/;?\s*$/', '', $query);
			$query .= ' LIMIT 100';
		}

		$st = DB::getInstance()->protectSelect($allowed_tables, $query);
		$res = $st->execute();
		$out = [];

		while ($row = $res->fetchArray(\SQLITE3_ASSOC)) {
			$out[] = (object) $row;
		}

		return $out;
	}

	/**
	 * Lancer une recherche SQL
	 */
	public function searchSQL(string $target, $query, array $force_select = null, bool $no_limit = false, bool $unprotected = false)
	{
		if (!in_array($target, self::TARGETS, true))
		{
			throw new \InvalidArgumentException('Cible inconnue : ' . $target);
		}

		if (null !== $force_select)
		{
			$query = preg_replace('/^\s*SELECT\s+(.*)\s+FROM\s+/Uis', 'SELECT $1, ' . implode(', ', $force_select) . ' FROM ', $query);
		}

		if (!$no_limit && !preg_match('/LIMIT\s+\d+/i', $query))
		{
			$query = preg_replace('/;?\s*$/', '', $query);
			$query .= ' LIMIT 100';
		}

		try {
			$db = DB::getInstance();
			static $allowed = [
				'compta' => ['acc_transactions' => null, 'acc_transactions_lines' => null, 'acc_accounts' => null, 'acc_charts' => null, 'acc_years' => null, 'acc_transactions_users' => null],
				'membres' => ['membres' => null, 'users_categories' => null],
			];

			if ($unprotected) {
				$allowed_tables = null;
			}
			else {
				$allowed_tables = $allowed[$target];
			}

			$db->protectSelect($allowed_tables, $query);
			return $db->get($query);
		}
		catch (\Exception $e) {
			$message = 'Erreur dans la requête : ' . $e->getMessage();

			if (null !== $force_select)
			{
				$message .= "\nVérifiez que votre requête sélectionne bien les colonnes suivantes : " . implode(', ', $force_select);
			}

			throw new UserException($message);
		}
	}

	public function searchQuery(string $table, $query, $order, $desc = false, $limit = 100)
	{
		$sql_query = $this->buildQuery($table, $query, $order, $desc, $limit);
		return $this->searchSQL($table, $sql_query);
	}

	public function buildSimpleMemberQuery(string $query)
	{
		$operator = 'LIKE %?%';

		if (is_numeric(trim($query)))
		{
			$column = 'numero';
			$operator = '= ?';
		}
		elseif (strpos($query, '@') !== false)
		{
			$column = 'email';
		}
		else
		{
			$column = Config::getInstance()->get('champ_identite');
		}

		$query = [[
			'operator' => 'AND',
			'conditions' => [
				[
					'column'   => $column,
					'operator' => $operator,
					'values'   => [$query],
				],
			],
		]];

		return (object) [
			'query' => $query,
			'order' => $column,
			'desc' => false,
			'limit' => 50,
		];
	}

	public function buildSimpleAccountingQuery(string $text, ?int $id_year = null)
	{
		$query = [];

		$text = trim($text);

		if ($id_year) {
			$query[] = [
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 't.id_year',
						'operator' => '= ?',
						'values'   => [$id_year],
					],
				],
			];
		}

		// Match number: find transactions per credit or debit
		if (preg_match('/^=\s*\d+([.,]\d+)?$/', $text))
		{
			$text = ltrim($text, "\n\t =");
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 'l.debit',
						'operator' => '= ?',
						'values'   => [$text],
					],
					[
						'column'   => 'l.credit',
						'operator' => '= ?',
						'values'   => [$text],
					],
				],
			];
		}
		// Match account number
		elseif ($id_year && preg_match('/^[0-9]+[A-Z]*$/', $text)
			&& ($year = Years::get($id_year))
			&& ($id = (new Accounts($year->id_chart))->getIdFromCode($text))) {
			return sprintf('!acc/accounts/journal.php?id=%d&year=%d', $id, $id_year);
		}
		// Match date
		elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $text) && ($d = Utils::get_datetime($text)))
		{
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 't.date',
						'operator' => '= ?',
						'values'   => [$d->format('Y-m-d')],
					],
				],
			];
		}
		// Match transaction ID
		elseif (preg_match('/^#[0-9]+$/', $text)) {
			return sprintf('!acc/transactions/details.php?id=%d', (int)substr($text, 1));
		}
		// Or search in label or reference
		else
		{
			$operator = 'LIKE %?%';
			$query[] = [
				'operator' => 'OR',
				'conditions' => [
					[
						'column'   => 't.label',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 't.reference',
						'operator' => $operator,
						'values'   => [$text],
					],
					[
						'column'   => 'l.reference',
						'operator' => $operator,
						'values'   => [$text],
					],
				],
			];
		}

		return (object) [
			'query' => $query,
			'order' => 't.id',
			'desc' => true,
			'limit' => 50,
		];
	}

	public function schema(string $target)
	{
		$db = DB::getInstance();

		if ($target == 'membres') {
			$tables = [
				'membres'    => $db->firstColumn('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'membres\';'),
				'users_categories' => $db->firstColumn('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'users_categories\';'),
			];
		}
		elseif ($target == 'compta') {
			$tables = [
				'acc_transactions'       => $db->firstColumn('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'acc_transactions\';'),
				'acc_transactions_lines' => $db->firstColumn('SELECT sql FROM sqlite_master WHERE type = \'table\' AND name = \'acc_transactions_lines\';'),
			];
		}
		else {
			throw new \LogicException('Unknown target');
		}

		return $tables;
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Modified src/include/lib/Garradin/Sauvegarde.php from [ef64debf4f] to [0d9222bd5d].

1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin;

use Garradin\Membres\Session;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\ZipWriter;

class Sauvegarde
{




|







1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin;

use Garradin\Users\Session;
use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\ZipWriter;

class Sauvegarde
{
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
		$backup = str_replace('.sqlite', sprintf('.%s.sqlite', $suffix), DB_FILE);

		$this->make($backup);

		return basename($backup);
	}

	protected function make(string $dest)
	{
		// Acquire lock
		$version = \SQLite3::version();
		$db = DB::getInstance();

		Utils::safe_unlink($dest);








|







102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
		$backup = str_replace('.sqlite', sprintf('.%s.sqlite', $suffix), DB_FILE);

		$this->make($backup);

		return basename($backup);
	}

	public function make(string $dest)
	{
		// Acquire lock
		$version = \SQLite3::version();
		$db = DB::getInstance();

		Utils::safe_unlink($dest);

134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
	/**
	 * Effectue une rotation des sauvegardes automatiques
	 * association.auto.1.sqlite deviendra association.auto.2.sqlite par exemple
	 */
	public function rotate(): void
	{
		$config = Config::getInstance();
		$nb = $config->get('nombre_sauvegardes');

		$list = $this->getList(true);

		// Sort backups from oldest to newest
		usort($list, function ($a, $b) {
			return $a->auto > $b->auto ? -1 : 1;
		});







|







134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
	/**
	 * Effectue une rotation des sauvegardes automatiques
	 * association.auto.1.sqlite deviendra association.auto.2.sqlite par exemple
	 */
	public function rotate(): void
	{
		$config = Config::getInstance();
		$nb = $config->get('backup_limit');

		$list = $this->getList(true);

		// Sort backups from oldest to newest
		usort($list, function ($a, $b) {
			return $a->auto > $b->auto ? -1 : 1;
		});
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
	 * @return boolean true
	 */
	public function auto()
	{
		$config = Config::getInstance();

		// Pas besoin d'aller plus loin si on ne fait pas de sauvegarde auto
		if ($config->get('frequence_sauvegardes') == 0 || $config->get('nombre_sauvegardes') == 0)
			return true;

		$list = $this->getList(true);

		if (count($list))
		{
			$last = current($list)->date;
		}
		else
		{
			$last = false;
		}

		// Test de la date de création de la dernière sauvegarde
		if ($last >= (time() - ($config->get('frequence_sauvegardes') * 3600 * 24)))
		{
			return true;
		}

		// Si pas de modif depuis la dernière sauvegarde, ça sert à rien d'en faire
		if ($last >= filemtime(DB_FILE))
		{







|














|







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
	 * @return boolean true
	 */
	public function auto()
	{
		$config = Config::getInstance();

		// Pas besoin d'aller plus loin si on ne fait pas de sauvegarde auto
		if ($config->get('backup_frequency') == 0 || $config->get('backup_limit') == 0)
			return true;

		$list = $this->getList(true);

		if (count($list))
		{
			$last = current($list)->date;
		}
		else
		{
			$last = false;
		}

		// Test de la date de création de la dernière sauvegarde
		if ($last >= (time() - ($config->get('backup_frequency') * 3600 * 24)))
		{
			return true;
		}

		// Si pas de modif depuis la dernière sauvegarde, ça sert à rien d'en faire
		if ($last >= filemtime(DB_FILE))
		{
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
	public function dump(?string $file = null): void
	{
		$config = Config::getInstance();
		$tmp_file = null;

		if (null === $file) {
			$file = DB_FILE;
			$name = sprintf('%s - Sauvegarde données - %s.sqlite', $config->get('nom_asso'), date('Y-m-d'));

			$tmp_file = tempnam(sys_get_temp_dir(), 'gdin');
			$this->make($tmp_file);

			$file = $tmp_file;
		}
		else {
			if (preg_match('!\.\.+!', $file) || !preg_match('!^[\w\d._ -]+$!iu', $file)) {
				throw new UserException('Nom de fichier non valide.');
			}

			$name = sprintf('%s - %s', $config->get('nom_asso'), str_replace('association.', '', $file));
			$file = DATA_ROOT . '/' . $file;

			if (!file_exists($file)) {
				throw new UserException('Le fichier fourni n\'existe pas.');
			}
		}








|











|







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
	public function dump(?string $file = null): void
	{
		$config = Config::getInstance();
		$tmp_file = null;

		if (null === $file) {
			$file = DB_FILE;
			$name = sprintf('%s - Sauvegarde données - %s.sqlite', $config->get('org_name'), date('Y-m-d'));

			$tmp_file = tempnam(sys_get_temp_dir(), 'gdin');
			$this->make($tmp_file);

			$file = $tmp_file;
		}
		else {
			if (preg_match('!\.\.+!', $file) || !preg_match('!^[\w\d._ -]+$!iu', $file)) {
				throw new UserException('Nom de fichier non valide.');
			}

			$name = sprintf('%s - %s', $config->get('org_name'), str_replace('association.', '', $file));
			$file = DATA_ROOT . '/' . $file;

			if (!file_exists($file)) {
				throw new UserException('Le fichier fourni n\'existe pas.');
			}
		}

269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
		if (null !== $tmp_file) {
			@unlink($tmp_file);
		}
	}

	public function dumpFilesZip(): void
	{
		$name = Config::getInstance()->get('nom_asso') . ' - Documents.zip';
		header('Content-type: application/zip');
		header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

		$zip = new ZipWriter('php://output');
		$zip->setCompression(0);

		$add_directory = function ($path) use ($zip, &$add_directory) {







|







269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
		if (null !== $tmp_file) {
			@unlink($tmp_file);
		}
	}

	public function dumpFilesZip(): void
	{
		$name = Config::getInstance()->get('org_name') . ' - Documents.zip';
		header('Content-type: application/zip');
		header(sprintf('Content-Disposition: attachment; filename="%s"', $name));

		$zip = new ZipWriter('php://output');
		$zip->setCompression(0);

		$add_directory = function ($path) use ($zip, &$add_directory) {
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
		{
			throw new UserException('Ce fichier n\'est pas une sauvegarde Garradin (application_id ne correspond pas).', self::NO_APP_ID);
		}

		$session = Session::getInstance();

		// Empêchons l'admin de se tirer une balle dans le pied
		if ($session->isLogged())
		{
			if (version_compare($version, '1.1.0', '<')) { // FIXME remove in 1.2
				$sql = 'SELECT 1 FROM membres_categories WHERE id = (SELECT id_categorie FROM membres WHERE id = %d) AND droit_connexion >= %d AND droit_config >= %d';
			}
			else {
				$sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM membres WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
			}

			$sql = sprintf($sql, $session->getUser()->id, Session::ACCESS_READ, Session::ACCESS_ADMIN);
			$is_still_admin = $db->querySingle($sql);

			if (!$is_still_admin)
			{







|

|
|


|







480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
		{
			throw new UserException('Ce fichier n\'est pas une sauvegarde Garradin (application_id ne correspond pas).', self::NO_APP_ID);
		}

		$session = Session::getInstance();

		// Empêchons l'admin de se tirer une balle dans le pied
		if ($session->isLogged(true))
		{
			if (version_compare($version, '1.2', '<')) { // FIXME remove in 1.3
				$sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM membres WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
			}
			else {
				$sql = 'SELECT 1 FROM users_categories WHERE id = (SELECT id_category FROM users WHERE id = %d) AND perm_connect >= %d AND perm_config >= %d';
			}

			$sql = sprintf($sql, $session->getUser()->id, Session::ACCESS_READ, Session::ACCESS_ADMIN);
			$is_still_admin = $db->querySingle($sql);

			if (!$is_still_admin)
			{
538
539
540
541
542
543
544






545
546
547
548
549
550






551
552
553
554
555
556
557
		}

		if ($version != garradin_version())
		{
			$return |= self::NEED_UPGRADE;
		}
		else {






			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugin::upgradeAllIfRequired();
		}

		return $return;
	}







	/**
	 * Taille de la base de données actuelle
	 * @return integer Taille en octets du fichier SQLite
	 */
	public function getDBSize($signed = false)
	{







>
>
>
>
>
>






>
>
>
>
>
>







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
		}

		if ($version != garradin_version())
		{
			$return |= self::NEED_UPGRADE;
		}
		else {
			// If logged-in user no longer exists, then login to first admin account
			if (!$session->refresh()) {
				$session->forceLogin(-1);
				$return |= self::CHANGED_USER;
			}

			// Check and upgrade plugins, if a software upgrade is necessary, plugins will be upgraded after the upgrade
			Plugin::upgradeAllIfRequired();
		}

		return $return;
	}

	public function restore(string $file)
	{
		DB::getInstance()->close();
		return copy($file, DB_FILE);
	}

	/**
	 * Taille de la base de données actuelle
	 * @return integer Taille en octets du fichier SQLite
	 */
	public function getDBSize($signed = false)
	{

Added src/include/lib/Garradin/Search.php version [237cb6df6a].













































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

namespace Garradin;

use Garradin\Entities\Search as SE;

use KD2\DB\EntityManager as EM;

class Search
{
	static public function list(int $id_user, string $target): array
	{
		return EM::getInstance(SE::class)->all('SELECT * FROM @TABLE
			WHERE (id_user IS NULL OR id_user = ?) AND target = ?
			ORDER BY label COLLATE U_NOCASE;', $id_user, $target);
	}

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

Modified src/include/lib/Garradin/Services/Fees.php from [3134c2a122] to [1d81462f42].

1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Users\Categories;
use Garradin\Entities\Services\Fee;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;




<







1
2
3
4

5
6
7
8
9
10
11
<?php

namespace Garradin\Services;


use Garradin\DB;
use Garradin\UserException;
use Garradin\Users\Categories;
use Garradin\Entities\Services\Fee;
use Garradin\Entities\Accounting\Year;
use KD2\DB\EntityManager;
use KD2\DB\DB_Exception;
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

		foreach ($result as &$row) {
			if (!$row->formula) {
				continue;
			}

			try {
				$sql = sprintf('SELECT %s FROM membres WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);
			}
			catch (DB_Exception $e) {
				$row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $e->getMessage());
				$row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
				$row->user_amount = -1;
			}
		}

		return $result;
	}

	public function listWithStats()
	{
		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listHidden());

		$condition = sprintf('SELECT COUNT(DISTINCT su.id_user) 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 membres m ON m.id = su.id_user WHERE su.id_fee = f.id AND m.id_category NOT IN (%s)',
			implode(',', $hidden_cats));

		$sql = sprintf('SELECT f.*,
			(%s AND (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1) AS nb_users_ok,
			(%1$s AND expiry_date < date()) AS nb_users_expired,
			(%1$s AND paid = 0) AS nb_users_unpaid
			FROM services_fees f
			WHERE id_service = ?
			ORDER BY amount, label COLLATE U_NOCASE;', $condition);

		return $db->get($sql, $this->service_id);
	}
}







|



















|













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

		foreach ($result as &$row) {
			if (!$row->formula) {
				continue;
			}

			try {
				$sql = sprintf('SELECT %s FROM users WHERE id = %d;', $row->formula, $user_id);
				$row->user_amount = $db->firstColumn($sql);
			}
			catch (DB_Exception $e) {
				$row->label .= sprintf(' (**FORMULE DE CALCUL INVALIDE: %s**)', $e->getMessage());
				$row->description .= "\n\n**MERCI DE CORRIGER LA FORMULE**";
				$row->user_amount = -1;
			}
		}

		return $result;
	}

	public function listWithStats()
	{
		$db = DB::getInstance();
		$hidden_cats = array_keys(Categories::listHidden());

		$condition = sprintf('SELECT COUNT(DISTINCT su.id_user) 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 su.id_fee = f.id AND u.id_category NOT IN (%s)',
			implode(',', $hidden_cats));

		$sql = sprintf('SELECT f.*,
			(%s AND (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1) AS nb_users_ok,
			(%1$s AND expiry_date < date()) AS nb_users_expired,
			(%1$s AND paid = 0) AS nb_users_unpaid
			FROM services_fees f
			WHERE id_service = ?
			ORDER BY amount, label COLLATE U_NOCASE;', $condition);

		return $db->get($sql, $this->service_id);
	}
}

Modified src/include/lib/Garradin/Services/Reminders.php from [2361eca67b] to [4a9d3b6883].

1
2
3
4
5
6
7
8
9

10
11
12
13
14
15
16
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;

use Garradin\Users\Emails;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;










>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

namespace Garradin\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\Users\DynamicFields;
use Garradin\Users\Emails;
use Garradin\Entities\Services\Reminder;
use KD2\DB\EntityManager;

use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
	 * @param  array  $data    Données supplémentaires à utiliser comme tags (tableau associatif)
	 * @return string          $content dont les tags ont été remplacés par le contenu correct
	 */
	static public function replaceTagsInContent(string $content, ?array $data = null)
	{
		$config = Config::getInstance();
		$tags = [
			'#NOM_ASSO'		=>	$config->get('nom_asso'),
			'#ADRESSE_ASSO'	=>	$config->get('adresse_asso'),
			'#EMAIL_ASSO'	=>	$config->get('email_asso'),
			'#SITE_ASSO'	=>	$config->get('site_asso'),
			'#URL_RACINE'	=>	WWW_URL,
			'#URL_SITE'		=>	WWW_URL,
			'#URL_ADMIN'	=>	ADMIN_URL,
		];

		if (!empty($data) && is_array($data))
		{







|
|
|
|







75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
	 * @param  array  $data    Données supplémentaires à utiliser comme tags (tableau associatif)
	 * @return string          $content dont les tags ont été remplacés par le contenu correct
	 */
	static public function replaceTagsInContent(string $content, ?array $data = null)
	{
		$config = Config::getInstance();
		$tags = [
			'#NOM_ASSO'		=>	$config->get('org_name'),
			'#ADRESSE_ASSO'	=>	$config->get('org_address'),
			'#EMAIL_ASSO'	=>	$config->get('org_email'),
			'#SITE_ASSO'	=>	$config->get('org_web'),
			'#URL_RACINE'	=>	WWW_URL,
			'#URL_SITE'		=>	WWW_URL,
			'#URL_ADMIN'	=>	ADMIN_URL,
		];

		if (!empty($data) && is_array($data))
		{
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
	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès
	 */
	static public function sendPending()
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		$sql = 'SELECT
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(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,
			m.email, m.%s AS identity
			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(expiry_date) AS expiry_date, id_user, id_service FROM services_users GROUP BY id_user, id_service) AS su ON s.id = su.id_service
			-- Join with users, but not ones part of a hidden category
			INNER JOIN membres m ON su.id_user = m.id
				AND m.email IS NOT NULL
				AND (m.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\'))
			GROUP BY su.id_user, sr.id_service
			ORDER BY su.id_user;';

		$sql = sprintf($sql, $config->get('champ_identite'));

		foreach ($db->iterate($sql) as $row)
		{
			self::sendAuto($row);
		}

		return true;
	}
}







<






|





|
|
|








|









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
	/**
	 * Envoi des rappels automatiques par e-mail
	 * @return boolean TRUE en cas de succès
	 */
	static public function sendPending()
	{
		$db = DB::getInstance();


		$sql = 'SELECT
			date(su.expiry_date, sr.delay || \' days\') AS reminder_date,
			ABS(julianday(date()) - julianday(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,
			u.%s, %s AS identity
			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(expiry_date) AS expiry_date, id_user, id_service FROM services_users GROUP BY id_user, id_service) AS su ON s.id = su.id_service
			-- Join with users, but not ones part of a hidden category
			INNER JOIN users u ON su.id_user = u.id
				AND u.%1$s IS NOT NULL
				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\'))
			GROUP BY su.id_user, sr.id_service
			ORDER BY su.id_user;';

		$sql = sprintf($sql, DynamicFields::getFirstEmailField(), DynamicFields::getNameFieldsSQL('u'));

		foreach ($db->iterate($sql) as $row)
		{
			self::sendAuto($row);
		}

		return true;
	}
}

Modified src/include/lib/Garradin/Services/Services.php from [25df3ae571] to [baf59edfaa].

63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
			INSERT INTO services_list_stats SELECT
				id_service, id_user,
				CASE WHEN (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN 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 membres m ON m.id = su.id_user WHERE %s',
			$db->where('m.id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);


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







|
|







63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
			INSERT INTO services_list_stats SELECT
				id_service, id_user,
				CASE WHEN (expiry_date IS NULL OR expiry_date >= date()) AND paid = 1 THEN 1 ELSE 0 END,
				CASE WHEN 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 %s',
			$db->where('u.id_category', 'NOT IN', $hidden_cats));

		$db->exec($sql);


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

Modified src/include/lib/Garradin/Template.php from [798e816bb7] to [5a09fd3219].

1
2
3
4
5
6
7
8

9
10

11
12
13
14
15
16
17
<?php

namespace Garradin;

use KD2\Form;
use KD2\HTTP;
use KD2\Translate;
use Garradin\Membres\Session;

use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Users\Category;

use Garradin\UserTemplate\CommonModifiers;
use Garradin\Web\Render\Skriv;
use Garradin\Files\Files;

class Template extends \KD2\Smartyer
{
	static protected $_instance = null;







|
>


>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

namespace Garradin;

use KD2\Form;
use KD2\HTTP;
use KD2\Translate;
use Garradin\Users\Session;
use Garradin\Users\DynamicFields;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Users\Category;
use Garradin\Entities\Users\User;
use Garradin\UserTemplate\CommonModifiers;
use Garradin\Web\Render\Skriv;
use Garradin\Files\Files;

class Template extends \KD2\Smartyer
{
	static protected $_instance = null;
69
70
71
72
73
74
75

76
77
78



79



80
81
82
83
84
85
86
		// en cas de faille, on cache donc la version utilisée, chaque instance
		// aura sa propre version
		$this->assign('version_hash', substr(sha1(garradin_version() . garradin_manifest() . ROOT . SECRET_KEY), 0, 10));

		$this->assign('www_url', WWW_URL);
		$this->assign('admin_url', ADMIN_URL);
		$this->assign('help_url', HELP_URL);

		$this->assign('self_url', Utils::getSelfURI());
		$this->assign('self_url_no_qs', Utils::getSelfURI(false));




		$this->assign('is_logged', false);



		$this->assign('dialog', isset($_GET['_dialog']));

		$this->assign('password_pattern', sprintf('.{%d,}', Session::MINIMUM_PASSWORD_LENGTH));
		$this->assign('password_length', Session::MINIMUM_PASSWORD_LENGTH);

		$this->register_compile_function('continue', function ($pos, $block, $name, $raw_args) {
			if ($block == 'continue')







>



>
>
>
|
>
>
>







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
		// en cas de faille, on cache donc la version utilisée, chaque instance
		// aura sa propre version
		$this->assign('version_hash', substr(sha1(garradin_version() . garradin_manifest() . ROOT . SECRET_KEY), 0, 10));

		$this->assign('www_url', WWW_URL);
		$this->assign('admin_url', ADMIN_URL);
		$this->assign('help_url', HELP_URL);
		$this->assign('admin_url', ADMIN_URL);
		$this->assign('self_url', Utils::getSelfURI());
		$this->assign('self_url_no_qs', Utils::getSelfURI(false));

		$session = Session::getInstance();
		$logged = $session->isLogged();

		$this->assign('is_logged', $logged);
		$this->assign('logged_user', $logged ? $session->getUser() : null);
		$this->assign('session', $session);
		$this->assign('config', Config::getInstance());
		$this->assign('dialog', isset($_GET['_dialog']));

		$this->assign('password_pattern', sprintf('.{%d,}', Session::MINIMUM_PASSWORD_LENGTH));
		$this->assign('password_length', Session::MINIMUM_PASSWORD_LENGTH);

		$this->register_compile_function('continue', function ($pos, $block, $name, $raw_args) {
			if ($block == 'continue')
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
				return sprintf('use %s;', $raw_args);
			}
		});

		$this->register_function('form_errors', [$this, 'formErrors']);
		$this->register_function('show_error', [$this, 'showError']);
		$this->register_function('form_field', [$this, 'formField']);
		$this->register_function('html_champ_membre', [$this, 'formChampMembre']);
		$this->register_function('input', [$this, 'formInput']);
		$this->register_function('password_change', [$this, 'passwordChangeInput']);

		$this->register_function('custom_colors', [$this, 'customColors']);
		$this->register_function('plugin_url', ['Garradin\Utils', 'plugin_url']);
		$this->register_function('diff', [$this, 'diff']);
		$this->register_function('display_permissions', [$this, 'displayPermissions']);



		$this->register_function('csrf_field', function ($params) {
			return Form::tokenHTML($params['key']);
		});

		$this->register_function('icon', [$this, 'widgetIcon']);
		$this->register_function('button', [$this, 'widgetButton']);
		$this->register_function('link', [$this, 'widgetLink']);
		$this->register_function('linkbutton', [$this, 'widgetLinkButton']);

		$this->register_modifier('strlen', 'strlen');
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', function($a) { return abs($a ?? 0); });
		$this->register_modifier('display_champ_membre', [$this, 'displayChampMembre']);

		$this->register_modifier('linkify_transactions', function ($str) {
			return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {
				return sprintf('<a href="%s%d">#%2$d</a>',
					Utils::getLocalURL('!acc/transactions/details.php?id='),
					$m[1]
				);







<







>
>















<







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
				return sprintf('use %s;', $raw_args);
			}
		});

		$this->register_function('form_errors', [$this, 'formErrors']);
		$this->register_function('show_error', [$this, 'showError']);
		$this->register_function('form_field', [$this, 'formField']);

		$this->register_function('input', [$this, 'formInput']);
		$this->register_function('password_change', [$this, 'passwordChangeInput']);

		$this->register_function('custom_colors', [$this, 'customColors']);
		$this->register_function('plugin_url', ['Garradin\Utils', 'plugin_url']);
		$this->register_function('diff', [$this, 'diff']);
		$this->register_function('display_permissions', [$this, 'displayPermissions']);
		$this->register_function('display_dynamic_field', [$this, 'displayDynamicField']);
		$this->register_function('edit_dynamic_field', [$this, 'editDynamicField']);

		$this->register_function('csrf_field', function ($params) {
			return Form::tokenHTML($params['key']);
		});

		$this->register_function('icon', [$this, 'widgetIcon']);
		$this->register_function('button', [$this, 'widgetButton']);
		$this->register_function('link', [$this, 'widgetLink']);
		$this->register_function('linkbutton', [$this, 'widgetLinkButton']);

		$this->register_modifier('strlen', 'strlen');
		$this->register_modifier('dump', ['KD2\ErrorManager', 'dump']);
		$this->register_modifier('get_country_name', ['Garradin\Utils', 'getCountryName']);
		$this->register_modifier('format_tel', [$this, 'formatPhoneNumber']);
		$this->register_modifier('abs', function($a) { return abs($a ?? 0); });


		$this->register_modifier('linkify_transactions', function ($str) {
			return preg_replace_callback('/(?<=^|\s)#(\d+)(?=\s|$)/', function ($m) {
				return sprintf('<a href="%s%d">#%2$d</a>',
					Utils::getLocalURL('!acc/transactions/details.php?id='),
					$m[1]
				);
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
		]));

		$out.= '<dd class="help">Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr et plus simple à retenir qu\'un mot de passe composé de 10 lettres et chiffres.</dd>';

		$suggestion = Utils::suggestPassword();

		$out .= sprintf('<dd class="help">Pas d\'idée&nbsp;? Voici une suggestion choisie au hasard&nbsp;:
                <input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="f_%s_suggest" value="%s" autocomplete="off" size="%d" /></dd>', $params['name'], $suggestion, strlen($suggestion));

		$out .= $this->formInput([
			'type' => 'password',
			'label' => 'Répéter le mot de passe',
			'required' => true,
			'name' => $params['name'] . '_confirm',
			'minlength' => Session::MINIMUM_PASSWORD_LENGTH,







|







303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
		]));

		$out.= '<dd class="help">Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr et plus simple à retenir qu\'un mot de passe composé de 10 lettres et chiffres.</dd>';

		$suggestion = Utils::suggestPassword();

		$out .= sprintf('<dd class="help">Pas d\'idée&nbsp;? Voici une suggestion choisie au hasard&nbsp;:
				<input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="f_%s_suggest" value="%s" autocomplete="off" size="%d" /></dd>', $params['name'], $suggestion, strlen($suggestion));

		$out .= $this->formInput([
			'type' => 'password',
			'label' => 'Répéter le mot de passe',
			'required' => true,
			'name' => $params['name'] . '_confirm',
			'minlength' => Session::MINIMUM_PASSWORD_LENGTH,
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
				$current_value = Utils::money_format($current_value, ',', '');
			}

			if ((string) $current_value === '0') {
				$current_value = '';
			}

			$currency = Config::getInstance()->get('monnaie');
			$input = sprintf('<nobr><input type="text" pattern="-?[0-9]*([.,][0-9]{1,2})?" inputmode="decimal" size="8" class="money" %s value="%s" /><b>%s</b></nobr>', $attributes_string, $this->escape($current_value), $currency);
		}
		else {
			$value = isset($attributes['value']) ? '' : sprintf(' value="%s"', $this->escape($current_value));
			$input = sprintf('<input type="%s" %s %s />', $type, $attributes_string, $value);
		}








|







506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
				$current_value = Utils::money_format($current_value, ',', '');
			}

			if ((string) $current_value === '0') {
				$current_value = '';
			}

			$currency = Config::getInstance()->get('currency');
			$input = sprintf('<nobr><input type="text" pattern="-?[0-9]*([.,][0-9]{1,2})?" inputmode="decimal" size="8" class="money" %s value="%s" /><b>%s</b></nobr>', $attributes_string, $this->escape($current_value), $currency);
		}
		else {
			$value = isset($attributes['value']) ? '' : sprintf(' value="%s"', $this->escape($current_value));
			$input = sprintf('<input type="%s" %s %s />', $type, $attributes_string, $value);
		}

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

	protected function formatPhoneNumber($n)
	{
		if (empty($n)) {
			return '';
		}

		$country = Config::getInstance()->get('pays');

		if ($country !== 'FR') {
			return $n;
		}

		if ('FR' === $country && $n[0] === '0' && strlen($n) === 10) {
			$n = preg_replace('!(\d{2})!', '\\1 ', $n);
		}

		return $n;
	}

	protected function customColors()
	{
		$config = Config::getInstance();

		$couleur1 = $config->get('couleur1') ?: ADMIN_COLOR1;
		$couleur2 = $config->get('couleur2') ?: ADMIN_COLOR2;
		$admin_background = ADMIN_BACKGROUND_IMAGE;

		if ($url = $config->fileURL('admin_background')) {
			$admin_background = $url;
		}

		// Transformation Hexa vers décimal
		$couleur1 = implode(', ', sscanf($couleur1, '#%02x%02x%02x'));
		$couleur2 = implode(', ', sscanf($couleur2, '#%02x%02x%02x'));

		$out = '
		<style type="text/css">
		:root {
			--gMainColor: %s;
			--gSecondColor: %s;
			--gBgImage: url("%s");
		}
		</style>';

		if ($url = $config->fileURL('admin_css')) {
			$out .= "\n" . sprintf('<link rel="stylesheet" type="text/css" href="%s" />', $url);
		}

		return sprintf($out, $couleur1, $couleur2, $admin_background);
	}

	protected function displayChampMembre($v, $config = null)
	{
		if (is_string($config)) {
			$config = Config::getInstance()->get('champs_membres')->get($config);
		}

		if (null === $config) {
			return htmlspecialchars((string)$v);
		}

		if ($config->type == 'checkbox') {
			return $v ? 'Oui' : 'Non';
		}

		if (empty($v)) {
			return '';
		}

		switch ($config->type)
		{
			case 'password':
				return '*****';
			case 'email':
				return '<a href="mailto:' . rawurlencode($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'tel':
				return '<a href="tel:' . rawurlencode($v) . '">' . htmlspecialchars($this->formatPhoneNumber($v)) . '</a>';







|
















|
|







|
|














|


|

|
|
|
|
<



|







|







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

	protected function formatPhoneNumber($n)
	{
		if (empty($n)) {
			return '';
		}

		$country = Config::getInstance()->get('country');

		if ($country !== 'FR') {
			return $n;
		}

		if ('FR' === $country && $n[0] === '0' && strlen($n) === 10) {
			$n = preg_replace('!(\d{2})!', '\\1 ', $n);
		}

		return $n;
	}

	protected function customColors()
	{
		$config = Config::getInstance();

		$color1 = $config->get('color1') ?: ADMIN_COLOR1;
		$color2 = $config->get('color2') ?: ADMIN_COLOR2;
		$admin_background = ADMIN_BACKGROUND_IMAGE;

		if ($url = $config->fileURL('admin_background')) {
			$admin_background = $url;
		}

		// Transformation Hexa vers décimal
		$color1 = implode(', ', sscanf($color1, '#%02x%02x%02x'));
		$color2 = implode(', ', sscanf($color2, '#%02x%02x%02x'));

		$out = '
		<style type="text/css">
		:root {
			--gMainColor: %s;
			--gSecondColor: %s;
			--gBgImage: url("%s");
		}
		</style>';

		if ($url = $config->fileURL('admin_css')) {
			$out .= "\n" . sprintf('<link rel="stylesheet" type="text/css" href="%s" />', $url);
		}

		return sprintf($out, $color1, $color2, $admin_background);
	}

	protected function displayDynamicField(array $params): string
	{
		$field = $params['field'] ?? DynamicFields::get($params['key']);
		$v = $params['value'];

		if (!$field) {

			return htmlspecialchars((string)$v);
		}

		if ($field->type == 'checkbox') {
			return $v ? 'Oui' : 'Non';
		}

		if (empty($v)) {
			return '';
		}

		switch ($field->type)
		{
			case 'password':
				return '*****';
			case 'email':
				return '<a href="mailto:' . rawurlencode($v) . '">' . htmlspecialchars($v) . '</a>';
			case 'tel':
				return '<a href="tel:' . rawurlencode($v) . '">' . htmlspecialchars($this->formatPhoneNumber($v)) . '</a>';
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
				// Useful for search results, if a value is not a number
				if (!is_numeric($v)) {
					return htmlspecialchars($v);
				}

				$out = [];

				foreach ($config->options as $b => $name)
				{
					if ($v & (0x01 << $b))
						$out[] = $name;
				}

				return htmlspecialchars(implode(', ', $out));
			default:
				return nl2br(htmlspecialchars(rtrim((string) $v)));
		}
	}

	protected function formChampMembre($params)
	{
		if (empty($params['config']) || empty($params['name']))
			throw new \BadFunctionCallException('Paramètres type et name obligatoires.');

		$config = $params['config'];
		$type = $config->type;

		if ($params['name'] == 'passe' || (!empty($params['user_mode']) && !empty($config->private)))
		{


			return '';
		}

		// Files are managed out of the form
		if ($config->type == 'file') {




			return '';
		}

		$options = [];

		if ($type == 'select' || $type == 'multiple')
		{
			if (empty($config->options))
			{
				throw new \BadFunctionCallException('Paramètre options obligatoire pour champ de type ' . $type);
			}

			$options = (array) $config->options;
		}
		elseif ($type == 'country')
		{
			$type = 'select';
			$options = Utils::getCountryList();
			$params['default'] = Config::getInstance()->get('pays');
		}
		elseif ($type == 'date')
		{
			$params['pattern'] = '\d{4}-\d{2}-\d{2}';
		}

		$field = '';
		$value = $this->formField($params, false);
		$attributes = 'name="' . htmlspecialchars($params['name'], ENT_QUOTES, 'UTF-8') . '" ';
		$attributes .= 'id="f_' . htmlspecialchars($params['name'], ENT_QUOTES, 'UTF-8') . '" ';

		if ($params['name'] == 'numero' && $config->type == 'number' && !$value)
		{
			$value = DB::getInstance()->firstColumn('SELECT MAX(numero) + 1 FROM membres;');
		}

		if (!empty($params['disabled']))
		{
			$attributes .= 'disabled="disabled" ';
		}

		if (!empty($config->mandatory) && $type != 'checkbox' && $type != 'multiple')
		{
			$attributes .= 'required="required" ';
		}

		// Fix for autocomplete, lpignore is for Lastpass
		$attributes .= 'autocomplete="off" data-lpignore="true" ';

		if (!empty($params['user_mode']) && empty($config->editable))
		{
			$out = '<dt>' . htmlspecialchars($config->title, ENT_QUOTES, 'UTF-8') . '</dt>';
			$out .= '<dd>' . (trim((string) $value) === '' ? 'Non renseigné' : $this->displayChampMembre($value, $config)) . '</dd>';
			return $out;
		}

		if ($type == 'select')
		{
			$field .= '<select '.$attributes.'>';
			foreach ($options as $k=>$v)
			{
				if (is_int($k))
					$k = $v;

				$field .= '<option value="' . htmlspecialchars($k, ENT_QUOTES, 'UTF-8') . '"';

				if ($value == $k || empty($value) && !empty($params['default']))
					$field .= ' selected="selected"';

				$field .= '>' . htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '</option>';
			}
			$field .= '</select>';
		}
		elseif ($type == 'multiple')
		{
			if (is_array($value))
			{
				$binary = 0;

				foreach ($value as $k => $v)
				{
					if (array_key_exists($k, $options) && !empty($v))
					{
						$binary |= 0x01 << $k;
					}
				}


				$value = $binary;
			}

			// Forcer la valeur à être un entier (depuis PHP 7.1)
			$value = (int)$value;



			foreach ($options as $k=>$v)
			{
				$b = 0x01 << (int)$k;
				$field .= sprintf('<input type="checkbox" name="%s[%d]" id="f_%1$s_%2$d" value="1" %s %s /> <label for="f_%1$s_%2$d">%s</label><br />',
					htmlspecialchars($params['name']), $k, ($value & $b) ? 'checked="checked"' : '', $attributes, htmlspecialchars($v));
			}



		}
		elseif ($type == 'textarea')
		{
			$field .= '<textarea ' . $attributes . 'cols="30" rows="5">' . htmlspecialchars((string) $value, ENT_QUOTES) . '</textarea>';
		}
		elseif ($type == 'date') {
			$field = self::formInput(['required' => $config->mandatory, 'name' => $params['name'], 'value' => $value, 'type' => 'date', 'default' => $value]);
		}
		else
		{
			if ($type == 'checkbox')
			{
				if (!empty($value))
				{
					$attributes .= 'checked="checked" ';
				}

				$value = '1';

			}
			elseif ($type == 'number') {
				$attributes .= 'step="any" ';
			}

			$field .= '<input type="' . $type . '" ' . $attributes . ' value="' . htmlspecialchars((string) $value, ENT_QUOTES) . '" />';
		}





		$out = '
		<dt>';

		if ($type == 'checkbox')
		{

			$out .= $field . ' ';


		}

		$out .= '<label for="f_' . htmlspecialchars($params['name'], ENT_QUOTES, 'UTF-8') . '">'
			. htmlspecialchars($config->title, ENT_QUOTES, 'UTF-8') . '</label>';

		if (!empty($config->mandatory))
		{
			$out .= ' <b title="(Champ obligatoire)">obligatoire</b>';

		}

		$out .= '</dt>';

		if (!empty($config->help))
		{
			$out .= '
		<dd class="help">' . htmlspecialchars($config->help, ENT_QUOTES, 'UTF-8') . '</dd>';
		}

		$id_field = Config::getInstance()->get('champ_identifiant');

		if ($params['name'] == $id_field && empty($params['user_mode'])) {
			$out .= '<dd class="help"><small>(Sera utilisé comme identifiant de connexion si le membre a le droit de se connecter.)</small></dd>';
		}

		if ($type != 'checkbox')
		{



			$out .= '
		<dd>' . $field . '</dd>';
		}

		return $out;
	}

	protected function diff(array $params)
	{







|











|

|
<
|
|
|
|
<
|
>
>




|
>
>
>
>



|
|
<
<
<
<
<
<
|
<

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

|
<
|
<
|


|
>
|




>
>




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

<
>
|
|
<
<
|
<

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

|
<
<
|
<
<
<
>

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



|
<
>
>
>
|
<







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
				// Useful for search results, if a value is not a number
				if (!is_numeric($v)) {
					return htmlspecialchars($v);
				}

				$out = [];

				foreach ($field->options as $b => $name)
				{
					if ($v & (0x01 << $b))
						$out[] = $name;
				}

				return htmlspecialchars(implode(', ', $out));
			default:
				return nl2br(htmlspecialchars(rtrim((string) $v)));
		}
	}

	protected function editDynamicField(array $params): string
	{
		// context = user_edit/new/edit

		assert(isset($params['field'], $params['user'], $params['context']));
		extract($params);
		$key = $field->name;
		$type = $field->type;


		// The password must be changed using a specific field
		if ($field->system & $field::PASSWORD) {
			return '';
		}

		// Files are managed out of the form
		if ($type == 'file') {
			return '';
		}

		if ($context == 'user_edit' && !$field->read_access) {
			return '';
		}

		if ($context == 'user_edit' && !$field->write_access) {
			$v = $this->displayDynamicField(['key' => $key, 'value' => $params['user']->$key]);






			return sprintf('<dt>%s</dt><dd>%s</dd>', $field->label, $v ?: '<em>Non renseigné</em>');

		}




		$params = [

			'type'     => $type,



			'name'     => $key,
			'label'    => $field->label,



			'required' => $field->required,




			'source'   => $params['user'],




			'disabled' => !empty($disabled),
			'required' => $field->required && $type != 'checkbox',



			'help'     => $field->help,
			// Fix for autocomplete, lpignore is for Lastpass
			'autocomplete' => 'off',
			'data-lpignore' => 'true',
		];





		// Multiple choice checkboxes is a specific thingy
		if ($type == 'multiple') {


			$options = $field->options;



			if (isset($_POST[$key]) && is_array($_POST[$key])) {













				$value = 0;

				foreach ($_POST[$key] as $k => $v) {

					if (array_key_exists($k, $options) && !empty($v)) {

						$value |= 0x01 << $k;
					}
				}
			}
			else {
				$value = $params['user']->$key;
			}

			// Forcer la valeur à être un entier (depuis PHP 7.1)
			$value = (int)$value;
			$params['required'] = false;
			$out  = '';

			foreach ($options as $k=>$v)
			{
				$b = 0x01 << (int)$k;



				$params['name'] = sprintf('%s[%d]', $key, $k);
				$params['value'] = 1;
				$params['default'] = $value & $b;





				if ($k > 0) {
					unset($params['label']);
				}






				$out .= self::formInput($params);
			}


			return $out;
		}
		elseif ($type == 'select') {


			$params['options'] = (array) $config->options;

		}
		elseif ($type == 'country') {
			$params['type'] = 'select';
			$params['options'] = Utils::getCountryList();
			$params['default'] = Config::getInstance()->get('country');
		}



		elseif ($type == 'checkbox') {

			$params['required'] = false;
			$params['value'] = 1;
			unset($params['label']);
			return sprintf('<dt><label>%s %s</label></dt>', self::formInput($params), htmlspecialchars($field->label));
		}
		elseif ($field->system & $field::NUMBER && $context == 'new') {


			$params['default'] = DB::getInstance()->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s;', $key, User::TABLE));



			$params['required'] = false;
		}
		elseif ($type == 'number') {
			$params['step'] = 'any';
		}


		$out = self::formInput($params);


		if ($context != 'edit' && $field->system & $field::LOGIN) {



			$out .= '<dd class="help"><small>(Sera utilisé comme identifiant de connexion si le membre a le droit de se connecter.)</small></dd>';
		}

		if ($context == 'new' && $field->system & $field::NUMBER) {

			$out .= '<dd class="help"><small>Doit être unique, laisser vide pour que le numéro soit attribué automatiquement.</small></dd>';
		}
		elseif ($context == 'edit' && $field->system & $field::NUMBER) {
			$out .= '<dd class="help"><small>Doit être unique pour chaque membre.</small></dd>';

		}

		return $out;
	}

	protected function diff(array $params)
	{

Modified src/include/lib/Garradin/Upgrade.php from [6fce8d0f39] to [a5fdaf0f68].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace Garradin;

use Garradin\Membres\Session;
use Garradin\Membres\Champs;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\HTTP;

use KD2\FossilInstaller;

class Upgrade
{
	const MIN_REQUIRED_VERSION = '0.9.8';

	static protected $installer = null;

	static public function preCheck(): bool
	{
		$v = DB::getInstance()->version();





<
|










|







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


use Garradin\Users\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;

use KD2\HTTP;

use KD2\FossilInstaller;

class Upgrade
{
	const MIN_REQUIRED_VERSION = '1.1.19';

	static protected $installer = null;

	static public function preCheck(): bool
	{
		$v = DB::getInstance()->version();

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
		$session->isLogged(true);
		return true;
	}

	static public function upgrade()
	{
		$db = DB::getInstance();

		$v = $db->version();

		Static_Cache::store('upgrade', 'Mise à jour en cours.');

		// Créer une sauvegarde automatique
		$backup_name = (new Sauvegarde)->create(false, 'pre-upgrade-' . garradin_version());

		try {
			if (version_compare($v, '1.0.0-rc1', '<'))
			{
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/data/1.0.0_migration.sql');
				$db->commitSchemaUpdate();
			}


			if (version_compare($v, '1.0.0-rc10', '<'))
			{
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/data/1.0.0-rc10_migration.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.0.0-beta1', '>=') && version_compare($v, '1.0.0-rc11', '<'))
			{
				// Missing trigger
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/data/1.0.0_schema.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.0.0-rc14', '<'))
			{
				// Missing trigger
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/data/1.0.0-rc14_migration.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.0.0-rc16', '<'))
			{
				// Missing trigger
				$db->beginSchemaUpdate();
				$db->import(ROOT . '/include/data/1.0.0-rc16_migration.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.0.1', '<'))
			{
				// Missing trigger
				$db->begin();
				$db->import(ROOT . '/include/data/1.0.1_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.0.3', '<'))
			{
				// Missing trigger
				$db->begin();
				$db->import(ROOT . '/include/data/1.0.3_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.0.6', '<'))
			{
				// Missing trigger
				$db->begin();
				$db->import(ROOT . '/include/data/1.0.6_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.0.7', '<'))
			{
				// Missing trigger
				$db->begin();
				$db->import(ROOT . '/include/data/1.0.7_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.0-beta1', '<'))
			{
				// Missing trigger
				$db->beginSchemaUpdate();

				$attachments = $db->getAssoc('SELECT f.id, w.uri || \'/\' || f.id || \'_\' || f.nom FROM fichiers f
					INNER JOIN fichiers_wiki_pages fw ON fw.fichier = f.id
					INNER JOIN wiki_pages w ON w.id = fw.id;');

				// Update Skriv content for attachments
				foreach ($db->iterate('SELECT r.rowid, r.contenu, p.uri FROM wiki_revisions r INNER JOIN wiki_pages p ON p.revision = r.revision AND p.id = r.id_page;') as $r) {
					$uri = $r->uri;
					$content = preg_replace_callback('!<<(image|fichier)\s*\|\s*(\d+)\s*(?:\|\s*(gauche|droite|centre))?\s*(?:\|\s*(.+)\s*)?>>!', function ($match) use ($attachments, $uri) {
						if (isset($attachments[$match[2]])) {
							$name = $attachments[$match[2]];

							if (dirname($name) == $uri) {
								$name = basename($name);
							}
							else {
								$name = '../' . $name;
							}
						}
						else {
							$name = '_ERREUR_fichier_inconnu_' . $match[2];
						}

						if (isset($match[3])) {
							$align = '|' . ($match[3] == 'centre' ? 'center' : ($match[3] == 'gauche' ? 'left' : 'right'));
						}
						else {
							$align = '';
						}

						$caption = isset($match[4]) ? '|' . $match[4] : '';

						return sprintf('<<%s|%s%s%s>>', $match[1] == 'fichier' ? 'file' : 'image', $name, $align, $caption);
					}, $r->contenu);

					$content = preg_replace_callback('!(image|fichier)://(\d+)!', function ($match) use ($attachments) {
						$name = $attachments[$match[2]] ?? '_ERREUR_fichier_inconnu_' . $match[2];
						return sprintf('#file:[%s]', $name);
					}, $content);

					if ($content != $r->contenu) {
						$db->update('wiki_revisions', ['contenu' => $content], 'rowid = :id', ['id' => $r->rowid]);
					}
				}

				$id_field = $db->firstColumn('SELECT valeur FROM config WHERE cle = \'champ_identifiant\';');
				$champs = new Champs($db->firstColumn('SELECT valeur FROM config WHERE cle = \'champs_membres\';'));
				$db->import(ROOT . '/include/data/1.1.0_migration.sql');

				// Rename membres table
				$champs->createTable($champs::TABLE  .'_tmp');

				$fields = $champs->getCopyFields(true);
				unset($fields['id_category']);
				$fields['id_categorie'] = 'id_category';
				$champs->copy($champs::TABLE, $champs::TABLE . '_tmp', $fields);

				$db->exec(sprintf('DROP TABLE IF EXISTS %s;', $champs::TABLE));
				$db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', $champs::TABLE));

				$champs->createIndexes($champs::TABLE, $id_field);

				$db->commitSchemaUpdate();

				// Migrate to a different storage
				if (FILE_STORAGE_BACKEND != 'SQLite') {
					Files::migrateStorage('SQLite', FILE_STORAGE_BACKEND, null, FILE_STORAGE_CONFIG);
					Files::truncateStorage('SQLite', null);
				}

				$pages = $db->iterate('SELECT * FROM web_pages;');

				foreach ($pages as $data) {
					$page = new \Garradin\Entities\Web\Page;
					$page->exists(true);
					$page->load((array) $data);
					$page->syncSearch();
				}
			}

			if (version_compare($v, '1.1.1', '<')) {
				// Reset admin_background if the file does not exist
				$bg = $db->firstColumn('SELECT value FROM config WHERE key = \'admin_background\';');

				if ($bg) {
					$file = Files::get($bg);

					if (!$file) {
						$db->exec('UPDATE config SET value = NULL WHERE key = \'admin_background\';');
					}
				}

				// Fix links of admin homepage
				$homepage = $db->firstColumn('SELECT value FROM config WHERE key = \'admin_homepage\';');

				if ($homepage) {
					$file = Files::get($homepage);

					if ($file) {
						$content = $file->fetch();
						$new_content = preg_replace_callback(';\[\[((?!\]\]).*)\]\];', function ($match) {
							$link = explode('|', $match[1]);
							if (count($link) == 2) {
								list($label, $link) = $link;
							}
							else {
								$label = $link = $link[0];
							}

							if (strpos(trim($link), '/') !== false) {
								return $match[0];
							}

							$link = sprintf('!web/page.php?p=%s', trim($link));
							return sprintf('[[%s|%s]]', $label, $link);
						}, $content);

						if ($new_content != $content) {
							Files::disableQuota();
							$file->setContent($new_content);
						}
					}
				}
			}

			if (version_compare($v, '1.1.3', '<')) {
				// Missing trigger
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.3_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.4', '<')) {
				// Set config file names
				$file = Files::get(Config::FILES['admin_background']);
				$db->update('config', ['value' => $file ? Config::FILES['admin_background'] : null], 'key = :key', ['key' => 'admin_background']);

				$file = Files::get(Config::FILES['admin_homepage']);
				$db->update('config', ['value' => $file ? Config::FILES['admin_homepage'] : null], 'key = :key', ['key' => 'admin_homepage']);
			}

			if (version_compare($v, '1.1.7', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.7_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.8', '<')) {
				$db->begin();
				// Force sync to remove pages that don't exist anymore
				\Garradin\Web\Web::sync();

				$uris = [];
				$i = 1;

				$treat_duplicate_uris = function ($path) use (&$i, &$uris, &$treat_duplicate_uris) {
					// Rename duplicate URIs
					foreach (Files::callStorage('list', $path) as $f) {
						if ($f->type != $f::TYPE_DIRECTORY) {
							continue;
						}

						if (array_key_exists($f->name, $uris)) {
							$f->changeFileName($f->name . '_' . $i++);
						}

						$uris[$f->name] = $f->path;

						$treat_duplicate_uris($f->path);
					}
				};

				$treat_duplicate_uris(\Garradin\Entities\Files\File::CONTEXT_WEB);

				// Force sync to add renamed pages
				\Garradin\Web\Web::sync();

				// Add UNIQUE index
				$db->import(ROOT . '/include/data/1.1.8_migration.sql');

				$db->commit();
			}

			if (version_compare($v, '1.1.8', '==')) {
				// Force sync to add missing pages if you had the buggy 1.1.8 version
				\Garradin\Web\Web::sync(true);
			}

			if (version_compare($v, '1.1.10', '<')) {
				\Garradin\Web\Web::sync(); // Force sync of web pages
				Files::syncVirtualTable('', true);

				$db->begin();
				$db->exec(sprintf('DELETE FROM files_search WHERE path NOT IN (SELECT path FROM %s);', Files::getVirtualTableName()));
				$db->commit();
			}

			if (version_compare($v, '1.1.15', '<')) {
				$db->begin();
				$db->import(ROOT . '/include/data/1.1.15_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.16', '<')) {
				$files = Config::FILES;

				foreach ($files as $key => &$set) {
					$f = Files::get($set);
					$set = $f !== null ? $f->modified->getTimestamp() : null;
				}

				unset($set);

				// Migrate files
				if ($f = Files::get(File::CONTEXT_SKELETON . '/favicon.png')) {
					$f->copy(Config::FILES['favicon']);
					$files['favicon'] = $f->modified->getTimestamp();
				}

				if ($f = Files::get(File::CONTEXT_SKELETON . '/logo.png')) {
					$f->copy(Config::FILES['icon']);
					$files['icon'] = $f->modified->getTimestamp();
				}

				$db->begin();
				$db->exec('DELETE FROM config WHERE key IN (\'admin_background\', \'admin_css\', \'admin_homepage\');');
				$db->exec(sprintf('INSERT INTO config (key, value) VALUES (\'files\', %s);', $db->quote(json_encode($files))));
				$db->commit();
			}

			if (version_compare($v, '1.1.18', '<')) {
				$db->begin();
				// Re-do the 1.1.15 migration as the LIKE did not work and accounts were not updated
				$db->import(ROOT . '/include/data/1.1.15_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.19', '<')) {
				$db->exec('VACUUM;'); // This will rebuild the index correctly, fixing the corrupted DB

				// Some people were able to insert invalid charsets in the database, this messes up the indexes
				// Let's try to fix that
				$db->createFunction('utf8_encode', [Utils::class, 'utf8_encode']);
				$db->beginSchemaUpdate();

				// Now let's fix the content itself
				$res = $db->first('SELECT * FROM membres WHERE 1;');

				$columns = array_keys((array) $res);
				$columns = array_map(fn($c) => sprintf('"%s" = utf8_encode("%1$s")', $c), $columns);
				$db->exec(sprintf('UPDATE membres SET %s;', implode(', ', $columns)));

				// Let's re-create users table with the correct index
				$champs = Config::getInstance()->champs_membres;
				$db->exec('ALTER TABLE membres RENAME TO membres_old;');
				$db->commit();
				$db->close();
				$db->connect();
				$db->beginSchemaUpdate();
				$champs->create('membres');
				$champs->copy('membres_old', 'membres');
				$db->exec('DROP TABLE membres_old;');

				// Set new types for accounts
				$db->import(ROOT . '/include/data/1.1.19_migration.sql');

				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.1.21', '<')) {
				$db->begin();
				// Add id_analytical column to services_fees
				$db->import(ROOT . '/include/data/1.1.21_migration.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.22', '<')) {
				$db->begin();
				// Create acc_accounts_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commit();
			}

			if (version_compare($v, '1.1.23', '<')) {
				$db->begin();
				// Create acc_accounts_projects_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commit();







>


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

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

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

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

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


|







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
		$session->isLogged(true);
		return true;
	}

	static public function upgrade()
	{
		$db = DB::getInstance();
		$backup = new Sauvegarde;
		$v = $db->version();


		Plugin::toggleSignals(false);





































































































		Static_Cache::store('upgrade', 'Updating');






		// Créer une sauvegarde automatique

		$backup_file = sprintf(DATA_ROOT . '/association.pre_upgrade-%s.sqlite', garradin_version());


		$backup->make($backup_file);






































































		try {



















			if (version_compare($v, '1.1.21', '<')) {

				$db->beginSchemaUpdate();



				// Add id_analytical column to services_fees











				$db->import(ROOT . '/include/data/1.1.21_migration.sql');
				$db->commitSchemaUpdate();
			}



















































			if (version_compare($v, '1.1.22', '<')) {













































				$db->beginSchemaUpdate();


































				// Create acc_accounts_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commitSchemaUpdate();
			}

			if (version_compare($v, '1.1.23', '<')) {
				$db->begin();
				// Create acc_accounts_projects_balances view
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');
				$db->commit();
442
443
444
445
446
447
448









































449
450
451
452
453
454
455
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');

				// Rename signals
				$db->import(ROOT . '/include/data/1.1.25_migration.sql');

				$db->commit();
			}










































			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);







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







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
				$db->import(ROOT . '/include/data/1.1.0_schema.sql');

				// Rename signals
				$db->import(ROOT . '/include/data/1.1.25_migration.sql');

				$db->commit();
			}

			if (version_compare($v, '1.2.0', '<')) {
				$config = (object) $db->getAssoc('SELECT key, value FROM config WHERE key IN (\'champs_membres\', \'champ_identifiant\', \'champ_identite\');');
				$db->beginSchemaUpdate();

				// Create config_users_fields table
				$db->import(ROOT . '/include/data/1.2.0_schema.sql');

				// Migrate users table
				$df = \Garradin\Users\DynamicFields::fromOldINI($config->champs_membres, $config->champ_identifiant, $config->champ_identite, 'numero');
				$df->save(false);

				// Migrate other stuff
				$db->import(ROOT . '/include/data/1.2.0_migration.sql');

				// Update searches
				foreach ($db->iterate('SELECT * FROM searches;') as $row) {
					if ($row->type == 'json') {
						$json = json_decode($row->content);

						if (!$json) {
							$db->delete('searches', 'id = ?', $row->id);
							continue;
						}

						$json->groups = $json->query;
						unset($json->query, $json->limit);

						$content = json_encode($json);
					}
					else {
						$content = preg_replace('/\bmembres\b/', 'users', $row->content);
					}

					$db->update('searches', ['content' => $content], 'id = ' . (int) $row->id);
				}

				$db->commitSchemaUpdate();
			}

			Plugin::upgradeAllIfRequired();

			// Vérification de la cohérence des clés étrangères
			$db->foreignKeyCheck();

			// Delete local cached files
			Utils::resetCache(USER_TEMPLATES_CACHE_ROOT);
			Utils::resetCache(STATIC_CACHE_ROOT);
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
		}
		catch (\Exception $e)
		{
			if ($db->inTransaction()) {
				$db->rollback();
			}

			$s = new Sauvegarde;
			$s->restoreFromLocal($backup_name);
			$s->remove($backup_name);
			Static_Cache::remove('upgrade');
			throw $e;
		}


		$session = Session::getInstance();
		$user_is_logged = $session->isLogged(true);

		// Forcer à rafraîchir les données de la session si elle existe
		if ($user_is_logged && !headers_sent())
		{







|
|
|



<







174
175
176
177
178
179
180
181
182
183
184
185
186

187
188
189
190
191
192
193
		}
		catch (\Exception $e)
		{
			if ($db->inTransaction()) {
				$db->rollback();
			}

			$db->close();
			rename($backup_file, DB_FILE);

			Static_Cache::remove('upgrade');
			throw $e;
		}


		$session = Session::getInstance();
		$user_is_logged = $session->isLogged(true);

		// Forcer à rafraîchir les données de la session si elle existe
		if ($user_is_logged && !headers_sent())
		{

Modified src/include/lib/Garradin/UserTemplate/CommonModifiers.php from [a9fbcd8794] to [7c5f592615].

42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
	}

	static public function money_currency($number, bool $hide_empty = true): string
	{
		$out = self::money($number, $hide_empty);

		if ($out !== '') {
			$out .= '&nbsp;' . Config::getInstance()->get('monnaie');
		}

		return $out;
	}

	static public function date_long($ts, bool $with_hour = false): ?string
	{







|







42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
	}

	static public function money_currency($number, bool $hide_empty = true): string
	{
		$out = self::money($number, $hide_empty);

		if ($out !== '') {
			$out .= '&nbsp;' . Config::getInstance()->get('currency');
		}

		return $out;
	}

	static public function date_long($ts, bool $with_hour = false): ?string
	{
184
185
186
187
188
189
190




191
192

193
194
195
196
197
198
199
200
			$out .= '<li'.$attributes.'>';

			$attributes = '';

			if (!empty($page['accesskey']))
				$attributes .= ' accesskey="' . htmlspecialchars($page['accesskey']) . '" ';





			$out .= '<a' . $attributes . ' href="' . str_replace(['[ID]', $encoded_url], htmlspecialchars($page['id']), $params['url']) . '">';
			$out .= htmlspecialchars($page['label']);

			$out .= '</a>';
			$out .= '</li>' . "\n";
		}

		$out .= '</ul>';

		return $out;
	}







>
>
>
>
|
|
>
|







184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
			$out .= '<li'.$attributes.'>';

			$attributes = '';

			if (!empty($page['accesskey']))
				$attributes .= ' accesskey="' . htmlspecialchars($page['accesskey']) . '" ';

			if (!empty($params['use_buttons'])) {
				$out .= sprintf('<button type="submit" name="_dl_page" value="%d">%s</button>', $page['id'], htmlspecialchars($page['label']));
			}
			else {
				$url = str_replace(['[ID]', $encoded_url], $page['id'], $params['url']);
				$out .= sprintf('<a %s href="%s">%s</a>', $attributes, $url, htmlspecialchars($page['label']));
			}

			$out .= '</li>' . "\n";
		}

		$out .= '</ul>';

		return $out;
	}

Modified src/include/lib/Garradin/UserTemplate/UserTemplate.php from [ead19c7318] to [4b979c86e6].

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
use Garradin\Web\Skeleton;
use Garradin\Entities\Files\File;

use Garradin\UserTemplate\Modifiers;
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT};

class UserTemplate extends Brindille
{
	protected $path = null;
	protected $modified;
	protected $file = null;
	protected $code = null;
	protected $cache_path = USER_TEMPLATES_CACHE_ROOT;

	protected $escape_default = 'html';

	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {
			return self::$root_variables;
		}

		static $keys = ['adresse_asso', 'champ_identifiant', 'champ_identite', 'couleur1', 'couleur2', 'email_asso', 'monnaie', 'nom_asso', 'pays', 'site_asso', 'telephone_asso', 'files'];

		$config = Config::getInstance();

		$files = $config::FILES;

		// Put URL in files array
		array_walk($files, function (&$v, $k) use ($config) {
			$v = $config->fileURL($k);
		});

		$config = array_intersect_key($config->asArray(), array_flip($keys));
		$config['files'] = $files;









		self::$root_variables = [
			'root_url'     => WWW_URL,
			'request_url'  => Utils::getRequestURI(),
			'admin_url'    => ADMIN_URL,
			'_GET'         => &$_GET,
			'_POST'        => &$_POST,
			'visitor_lang' => Translate::getHttpLang(),
			'config'       => $config,

		];

		return self::$root_variables;
	}

	public function __construct(?File $file = null)
	{







|



















|













>
>
>
>
>
>
>
>








>







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
use Garradin\Web\Skeleton;
use Garradin\Entities\Files\File;

use Garradin\UserTemplate\Modifiers;
use Garradin\UserTemplate\Functions;
use Garradin\UserTemplate\Sections;

use const Garradin\{WWW_URL, ADMIN_URL, SHARED_USER_TEMPLATES_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, DATA_ROOT, LEGAL_LINE};

class UserTemplate extends Brindille
{
	protected $path = null;
	protected $modified;
	protected $file = null;
	protected $code = null;
	protected $cache_path = USER_TEMPLATES_CACHE_ROOT;

	protected $escape_default = 'html';

	static protected $root_variables;

	static public function getRootVariables()
	{
		if (null !== self::$root_variables) {
			return self::$root_variables;
		}

		static $keys = ['color1', 'color2', 'org_name', 'org_address', 'org_email', 'org_phone', 'org_web', 'currency', 'country', 'files'];

		$config = Config::getInstance();

		$files = $config::FILES;

		// Put URL in files array
		array_walk($files, function (&$v, $k) use ($config) {
			$v = $config->fileURL($k);
		});

		$config = array_intersect_key($config->asArray(), array_flip($keys));
		$config['files'] = $files;

		// @deprecated
		// FIXME: remove in a future version
		$config['nom_asso'] = $config['org_name'];
		$config['adresse_asso'] = $config['org_address'];
		$config['email_asso'] = $config['org_email'];
		$config['telephone_asso'] = $config['org_phone'];
		$config['site_asso'] = $config['org_web'];

		self::$root_variables = [
			'root_url'     => WWW_URL,
			'request_url'  => Utils::getRequestURI(),
			'admin_url'    => ADMIN_URL,
			'_GET'         => &$_GET,
			'_POST'        => &$_POST,
			'visitor_lang' => Translate::getHttpLang(),
			'config'       => $config,
			'legal_line'   => LEGAL_LINE,
		];

		return self::$root_variables;
	}

	public function __construct(?File $file = null)
	{

Added src/include/lib/Garradin/Users/AdvancedSearch.php version [d0b9e29875].







































































































































































































































































































































































































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

namespace Garradin\Users;

use Garradin\DynamicList;
use Garradin\Users\DynamicFields;
use Garradin\AdvancedSearch as A_S;
use Garradin\DB;

class AdvancedSearch extends A_S
{
	/**
	 * Returns list of columns for search
	 * @return array
	 */
	public function columns(): array
	{
		$db = DB::getInstance();
		$fields = DynamicFields::getInstance();

		$columns = [];

		/*
		$columns['identity'] = (object) [
			'label'    => $fields::getNameLabel(),
			'type'     => 'text',
			'null'     => true,
			'select'   => $fields::getNameFieldsSQL(),
			'order'    => sprintf('%s COLLATE U_NOCASE %%s', current($fields::getNameFields())),
		];
		*/

		foreach ($fields->all() as $name => $field)
		{
			/*
			// already included in identity
			if ($field->system & $field::NAME) {
				continue;
			}
			*/

			// nope
			if ($field->system & $field::PASSWORD) {
				continue;
			}

			$column = [
				'label'    => $field->label,
				'type'     => 'text',
				'null'     => true,
			];

			if ($fields->isText($name)) {
				$column['order'] = sprintf('%s COLLATE U_NOCASE %%s', $name);
			}

			if ($field->type == 'checkbox')
			{
				$column['type'] = 'boolean';
				$column['null'] = false;
			}
			elseif ($field->type == 'select')
			{
				$column['type'] = 'enum';
				$column['values'] = array_combine($field->options, $field->options);
			}
			elseif ($field->type == 'multiple')
			{
				$column['type'] = 'bitwise';
				$column['values'] = $field->options;
			}
			elseif ($field->type == 'date' || $field->type == 'datetime')
			{
				$column['type'] = $field->type;
			}
			elseif ($field->type == 'number')
			{
				$column['type'] = 'integer';
			}

			if ($field->type == 'tel') {
				$column['normalize'] = 'tel';
			}

			$columns[$name] = $column;
		}

		$columns['id_category'] = [
			'label'  => 'Catégorie',
			'type'   => 'enum',
			'null'   => false,
			'values' => $db->getAssoc('SELECT id, name FROM users_categories ORDER BY name COLLATE U_NOCASE;'),
			'select' => '(SELECT name FROM users_categories WHERE id = id_category)',
			'where'  => 'id_category %s',
		];

		$columns['service'] = [
			'label'  => '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_active'] = [
			'label'  => 'À 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 services_users WHERE id_service %s AND (expiry_date IS NULL OR expiry_date > date()))',
		];

		return $columns;
	}

	public function schema(): array
	{
		$db = DB::getInstance();
		$sql = sprintf('SELECT name, sql FROM sqlite_master WHERE %s ORDER BY name;', $db->where('name', ['users', 'users_categories']));
		return $db->getAssoc($sql);
	}

	public function simple(string $query): \stdClass
	{
		$operator = 'LIKE %?%';
		$db = DB::getInstance();

		if (is_numeric(trim($query)))
		{
			$column = DynamicFields::getNumberField();
			$operator = '= ?';
		}
		elseif (strpos($query, '@') !== false)
		{
			$column = DynamicFields::getFirstEmailField();
		}
		else
		{
			$column = 'identity';
		}

		// Try to redirect to user if there is only one user
		if ($operator == '= ?') {
			$sql = sprintf('SELECT id, COUNT(*) AS count FROM users WHERE %s = ?;', $column);
			$single_query = (int) $query;
		}
		else {
			$sql = sprintf('SELECT id, COUNT(*) AS count FROM users WHERE %s LIKE ?;', $column);
			$single_query = '%' . trim($query) . '%';
		}

		if (($row = $db->first($sql, $single_query)) && $row->count == 1) {
			Utils::redirect('!users/details.php?id=' . $id);
		}

		$query = [[
			'operator' => 'AND',
			'conditions' => [
				[
					'column'   => $column,
					'operator' => $operator,
					'values'   => [$query],
				],
			],
		]];

		return (object) [
			'query' => $query,
			'order' => $column,
			'desc'  => false,
		];
	}

	public function make(string $query): DynamicList
	{
		$tables = 'users u';
		return $this->makeList($query, $tables, current(DynamicFields::getNameFields()), false);
	}

	public function defaults(): \stdClass
	{
		return (object) ['groups' => [[
			'operator' => 'AND',
			'conditions' => [
				[
					'column'   => current(DynamicFields::getNameFields()),
					'operator' => 'LIKE %?%',
					'values'   => [''],
				],
			],
		]]];
	}
}

Modified src/include/lib/Garradin/Users/Categories.php from [9baeb0d492] to [d8ccca1c6a].

17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s ORDER BY name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listWithStats(): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM membres WHERE id_category = c.id) AS count
			FROM %s c ORDER BY c.name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 1
			ORDER BY name COLLATE U_NOCASE;', Category::TABLE));







|







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s ORDER BY name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listWithStats(): array
	{
		return DB::getInstance()->getGrouped(sprintf('SELECT c.id, c.*,
			(SELECT COUNT(*) FROM users WHERE id_category = c.id) AS count
			FROM %s c ORDER BY c.name COLLATE U_NOCASE;', Category::TABLE));
	}

	static public function listHidden(): array
	{
		return DB::getInstance()->getAssoc(sprintf('SELECT id, name FROM %s WHERE hidden = 1
			ORDER BY name COLLATE U_NOCASE;', Category::TABLE));

Added src/include/lib/Garradin/Users/DynamicFields.php version [3bd41b08fa].































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































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

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\ValidationException;

use Garradin\Entities\Users\DynamicField;
use Garradin\Entities\Users\User;

use KD2\DB\EntityManager as EM;

use const Garradin\ROOT;

class DynamicFields
{
	const PRESETS_FILE = ROOT . '/include/data/users_fields_presets.ini';

	const TABLE = DynamicField::TABLE;

	protected $_fields = [];
	protected $_fields_by_type = [];
	protected $_fields_by_system_use = [
		'login' => [],
		'password' => [],
		'name' => [],
		'number' => [],
	];

	protected array $_presets = [];

	protected array $_deleted = [];

	static protected $_instance;

	static public function getInstance()
	{
		if (null === self::$_instance) {
			self::$_instance = new self;
		}

		return self::$_instance;
	}

	static public function get(string $key)
	{
		return self::getInstance()->fieldByKey($key);
	}

	/**
	 * Returns the list of columns containing an email address (there might be more than one)
	 * @return array
	 */
	static public function getEmailFields(): array
	{
		return array_keys(self::getInstance()->fieldsByType('email'));
	}

	static public function getFirstEmailField(): string
	{
		return key(self::getInstance()->fieldsByType('email'));
	}

	/**
	 * FIXME use generated columns instead https://www.sqlite.org/gencol.html
	 */
	static public function getNumberField(): string
	{
		return key(self::getInstance()->fieldsBySystemUse('number'));
	}

	static public function getLoginField(): string
	{
		return key(self::getInstance()->fieldsBySystemUse('login'));
	}

	static public function getNameFields(): array
	{
		return array_keys(self::getInstance()->fieldsBySystemUse('name'));
	}

	static public function getNameLabel(): string
	{
		$list = self::getInstance()->fieldsBySystemUse('name');
		$labels = [];

		foreach ($list as $field) {
			$labels[] = $field->label;
		}

		return implode(', ', $labels);
	}

	static public function getNameFieldsSQL(?string $prefix = null): string
	{
		$fields = self::getNameFields();
		$db= DB::getInstance();

		if ($prefix) {
			$fields = array_map(fn($v) => $prefix . '.' . $db->quoteIdentifier($v), $fields);
		}

		return implode(' || \' \' ', $fields);
	}

	static public function getEntityProperties(): array
	{
		$fields = self::getEntityTypes();
		return DynamicField::SYSTEM_FIELDS + $fields;
	}

	static public function changeLoginField(string $new_field): void
	{
		$old_field = self::getLoginField();

		if ($old_field === $new_field) {
			return;
		}

		$db = DB::getInstance();

		// First check that the field can be used as login
		$sql = sprintf('SELECT (COUNT(DISTINCT transliterate_to_ascii(%s)) = COUNT(*)) FROM users WHERE %1$s IS NOT NULL AND %1$s != \'\';', $new_field);

		if (!$db->firstColumn($sql)) {
			throw new UserException(sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $new_field));
		}

		$sql = sprintf('UPDATE %s SET system = NULL WHERE system = \'login\';
			UPDATE %1$s SET system = \'login\' WHERE key = %s;',
			self::TABLE,
			$new_field
		);

		$db->exec($sql);

		// Regenerate login index
		$db->exec('DROP INDEX IF EXISTS users_id_field;');
		$this->createIndexes();
	}

	protected function __construct(bool $load = true)
	{
		if ($load) {
			$this->reload();
		}
	}

	protected function reload()
	{
		$db = DB::getInstance();
		$i = EM::getInstance(DynamicField::class)->iterate('SELECT * FROM @TABLE ORDER BY sort_order;');

		foreach ($i as $field) {
			$this->_fields[$field->name] = $field;
		}

		$this->reloadCache();
	}

	public function install(): void
	{
		$presets = $this->getInstallPresets();
		$i = 0;

		foreach ($presets as $name => $preset) {
			$field = new DynamicField;

			if ($name == 'password') {
				$name = 'password';
				$field->system |= $field::PASSWORD;
			}

			if ($name == 'email') {
				$field->system |= $field::LOGIN;
			}

			if ($name == 'name') {
				$field->system |= $field::NAME;
			}

			if ($name == 'numero') {
				$field->system |= $field::NUMBER;
			}

			$field->set('name', $name);
			$field->set('label', $data['label']);
			$field->set('type', $data['type']);
			$field->set('help', $data['help'] ?? null);
			$field->set('read_access', $data['read_access'] ?? 0);
			$field->set('write_access', $data['write_access'] ?? 0);
			$field->set('required', $data['required'] ?? false);
			$field->set('list_table', $data['list_table'] ?? false);
			$field->set('sort_order', $i++);
			$self->add($field);
		}

		$this->save();
	}

	protected function reloadCache()
	{
		$this->_fields_by_type = [];

		foreach ($this->_fields_by_system_use as &$list) {
			$list = [];
		}
		unset($list);

		foreach ($this->_fields as $key => $field) {
			if (!isset($this->_fields_by_type[$field->type])) {
				$this->_fields_by_type[$field->type] = [];
			}

			$this->_fields_by_type[$field->type][$key] = $field;

			if (!$field->system) {
				continue;
			}

			if ($field->system & $field::PASSWORD) {
				$this->_fields_by_system_use['password'][$key] = $field;
			}

			if ($field->system & $field::NAME) {
				$this->_fields_by_system_use['name'][$key] = $field;
			}

			if ($field->system & $field::NUMBER) {
				$this->_fields_by_system_use['number'][$key] = $field;
			}

			if ($field->system & $field::LOGIN) {
				$this->_fields_by_system_use['login'][$key] = $field;
			}
		}
	}

	public function fieldsByType(string $type): array
	{
		return $this->_fields_by_type[$type] ?? [];
	}

	public function fieldByKey(string $key): ?DynamicField
	{
		return $this->_fields[$key] ?? null;
	}

	public function fieldById(int $id): ?DynamicField
	{
		foreach ($this->_fields as $field) {
			if ($field->id === $id) {
				return $field;
			}
		}

		return null;
	}

	public function fieldsBySystemUse(string $use): array
	{
		return $this->_fields_by_system_use[$use] ?? [];
	}

	public function getEntityTypes(): array
	{
		$types = [];

		foreach ($this->_fields as $key => $field) {
			$types[$key] = $field->type;
		}

		return $types;
	}

	public function getPresets(): array
	{
		if (null === $this->_presets)
		{
			$this->_presets = parse_ini_file(self::PRESETS_FILE, true);

			foreach ($this->_presets as &$preset) {
				$preset = (object) $preset;
			}

			unset($preset);
		}

		return $this->_presets;
	}

	public function getInstallPresets()
	{
		return array_filter($this->getPresets(), fn ($row) => !$row->install );
	}

	/**
	 * Import from old INI config
	 * @deprecated Only use when migrating from an old version
	 */
	static public function fromOldINI(string $config, string $login_field, string $name_field, string $number_field)
	{
		$db = DB::getInstance();
		$config = parse_ini_string($config, true);

		$i = 0;

		$self = new self(false);
		$fields = [
			'date_connexion'   => 'date_login',
			'date_inscription' => 'date_created',
			'clef_pgp'         => 'pgp_key',
			'secret_otp'       => 'otp_secret',
			'id_category'      => 'id_category',
		];

		$defaults = [
			'help'      => null,
			'private'   => false,
			'editable'  => true,
			'mandatory' => false,
			'list_row'  => null,
		];

		foreach ($config as $name => $data) {
			$field = new DynamicField;

			$fields[$name] = $name;

			if ($data['type'] == 'checkbox' || $data['type'] == 'multiple') {
				// A checkbox/multiple checkbox can either be 0 or 1, not NULL
				$db->exec(sprintf('UPDATE membres SET %s = 0 WHERE %1$s IS NULL OR %1$s = \'\';', $name));
			}
			else {
				// Make sure data is NULL if empty
				$db->exec(sprintf('UPDATE membres SET %s = NULL WHERE %1$s = \'\';', $name));
			}

			if ($name == 'passe') {
				$name = 'password';
				$data['title'] = 'Mot de passe';
				$field->system |= $field::PASSWORD;
				$fields['passe'] = 'password';
			}

			if ($name == $login_field) {
				$field->system |= $field::LOGIN;
			}

			if ($name == $name_field) {
				$field->system |= $field::NAME;
			}

			if ($name == $number_field) {
				$field->system |= $field::NUMBER;
				$data['help'] = null;
				$data['mandatory'] = true;
				$data['editable'] = false;
			}

			$data = array_merge($defaults, $data);

			$field->set('name', $name);
			$field->set('label', $data['title']);
			$field->set('type', $data['type']);
			$field->set('help', empty($data['help']) ? null : $data['help']);
			$field->set('read_access', $data['private'] ? $field::ACCESS_ADMIN : $field::ACCESS_USER);
			$field->set('write_access', $data['editable'] ? $field::ACCESS_ADMIN : $field::ACCESS_USER);
			$field->set('required', (bool) $data['mandatory']);
			$field->set('list_table', (bool) $data['list_row']);
			$field->set('sort_order', $i++);
			$self->add($field);
		}

		self::$_instance = $self;

		$self->createTable();
		$self->createIndexes();
		$self->copy('membres', User::TABLE, $fields);

		return $self;
	}

	public function isText(string $field)
	{
		$type = $this->_fields[$field]->type;
		return DynamicField::SQL_TYPES[$type] == 'TEXT';
	}

	public function getKeys()
	{
		return array_keys($this->_fields);
	}

	public function all()
	{
		return $this->_fields;
	}

	public function allExceptPassword()
	{
		return array_filter($this->_fields, function ($a) {
			return !($a->system & DynamicField::PASSWORD);
		});
	}

	public function listAssocNames()
	{
		$out = [];

		foreach ($this->_fields as $key => $field) {
			if ($field->system & $field::PASSWORD) {
				continue;
			}

			$out[$key] = $field->label;
		}

		return $out;
	}

	public function getMultiples()
	{
		return array_filter($this->_fields, function ($a) {
			return $a->type == 'multiple';
		});
	}

	public function getListedFields(): array
	{
		$name_fields = self::getNameFields();
		$name_fields[] = self::getNumberField();

		$fields = array_filter(
			$this->_fields,
			function ($a, $b) use ($name_fields) {
				if (in_array($b, $name_fields)) {
					return false;
				}

				return empty($a->list_table) ? false : true;
			},
			ARRAY_FILTER_USE_BOTH
		);

		uasort($fields, function ($a, $b) {
			if ($a->sort_order == $b->sort_order)
				return 0;

			return ($a->sort_order > $b->sort_order) ? 1 : -1;
		});

		return $fields;
	}

	public function getSQLSchema(string $table_name = User::TABLE): string
	{
		$db = DB::getInstance();

		// Champs à créer
		$create = [
			'id INTEGER PRIMARY KEY, -- Numéro attribué automatiquement',
			'id_category INTEGER NOT NULL REFERENCES users_categories(id),',
			'date_login TEXT NULL CHECK (date_login IS NULL OR datetime(date_login) = date_login), -- Date de dernière connexion',
			'date_created TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date_created) = date_created), -- Date d\'inscription',
			'otp_secret TEXT NULL, -- Code secret pour TOTP',
			'pgp_key TEXT NULL, -- Clé publique PGP'
		];

		end($this->_fields);
		$last_one = key($this->_fields);

		foreach ($this->_fields as $key => $cfg)
		{
			$type = DynamicField::SQL_TYPES[$cfg->type];
			$line = sprintf('%s %s', $db->quoteIdentifier($key), $type);

			if ($type == 'TEXT' && $cfg->type != 'password') {
				$line .= ' COLLATE NOCASE';
			}

			if ($last_one != $key) {
				$line .= ',';
			}

			if (!empty($cfg->label))
			{
				$line .= ' -- ' . str_replace(["\n", "\r"], '', $cfg->label);
			}

			$create[] = $line;
		}

		$sql = sprintf("CREATE TABLE %s\n(\n\t%s\n);", $table_name, implode("\n\t", $create));
		return $sql;
	}

	/**
	 * Returns the SQL query used to create the search table and triggers
	 * This table is useful to make LIKE searches on unicode columns
	 */
	public function getSQLSearchSchema(string $table_name = User::TABLE): ?string
	{
		$db = DB::getInstance();
		$search_table = $table_name . '_search';

		$columns = [];

		foreach ($this->_fields as $key => $cfg) {
			if ($cfg->type == 'text' || $cfg->list_table) {
				$columns[] = $key;
			}
		}

		if (!count($columns)) {
			return null;
		}

		$new_columns = array_map(fn ($v) => sprintf('transliterate_to_ascii(NEW.%s)', $v), $columns);

		$sql = sprintf("CREATE TABLE IF NOT EXISTS %s\n(\n\tid INTEGER PRIMARY KEY NOT NULL REFERENCES %s (id) ON DELETE CASCADE,\n\t%s\n);", $search_table, $table_name, implode(",\n\t", $columns));
		$sql .= "\n";

		// Triggers
		$sql .= sprintf("CREATE TRIGGER IF NOT EXISTS %s_ai AFTER INSERT ON %s BEGIN\n\t", $search_table, $table_name);
		$sql .= sprintf("REPLACE INTO %s (id, %s) VALUES (NEW.id, %s);\n", $search_table, implode(', ', $columns), implode(', ', $new_columns));
		$sql .= "END;\n";
		$sql .= sprintf("CREATE TRIGGER IF NOT EXISTS %s_au AFTER UPDATE ON %s BEGIN\n\t", $search_table, $table_name);
		$sql .= sprintf("REPLACE INTO %s (id, %s) VALUES (NEW.id, %s);\n", $search_table, implode(', ', $columns), implode(', ', $new_columns));
		$sql .= "END;\n";

		foreach ($columns as $column) {
			$sql .= sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s);\n", $db->quoteIdentifier($search_table . '_' . $column), $search_table, $db->quoteIdentifier($column));
		}

		return $sql;
	}

	public function getCopyFields(): array
	{
		// Champs à recopier
		$copy = array_keys(DynamicField::SYSTEM_FIELDS) + array_keys($this->_fields);
		return array_combine($copy, $copy);
	}

	public function getSQLCopy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null): string
	{
		if (null === $fields) {
			$fields = $this->getCopyFields();
		}

		$db = DB::getInstance();

		return sprintf('INSERT INTO %s (id, %s) SELECT id, %s FROM %s;',
			$new_table_name,
			implode(', ', array_map([$db, 'quoteIdentifier'], $fields)),
			implode(', ', array_map([$db, 'quoteIdentifier'], array_keys($fields))),
			$old_table_name
		);
	}

	public function copy(string $old_table_name, string $new_table_name = User::TABLE, array $fields = null): void
	{
		//var_dump($this->getSQLCopy($old_table_name, $new_table_name, $fields)); exit;
		DB::getInstance()->exec($this->getSQLCopy($old_table_name, $new_table_name, $fields));
	}

	public function create(string $table_name = User::TABLE)
	{
		$db = DB::getInstance();
		$db->begin();
		$this->createTable($table_name);
		$this->createIndexes($table_name);
		$db->commit();
	}

	public function createTable(string $table_name = User::TABLE): void
	{
		$db = DB::getInstance();
		$schema = $this->getSQLSchema($table_name);
		$db->exec($schema);

		$schema = $this->getSQLSearchSchema($table_name);

		if ($schema) {
			$db->exec($schema);
		}
	}

	public function createIndexes(string $table_name = User::TABLE): void
	{
		$id_field = null;
		$db = DB::getInstance();

		if ($id_field = $this->getLoginField()) {
			// Mettre les champs identifiant vides à NULL pour pouvoir créer un index unique
			$db->exec(sprintf('UPDATE %s SET %s = NULL WHERE %2$s = \'\';',
				$table_name, $id_field));

			$collation = '';

			if ($this->isText($id_field)) {
				$collation = ' COLLATE NOCASE';
			}

			// Création de l'index unique
			$db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_id_field ON %s (%s%s);', $table_name, $id_field, $collation));
		}

		$db->exec(sprintf('CREATE UNIQUE INDEX IF NOT EXISTS users_number ON %s (%s);', $table_name, $this->getNumberField()));
		$db->exec(sprintf('CREATE INDEX IF NOT EXISTS users_category ON %s (id_category);', $table_name));
	}

	/**
	 * Enregistre les changements de champs en base de données
	 * @return boolean true
	 */
	public function rebuildUsersTable()
	{
		$db = DB::getInstance();

		$db->beginSchemaUpdate();
		$this->createTable(User::TABLE . '_tmp');
		$this->copy(User::TABLE, User::TABLE . '_tmp');
		$db->exec(sprintf('DROP TABLE IF EXISTS %s;', User::TABLE));
		$db->exec(sprintf('ALTER TABLE %s_tmp RENAME TO %1$s;', User::TABLE));

		$this->createIndexes(User::TABLE);

		$db->commitSchemaUpdate();

		return true;
	}

	public function preview(array $fields)
	{
	}

	public function add(DynamicField $df)
	{
		$this->_fields[$df->name] = $df;
		$this->reloadCache();
	}

	public function delete(string $name)
	{
		$this->_deleted[] = $this->_fields[$name];
		unset($this->_fields[$name]);

		$this->reloadCache();
	}

	public function save(bool $allow_rebuild = true)
	{
		if (empty($this->_fields_by_system_use['number'])) {
			throw new ValidationException('Aucun champ de numéro de membre n\'existe');
		}

		if (count($this->_fields_by_system_use['number']) != 1) {
			throw new ValidationException('Un seul champ peut être défini comme numéro');
		}

		if (empty($this->_fields_by_system_use['name'])) {
			throw new ValidationException('Aucun champ de nom de membre n\'existe');
		}

		if (empty($this->_fields_by_system_use['login'])) {
			throw new ValidationException('Aucun champ d\'identifiant de connexion n\'existe');
		}

		if (count($this->_fields_by_system_use['login']) != 1) {
			throw new ValidationException('Un seul champ peut être défini comme identifiant');
		}

		if (empty($this->_fields_by_system_use['password'])) {
			throw new ValidationException('Aucun champ de mot de passe n\'existe');
		}

		if (count($this->_fields_by_system_use['password']) != 1) {
			throw new ValidationException('Un seul champ peut être défini comme mot de passe');
		}

		$rebuild = false;

		foreach ($this->_fields as $field) {
			if (!$field->exists()) {
				$rebuild = true;
			}

			if ($field->isModified()) {
				$field->save();
			}
		}


		foreach ($this->_deleted as $f) {
			$f->delete();
			$rebuild = true;
		}

		$this->_deleted = [];

		if ($rebuild && $allow_rebuild) {
			$db = DB::getInstance();

			$db->begin();

			// FIXME/TODO: use ALTER TABLE ... DROP COLUMN for SQLite 3.35.0+
			// some conditions apply
			// https://www.sqlite.org/lang_altertable.html#altertabdropcol
			$this->rebuildUsersTable();

			$db->commit();
			$this->reload();
		}
	}

	public function setOrderAll(array $order)
	{
		foreach (array_values($order) as $sort => $key) {
			if (!array_key_exists($key, $this->_fields)) {
				throw new \InvalidArgumentException('Unknown field name: ' . $key);
			}

			$this->_fields[$key]->set('sort_order', $sort);
		}
	}

	public function getLastOrderIndex()
	{
		return count($this->_fields);
	}
}

Modified src/include/lib/Garradin/Users/Emails.php from [8018f4f5dd] to [8a6e6dff56].

350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'identity' => [
				'label' => 'Membre',
				'select' => 'u.' . $db->quoteIdentifier(Config::getInstance()->champ_identite),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => 'u.email',
			],
			'user_id' => [
				'select' => 'u.id',







|







350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
	static public function listRejectedUsers(): DynamicList
	{
		$db = DB::getInstance();

		$columns = [
			'identity' => [
				'label' => 'Membre',
				'select' => DynamicFields::getNameFieldsSQL('u'),
			],
			'email' => [
				'label' => 'Adresse',
				'select' => 'u.email',
			],
			'user_id' => [
				'select' => 'u.id',
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = 'emails e
			INNER JOIN membres u ON u.email IS NOT NULL AND u.email != \'\' AND e.hash = email_hash(u.email)';

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







|







385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
				'label' => 'Dernière tentative d\'envoi',
			],
			'optout' => [],
			'fail_count' => [],
		];

		$tables = 'emails e
			INNER JOIN users u ON u.email IS NOT NULL AND u.email != \'\' AND e.hash = email_hash(u.email)';

		$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);
		return $list;
	}
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
		$message->setBody($content);

		if (null !== $content_html) {
			$message->addPart('text/html', $content_html);
		}

		$config = Config::getInstance();
		$message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->email_asso);
		$message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers

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

	static public function sendMessage(int $context, Mail_Message $message)
	{







|







438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
		$message->setBody($content);

		if (null !== $content_html) {
			$message->addPart('text/html', $content_html);
		}

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

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

	static public function sendMessage(int $context, Mail_Message $message)
	{
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
		}
		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->email_asso,
				'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());








|







491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
		}
		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());

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

		return $return;
	}

	/**
	 * Create a mass mailing
	 */
	static public function createMailing(array $recipients, string $subject, string $message, bool $send_copy, ?string $render): \stdClass
	{
		$list = [];

		foreach ($recipients as $recipient) {
			if (empty($recipient->email)) {
				continue;
			}

			$list[$recipient->email] = $recipient;
		}

		if (!count($list)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		$html = null;
		$tpl = null;

		$random = array_rand($list);








|












|







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

		return $return;
	}

	/**
	 * Create a mass mailing
	 */
	static public function createMailing(iterable $recipients, string $subject, string $message, bool $send_copy, ?string $render): \stdClass
	{
		$list = [];

		foreach ($recipients as $recipient) {
			if (empty($recipient->email)) {
				continue;
			}

			$list[$recipient->email] = $recipient;
		}

		if (!count($list)) {
			throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.');
		}

		$html = null;
		$tpl = null;

		$random = array_rand($list);

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
		else {
			$html = '<pre>' . htmlspecialchars(wordwrap($message)) . '</pre>';
		}

		$recipients = $list;

		$config = Config::getInstance();
		$sender = sprintf('"%s" <%s>', $config->nom_asso, $config->email_asso);
		$message = (object) compact('recipients', 'subject', 'message', 'sender', 'tpl', 'send_copy', 'render');
		$message->preview = (object) [
			'to'      => $random,
			// Not required to be a valid From header, this is just a preview
			'from'    => $sender,
			'subject' => $subject,
			'html'    => $html,
		];

		return $message;
	}

	static public function getFromHeader(string $name = null, string $email = null): string
	{
		$config = Config::getInstance();

		if (null === $name) {
			$name = $config->nom_asso;
		}
		if (null === $email) {
			$email = $config->email_asso;
		}

		$name = str_replace('"', '\\"', $name);
		$name = str_replace(',', '', $name); // Remove commas

		return sprintf('"%s" <%s>', $name, $email);
	}







|

















|


|







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
		else {
			$html = '<pre>' . htmlspecialchars(wordwrap($message)) . '</pre>';
		}

		$recipients = $list;

		$config = Config::getInstance();
		$sender = sprintf('"%s" <%s>', $config->org_name, $config->org_email);
		$message = (object) compact('recipients', 'subject', 'message', 'sender', 'tpl', 'send_copy', 'render');
		$message->preview = (object) [
			'to'      => $random,
			// Not required to be a valid From header, this is just a preview
			'from'    => $sender,
			'subject' => $subject,
			'html'    => $html,
		];

		return $message;
	}

	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);
	}
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
			$mailing->tpl ?? $mailing->message,
			$mailing->render ?? null
		);

		if ($mailing->send_copy)
		{
			$config = Config::getInstance();
			Emails::queue(Emails::CONTEXT_BULK, [$config->get('email_asso') => null], null, $mailing->subject, $mailing->message);
		}
	}

	static public function exportMailing(string $format, \stdClass $mailing): void
	{
		$rows = $mailing->recipients;
		$id_field = Config::getInstance()->get('champ_identite');







|







634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
			$mailing->tpl ?? $mailing->message,
			$mailing->render ?? null
		);

		if ($mailing->send_copy)
		{
			$config = Config::getInstance();
			Emails::queue(Emails::CONTEXT_BULK, [$config->org_email => null], null, $mailing->subject, $mailing->message);
		}
	}

	static public function exportMailing(string $format, \stdClass $mailing): void
	{
		$rows = $mailing->recipients;
		$id_field = Config::getInstance()->get('champ_identite');

Added src/include/lib/Garradin/Users/Session.php version [bb5c2e7d5a].

















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































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

namespace Garradin\Users;

use Garradin\Config;
use Garradin\DB;
use Garradin\Utils;
use Garradin\Users\Users;
use Garradin\UserException;
use Garradin\Plugin;
use Garradin\Users\Emails;

use Garradin\Entities\Users\User;

use const Garradin\SECRET_KEY;
use const Garradin\WWW_URL;
use const Garradin\ADMIN_URL;

use KD2\Security;
use KD2\Security_OTP;
use KD2\Graphics\QRCode;
use KD2\HTTP;

class Session extends \KD2\UserSession
{
	const SECTION_WEB = 'web';
	const SECTION_DOCUMENTS = 'documents';
	const SECTION_USERS = 'users';
	const SECTION_ACCOUNTING = 'accounting';
	const SECTION_CONNECT = 'connect';
	const SECTION_CONFIG = 'config';
	const SECTION_SUBSCRIBE = 'subscribe';

	const ACCESS_NONE = 0;
	const ACCESS_READ = 1;
	const ACCESS_WRITE = 2;
	const ACCESS_ADMIN = 9;

	// Personalisation de la config de UserSession
	protected $cookie_name = 'gdin';
	protected $remember_me_cookie_name = 'gdinp';
	protected $remember_me_expiry = '+3 months';

	const MINIMUM_PASSWORD_LENGTH = 8;

	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}

	public function __clone()
	{
		throw new \LogicException('Cannot clone');
	}

	public function __construct()
	{
		if (self::$_instance !== null) {
			throw new \LogicException('Wrong call, use getInstance');
		}

		$url = parse_url(ADMIN_URL);

		parent::__construct(DB::getInstance(), [
			'cookie_domain' => $url['host'],
			'cookie_path'   => preg_replace('!/admin/$!', '/', $url['path']),
			'cookie_secure' => HTTP::getScheme() == 'https' ? true : false,
		]);
	}

	static public function checkPasswordValidity($password)
	{
		if (strlen($password) < self::MINIMUM_PASSWORD_LENGTH)
		{
			throw new UserException(sprintf('Le mot de passe doit faire au moins %d caractères.', self::MINIMUM_PASSWORD_LENGTH));
		}

		$session = self::getInstance();
		$session->http = new HTTP;

		if ($session->isPasswordCompromised($password)) {
			throw new UserException('Ce mot de passe figure dans une liste de mots de passe compromis, il ne peut donc être utilisé ici. Si vous l\'avez utilisé sur d\'autres sites il est recommandé de le changer sur ces autres sites également.');
		}
	}

	public function isPasswordCompromised($password)
	{
		// Vérifier s'il n'y a pas un plugin qui gère déjà cet aspect
		// notamment en installation mutualisée c'est plus efficace
		$return = ['is_compromised' => null];
		$called = Plugin::fireSignal('motdepasse.compromis', ['password' => $password], $return);

		if ($called !== null) {
			return $return['is_compromised'];
		}

		return parent::isPasswordCompromised($password);
	}

	protected function getUserForLogin($login)
	{
		$id_field = DynamicFields::getLoginField();

		// Ne renvoie un membre que si celui-ci a le droit de se connecter
		$query = 'SELECT u.id, u.%1$s AS login, u.password, u.otp_secret
			FROM users AS u
			INNER JOIN users_categories AS c ON c.id = u.id_category
			WHERE u.%1$s = ? COLLATE NOCASE AND c.perm_connect >= %2$d
			LIMIT 1;';

		$query = sprintf($query, $id_field, self::ACCESS_READ);

		return $this->db->first($query, $login);
	}

	protected function getUserDataForSession($id)
	{
		// Mettre à jour la date de connexion
		$this->db->preparedQuery('UPDATE users SET date_login = datetime() WHERE id = ?;', [$id]);

		$sql = sprintf('SELECT u.*,
			c.perm_connect, c.perm_web, c.perm_users, c.perm_documents,
			c.perm_subscribe, c.perm_accounting, c.perm_config
			FROM users AS u
			INNER JOIN users_categories AS c ON u.id_category = c.id
			WHERE u.id = ? LIMIT 1;',
			$this->db->quoteIdentifier(DynamicFields::getLoginField('u')));

		$u = $this->db->first($sql, $id);

		if (!$u) {
			return null;
		}

		$this->set('permissions', array_filter((array) $u,
			fn($k) => substr($k, 0, 5) == 'perm_',
			\ARRAY_FILTER_USE_KEY)
		);

		$u = array_filter(
			(array) $u,
			fn($k) => substr($k, 0, 5) != 'perm_',
			\ARRAY_FILTER_USE_KEY
		);

		return (new User)->load($u);
	}

	protected function storeRememberMeSelector($selector, $hash, $expiry, $user_id)
	{
		return $this->db->insert('users_sessions', [
			'selector'  => $selector,
			'hash'      => $hash,
			'expiry'    => $expiry,
			'id_user'   => $user_id,
		]);
	}

	protected function expireRememberMeSelectors()
	{
		return $this->db->delete('users_sessions', $this->db->where('expiry', '<', time()));
	}

	protected function getRememberMeSelector($selector)
	{
		return $this->db->first('SELECT selector, hash,
			s.id_user AS user_id, u.password AS user_password, expiry
			FROM users_sessions AS s
			INNER JOIN users AS u ON u.id = s.id_user
			WHERE s.selector = ? LIMIT 1;', $selector);
	}

	protected function deleteRememberMeSelector($selector)
	{
		return $this->db->delete('users_sessions', $this->db->where('selector', $selector));
	}

	protected function deleteAllRememberMeSelectors($user_id)
	{
		return $this->db->delete('users_sessions', $this->db->where('id_user', $user_id));
	}

	public function isLogged(bool $disable_local_login = false)
	{
		$logged = parent::isLogged();

		// Ajout de la gestion de LOCAL_LOGIN
		if (!$disable_local_login && defined('\Garradin\LOCAL_LOGIN')) {
			$logged = $this->forceLogin(\Garradin\LOCAL_LOGIN);
		}

		return $logged;
	}

	public function forceLogin(int $id)
	{
		// On va chercher le premier membre avec le droit de gérer la config
		if (-1 === $id) {
			$id = $this->db->firstColumn('SELECT id FROM users
				WHERE id_category IN (SELECT id FROM users_categories WHERE perm_config = ?)
				LIMIT 1', self::ACCESS_ADMIN);
		}

		$logged = parent::isLogged();

		// Only login if required
		if ($id > 0 && (!$logged || ($logged && $this->user->id != $id))) {
			return $this->create($id);
		}

		return $logged;
	}

	// Ici checkOTP utilise NTP en second recours
	public function checkOTP($secret, $code)
	{
		if (Security_OTP::TOTP($secret, $code))
		{
			return true;
		}

		// Vérifier encore, mais avec le temps NTP
		// au cas où l'horloge du serveur n'est pas à l'heure
		if (\Garradin\NTP_SERVER
			&& ($time = Security_OTP::getTimeFromNTP(\Garradin\NTP_SERVER))
			&& Security_OTP::TOTP($secret, $code, $time))
		{
			return true;
		}

		return false;
	}

	public function getOTPSecret($secret = null)
	{
		if (!$secret)
		{
			$secret = Security_OTP::getRandomSecret();
		}

		$out = [];
		$out['secret'] = $secret;
		$out['secret_display'] = implode(' ', str_split($secret, 4));
		$out['url'] = Security_OTP::getOTPAuthURL(Config::getInstance()->get('org_name'), $secret);

		$qrcode = new QRCode($out['url']);
		$out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG());

		return $out;
	}

	public function recoverPasswordSend(int $id)
	{
		$user = $this->fetchUserForPasswordRecovery($id);

		if (!$user) {
			return false;
		}

		$query = $this->makePasswordRecoveryQuery($user);

		$message = "Bonjour,\n\nVous avez oublié votre mot de passe ? Pas de panique !\n\n";
		$message.= "Il vous suffit de cliquer sur le lien ci-dessous pour modifier votre mot de passe.\n\n";
		$message.= ADMIN_URL . 'password.php?c=' . $query;
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le, votre mot de passe restera inchangé.";

		if ($membre->clef_pgp) {
			$content = Security::encryptWithPublicKey($membre->clef_pgp, $message);
		}

		Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe perdu ?', $message);
		return true;
	}

	protected function fetchUserForPasswordRecovery(int $id): ?\stdClass
	{
		$db = DB::getInstance();

		$id_field = DynamicFields::getLoginField();
		$email_field = DynamicFields::getFirstEmailField();

		// Fetch user, must have an email
		$sql = sprintf('SELECT id, %s AS email, password, pgp_key
			FROM users
			WHERE %s = ? COLLATE NOCASE
				AND %1$s IS NOT NULL
			LIMIT 1;',
			$db->quoteIdentifier($email_field),
			$db->quoteIdentifier($id_field));

		return $db->first($sql, trim($id));
	}

	protected function makePasswordRecoveryHash(\stdClass $user, ?int $expire = null): string
	{
		// valide pour 1 heure minimum
		$expire = $expire ?? ceil((time() - strtotime('2017-01-01')) / 3600) + 1;

		$hash = hash_hmac('sha256', $user->email . $user->id . $user->password . $expire, SECRET_KEY, true);
		$hash = substr(Security::base64_encode_url_safe($hash), 0, 16);
		return $hash;
	}

	protected function makePasswordRecoveryQuery(\stdClass $user): string
	{
		$id = base_convert($user->id, 10, 36);
		$expire = base_convert($expire, 10, 36);
		return sprintf('%s.%s.%s', $id, $expire, $hash);
	}

	/**
	 * Check that the supplied query is valid, if so, return the user information
	 * @param  string $query User-supplied query
	 */
	public function checkRecoveryPasswordQuery(string $query): ?\stdClass
	{
		if (substr_count($query, '.') !== 2) {
			return null;
		}

		list($id, $expire, $email_hash) = explode('.', $query);

		$id = base_convert($id, 36, 10);
		$expire = base_convert($expire, 36, 10);

		$expire_timestamp = ($expire * 3600) + strtotime('2017-01-01');

		// Check that the query has not expired yet
		if (time() / 3600 > $expire_timestamp) {
			return null;
		}

		// Fetch user info
		$user = $this->fetchUserForPasswordRecovery($id);

		if (!$user) {
			return null;
		}

		// Check hash using secret data from the user
		$hash = $this->makePasswordRecoveryHash($user, $expire);

		if (!hash_equals($hash, $email_hash)) {
			return null;
		}

		return $user;
	}

	public function recoverPasswordChange(string $query, string $password, string $password_confirm)
	{
		$user = $this->checkRecoveryPasswordQuery($code);

		if (null === $user) {
			throw new UserException('Le code permettant de changer le mot de passe a expiré. Merci de bien vouloir recommencer la procédure.');
		}

		$password = trim($password);
		$password_confirm = trim($password_confirm);

		if (!hash_equals($password, $password_confirm)) {
			throw new UserException('Le mot de passe et sa vérification ne sont pas identiques.');
		}

		self::checkPasswordValidity($password);

		$password = self::hashPassword($password);

		$message = "Bonjour,\n\nLe mot de passe de votre compte a bien été modifié.\n\n";
		$message.= "Votre adresse email : ".$user->email."\n";
		$message.= "La demande émanait de l'adresse IP : ".Utils::getIP()."\n\n";
		$message.= "Si vous n'avez pas demandé à changer votre mot de passe, merci de nous le signaler.";

		DB::getInstance()->update('users', ['password' => $password], 'id = :id', ['id' => (int)$user->id]);

		return Emails::queue(Emails::CONTEXT_SYSTEM, [$membre->email => null], null, 'Mot de passe changé', $message);
	}

	public function user(): ?User
	{
		return $this->getUser();
	}

	static public function getUserId(): int
	{
		return self::getInstance()->user()->id;
	}

	public function canAccess(string $category, int $permission): bool
	{
		$permissions = $this->get('permissions');

		if (!$permissions) {
			return false;
		}

		$perm_name = 'perm_' . $category;
		$perm = $permissions[$perm_name];

		return ($perm >= $permission);
	}

	public function requireAccess(string $category, int $permission): void
	{
		if (!$this->canAccess($category, $permission))
		{
			throw new UserException('Vous n\'avez pas le droit d\'accéder à cette page.');
		}
	}

	public function getNewOTPSecret()
	{
		$out = [];
		$out['secret'] = Security_OTP::getRandomSecret();
		$out['secret_display'] = implode(' ', str_split($out['secret'], 4));
		$out['url'] = Security_OTP::getOTPAuthURL(Config::getInstance()->get('org_name'), $out['secret']);

		$qrcode = new QRCode($out['url']);
		$out['qrcode'] = 'data:image/svg+xml;base64,' . base64_encode($qrcode->toSVG());

		return $out;
	}

	public function sendMessage($dest, $sujet, $message, $copie = false)
	{
		$user = $this->getUser();

		$content = "Ce message vous a été envoyé par :\n";
		$content.= sprintf("%s\n%s\n\n", $user->identite, $user->email);
		$content.= str_repeat('=', 70) . "\n\n";
		$content.= $message;

		$dest = $copie ? [$dest => null, $user->email => null] : [$dest => null];

		return Emails::queue(Emails::CONTEXT_PRIVATE, $dest, null, $sujet, $content);
	}

	/**
	 * Change self security data
	 * @param  \stdClass $data Security data, eg. password, pgp_key or otp_secret
	 */
	public function editSecurity(\stdClass $data): void
	{
		$user = Users::get($this->user->id);

		$allowed_fields = ['password', 'pgp_key', 'otp_secret'];
		$data = array_intersect_key($data, array_flip($allowed_fields));

		if (isset($data->password) && trim($data->password) !== '') {
			self::checkPasswordValidity($data->password);
			$user->set('password', self::hashPassword($data->password));
		}

		if (isset($data->pgp_key) && trim($data->pgp_key) !== '') {
			$data->pgp_key = trim($data->pgp_key);

			if (!$this->getPGPFingerprint($data->pgp_key)) {
				throw new UserException('Clé PGP invalide : impossible d\'extraire l\'empreinte.');
			}

			$user->set('pgp_key', $data->pgp_key);
		}

		if (!empty($data->otp_secret)) {
			$user->set('otp_secret', $otp_secret);
		}

		$this->refresh();
	}
}

Added src/include/lib/Garradin/Users/Users.php version [c80a5b0096].





































































































































































































































































































































































































































































































































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

namespace Garradin\Users;

use Garradin\Entities\Users\User;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Search;
use Garradin\Utils;

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

class Users
{
	/**
	 * 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 protected function iterateEmailsByCategory(?int $id_category = null): iterable
	{
		$db = DB::getInstance();
		$fields = DynamicFields::getEmailFields();
		$sql = [];
		$where = $id_category ? sprintf('id_category = %d', $id_category) : 'id_category IN (SELECT id FROM users_categories WHERE hidden = 0)';

		foreach ($fields as $field) {
			$sql[] = sprintf('SELECT *, %s AS email FROM users WHERE %s AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $where);
		}

		return $db->iterate(implode(' UNION ALL ', $sql));
	}

	/**
	 * Return a list of all emails by service (user must be active)
	 */
	static public function iterateEmailsByActiveService(int $id_service): iterable
	{
		$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();
		$sql = [];

		foreach ($fields as $field) {
			$sql[] = sprintf('SELECT u.*, u.%s AS email FROM users u INNER JOIN users_active_services s ON s.id = u.id
				WHERE s.service = %d AND %1$s IS NOT NULL', $db->quoteIdentifier($field), $id_service);
		}

		return $db->iterate(implode(' UNION ALL ', $sql));
	}

	static public function iterateEmailsBySearch(int $id_search): iterable
	{
		$db = DB::getInstance();

		$s = Search::get($id_search);
		// Make sure the query is protected and safe, by doing a protectSelect
		$s->query(1);

		$header = $s->getHeader();
		$id_column = null;

		if (in_array('id', $header)) {
			$id_column = 'id';
		}
		elseif (in_array('_user_id', $header)) {
			$id_column = '_user_id';
		}
		else {
			throw new UserException('La recherche ne comporte pas de colonne "id" ou "_user_id", et donc ne permet pas l\'envoi d\'email.');
		}

		// We only need the user id, store it in a temporary table for now
		$db->exec('DROP TABLE IF EXISTS users_search; CREATE TEMPORARY TABLE IF NOT EXISTS users_search (id);');
		$db->exec(sprintf('INSERT INTO users_search SELECT %s FROM (%s)', $id_column, $s->SQL(null)));

		$fields = DynamicFields::getEmailFields();

		$sql = [];

		foreach ($fields as $field) {
			$sql[] = sprintf('SELECT u.*, u.%s AS email FROM users u INNER JOIN users_search AS s ON s.id = u.id', $db->quoteIdentifier($field));
		}

		return $db->iterate(implode(' UNION ALL ', $sql));
	}

	static public function listByCategory(?int $id_category = null): DynamicList
	{
		$df = DynamicFields::getInstance();

		$columns = [
			'_user_id' => [
				'select' => 'id',
			],
			'number' => [
				'label' => 'Num.',
				'select' => $df->getNumberField(),
			],
			'identity' => [
				'label' => $df->getNameLabel(),
				'select' => $df->getNameFieldsSQL(),
			]
		];

		$fields = $df->getListedFields();

		foreach ($fields as $key => $config) {
			if (isset($columns[$key])) {
				continue;
			}

			$columns[$key] = [
				'label' => $config->label,
			];
		}

		$tables = User::TABLE;
		$conditions = $id_category ? sprintf('id_category = %d', $id_category) : sprintf('id_category IN (SELECT id FROM users_categories WHERE hidden = 0)');

		$order = 'identity';

		if (!isset($columns[$order])) {
			$order = key($fields) ?? 'number';
		}

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy($order, false);

		return $list;
	}

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

	static public function getName(int $id): ?string
	{
		$name = DynamicFields::getNameFieldsSQL();
		return EM::getInstance(User::class)->col(sprintf('SELECT %s FROM @TABLE WHERE id = ?;', $name), $id);
	}

	static public function deleteMultiple(array $ids): void
	{
		$session = Session::getInstance();

		if ($session->isLogged()) {
			$user = $session->getUser();

			foreach ($ids as $id) {
				if ($user->id == $id) {
					throw new UserException('Il n\'est pas possible de supprimer son propre compte.');
				}
			}
		}

		foreach ($ids as &$id)
		{
			$id = (int) $id;
			Files::delete(File::CONTEXT_USER . '/' . $id);
		}

		$db = DB::getInstance();

		// Suppression du membre
		$db->delete(User::TABLE, $db->where('id', $membres));
	}

	static public function changeCategory(int $category_id, array $ids)
	{
		$session = Session::getInstance();
		$user_id = null;

		if ($session->isLogged()) {
			$user_id = $session->getUser()->id;
		}

		foreach ($ids as &$id) {
			$id = (int) $id;

			// Don't allow current user ID to change his/her category
			// as that means he/she could be logged out
			if ($id == $user_id) {
				$id = null;
			}
		}

		unset($id);

		// Remove logged-in user ID
		$ids = array_filter($ids);

		$db = DB::getInstance();
		return $db->update(User::TABLE,
			['id_category' => $category_id],
			$db->where('id', $ids)
		);
	}

	static public function sendMessage(array $recipients, $subject, $message, $send_copy)
	{
		$emails = [];

		foreach ($recipients as $key => $recipient)
		{
			// Ignorer les destinataires avec une adresse email vide
			if (empty($recipient->email))
			{
				continue;
			}

			if (!isset($recipient->email, $recipient->id)) {
				throw new UserException('Il manque l\'identifiant ou l\'email dans le résultat');
			}

			// Refuser d'envoyer un mail à une adresse invalide, sans vérifier le MX
			// sinon ça serait trop lent
			if (!SMTP::checkEmailIsValid($recipient->email, false))
			{
				throw new UserException(sprintf('Adresse email invalide : "%s". Aucun message n\'a été envoyé.', $recipient->email));
			}

			// This is to avoid having duplicate emails
			$emails[$recipient->email] = $recipient->id;
		}

		if (!count($emails)) {
			throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.');
		}

		foreach ($emails as $email => $id)
		{
			Utils::sendEmail(Utils::EMAIL_CONTEXT_BULK, $email, $subject, $message, $id);
		}

		if ($send_copy)
		{
			Utils::sendEmail(Utils::EMAIL_CONTEXT_BULK, Config::getInstance()->org_email, $subject, $message);
		}

		return true;
	}
}

Modified src/include/lib/Garradin/Utils.php from [f3e3fd0aee] to [a9c095a5f8].

854
855
856
857
858
859
860











861
862
863
864
865
866
867
    {
        $str = str_replace(DIRECTORY_SEPARATOR, '/', $str);
        $str = trim($str, '/');
        $str = substr($str, strrpos($str, '/'));
        $str = trim($str, '/');
        return $str;
    }












    static public function unicodeCaseComparison($a, $b): int
    {
        if (!isset(self::$collator) && function_exists('collator_create')) {
            self::$collator = \Collator::create('fr_FR');

            // This is what makes the comparison case insensitive







>
>
>
>
>
>
>
>
>
>
>







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
    {
        $str = str_replace(DIRECTORY_SEPARATOR, '/', $str);
        $str = trim($str, '/');
        $str = substr($str, strrpos($str, '/'));
        $str = trim($str, '/');
        return $str;
    }

    static public function unicodeTransliterate($str): ?string
    {
        if ($str === null) {
            return null;
        }

        $str = str_replace('’', '\'', $str); // Normalize French apostrophe

        return transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $str);
    }

    static public function unicodeCaseComparison($a, $b): int
    {
        if (!isset(self::$collator) && function_exists('collator_create')) {
            self::$collator = \Collator::create('fr_FR');

            // This is what makes the comparison case insensitive

Added src/include/lib/Garradin/Web/Render/AttachmentAwareTrait.php version [5455ee42ef].



























































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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 Garradin\Web\Render;

use Garradin\Utils;
use Garradin\Entities\Files\File;


trait AttachmentAwareTrait
{
	protected $current_path;
	protected $context;
	protected $link_prefix;
	protected $link_suffix;

	protected function resolveAttachment(string $uri) {
		$prefix = $this->current_path;
		$pos = strpos($uri, '/');

		// "Image.jpg"
		if ($pos === false) {
			return WWW_URL . $prefix . '/' . $uri;
		}
		// "bla/Image.jpg" outside of web context
		elseif ($this->context !== File::CONTEXT_WEB && $pos !== 0) {
			return WWW_URL . $this->context . '/' . $uri;
		}
		// "bla/Image.jpg" in web context or absolute link, eg. "/transactions/2442/42.jpg"
		else {
			return WWW_URL . ltrim($uri, '/');
		}
	}

	protected function resolveLink(string $uri) {
		$first = substr($uri, 0, 1);
		if ($first == '/' || $first == '!') {
			return Utils::getLocalURL($uri);
		}

		if (strpos(Utils::basename($uri), '.') === false) {
			$uri .= $this->link_suffix;
		}

		return $this->link_prefix . $uri;
	}

	public function isRelativeTo(File $file) {
		$this->current_path = Utils::dirname($file->path);
		$this->context = strtok($this->current_path, '/');
		$this->link_suffix = '';

		if ($this->context === File::CONTEXT_WEB) {
			$this->link_prefix = WWW_URL . '/';
			$this->current_path = Utils::basename(Utils::dirname($file->path));
		}
		else {
			$this->link_prefix = $options['prefix'] ?? sprintf(ADMIN_URL . 'common/files/preview.php?p=%s/', $this->context);
			$this->link_suffix = '.skriv';
		}
	}
}

Modified src/include/lib/Garradin/Web/Web.php from [19b827614f] to [b477fcfe47].

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use Garradin\API;
use Garradin\Config;
use Garradin\DB;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;

use KD2\DB\EntityManager as EM;

use const Garradin\{WWW_URI, ADMIN_URL, FILE_STORAGE_BACKEND, ROOT};

class Web
{







|







9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use Garradin\API;
use Garradin\Config;
use Garradin\DB;
use Garradin\Plugin;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;

use KD2\DB\EntityManager as EM;

use const Garradin\{WWW_URI, ADMIN_URL, FILE_STORAGE_BACKEND, ROOT};

class Web
{

Modified src/include/test_required.php from [3b6f01c207] to [cf0eebbb30].

55
56
57
58
59
60
61












    'SQLite3 version 3.16 ou supérieur requise. Version installée : ' . $v['versionString']
);

test_requis(
    file_exists(__DIR__ . '/lib/KD2'),
    'Librairie KD2 non disponible.'
);



















>
>
>
>
>
>
>
>
>
>
>
>
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
    'SQLite3 version 3.16 ou supérieur requise. Version installée : ' . $v['versionString']
);

test_requis(
    file_exists(__DIR__ . '/lib/KD2'),
    'Librairie KD2 non disponible.'
);

$db = new \SQLite3(':memory:');
$r = $db->query('PRAGMA compile_options;');
$options = [];
while ($row = $r->fetchArray(\SQLITE3_NUM)) {
    $options[] = $row[0];
}

test_requis(
    in_array('ENABLE_FTS4', $options),
    'Le module SQLite3 FTS4 (permettant de faire des recherches) n\'est pas installé ou activé.'
);

Modified src/scripts/cron.php from [11940cccfa] to [4119a18488].

10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

require_once __DIR__ . '/../include/init.php';

// Exécution des tâches automatiques

$config = Config::getInstance();

if ($config->get('frequence_sauvegardes') && $config->get('nombre_sauvegardes'))
{
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugin::fireSignal('cron');







|









10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

require_once __DIR__ . '/../include/init.php';

// Exécution des tâches automatiques

$config = Config::getInstance();

if ($config->backup_frequency && $config->backup_limit)
{
	$s = new Sauvegarde;
	$s->auto();
}

// Exécution des rappels automatiques
Reminders::sendPending();

Plugin::fireSignal('cron');

Modified src/templates/acc/reports/_header.tpl from [ecdb90ea8a] to [1d8a426043].

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
		<ul class="sub">
			<li{if $sub_current == 'simple'} class="current"{/if}>{link href="?%s"|args:$criterias_query_no_compare label="Vue simplifiée"}</li>
			<li{if $sub_current != 'simple'} class="current"{/if}>{link href="?%s&simple=0"|args:$criterias_query_no_compare label="Vue comptable"}</li>
		</ul>
		{/if}
	</nav>

	<h2>{$config.nom_asso} — {$title}</h2>
	{if isset($analytical)}
		<h3>Projet&nbsp;: {$analytical.label}</h3>
	{/if}
	{if isset($year)}
		<p>Exercice&nbsp;: {$year.label} ({if $year.closed}clôturé{else}en cours{/if}, du
			{$year.start_date|date_short} au {$year.end_date|date_short}, généré le {$close_date|date_short})</p>
	{/if}







|







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
		<ul class="sub">
			<li{if $sub_current == 'simple'} class="current"{/if}>{link href="?%s"|args:$criterias_query_no_compare label="Vue simplifiée"}</li>
			<li{if $sub_current != 'simple'} class="current"{/if}>{link href="?%s&simple=0"|args:$criterias_query_no_compare label="Vue comptable"}</li>
		</ul>
		{/if}
	</nav>

	<h2>{$config.org_name} — {$title}</h2>
	{if isset($analytical)}
		<h3>Projet&nbsp;: {$analytical.label}</h3>
	{/if}
	{if isset($year)}
		<p>Exercice&nbsp;: {$year.label} ({if $year.closed}clôturé{else}en cours{/if}, du
			{$year.start_date|date_short} au {$year.end_date|date_short}, généré le {$close_date|date_short})</p>
	{/if}

Modified src/templates/acc/reports/balance_sheet.tpl from [9e34e6b155] to [ae61b81ebb].

9
10
11
12
13
14
15
16
17
18
		<strong>Le bilan n'est pas équilibré&nbsp;!</strong><br />
		Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice.
	</p>
{/if}

{include file="acc/reports/_statement.tpl" statement=$balance}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}







|


9
10
11
12
13
14
15
16
17
18
		<strong>Le bilan n'est pas équilibré&nbsp;!</strong><br />
		Vérifiez que vous n'avez pas oublié de reporter des soldes depuis le précédent exercice.
	</p>
{/if}

{include file="acc/reports/_statement.tpl" statement=$balance}

<p class="help">Toutes les écritures sont libellées en {$config.currency}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/journal.tpl from [fd83a86c0b] to [5b5c44580d].

1
2
3
4
5
6
7
8
9
{include file="admin/_head.tpl" title="Journal général" current="acc/years"}

{include file="acc/reports/_header.tpl" current="journal" title="Journal général"}

{include file="acc/reports/_journal.tpl"}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}






|


1
2
3
4
5
6
7
8
9
{include file="admin/_head.tpl" title="Journal général" current="acc/years"}

{include file="acc/reports/_header.tpl" current="journal" title="Journal général"}

{include file="acc/reports/_journal.tpl"}

<p class="help">Toutes les écritures sont libellées en {$config.currency}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/ledger.tpl from [d881a4ac4b] to [1be5c68b8c].

95
96
97
98
99
100
101
102
103
104
	document.querySelectorAll('details').forEach((e) => {
		e.removeAttribute('open');
	});
};
</script>
{/literal}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}







|


95
96
97
98
99
100
101
102
103
104
	document.querySelectorAll('details').forEach((e) => {
		e.removeAttribute('open');
	});
};
</script>
{/literal}

<p class="help">Toutes les écritures sont libellées en {$config.currency}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/projects.tpl from [b9dfb20af8] to [373a330bf0].

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
	<ul class="sub">
		<li{if !$by_year} class="current"{/if}><a href="{$self_url_no_qs}?order_code={$order_code}">Par projet</a></li>
		<li{if $by_year} class="current"{/if}><a href="{$self_url_no_qs}?by_year=1&order_code={$order_code}">Par exercice</a></li>
	</ul>
</nav>

<div class="year-header">
	<h2>{$config.nom_asso} — Projets</h2>

	<p class="noprint print-btn">
		<button onclick="window.print(); return false;" class="icn-btn" data-icon="⎙">Imprimer</button>
	</p>
</div>

{if !empty($list)}







|







26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
	<ul class="sub">
		<li{if !$by_year} class="current"{/if}><a href="{$self_url_no_qs}?order_code={$order_code}">Par projet</a></li>
		<li{if $by_year} class="current"{/if}><a href="{$self_url_no_qs}?by_year=1&order_code={$order_code}">Par exercice</a></li>
	</ul>
</nav>

<div class="year-header">
	<h2>{$config.org_name} — Projets</h2>

	<p class="noprint print-btn">
		<button onclick="window.print(); return false;" class="icn-btn" data-icon="⎙">Imprimer</button>
	</p>
</div>

{if !empty($list)}

Modified src/templates/acc/reports/statement.tpl from [ab2ea61147] to [b6c1d83d2e].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Compte de résultat" current="acc/years"}

{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true}

<p class="help noprint">Le compte de résultat indique les recettes (produits) et dépenses (charges), ainsi que le résultat réalisé.</p>

{include file="acc/reports/_statement.tpl" statement=$general caption1="Charges" caption2="Produits"}

{if !empty($volunteering.body_left) || !empty($volunteering.body_right)}
	<h2 class="ruler">Contributions en nature</h2>
	{include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature"}
{/if}

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}.</p>

{include file="admin/_foot.tpl"}













|


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{include file="admin/_head.tpl" title="Compte de résultat" current="acc/years"}

{include file="acc/reports/_header.tpl" current="statement" title="Compte de résultat" allow_compare=true}

<p class="help noprint">Le compte de résultat indique les recettes (produits) et dépenses (charges), ainsi que le résultat réalisé.</p>

{include file="acc/reports/_statement.tpl" statement=$general caption1="Charges" caption2="Produits"}

{if !empty($volunteering.body_left) || !empty($volunteering.body_right)}
	<h2 class="ruler">Contributions en nature</h2>
	{include file="acc/reports/_statement.tpl" statement=$volunteering header=false caption1="Emplois des contributions volontaires en nature" caption2="Contributions volontaires en nature"}
{/if}

<p class="help">Toutes les écritures sont libellées en {$config.currency}.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/reports/trial_balance.tpl from [615125a575] to [9cebc2c9dd].

34
35
36
37
38
39
40
41
42
43
			<td class="money{if !$account.credit} disabled{/if}">{$account.credit|raw|money:false}</td>
			<td class="money">{if $account.balance !== null}<b>{$account.balance|escape|money:false}</b>{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>

<p class="help">Toutes les écritures sont libellées en {$config.monnaie}. Les lignes grisées correspondent aux comptes soldés.</p>

{include file="admin/_foot.tpl"}







|


34
35
36
37
38
39
40
41
42
43
			<td class="money{if !$account.credit} disabled{/if}">{$account.credit|raw|money:false}</td>
			<td class="money">{if $account.balance !== null}<b>{$account.balance|escape|money:false}</b>{/if}</td>
		</tr>
	{/foreach}
	</tbody>
</table>

<p class="help">Toutes les écritures sont libellées en {$config.currency}. Les lignes grisées correspondent aux comptes soldés.</p>

{include file="admin/_foot.tpl"}

Modified src/templates/acc/search.tpl from [d1ddd847c2] to [731aa3873d].

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
{include file="admin/_head.tpl" title="Recherche" current="acc" custom_js=['lib/query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>



{include file="common/search/advanced.tpl" action_url=$self_url}



{if !empty($result)}
	{*if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}






		<form method="post" action="{$admin_url}membres/action.php" class="memberList">
























	{/if*}













	<p class="help">{$result|count} écritures trouvées pour cette recherche.</p>










	<table class="list search">
		<thead>
			<tr>
				{*if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}<td class="check"><input type="checkbox" value="Tout cocher / décocher" /></td>{/if*}
				{foreach from=$result_header item="label"}
					<td>{$label}</td>
				{/foreach}
				<td></td>
			</tr>
		</thead>
		<tbody>
			{foreach from=$result item="row"}
				<tr>
					{*if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}<td class="check"><input type="checkbox" name="selected[]" value="{$row.id}" /></td>{/if*}
					{foreach from=$row key="key" item="value"}
						{if $key == 'transaction_id'}
						<td class="num">
							<a href="{$admin_url}acc/transactions/details.php?id={$value}">{$value}</a>
						</td>
						{else}
						<td>
							{if $key == 'credit' || $key == 'debit'}
								{$value|raw|money:false}
							{elseif $key == 'date'}
								{$value|date_short}
							{elseif null === $value}
								<em>(nul)</em>
							{else}
								{$value}
							{/if}
						</td>
						{/if}
					{/foreach}
					<td class="actions">
						{if $row.transaction_id}
						{linkbutton shape="search" label="Détails" href="!acc/transactions/details.php?id=%d"|args:$row.transaction_id}
						{/if}
					</td>
				</tr>
			{/foreach}
		</tbody>
	{*if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
		{include file="admin/membres/_list_actions.tpl" colspan=count($result_header)+1}
	{/if*}
	</table>

	{*if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
		</form>
	{/if*}

{elseif $result !== null}

	<p class="block alert">
		Aucun résultat trouvé.
	</p>

	</form>
{/if}


{include file="admin/_foot.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
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
{include file="admin/_head.tpl" title="Recherche" current="acc" custom_js=['lib/query_builder.min.js']}

<nav class="tabs">
	<ul>
		<li class="current"><a href="{$self_url}">Recherche</a></li>
		<li><a href="saved_searches.php">Recherches enregistrées</a></li>
	</ul>
</nav>

<form method="post" action="{$self_url}" id="queryBuilderForm" data-disable-progress="1">

{include file="common/search/advanced.tpl"}

{if $list !== null}
	<p class="help">{$list->count()} écritures trouvées pour cette recherche.</p>

	{if $list->count() > 0 && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
	<p class="actions">
		{button type="submit" name="_dl_export" value="csv" shape="export" label="Export CSV"}
		{button type="submit" name="_dl_export" value="ods" shape="export" label="Export LibreOffice"}
		{button type="submit" name="_dl_export" value="xlsx" shape="export" label="Export Excel"}
	</p>
	{/if}

	{include file="common/dynamic_list_head.tpl" check=$is_admin use_buttons=true}

	<?php
	$prev_id = null;
	?>

	{foreach from=$list->iterate() item="row"}
		<tr>
			{if $is_admin}<td class="check">{input type="checkbox" name="selected[]" value=$row.id}</td>{/if}
			{foreach from=$row key="key" item="value"}
				{if $prev_id == $row.id && !in_array($key, ['debit', 'credit', 'account_code', 'line_label', 'line_reference', 'project_code'])}
					<td></td>
				{elseif $key == 'id'}
				<td class="num">
					{link href="!acc/transactions/details.php?id=%d"|args:$value label="#%d"|args:$value}
				</td>
				{else}
				<td>
					{if $key == 'credit' || $key == 'debit'}
						{$value|raw|money:false}
					{elseif $key == 'date'}
						{$value|date_short}
					{else}
						{$value}
					{/if}
				</td>
				{/if}
			{/foreach}
			<td class="actions">
				{linkbutton shape="search" label="Détails" href="!acc/transactions/details.php?id=%d"|args:$row.id}
			</td>
		</tr>
		<?php $prev_id = $row->id; ?>
	{/foreach}
	</tbody>
	</table>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count() use_buttons=true}

{elseif $results}

	{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
	<p class="actions">
		{button type="submit" name="_export" value="csv" shape="export" label="Export CSV"}
		{button type="submit" name="_export" value="ods" shape="export" label="Export LibreOffice"}
		{button type="submit" name="_export" value="xlsx" shape="export" label="Export Excel"}
	</p>
	{/if}

	<table class="list">
		<thead>
			<tr>

				{foreach from=$header item="column"}
				<td>{$column}</td>
				{/foreach}

			</tr>
		</thead>
		<tbody>
			{foreach from=$results item="row"}
			<tr>

				{foreach from=$row item="column"}



				<td>{$column}</td>










				{/foreach}
			</tr>

			{foreachelse}
			<tr>



				<td colspan="{$header|count}"><p class="alert block">Aucun résultat</p></td>
			</tr>
			{/foreach}
		</tbody>



	</table>



{/if}







</form>



{include file="admin/_foot.tpl"}

Modified src/templates/acc/transactions/details.tpl from [424b045f3a] to [bb29caccd0].

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
		| <strong>{if $tr_year.closed}Clôturé{else}En cours{/if}</strong>
	</dd>

	<dt>Écriture créée par</dt>
	<dd>
		{if $transaction.id_creator}
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
				<a href="{$admin_url}membres/fiche.php?id={$transaction.id_creator}">{$creator_name}</a>
			{else}
				{$creator_name}
			{/if}
		{else}
			<em>membre supprimé</em>
		{/if}
	</dd>

	<dt>Écriture liée à</dt>
	{if empty($related_users)}
		<dd><em>Aucun membre n'est lié à cette écriture.</em></dd>
	{else}
		{foreach from=$related_users item="u"}
			<dd>
				<a href="{$admin_url}membres/fiche.php?id={$u.id}">{$u.identity}</a>
				{if $u.id_service_user}— en règlement d'une <a href="{$admin_url}services/user/?id={$u.id}&amp;only={$u.id_service_user}">activité</a>{/if}
			</dd>
		{/foreach}
	{/if}

	<dt>Remarques</dt>
	<dd>{if $transaction.notes}{$transaction.notes|escape|nl2br|linkify_transactions}{else}-{/if}</dd>







|














|







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
		| <strong>{if $tr_year.closed}Clôturé{else}En cours{/if}</strong>
	</dd>

	<dt>Écriture créée par</dt>
	<dd>
		{if $transaction.id_creator}
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)}
				<a href="{$admin_url}users/details.php?id={$transaction.id_creator}">{$creator_name}</a>
			{else}
				{$creator_name}
			{/if}
		{else}
			<em>membre supprimé</em>
		{/if}
	</dd>

	<dt>Écriture liée à</dt>
	{if empty($related_users)}
		<dd><em>Aucun membre n'est lié à cette écriture.</em></dd>
	{else}
		{foreach from=$related_users item="u"}
			<dd>
				<a href="{$admin_url}users/details.php?id={$u.id}">{$u.identity}</a>
				{if $u.id_service_user}— en règlement d'une <a href="{$admin_url}services/user/?id={$u.id}&amp;only={$u.id_service_user}">activité</a>{/if}
			</dd>
		{/foreach}
	{/if}

	<dt>Remarques</dt>
	<dd>{if $transaction.notes}{$transaction.notes|escape|nl2br|linkify_transactions}{else}-{/if}</dd>

Modified src/templates/acc/transactions/edit.tpl from [a98552bace] to [90fcc092c0].

59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$first_line.reference}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php" default=$linked_users}
			{input type="textarea" name="notes" label="Remarques" rows=2 cols=30 source=$transaction}
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$first_line.id_analytical}
			{/if}







|







59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$first_line.reference}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!users/selector.php" default=$linked_users}
			{input type="textarea" name="notes" label="Remarques" rows=2 cols=30 source=$transaction}
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$first_line.id_analytical}
			{/if}

Modified src/templates/acc/transactions/new.tpl from [5d2788881b] to [3bc95d73a2].

63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$transaction->payment_reference()}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="t{$transaction::TYPE_ADVANCED}">
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}







|







63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." default=$transaction->payment_reference()}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!users/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="t{$transaction::TYPE_ADVANCED}">
			{input type="number" name="id_related" label="Lier à l'écriture numéro" source=$transaction help="Indiquer ici un numéro d'écriture pour faire le lien par exemple avec une dette"}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}

Modified src/templates/acc/transactions/payoff.tpl from [aaa45e359f] to [e29430d206].

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." source=$transaction}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!membres/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>







|







31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

	<fieldset>
		<legend>Détails facultatifs</legend>
		<dl data-types="t{$transaction::TYPE_REVENUE} t{$transaction::TYPE_EXPENSE} t{$transaction::TYPE_TRANSFER}">
			{input type="text" name="payment_reference" label="Référence de paiement" help="Numéro de chèque, numéro de transaction CB, etc." source=$transaction}
		</dl>
		<dl>
			{input type="list" multiple=true name="users" label="Membres associés" target="!users/selector.php"}
			{input type="textarea" name="notes" label="Remarques" rows=4 cols=30}
		</dl>
		<dl data-types="all-but-advanced">
			{if count($analytical_accounts) > 1}
				{input type="select" name="id_analytical" label="Projet (compte analytique)" options=$analytical_accounts default=$id_analytical}
			{/if}
		</dl>

Modified src/templates/acc/transactions/service_user.tpl from [a9660dff87] to [c94a642696].

1
2
3
4
5
6
7
8
9
10
11
{include file="admin/_head.tpl" title="Écritures liées à une inscription" current="acc/accounts"}

<nav class="tabs">
	{linkbutton href="!membres/fiche.php?id=%d"|args:$user_id label="Retour à la fiche membre" shape="left"}
	{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)}



|







1
2
3
4
5
6
7
8
9
10
11
{include file="admin/_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="left"}
	{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)}

Modified src/templates/acc/transactions/user.tpl from [ebafbd05ec] to [ba199ed9c3].

1
2
3
4
5
6
7
8
9
10
11
{include file="admin/_head.tpl" title="Écritures liées à %s"|args:$transaction_user.identite current="acc/accounts"}

<p>
	{linkbutton href="!membres/fiche.php?id=%d"|args:$transaction_user.id label="Retour à la fiche membre" shape="user"}
</p>

{include file="acc/reports/_journal.tpl"}

<h2 class="ruler">Solde des comptes</h2>

<form method="get" action="{$self_url_no_qs}">



|







1
2
3
4
5
6
7
8
9
10
11
{include file="admin/_head.tpl" title="Écritures liées à %s"|args:$transaction_user.identite current="acc/accounts"}

<p>
	{linkbutton href="!users/details.php?id=%d"|args:$transaction_user.id label="Retour à la fiche membre" shape="user"}
</p>

{include file="acc/reports/_journal.tpl"}

<h2 class="ruler">Solde des comptes</h2>

<form method="get" action="{$self_url_no_qs}">

Modified src/templates/admin/_foot.tpl from [ac8f1a0908] to [9203feaf66].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</main>

<script type="text/javascript" defer="defer">
var keep_session_url = "{$admin_url}login.php?keepSessionAlive&";
{literal}
(function () {
    function refreshSession()
    {
        var i = new Image(1, 1);
        var d = +new Date;
        i.src = keep_session_url + d;
    }

    window.setInterval(refreshSession, 10 * 60 * 1000);
} ());
{/literal}
</script>

</body>
</html>






|
|
|
|
|
|

|






1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</main>

<script type="text/javascript" defer="defer">
var keep_session_url = "{$admin_url}login.php?keepSessionAlive&";
{literal}
(function () {
	function refreshSession()
	{
		var i = new Image(1, 1);
		var d = +new Date;
		i.src = keep_session_url + d;
	}

	window.setInterval(refreshSession, 10 * 60 * 1000);
} ());
{/literal}
</script>

</body>
</html>

Modified src/templates/admin/_head.tpl from [32e036d1b7] to [4dc5a3df65].

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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr" class="{if $dialog}dialog{/if}" data-version="{$version_hash}">
<head>
    <meta charset="utf-8" />
    <meta name="v" content="{$version_hash}" />
    <title>{$title}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" type="text/css" href="{$admin_url}static/admin.css?{$version_hash}" media="all" />
    <script type="text/javascript" src="{$admin_url}static/scripts/global.js?{$version_hash}"></script>
    {if isset($custom_js)}
        {foreach from=$custom_js item="js"}
            <script type="text/javascript" src="{$admin_url}static/scripts/{$js}?{$version_hash}"></script>
        {/foreach}
    {/if}
    {if isset($custom_css)}
        {foreach from=$custom_css item="css_url"}
            <link rel="stylesheet" type="text/css" href="{$css_url|local_url:"!static/styles/"}?{$version_hash}" media="all" />
        {/foreach}
    {/if}
    {if isset($plugin_css)}
        {foreach from=$plugin_css item="css"}
            <link rel="stylesheet" type="text/css" href="{plugin_url file=$css}?{$version_hash}" />
        {/foreach}
    {/if}
    {if isset($plugin_js)}
        {foreach from=$plugin_js item="js"}
            <script type="text/javascript" src="{plugin_url file=$js}?{$version_hash}"></script>
        {/foreach}
    {/if}
    <link rel="stylesheet" type="text/css" href="{$admin_url}static/print.css?{$version_hash}" media="print" />
    <link rel="stylesheet" type="text/css" href="{$admin_url}static/handheld.css?{$version_hash}" media="handheld,screen and (max-width:981px)" />
    <link rel="manifest" href="{$admin_url}manifest.php" />
    {if isset($config)}
        <link rel="icon" type="image/png" href="{$config->fileURL('favicon')}" />
        {custom_colors config=$config}
    {/if}
</head>

<body{if isset($transparent)} class="transparent"{/if}>

{if !array_key_exists('_dialog', $_GET) && !isset($transparent)}
<header class="header">
    <nav class="menu">
        {if isset($config)}
        <figure class="logo">
        {if $url = $config->fileURL('logo', '150px')}
            <a href="{$admin_url}"><img src="{$url}" alt="" /></a>
        {/if}
        </figure>
        {/if}
    <ul>
    {if $is_logged}
    <?php
    $current_parent = substr($current, 0, strpos($current, '/'));
    ?>
        <li class="home{if $current == 'home'} current{elseif $current_parent == 'home'} current_parent{/if}"><h3><a href="{$admin_url}"><b data-icn="{icon html=false shape="home"}"></b><span>Accueil</span></a></h3>
            {if !empty($plugins_menu)}
                <ul>
                {foreach from=$plugins_menu key="key" item="html"}
                    <li{if $current == $key} class="current"{/if}>{$html|raw}</li>
                {/foreach}
                </ul>
            {/if}
        </li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
            <li class="member list{if $current == 'membres'} current{elseif $current_parent == 'membres'} current_parent{/if}"><h3><a href="{$admin_url}membres/"><b data-icn="{icon html=false shape="users"}"></b></b><span>Membres</span></a></h3>
            <ul>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member new{if $current == 'membres/ajouter'} current{/if}"><a href="{$admin_url}membres/ajouter.php">Ajouter</a></li>
            {/if}
                <li class="{if $current == 'membres/services'} current{/if}"><a href="{$admin_url}services/">Activités &amp; cotisations</a></li>
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                <li class="member message{if $current == 'membres/message'} current{/if}"><a href="{$admin_url}membres/message_collectif.php">Message collectif</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_url}acc/"><b data-icn="{icon html=false shape="money"}"></b><span>Comptabilité</span></a></h3>
            <ul>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/new'} current{/if}"><a href="{$admin_url}acc/transactions/new.php">Saisie</a></li>
            {/if}
                <li class="{if $current == 'acc/accounts'} current{/if}"><a href="{$admin_url}acc/accounts/">Comptes</a></li>
                <li class="{if $current == 'acc/simple'} current{/if}"><a href="{$admin_url}acc/accounts/simple.php">Suivi des écritures</a></li>
                <li class="{if $current == 'acc/years'} current{/if}"><a href="{$admin_url}acc/years/">Exercices &amp; rapports</a></li>
            {if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
                <li class="{if $current == 'acc/charts'} current{/if}"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
            {/if}
            </ul>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)}
            <li class="{if $current == 'docs'} current{elseif $current_parent == 'docs'} current_parent{/if}"><h3><a href="{$admin_url}docs/"><b data-icn="{icon html=false shape="folder"}"></b><span>Documents</span></a></h3>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)}
            <li class="{if $current == 'web'} current{elseif $current_parent == 'web'} current_parent{/if}"><h3><a href="{$admin_url}web/"><b data-icn="{icon html=false shape="globe"}"></b><span>Site web</span></a></h3>
            </li>
        {/if}

        {if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
            <li class="main config{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><h3><a href="{$admin_url}config/"><b data-icn="{icon html=false shape="settings"}"></b><span>Configuration</span></a></h3>
        {/if}

        <li class="{if $current == 'me'} current{elseif $current_parent == 'me'} current_parent{/if}"><h3><a href="{$admin_url}me/"><b data-icn="{icon html=false shape="user"}"></b><span> Mes infos personnelles</span></a></h3>
            <ul>
                <li{if $current == 'me/services'}  class="current"{/if}><a href="{$admin_url}me/services.php">Mes activités &amp; cotisations</a></li>
            </ul>
        </li>

        {if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
            <li class="logout"><h3><a href="{$admin_url}logout.php"><b data-icn="{icon html=false shape="logout"}"></b><span>Déconnexion</span></a></h3></li>
        {/if}

        {if $help_url}
        <li>
            <h3><a href="{$help_url}" target="_blank"><b data-icn="{icon html=false shape="help"}"></b><span>Aide</span></a></h3>
        </li>
        {/if}

    {elseif !defined('Garradin\INSTALL_PROCESS')}
        <li><a href="{if $config.site_asso}{$config.site_asso}{else}{$www_url}{/if}">&larr; Retour au site</a></li>
        <li><a href="{$admin_url}">Connexion</a>
            <ul>
                <li><a href="{$admin_url}password.php">Mot de passe perdu</a>
            </ul>
        </li>
    {/if}
    </ul>
    </nav>

    <h1>{$title}</h1>
</header>
{/if}

<main>



|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|






|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|

|
|
|
|

|
|
|
|

|
|
|

|
|
|
|
|

|
|
|

|
|
|
|
|

|
|
|
|
|
|
|
|
|
|

|




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
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr" class="{if $dialog}dialog{/if}" data-version="{$version_hash}">
<head>
	<meta charset="utf-8" />
	<meta name="v" content="{$version_hash}" />
	<title>{$title}</title>
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />
	<link rel="stylesheet" type="text/css" href="{$admin_url}static/admin.css?{$version_hash}" media="all" />
	<script type="text/javascript" src="{$admin_url}static/scripts/global.js?{$version_hash}"></script>
	{if isset($custom_js)}
		{foreach from=$custom_js item="js"}
			<script type="text/javascript" src="{$admin_url}static/scripts/{$js}?{$version_hash}"></script>
		{/foreach}
	{/if}
	{if isset($custom_css)}
		{foreach from=$custom_css item="css_url"}
			<link rel="stylesheet" type="text/css" href="{$css_url|local_url:"!static/styles/"}?{$version_hash}" media="all" />
		{/foreach}
	{/if}
	{if isset($plugin_css)}
		{foreach from=$plugin_css item="css"}
			<link rel="stylesheet" type="text/css" href="{plugin_url file=$css}?{$version_hash}" />
		{/foreach}
	{/if}
	{if isset($plugin_js)}
		{foreach from=$plugin_js item="js"}
			<script type="text/javascript" src="{plugin_url file=$js}?{$version_hash}"></script>
		{/foreach}
	{/if}
	<link rel="stylesheet" type="text/css" href="{$admin_url}static/print.css?{$version_hash}" media="print" />
	<link rel="stylesheet" type="text/css" href="{$admin_url}static/handheld.css?{$version_hash}" media="handheld,screen and (max-width:981px)" />
	<link rel="manifest" href="{$admin_url}manifest.php" />
	{if isset($config)}
		<link rel="icon" type="image/png" href="{$config->fileURL('favicon')}" />
		{custom_colors config=$config}
	{/if}
</head>

<body{if isset($transparent)} class="transparent"{/if}>

{if !array_key_exists('_dialog', $_GET) && !isset($transparent)}
<header class="header">
	<nav class="menu">
		{if isset($config)}
		<figure class="logo">
		{if $url = $config->fileURL('logo', '150px')}
			<a href="{$admin_url}"><img src="{$url}" alt="" /></a>
		{/if}
		</figure>
		{/if}
	<ul>
	{if $is_logged}
	<?php
	$current_parent = substr($current, 0, strpos($current, '/'));
	?>
		<li class="home{if $current == 'home'} current{elseif $current_parent == 'home'} current_parent{/if}"><h3><a href="{$admin_url}"><b data-icn="{icon html=false shape="home"}"></b><span>Accueil</span></a></h3>
			{if !empty($plugins_menu)}
				<ul>
				{foreach from=$plugins_menu key="key" item="html"}
					<li{if $current == $key} class="current"{/if}>{$html|raw}</li>
				{/foreach}
				</ul>
			{/if}
		</li>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)}
			<li class="{if $current == 'users'} current{elseif $current_parent == 'users'} current_parent{/if}"><h3><a href="{$admin_url}users/"><b data-icn="{icon html=false shape="users"}"></b></b><span>Membres</span></a></h3>
			<ul>
			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				<li{if $current == 'users/new'} class="current"{/if}><a href="{$admin_url}users/new.php">Ajouter</a></li>
			{/if}
				<li{if $current == 'users/services'} class="current"{/if}><a href="{$admin_url}services/">Activités &amp; cotisations</a></li>
			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
				<li{if $current == 'users/mailing'} class="current"{/if}><a href="{$admin_url}users/mailing.php">Message collectif</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_url}acc/"><b data-icn="{icon html=false shape="money"}"></b><span>Comptabilité</span></a></h3>
			<ul>
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
				<li class="{if $current == 'acc/new'} current{/if}"><a href="{$admin_url}acc/transactions/new.php">Saisie</a></li>
			{/if}
				<li class="{if $current == 'acc/accounts'} current{/if}"><a href="{$admin_url}acc/accounts/">Comptes</a></li>
				<li class="{if $current == 'acc/simple'} current{/if}"><a href="{$admin_url}acc/accounts/simple.php">Suivi des écritures</a></li>
				<li class="{if $current == 'acc/years'} current{/if}"><a href="{$admin_url}acc/years/">Exercices &amp; rapports</a></li>
			{if $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE)}
				<li class="{if $current == 'acc/charts'} current{/if}"><a href="{$admin_url}acc/charts/">Plans comptables</a></li>
			{/if}
			</ul>
			</li>
		{/if}

		{if $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)}
			<li class="{if $current == 'docs'} current{elseif $current_parent == 'docs'} current_parent{/if}"><h3><a href="{$admin_url}docs/"><b data-icn="{icon html=false shape="folder"}"></b><span>Documents</span></a></h3>
			</li>
		{/if}

		{if $session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)}
			<li class="{if $current == 'web'} current{elseif $current_parent == 'web'} current_parent{/if}"><h3><a href="{$admin_url}web/"><b data-icn="{icon html=false shape="globe"}"></b><span>Site web</span></a></h3>
			</li>
		{/if}

		{if $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)}
			<li class="{if $current == 'config'} current{elseif $current_parent == 'config'} current_parent{/if}"><h3><a href="{$admin_url}config/"><b data-icn="{icon html=false shape="settings"}"></b><span>Configuration</span></a></h3>
		{/if}

		<li class="{if $current == 'me'} current{elseif $current_parent == 'me'} current_parent{/if}"><h3><a href="{$admin_url}me/"><b data-icn="{icon html=false shape="user"}"></b><span> Mes infos personnelles</span></a></h3>
			<ul>
				<li{if $current == 'me/services'}  class="current"{/if}><a href="{$admin_url}me/services.php">Mes activités &amp; cotisations</a></li>
			</ul>
		</li>

		{if !defined('Garradin\LOCAL_LOGIN') || !LOCAL_LOGIN}
			<li><h3><a href="{$admin_url}logout.php"><b data-icn="{icon html=false shape="logout"}"></b><span>Déconnexion</span></a></h3></li>
		{/if}

		{if $help_url}
		<li>
			<h3><a href="{$help_url}" target="_blank"><b data-icn="{icon html=false shape="help"}"></b><span>Aide</span></a></h3>
		</li>
		{/if}

	{elseif !defined('Garradin\INSTALL_PROCESS')}
		<li><a href="{if $config.org_web}{$config.org_web}{else}{$www_url}{/if}">&larr; Retour au site</a></li>
		<li><a href="{$admin_url}">Connexion</a>
			<ul>
				<li><a href="{$admin_url}password.php">Mot de passe perdu</a>
			</ul>
		</li>
	{/if}
	</ul>
	</nav>

	<h1>{$title}</h1>
</header>
{/if}

<main>

Deleted src/templates/admin/config/_menu.tpl version [fffca7d347].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<nav class="tabs">
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/">Général</a></li>
		<li{if $current == 'custom'} class="current"{/if}><a href="{$admin_url}config/custom.php">Personnalisation</a></li>
		<li{if $current == 'categories'} class="current"{/if}><a href="{$admin_url}config/categories/">Catégories de membres</a></li>
		<li{if $current == 'fiches_membres'} class="current"{/if}><a href="{$admin_url}config/membres.php">Fiche des membres</a></li>
		<li{if $current == 'backup'} class="current"{/if}><a href="{$admin_url}config/backup/">Sauvegardes</a></li>
		<li{if $current == 'plugins'} class="current"{/if}><a href="{$admin_url}config/plugins.php">Extensions</a></li>
		<li{if $current == 'advanced'} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</a></li>
	</ul>

	{if $current == 'advanced'}
	<ul class="sub">
		<li{if !$sub_current} class="current"{/if}><a href="{$admin_url}config/advanced/">Fonctions avancées</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">Journal d'erreurs</a></li>
		{if SQL_DEBUG}
		<li{if $sub_current == 'sql_debug'} class="current"{/if}><a href="{$admin_url}config/advanced/sql_debug.php">Journal SQL</a></li>
		{/if}
		{/if}
	</ul>
	{/if}
</nav>
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































Deleted src/templates/admin/config/advanced/errors.tpl version [990ead8274].

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
{include file="admin/_head.tpl" title="Journaux" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current="errors"}

{if isset($reports) && isset($id)}
	<section class="error">
		{foreach from=$main.errors item="error"}
			<h2 class="ruler">{$error.type}: {$error.message} [Code: {$error.errorCode}]</h2>
			{if !empty($error.backtrace)}
				{foreach from=$error.backtrace item=trace}
				<article class="trace">
					{if $trace.function}
						<h4>
							{$trace.function}
						{if !empty($trace.args)}
							<table>
							{foreach from=$trace.args key=name item=arg}
								<tr>
									<th>{$name}</th>
									<td>{$arg}</td>
								</tr>
							{/foreach}
							</table>
						{/if}
						</h4>
					{/if}
					{if $trace.file}<h5>{$trace.file}:{$trace.line}</h5>{/if}
					{if !empty($trace.code)}
						<pre>{foreach from=$trace.code item=line key=n}{if $n == $trace.line}<b>{/if}<i>{$n}</i> {$line}{if $n == $trace.line}</b>{/if}<br />{/foreach}</pre>
					{/if}
				</article>
				{/foreach}
			{/if}
		{/foreach}

		{foreach from=$reports item=report}
		<article class="event">
			<h2 class="ruler">Occurence du {$report.context.date|date}</h2>
			<table class="list">
				{foreach from=$report.context key="k" item="v"}
				<tr>
					<th>{$k}</th>
					<td>{if $k == 'date'}{$v|date}{else}{$v}{/if}</td>
				</tr>
				{/foreach}
			</table>
		</article>
		{/foreach}
	</section>
{elseif isset($errors)}
	<p class="help">
		Liste des erreurs système et de code rencontrées par Garradin.
		Cliquer sur un des bugs pour le rapporter aux développeur⋅euses de Garradin.
	</p>

	{if !count($errors)}
		<p class="block alert">Aucune erreur n'a été trouvée dans le journal error.log</p>
	{else}
		<table class="list">
			<thead>
				<tr>
					<th>Réf.</th>
					<td>Site</td>
					<td>Erreur</td>
					<td>Occurences</td>
					<td>Dernière fois</td>
					<td></td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$errors item=error key=ref}
				<tr>
					<th><a href="?type=errors&id={$ref}">{$ref}</a></th>
					<td>{$error.hostname}</td>
					<td>
						{$error.message}<br />
						<tt>{$error.source}</tt>
					</td>
					<td>{$error.count}</td>
					<td>{$error.last_seen|date}</td>
					<td class="actions">
						{linkbutton shape="menu" label="Voir les détails" href="%s?type=errors&id=%s"|args:$self_url_no_qs,$ref}
					</td>
				</tr>
				{/foreach}
			</tbody>
		</table>
	{/if}
{/if}

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






















































































































































































Deleted src/templates/admin/config/advanced/index.tpl version [fc7d4690cb].

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
{include file="admin/_head.tpl" title="Fonctions avancées" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current=null}

<p class="help block">
	Attention, les fonctions avancées peuvent permettre de supprimer des données ou rendre votre instance inutilisable&nbsp;!
</p>

{form_errors}

{if $_GET.msg == 'RESET'}
	<p class="block confirm">
		La remise à zéro a été effectuée. Une sauvegarde a également été créée.</p>
	</p>
{else if $_GET.msg == 'REOPEN'}
	<p class="block confirm">
		L'exercice sélectionné a été réouvert.
	</p>
{/if}

<form method="post" action="{$self_url_no_qs}">
{if count($closed_years)}

<fieldset>
	<legend>Réouvrir un exercice clôturé</legend>
	<p class="help">
		À utiliser si vous avez clôturé un exercice par erreur. Attention, en comptabilité cette action est normalement exceptionnelle.
	</p>
	<p class="alert block">
		L'exercice sera réouvert, mais une écriture sera ajoutée au journal général indiquant que celui-ci a été réouvert après clôture. Cette écriture ne peut pas être supprimée.
	</p>
	<dl>
		{input type="select" options=$closed_years label="Exercicer à réouvrir" name="year"}
	</dl>
	<p>
		{csrf_field key="reopen_year"}
		{button type="submit" name="reopen_ok" label="Réouvrir l'exercice sélectionné" shape="reset"}
	</p>
</fieldset>
{/if}

{if ENABLE_TECH_DETAILS && $storage_backend != 'SQLite'}
	<h2 class="ruler">Stockage des fichiers</h2>
	{if !$quota_used}
	<fieldset>
		<legend>Migration de stockage de fichiers</legend>
		<p class="alert block">
			Les fichiers seront <strong>supprimés</strong> de la base de données après avoir été recopiés vers '{$storage_backend}'.
		</p>
		<p class="error block">
			Sauvegarde fortement recommandée avant de procéder à cette opération !
		</p>
		<p class="help">Cette opération peut prendre quelques minutes.</p>
		<p>
			{csrf_field key="migrate_backend"}
			{button type="submit" name="migrate_backend_ok" label="Copier tous les fichiers vers %s et les supprimer de la base de données"|args:$storage_backend shape="right"}
		</p>
	</fieldset>
	{else}
	<fieldset>
		<legend>Recopier les fichiers dans la base de données</legend>
		<p class="alert block">
			Les fichiers ne seront pas effacés de {$storage_backend} mais simplement recopiés dans la base de données.
		</p>
		<p class="help">Cette opération peut prendre quelques minutes. Elle est utile pour migrer entre deux systèmes de fichiers différents.</p>
		<p>
			{csrf_field key="migrate_back"}
			{button type="submit" name="migrate_back_ok" label="Copier tous les fichiers de %s vers la base de données"|args:$storage_backend shape="right"}
		</p>
	</fieldset>
	{/if}
{/if}
</form>

<h2 class="ruler">Actions destructrices</h2>

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

<fieldset>
	<legend>Remise à zéro</legend>
	<p class="block error">
		Attention : toutes les données seront effacées&nbsp;! Ceci inclut les membres, les écritures comptables, les pages du wiki, etc.
		Seul votre compte membre sera re-créé avec le même email et mot de passe.
	</p>
	<p class="help">
		Une sauvegarde sera automatiquement créée avant de procéder à la remise à zéro.
	</p>
	<dl>
		<dt><label for="f_passe_verif">Votre mot de passe</label> (pour vérification)</dt>
		<dd><input type="password" name="passe_verif" id="f_passe_verif" /></dd>
	</dl>
	<p>
		{csrf_field key="reset"}
		{button type="submit" name="reset_ok" label="Oui, je veux remettre à zéro" shape="delete"}
	</p>
</fieldset>

</form>


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










































































































































































































Deleted src/templates/admin/config/advanced/sql.tpl version [02fdf7334b].

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
{include file="admin/_head.tpl" title="SQL" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current="sql"}

{form_errors}

{if $query}
	<h2 class="ruler">Requête SQL</h2>

	<form method="post" action="{$self_url}">
		<fieldset>
			<legend>Requête SQL</legend>
			<dl>
				{input type="textarea" cols="70" rows="10" name="query" default=$query}
			</dl>
			<p class="submit">
				{button type="submit" name="run" label="Exécuter" shape="search" class="main"}
			</p>
		</fieldset>
	</form>

	{if !empty($result)}

		<p class="help">{$result|count} résultats trouvés pour cette requête.</p>
		<table class="list search">
			<thead>
				<tr>
					{foreach from=$result_header item="label"}
						<td>{$label}</td>
					{/foreach}
					<td></td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$result item="row"}
					<tr>
						{foreach from=$row key="key" item="value"}
							<td>
								{if null === $value}
									<em>NULL</em>
								{else}
									{$value}
								{/if}
							</td>
						{/foreach}
					</tr>
				{/foreach}
			</tbody>
		</table>

	{elseif $result !== null}

		<p class="block alert">
			Aucun résultat trouvé.
		</p>

	{/if}

{elseif $table}
	<h2 class="ruler">Table : {$table}</h2>

	{include file="common/dynamic_list_head.tpl"}

		{foreach from=$list->iterate() item="row"}
			<tr>
				{foreach from=$row item="value"}
				<td>
					{if null == $value}
						<em>NULL</em>
					{else}
						{$value}
					{/if}
				</td>
				{/foreach}
				<td></td>
			</tr>
		{/foreach}

		</tbody>
	</table>

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

{else}

	<p class="help block">
		Cette page vous permet de visualiser les données brutes de la base de données.
	</p>

	<form method="post" action="{$self_url}">
		<fieldset>
			<legend>Requête SQL</legend>
			<dl>
				{input type="textarea" cols="70" rows="10" name="query" default=$query}
			</dl>
			<p class="submit">
				{button type="submit" name="run" label="Exécuter" shape="search" class="main"}
			</p>
		</fieldset>
	</form>

	<h2 class="ruler">Liste des tables</h2>

	<dl class="describe">
		{foreach from=$tables_list key="name" item="table"}
			<dt><a href="?table={$name}">{$name}</a></dt>
			<dd><em>{$table.count} lignes</em></dd>
			<dd><pre>{$table.sql}</pre></dd>
		{/foreach}
	</dl>

	<h2 class="ruler">Liste des index</h2>

	<dl class="describe">
		{foreach from=$index_list key="name" item="sql"}
			<dt>{$name}</dt>
			<dd><pre>{$sql}</pre></dd>
		{/foreach}
	</dl>

	<h2 class="ruler">Liste des triggers</h2>

	<dl class="describe">
		{foreach from=$triggers_list key="name" item="sql"}
			<dt>{$name}</dt>
			<dd><pre>{$sql}</pre></dd>
		{/foreach}
	</dl>

{/if}

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








































































































































































































































































Deleted src/templates/admin/config/advanced/sql_debug.tpl version [6b74582aae].

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
{include file="admin/_head.tpl" title="Journal SQL" current="config" custom_css=["config.css"]}

{include file="admin/config/_menu.tpl" current="advanced" sub_current="sql_debug"}

{if isset($debug)}
	<table class="list multi">
		<thead>
			<tr>
				<th>T.</th>
				<td>Durée</td>
				<td>Trace</td>
				<td>SQL</td>
			</tr>
		</thead>
		{foreach from=$debug.list item="row"}
		<tbody>
			<tr>
				<th><?=round($row->time / 1000, 2)?></th>
				<td>
					<?php $d = round($row->duration / 1000, 2); ?>
					{if $d > 0.4}
						<h3 class="error">{$d}</h3>
					{else}
						{$d}
					{/if}
				</td>
				<td><pre>{$row.trace}</pre></td>
			</tr>
			<tr>
				<td colspan="3"><h4>Query <a href="sql.php?query={$row.sql|escape:'url'}">[replay]</a></h4><pre>{$row.sql}</pre></td>
			</tr>
			<tr>
				<td colspan="3"><h4>EXPLAIN:</h4><pre>{$row.explain}</pre></td>
			</tr>
		</tbody>
		{/foreach}
	</table>
{elseif isset($list)}
	<p class="help">
		Liste des pages consultées ayant mené à des requêtes SQL.
	</p>

	{if !count($list)}
		<p class="block alert">Aucune requête n'a été trouvée dans le log</p>
	{else}
		<table class="list">
			<thead>
				<tr>
					<th>ID</th>
					<td>Date</td>
					<td>Script</td>
					<td>Membre connecté</td>
					<td>Durée totale des requêtes</td>
					<td>Nombre de requêtes</td>
				</tr>
			</thead>
			<tbody>
				{foreach from=$list item="row"}
				<tr>
					<th><a href="?id={$row.id}">{$row.id}</a></th>
					<td>{$row.date|date_format}</td>
					<td>{$row.script}</td>
					<td>{$row.user}</td>
					<td class="num"><?=$row->duration/1000?> ms</td>
					<td class="num">{$row.count}</td>
				</tr>
				{/foreach}
			</tbody>
		</table>
	{/if}
{/if}

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


















































































































































Deleted src/templates/admin/config/backup/_menu.tpl version [18d2cda81c].

1
2
3
4
5
6
7
8
<nav class="tabs">
	<ul class="sub">
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}config/backup/">Informations</a></li>
		<li{if $current == 'save'} class="current"{/if}><a href="{$admin_url}config/backup/save.php">Sauvegarder</a></li>
		<li{if $current == 'restore'} class="current"{/if}><a href="{$admin_url}config/backup/restore.php">Restaurer</a></li>
		<li{if $current == 'documents'} class="current"{/if}><a href="{$admin_url}config/backup/documents.php">Documents</a></li>
	</ul>
</nav>
<
<
<
<
<
<
<
<
















Deleted src/templates/admin/config/backup/documents.tpl version [bfb17f7477].

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="admin/_head.tpl" title="Documents" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="documents"}

{if $ok}
<p class="confirm block">La restauration a été effectuée.</p>
{/if}

{if $failed}
<p class="alert block">{$failed} fichiers n'ont pas pu être restaurés car ils dépassaient la taille autorisée.</p>
{/if}

{form_errors}

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

<fieldset>
	<legend>Téléchargement des documents</legend>
	<p class="help">
		Les documents font {$files_size|size_in_bytes}.
	</p>
	{if $files_size > 0}
	<p class="submit">
		{csrf_field key="files_download"}
		{button type="submit" name="download_files" label="Télécharger une archive ZIP des documents sur mon ordinateur" shape="download" class="main"}
	</p>
	{/if}
</fieldset>

</form>

<form method="post" action="{$self_url_no_qs}" id="restoreDocuments" style="display: none;" enctype="multipart/form-data">

<fieldset>
	<legend>Restaurer les documents</legend>
	<p class="help">
		Sélectionner ici une sauvegarde (archive ZIP) des documents pour les restaurer.
	</p>
	<dl>
		{input type="file" name="file" label="Archive ZIP à restaurer" no_size_limit=true required=true}
	</dl>
	<p class="alert block">
		Les fichiers existants qui portent le même nom seront écrasés. Les documents existants qui ne figurent pas dans la sauvegarde ne seront pas affectés.
	</p>
	<p class="submit">
		{csrf_field key="files_restore"}
		{button type="submit" name="restore" label="Restaurer cette sauvegarde des documents" shape="upload" class="main"}
	</p>
	<span class="progress-status"></span>
</fieldset>

</form>

<script type="text/javascript">
g.script('scripts/lib/unzipit.min.js');
g.script('scripts/unzip_restore.js');
</script>

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


























































































































Deleted src/templates/admin/config/backup/index.tpl version [f2dd4c1906].

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="admin/_head.tpl" title="Sauvegardes" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="index"}

<fieldset>
	<legend>Politique de sauvegardes</legend>
	{if !$config.frequence_sauvegardes}
	<p class="help block">
		Les <a href="save.php">sauvegardes automatiques</a> sont désactivées. Il est recommandé de les activer pour pouvoir revenir en arrière en cas de problème majeur. Attention, cela ne dispense pas de réaliser des sauvegardes régulières sur votre ordinateur.
	</p>
	{/if}

	<p class="help">
		En cas de problème sur le serveur (plantage, dysfonctionnement du disque dur, incendie, etc.) vous pourriez perdre vos données.<br />
		<strong>Il est donc recommandé de réaliser régulièrement des sauvegardes et de les conserver sur votre ordinateur personnel&nbsp;!</strong><br /><br />
		Pour cela il convient de se rendre dans la section <a href="save.php">Sauvegarder</a> et de cliquer sur le bouton <em>«&nbsp;Télécharger une copie de la base de données sur mon ordinateur&nbsp;»</em>.
	</p>
</fieldset>

<fieldset>
	<legend>Import et export</legend>
	<p class="help">
		Il est possible d'exporter et importer des données afin d'interagir avec des logiciels tiers. Cette liste regroupe les imports et exports les plus courants. Il est également possible d'exporter la plupart des listes qui comportent un bouton "Exporter".
	</p>
	<dl>
		<dt><strong>Membres</strong></dt>
		<dd><a href="{$admin_url}membres/import.php">Import de la liste des membres</a></dd>
		<dd><a href="{$admin_url}membres/import.php?export=ods">Export de la liste des membres au format tableur LibreOffice Calc / Excel</a></dd>
		<dd><a href="{$admin_url}membres/import.php?export=csv">Export de la liste des membres au format CSV</a></dd>
		<dt><strong>Comptabilité</strong> (pour l'exercice courant)</dt>
		<dd><a href="{$admin_url}acc/years/import.php">Import des données comptables</a></dd>
		<dd><a href="{$admin_url}acc/years/export.php">Export des données comptables</a></dd>
	</dl>
</fieldset>

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












































































Deleted src/templates/admin/config/backup/restore.tpl version [03715cceb0].

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
{include file="admin/_head.tpl" title="Restaurer" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="restore"}

{form_errors}

{if $code == Sauvegarde::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT}
	<p class="block alert">Pour passer outre, renvoyez le fichier en cochant la case «&nbsp;Ignorer les erreurs&nbsp;».
	Attention, si vous avez effectué des modifications dans la base de données, cela peut créer des bugs&nbsp;!</p>
{/if}

{if $ok}
	<p class="block confirm">
		{if $ok == 'restore'}La restauration a bien été effectuée.
			{if $ok_code & Sauvegarde::NOT_AN_ADMIN}
			</p>
			<p class="block alert">
				<strong>Vous n'êtes pas administrateur dans cette sauvegarde.</strong> Garradin a donné les droits d'administration à toutes les catégories afin d'empêcher de ne plus pouvoir se connecter.
				Merci de corriger les droits des catégories maintenant.
			{elseif $ok_code & Sauvegarde::CHANGED_USER}
			</p>
			<p class="block alert">
				<strong>Votre compte membre n'existait pas dans la sauvegarde qui a été restaurée, vous êtes désormais connecté avec le premier compte administrateur.</strong>
			</p>
			{/if}
		{elseif $ok == 'remove'}La sauvegarde a été supprimée.
		{/if}
	</p>
{/if}


<form method="post" action="{$self_url_no_qs}" enctype="multipart/form-data">

<fieldset>
	<legend><label for="f_file">Restaurer depuis un fichier de sauvegarde</label></legend>
	<p class="block alert">
		Attention, l'intégralité des données courantes seront effacées et remplacées par celles
		contenues dans le fichier fourni.
	</p>
	<p class="help">
		Une sauvegarde des données courantes sera effectuée avant le remplacement,
		en cas de besoin d'annuler cette restauration.
	</p>
	<dl>
		{input type="file" name="file" label="Fichier de sauvegarde à restaurer" required=true}
	</dl>
	<p class="submit">
		{csrf_field key="backup_restore"}
		{button type="submit" name="restore_file" label="Restaurer depuis le fichier sélectionné" shape="upload" class="main"}
	</p>
	{if $code && ($code == Sauvegarde::INTEGRITY_FAIL && ALLOW_MODIFIED_IMPORT)}
	<p>
		{input type="checkbox" name="force_import" value="1" label="Ignorer les erreurs, je sais ce que je fait"}
	</p>
	{/if}
</fieldset>

</form>

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

<fieldset>
	<legend>Sauvegardes disponibles</legend>
	{if empty($list)}
		<p class="help">Aucune copie de sauvegarde disponible.</p>
	{else}
		<table class="list">
			<tbody>
				<thead>
					<tr>
						<td></td>
						<th>Nom</th>
						<td>Taille</td>
						<td>Date</td>
						<td>Version</td>
						<td></td>
					</tr>
				</thead>
			{foreach from=$list item="backup"}
				<tr>
					<td class="check">{if $backup.can_restore}{input type="radio" name="selected" value=$backup.filename}{/if}</td>
					<th><label for="f_selected_{$backup.filename}">{$backup.name}</label></th>
					<td>{$backup.size|size_in_bytes}</td>
					<td>{$backup.date|date_short:true}</td>
					<td>{$backup.version}{if !$backup.can_restore} — <span class="alert">Version trop ancienne pour pouvoir être restaurée</span>{/if}</td>
					<td class="actions">
						{linkbutton href="?download=%s"|args:$backup.filename label="Télécharger" shape="download"}
					</td>
				</tr>
			{/foreach}
			</tbody>
		</table>
		<p class="alert block">
			Attention, en cas de restauration, l'intégralité des données courantes seront effacées et remplacées par celles contenues dans la sauvegarde sélectionnée.
		</p>
		<p class="submit">
			{csrf_field key="backup_manage"}
			{button type="submit" name="restore" label="Restaurer cette sauvegarde" shape="reset" class="main"}
			{button type="submit" name="remove" label="Supprimer cette sauvegarde" shape="delete"}
		</p>
	{/if}
</fieldset>

</form>

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
























































































































































































































Deleted src/templates/admin/config/backup/save.tpl version [04540ba7a1].

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
{include file="admin/_head.tpl" title="Sauvegarder" current="config"}

{include file="admin/config/_menu.tpl" current="backup"}

{include file="admin/config/backup/_menu.tpl" current="save"}

{form_errors}

{if $ok}
	<p class="block confirm">
		{if $ok == 'create'}Une nouvelle sauvegarde a été créée.
		{elseif $ok == 'config'}La configuration a bien été enregistrée.
		{/if}
	</p>
{/if}

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

<fieldset>
	<legend>Téléchargement d'une sauvegarde</legend>
	<p class="help">
		Info : la base de données fait actuellement {$db_size|size_in_bytes}.
		{if FILE_STORAGE_BACKEND == 'SQLite'} (Dont {$files_size|size_in_bytes} pour les documents.){/if}
	</p>
	<p class="submit">
		{csrf_field key="backup_download"}
		{button type="submit" name="download" label="Télécharger une copie de la base de données sur mon ordinateur" shape="download" class="main"}
	</p>
</fieldset>

</form>

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

<fieldset>
	<legend>Sauvegarde manuelle de la base de données</legend>
	<p class="help">
		Cette sauvegarde sera enregistrée sur le serveur et pourra être restaurée plus tard.<br />
		Cette sauvegarde ne concerne que la base de données, mais pas les documents, fichiers joints aux écritures ou aux membres, ni le contenu du site web.
	</p>
	<p class="submit">
		{csrf_field key="backup_create"}
		{button type="submit" name="create" label="Créer une nouvelle sauvegarde" shape="right" class="main"}
	</p>
</fieldset>

</form>

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

<fieldset>
	<legend>Configuration de la sauvegarde automatique</legend>
	<p class="help">
		En activant cette option une sauvegarde sera automatiquement créée à chaque intervalle donné.
		Par exemple en activant une sauvegarde hebdomadaire, une copie des données sera réalisée
		une fois par semaine, sauf si aucune modification n'a été effectuée sur les données
		ou que personne ne s'est connecté.
	</p>
	<p class="alert block">
		Attention, la sauvegarde automatique permet uniquement de revenir à un état antérieur, mais ne prévient pas de la perte des données&nbsp;! Pour cela, il est recommandé de faire des sauvegardes manuelles en téléchargeant une copie des données sur votre ordinateur.
		{if FILE_STORAGE_BACKEND != 'SQLite'}<br /><br />
		La sauvegarde automatique ne concerne que la base de données, mais pas les documents, fichiers joints aux écritures ou aux membres, ni le contenu du site web.{/if}
	</p>
	<dl>
		<dt><label for="f_frequency">Intervalle de sauvegarde</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
		<dd>
			<select name="frequence_sauvegardes" required="required" id="f_frequency">
				<option value="0"{form_field name=frequence_sauvegardes data=$config selected=0}>Aucun — les sauvegardes automatiques sont désactivées</option>
				<option value="1"{form_field name=frequence_sauvegardes data=$config selected=1}>Quotidien, tous les jours</option>
				<option value="7"{form_field name=frequence_sauvegardes data=$config selected=7}>Hebdomadaire, tous les 7 jours</option>
				<option value="15"{form_field name=frequence_sauvegardes data=$config selected=15}>Bimensuel, tous les 15 jours</option>
				<option value="30"{form_field name=frequence_sauvegardes data=$config selected=30}>Mensuel</option>
				<option value="90"{form_field name=frequence_sauvegardes data=$config selected=90}>Trimestriel</option>
				<option value="365{form_field name=frequence_sauvegardes data=$config selected=365}">Annuel</option>
			</select>
		</dd>
		<dt><label for="f_max_backups">Nombre de sauvegardes conservées</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
		<dd class="help">
			Par exemple avec l'intervalle mensuel, en indiquant de conserver 12 sauvegardes,
			vous pourrez garder un an d'historique de sauvegardes.
		</dd>
		<dd class="help">
			<strong>Attention :</strong> si vous choisissez un nombre important et un intervalle réduit,
			l'espace disque occupé par vos sauvegardes va rapidement augmenter.
		</dd>
		<dd><input type="number" name="nombre_sauvegardes" value="{form_field name=nombre_sauvegardes data=$config}" if="f_max_backups" min="1" max="50" required="required" /></dd>
	</dl>
	<p class="submit">
		{csrf_field key="backup_config"}
		{button type="submit" name="config" label="Enregistrer" shape="right" class="main"}
	</p>
</fieldset>

</form>

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
































































































































































































Deleted src/templates/admin/config/categories/index.tpl version [7b5889b61d].

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="admin/_head.tpl" title="Catégories de membres" current="config"}

{include file="admin/config/_menu.tpl" current="categories"}

<table class="list">
	<thead>
		<th>Nom</th>
		<td class="num">Membres</td>
		<td>Droits</td>
		<td></td>
	</thead>
	<tbody>
		{foreach from=$list item="cat"}
			<tr>
				<th>{$cat.name}</th>
				<td class="num">{$cat.count}</td>
				<td class="permissions">
					{display_permissions permissions=$cat}
				</td>
				<td class="actions">
					{if $cat.id != $user.id_category}
						{linkbutton shape="delete" label="Supprimer" href="supprimer.php?id=%d"|args:$cat.id}
					{/if}
					{linkbutton shape="edit" label="Modifier" href="modifier.php?id=%d"|args:$cat.id}
					{linkbutton shape="users" label="Liste des membres" href="!membres/?cat=%d"|args:$cat.id}
				</td>
			</tr>
		{/foreach}
	</tbody>
</table>

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

	<fieldset>
		<legend>Ajouter une catégorie</legend>
		<dl>
			{input type="text" name="name" label="Nom" required=true}
		</dl>

		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="save" label="Ajouter" shape="right" class="main"}
		</p>
	</fieldset>

</form>


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


































































































Deleted src/templates/admin/config/categories/modifier.tpl version [abe451d3d1].

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="admin/_head.tpl" title="Modifier une catégorie de membre" current="config"}

{include file="admin/config/_menu.tpl" current="categories"}

{form_errors}

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

	<fieldset>
		<legend>Informations générales</legend>
		<dl>
			{input type="text" name="name" label="Nom" required=true source=$cat}
			<dt>Configuration</dt>
			{input type="checkbox" name="hidden" label="Catégorie cachée" source=$cat value=1 help="Si coché cette catégorie ne sera visible qu'aux administrateurs et ne recevra pas de messages collectifs ou de rappels"}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Droits</legend>
		<dl class="permissions">
		{foreach from=$permissions key="type" item="perm"}
			<dt><label for="f_perm_{$type}_0">{$perm.label}</label></dt>
			{if $perm.disabled}
				<dd class="help">
					Il n'est pas possible de désactiver ce droit pour votre propre catégorie.
				</dd>
			{/if}
			{foreach from=$perm.options key="level" item="label"}
			<dd>
				<input type="radio" name="perm_{$type}" value="{$level}" id="f_perm_{$type}_{$level}" {if $cat->{'perm_' . $type} == $level}checked="checked"{/if} {if $perm.disabled}disabled="disabled"{/if} />
				<label for="f_perm_{$type}_{$level}"><b class="access_{$level}">{$perm.shape}</b> {$label}</label>
			</dd>
			{/foreach}
		{/foreach}
		</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="admin/_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


























































































Deleted src/templates/admin/config/categories/supprimer.tpl version [b8906b9dfd].

1
2
3
4
5
6
7
8
9
10
11
{include file="admin/_head.tpl" title="Supprimer une catégorie de membre" current="config"}

{include file="admin/config/_menu.tpl" current="categories"}

{include file="common/delete_form.tpl"
	legend="Supprimer cette catégorie de membres ?"
	warning="Êtes-vous sûr de vouloir supprimer la catégorie « %s » ?"|args:$cat.name
	alert="Attention, la catégorie ne doit plus contenir de membres pour pouvoir être supprimée."
	info="Les écritures comptables liées à l'historique des membres inscrits à cette activité ne seront pas supprimées, et la comptabilité demeurera inchangée."}

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






















Deleted src/templates/admin/config/custom.tpl version [f0931e6c67].

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
{include file="admin/_head.tpl" title="Personnalisation" current="config" custom_css=['config.css']}

{include file="admin/config/_menu.tpl" current="custom"}

{if isset($_GET['ok']) && !$form->hasErrors()}
	<p class="block confirm">
		La configuration a bien été enregistrée.
	</p>
{/if}

{form_errors}

<fieldset>
	<legend>Association et site web</legend>
	<dl>
		<dt>Logo</dt>
		{if $url = $config->fileURL('logo', '150px')}
		<dd>
			<img src="{$url}" alt="" />
		</dd>
		{/if}
		<dd>
			{linkbutton href="!config/edit_file.php?k=%s"|args:'logo' label="Modifier" shape="edit" target="_dialog"}
		</dd>
		<dd class="help">
			Ce logo sera affiché en haut du menu de l'administration, sur le site web et sur les documents imprimés.
		</dd>
		<dt>Petite icône</dt>
		{if $url = $config->fileURL('favicon')}
		<dd>
			<img src="{$url}" alt="" />
		</dd>
		{/if}
		<dd>
			{linkbutton href="!config/edit_file.php?k=%s"|args:'favicon' label="Modifier" shape="edit" target="_dialog"}
		</dd>
		<dd class="help">
			Cette image sera affichée dans l'onglet du navigateur (favicon).
		</dd>
		<dt>Grande icône</dt>
		{if $url = $config->fileURL('icon', '150px')}
		<dd class="image-preview">
			<img src="{$url}" alt="" />
			<figure class="masked-icon" title="Aperçu de l'icône sur téléphone">
				<span class="icon"><img src="{$url}" alt="" /></span>
				<figcaption>{$config.nom_asso|truncate:12:'…':true}</figcaption>
			</figure>
		</dd>
		{/if}
		<dd>
			{linkbutton href="!config/edit_file.php?k=%s"|args:'icon' label="Modifier" shape="edit" target="_dialog"}
		</dd>
		<dd class="help">
			Cette image sera utilisée comme icône de l'application mobile (à installer depuis {link href="!" label="la page d'accueil"} et le bouton «&nbsp;Installer comme application sur l'écran d'accueil&nbsp;»).
		</dd>
	</dl>
</fieldset>

<form method="post" action="{$self_url}">
	<fieldset>
		<legend>Interface d'administration</legend>
		<dl>
			{input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color1 source=$config name="couleur1" label="Couleur primaire" placeholder=$color1}
			{input type="color" pattern="#[a-f0-9]{6}" title="Couleur au format hexadécimal" default=$color2 source=$config name="couleur2" label="Couleur secondaire" placeholder=$color2}
			{input type="file" label="Image de fond" name="background" help="Il est conseillé d'utiliser une image en noir et blanc avec un fond blanc pour un meilleur rendu. Dimensions recommandées : 380x200" accept="image/*,*.jpeg,*.jpg,*.png,*.gif"}
			<dt>Texte de la page d'accueil</dt>
			<dd>
				{linkbutton href="!config/edit_file.php?k=%s"|args:'admin_homepage' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
			</dd>
			<dd class="help">
				Ce contenu sera affiché à la connexion d'un membre, ou en cliquant sur l'onglet 'Accueil' du menu de gauche.
			</dd>
			<dt>Personnalisation CSS de l'administration</dt>
			<dd>
				{linkbutton href="!config/edit_file.php?k=%s"|args:'admin_css' label="Modifier" shape="edit" target="_dialog" data-dialog-height="90%"}
			</dd>
			<dd class="help">
				Permet de rajouter des <a href="https://developer.mozilla.org/fr/docs/Learn/CSS/First_steps" target="_blank">règles CSS</a> qui modifieront l'apparence de l'interface d'administration.
			</dd>		</dl>
		<input type="hidden" name="admin_background" id="f_admin_background" data-current="{$background_image_current}" data-default="{$background_image_default}" value="{$_POST.admin_background}" />

		<p class="submit">
			{csrf_field key="config_custom"}
			{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
		</p>
	</fieldset>


</form>

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






















































































































































































Deleted src/templates/admin/config/edit_image.tpl version [ecffc7fa59].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{include file="admin/_head.tpl" title="Envoi d'image"}

{form_errors}

<form method="post" action="{$self_url}" enctype="multipart/form-data" data-focus="1">
	<fieldset>
		<legend>Téléverser un fichier</legend>
		<dl>
			{input type="file" name="file" label="Fichier à envoyer" data-enhanced=1}
		</dl>
		<p class="submit">
			{csrf_field key=$csrf_key}
			{button type="submit" name="upload" label="Envoyer" shape="upload" class="main"}
			{button type="submit" name="reset" label="Supprimer" shape="delete"}
		</p>
	</fieldset>
</form>

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






































Deleted src/templates/admin/config/index.tpl version [265f30cf3e].

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
{include file="admin/_head.tpl" title="Configuration" current="config"}

{include file="admin/config/_menu.tpl" current="index"}

{if isset($_GET['ok']) && !$form->hasErrors()}
	<p class="block confirm">
		La configuration a bien été enregistrée.
	</p>
{/if}

{form_errors}

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

	<fieldset>
		<legend>Garradin</legend>
		<dl>
			<dt>Version installée</dt>
			<dd>{$garradin_version}</dd>
			{if CONTRIBUTOR_LICENSE === null}
			<dd class="help">
				Le développement et le support de Garradin ne sont possibles que grâce à votre soutien&nbsp;!<br />
				{linkbutton href="https://kd2.org/soutien.html" label="Faire un don pour soutenir le développement" target="_blank" shape="export"} :-)
			</dd>
			{/if}
			{if $new_version}
			<dd><p class="block alert">
				Une nouvelle version <strong>{$new_version}</strong> est disponible !<br />
				{if ENABLE_UPGRADES}
					{linkbutton shape="export" href="upgrade.php" label="Mettre à jour"}
				{else}
					{linkbutton shape="export" href=$garradin_website label="Télécharger la mise à jour" target="_blank"}
				{/if}
			</p></dd>
			{/if}
			{if ENABLE_TECH_DETAILS}
			<dt>Informations système</dt>
			<dd class="help">
				Version PHP&nbsp;: {$php_version}<br />
				Version SQLite&nbsp;: {$sqlite_version}<br />
				Heure du serveur&nbsp;: {$server_time|date}<br />
				Chiffrement GnuPG&nbsp;: {if $has_gpg_support}disponible, module activé{else}non, module PHP gnupg non installé&nbsp;?{/if}<br />
			</dd>
			{/if}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Informations sur l'association</legend>
		<dl>
			{input type="text" name="nom_asso" required=true source=$config label="Nom"}
			{input type="email" name="email_asso" required=true source=$config label="Adresse e-mail de contact"}
			{input type="textarea" name="adresse_asso" source=$config label="Adresse postale"}
			{input type="tel" name="telephone_asso" source=$config label="Numéro de téléphone"}
			{input type="url" name="site_asso" source=$config label="Site web" help="Si vous n'utilisez pas la fonctionnalité site web de Garradin"}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Localisation</legend>
		<dl>
			{input type="text" name="monnaie" required=true source=$config label="Monnaie" help="Inscrire ici la devise utilisée : €, CHF, XPF, etc." size="3"}
			{input type="select" name="pays" required=true source=$config label="Pays" options=$countries}
		</dl>
	</fieldset>

	<fieldset>
		<legend>Membres</legend>
		<dl>
			{input type="select" name="categorie_membres" source=$config options=$membres_cats required=true label="Catégorie par défaut des nouveaux membres"}
			{input type="select" name="champ_identite" source=$config options=$champs required=true label="Champ utilisé pour définir l'identité des membres" help="Ce champ des fiches membres sera utilisé comme identité du membre dans les emails, les fiches, les pages, etc."}
			{input type="select" name="champ_identifiant" source=$config options=$champs required=true label="Champ utilisé comme identifiant de connexion" help="Ce champ des fiches membres sera utilisé comme identifiant pour se connecter à Garradin. Ce champ doit être unique (il ne peut pas contenir deux membres ayant la même valeur dans ce champ)."}
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key="config"}
		{button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
	</p>

</form>

{if ENABLE_TECH_DETAILS}
	<script type="text/javascript" async="async">
	fetch(g.admin_url + 'config/?check_version');
	</script>
{/if}

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


















































































































































































Deleted src/templates/admin/config/membres.tpl version [e15825a3fa].

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
{include file="admin/_head.tpl" current="config" custom_css=['config.css']}

{include file="admin/config/_menu.tpl" current="fiches_membres"}

{if isset($status) && $status == 'OK'}
    <p class="block confirm">
        La configuration a bien été enregistrée.
    </p>
{elseif isset($status) && $status == 'ADDED'}
    <p class="block alert">
        Le champ a été ajouté à la fin de la liste. Pour vérifier et sauvegarder les modifications de la fiche membre cliquer sur le bouton «&nbsp;Vérifier les changements&nbsp;» en base de page.
    </p>
{/if}

{form_errors}

{if $review}
    <p class="help">
        Voici ce à quoi ressemblera la nouvelle fiche de membre, vérifiez vos modifications avant d'enregistrer les changements.
    </p>
    <p class="block alert">
        Attention&nbsp;! Si vous avez supprimé un champ, les données liées à celui-ci seront supprimées de toutes les fiches de tous les membres.
    </p>
    <fieldset>
        <legend>Fiche membre exemple</legend>
        <dl>
            {foreach from=$champs item="champ" key="nom"}
                {if $nom == 'passe'}{continue}{/if}
                {html_champ_membre config=$champ name=$nom disabled=true}
                {if empty($champ.editable) || !empty($champ.private)}
                <dd>
                    {if !empty($champ.private)}
                        (Champ caché)
                    {elseif empty($champ.editable)}
                        (Non-modifiable par les membres)
                    {/if}
                </dd>
                {/if}
            {/foreach}
        </dl>
    </fieldset>

    <fieldset id="f_passe">
        <legend>Connexion</legend>
        <dl>
            <dt><label for="f_passe">Mot de passe</label>{if !empty($champs.passe.mandatory)} <b title="(Champ obligatoire)">obligatoire</b>{/if}</dt>
            <dd><input type="password" id="f_passe" disabled="disabled" /></dd>
            {if empty($champs.passe.editable) || !empty($champs.passe.private)}
            <dd>
                {if !empty($champs.passe.private)}
                    (Champ caché)
                {elseif empty($champs.passe.editable)}
                    (Non-modifiable par les membres)
                {/if}
            </dd>
            {/if}
        </dl>
    </fieldset>

    <form method="post" action="{$admin_url}config/membres.php">
        <p class="submit">
            {csrf_field key="config_membres"}
            <input type="hidden" name="champs" value="{$champs|escape:json|escape}" />
            <input type="submit" name="back" value="&larr; Retour à l'édition" class="minor" />
            <input type="submit" name="reset" value="Annuler les changements" class="minor" />
            {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
        </p>
    </form>
{else}
    <p class="help">
        Cette page vous permet de personnaliser les fiches d'information des membres de l'association.<br />
        <strong>Attention :</strong> Les champs supprimés de la fiche seront effacés de toutes les fiches de tous les membres, et les données qu'ils contenaient seront perdues.
    </p>

    {if !empty($presets)}
    <form method="post" action="{$self_url}">
    <fieldset>
        <legend>Ajouter un champ pré-défini</legend>
        <p>
            <select name="preset" required="required">
                <option></option>
                {foreach from=$presets key="name" item="preset"}
                <option value="{$name}">{$name} &mdash; {$preset.title}</option>
                {/foreach}
            </select>
            {csrf_field key="config_membres"}
            {button type="submit" name="add" label="Ajouter ce champ à la fiche membre" shape="plus"}
        </p>
    </fieldset>
    </form>
    {/if}

<form method="post" action="{$self_url}">
    <fieldset>
        <legend>Ajouter un champ personnalisé</legend>
        <dl>
            <dt><label for="f_name">Nom unique</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd class="help">Ne peut comporter que des lettres minuscules et des tirets bas.</dd>
            <dd><input type="text" name="new" id="f_name" value="{form_field name=new}" required="required" /></dd>
            <dt><label for="f_title">Titre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="new_title" id="f_title" value="{form_field name=new_title}" required="required" /></dd>
            <dt><label for="f_type">Type de champ</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd>
                <select name="new_type" id="f_type" required="required">
                    {foreach from=$types key="type" item="nom"}
                    <option value="{$type}" {form_field name=new_type selected=$type}>{$nom}</option>
                    {/foreach}
                </select>
            </dd>
        </dl>
        <p>
            {csrf_field key="config_membres"}
            {button type="submit" name="add" label="Ajouter ce champ à la fiche membre" shape="plus"}
            <input type="hidden" name="champs" value="{$champs|escape:json|escape}" />
        </p>
    </fieldset>
</form>

<form method="post" action="{$self_url}">
    <div id="orderFields">
        {foreach from=$champs item="champ" key="nom"}
        {if $nom == 'passe'}{continue}{/if}
        <fieldset id="f_{$nom}">
            <legend>{$nom}</legend>
            <dl>
                <dt><label>Type</label></dt>
                <dd><input type="hidden" name="champs[{$nom}][type]" value="{$champ.type}" />{$champ.type|get_type}</dd>
                <dt><label for="f_{$nom}_title">Titre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
                <dd><input type="text" name="champs[{$nom}][title]" id="f_{$nom}_title" value="{form_field data=$champ name=title}" required="required" /></dd>
                <dt><label for="f_{$nom}_help">Aide</label></dt>
                <dd><input type="text" name="champs[{$nom}][help]" id="f_{$nom}_help" value="{form_field data=$champ name=help}" /></dd>

                <dt><input type="checkbox" name="champs[{$nom}][private]" value="1" {form_field data=$champ name=private checked="1"} id="f_{$nom}_private"/> <label for="f_{$nom}_private">Caché pour les membres</label></dt>
                <dd class="help">Si coché, ce champ ne sera pas visible par les membres dans leur espace personnel. Attention, il apparaîtra quand même sur l'export de données RGPD que le membre peut télécharger, et qui contiendra toutes les données concernant ce membre.</dd>
                <dt><input type="checkbox" name="champs[{$nom}][editable]" value="1" {form_field data=$champ name=editable checked="1"} id="f_{$nom}_editable" /> <label for="f_{$nom}_editable">Modifiable par les membres</label></dt>
                <dd class="help">Si coché, les membres pourront changer cette information depuis leur espace personnel.</dd>
                <dt><label><input type="checkbox" name="champs[{$nom}][mandatory]" value="1" {form_field data=$champ name=mandatory checked="1"} id="f_{$nom}_mandatory" /> <label for="f_{$nom}_mandatory">Champ obligatoire</label></dt>
                <dd class="help">Si coché, ce champ ne pourra rester vide.</dd>

                {if $champ.type == 'select' || $champ.type == 'multiple'}
                    <dt><label>Options disponibles</label></dt>
                    {if $champ.type == 'multiple'}
                        <dd class="help">Attention changer l'ordre des options peut avoir des effets indésirables.</dd>
                    {else}
                        <dd class="help">Attention renommer ou supprimer une option n'affecte pas ce qui a déjà été enregistré dans les fiches des membres.</dd>
                    {/if}
                    <dd>
                        <{if $champ.type == 'multiple'}ol{else}ul{/if} class="options">
                        {if !empty($champ.options)}
                            {foreach from=$champ.options key="key" item="opt"}
                                <li><input type="text" name="champs[{$nom}][options][]" value="{$opt}" /></li>
                            {/foreach}
                        {/if}
                        {if $champ.type == 'select' || empty($champ.options) || count($champ.options) < 32}
                            <li><input type="text" name="champs[{$nom}][options][]" value="" /></li>
                        {/if}
                    </dd>
                {/if}
                <dt><label for="f_{$nom}_list_row">Numéro de colonne dans la liste des membres</label></dt>
                <dd class="help">Laisser vide ou indiquer le chiffre zéro pour que ce champ n'apparaisse pas dans la liste des membres. Inscrire un chiffre entre 1 et 10 pour indiquer l'ordre d'affichage du champ dans le tableau de la liste des membres.</dd>
                <dd><input type="number" id="f_{$nom}_list_row" name="champs[{$nom}][list_row]" min="0" max="10" value="{form_field data=$champ name=list_row}" /></dd>
            </dl>
        </fieldset>
        {/foreach}
    </div>

    <fieldset id="f_passe">
        <legend>Mot de passe</legend>
        <dl>
            {input type="checkbox" name="champs[passe][private]" value="1" default=$champs.passe.private label="Caché pour les membres" help="Si coché, ce champ ne sera pas visible par les membres dans leur espace personnel"}
            {input type="checkbox" name="champs[passe][editable]" value="1" default=$champs.passe.editable label="Modifiable par les membres" help="Si coché, les membres pourront changer cette information depuis leur espace personnel"}
            {input type="checkbox" name="champs[passe][mandatory]" value="1" default=$champs.passe.mandatory label="Champ obligatoire" help="Si coché, ce champ ne pourra rester vide lors de la création d'un membre"}
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="config_membres"}
        {button type="submit" name="reset" label="Annuler les changements" shape="left"}
        {button type="submit" name="review" label="Vérifier les changements" shape="right" class="main"}
        <em class="help">(un récapitulatif sera présenté et une confirmation sera demandée)</em>
    </p>
</form>

<script type="text/javascript">
var champ_identifiant = "f_{$config.champ_identifiant|escape:'js'}";
var champ_identite = "f_{$config.champ_identite|escape:'js'}";

{literal}
(function () {
    if (!document.querySelector || !document.querySelectorAll)
    {
        return false;
    }

    var fields = document.querySelectorAll('#orderFields fieldset');

    for (i = 0; i < fields.length; i++)
    {
        var field = fields[i];
        field.querySelector('dl').classList.toggle('hidden');

        var legend = field.querySelector('legend');

        legend.onclick = function () {
            this.parentNode.querySelector('dl').classList.toggle('hidden');
        }

        legend.className = 'interactive';
        legend.title = 'Cliquer pour modifier ce champ';

        var actions = document.createElement('div');
        actions.className = 'actions';
        field.appendChild(actions);

        var up = document.createElement('a');
        up.className = 'icn up';
        up.innerHTML = '&uarr;';
        up.title = 'Déplacer vers le haut';
        up.onclick = function (e) {
            var field = this.parentNode.parentNode;
            var p = field.previousSibling;
            while (p.nodeType == 3) { p = p.previousSibling; }
            field.parentNode.insertBefore(field, p);
            return false;
        };
        actions.appendChild(up);

        var down = document.createElement('a');
        down.className = 'icn down';
        down.innerHTML = '&darr;';
        down.title = 'Déplacer vers le bas';
        down.onclick = function (e) {
            var field = this.parentNode.parentNode;
            var p = field.nextSibling;

            if (!p.nextSibling)
            {
                field.parentNode.appendChild(field);
            }
            else
            {
                while (p.nodeType == 3) { p = p.nextSibling; }
                p = p.nextSibling;
                while (p.nodeType == 3) { p = p.nextSibling; }
                field.parentNode.insertBefore(field, p);
            }
            return false;
        };
        actions.appendChild(down);

        var edit = document.createElement('a');
        edit.className = 'icn edit';
        edit.innerHTML = '&#x270e;';
        edit.title = 'Modifier ce champ';
        edit.onclick = function (e) {
            this.parentNode.parentNode.querySelector('dl').classList.toggle('hidden');
            return false;
        };
        actions.appendChild(edit);

        if (field.id != champ_identifiant && field.id != 'f_passe' && field.id != champ_identite && field.id != 'f_numero' && field.id != 'f_email')
        {
            var rem = document.createElement('a');
            rem.className = 'icn remove';
            rem.innerHTML = '✘';
            rem.title = 'Enlever ce champ de la fiche';
            rem.onclick = function (e) {
                if (!window.confirm('Êtes-vous sûr de supprimer ce champ des fiches de membre ?'))
                {
                    return false;
                }

                var field = this.parentNode.parentNode;
                this.parentNode.parentNode.querySelector('dl').classList.add('hidden');
                field.classList.toggle('removed');
                window.setTimeout(function () { field.parentNode.removeChild(field); }, 800);
                return false;
            };
            actions.appendChild(rem);
        }

        if (field.querySelector('.options'))
        {
            var options = field.querySelectorAll('.options li');
            var options_nb = options.length;

            if (options[0].parentNode.tagName.toLowerCase() == 'ul')
            {
                // champ select
                for (j = 0; j < options_nb; j++)
                {
                    var remove = document.createElement('input');
                    remove.type = 'button';
                    remove.className = 'icn';
                    remove.value = '-';
                    remove.title = 'Enlever cette option';
                    remove.onclick = function (e) {
                        var p = this.parentNode;
                        p.parentNode.removeChild(p);
                    };
                    options[j].appendChild(remove);
                }
            }

            var add = document.createElement('input');
            add.type = 'button';
            add.className = 'icn add';
            add.value = '+';
            add.title = 'Ajouter une option';
            add.onclick = function (e) {
                var p = this.parentNode.parentNode;
                var options = p.querySelectorAll('li');
                var new_option = this.parentNode.cloneNode(true);
                var btn = new_option.querySelector('input.add');
                new_option.getElementsByTagName('input')[0].value = '';

                if (options.length >= 30)
                {
                    new_option.removeChild(btn);
                }
                else
                {
                    btn.onclick = this.onclick;
                }

                p.appendChild(new_option);
                this.parentNode.removeChild(this);
            };

            options[options_nb - 1].appendChild(add);
        }
    }
}());
{/literal}
</script>
{/if}

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




































































































































































































































































































































































































































































































































































































































































































Deleted src/templates/admin/config/plugins.tpl version [c2d1b65842].

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
{include file="admin/_head.tpl" title="Extensions" current="config"}

{include file="admin/config/_menu.tpl" current="plugins"}

{form_errors}

{if !empty($delete)}
    <form method="post" action="{$self_url}">

        <fieldset>
            <legend>Désinstaller une extension</legend>
            <h3 class="warning">
                Êtes-vous sûr de vouloir supprimer l'extension «&nbsp;{$plugin.nom}&nbsp;» ?
            </h3>
            <p class="block alert">
                <strong>Attention</strong> : cette action est irréversible et effacera toutes les
                données associées à l'extension.
            </p>
        </fieldset>

        <p class="submit">
            {csrf_field key="delete_plugin_%s"|args:$plugin.id}
            {button type="submit" name="delete" label="Désinstaller" shape="delete" class="main"}
        </p>
    </form>
{else}
    {if !empty($liste_installes)}
        <table class="list">
            <thead>
                <tr>
                    <th>Extension</th>
                    <td></td>
                    <td>Version installée</td>
                    <td></td>
                </tr>
            </thead>
            <tbody>
                {foreach from=$liste_installes item="plugin"}
                <tr{if $plugin.disabled} class="disabled"{/if}>
                    <th>
                        <h4>{$plugin.nom}</h4>
                        <small>{$plugin.description}</small>
                    </th>
                    {if $plugin.disabled}
                    <td colspan="3">
                        <span class="alert">Code source du plugin non trouvé dans le répertoire <em>plugins</em>&nbsp;!</span><br />
                        Ce plugin ne peut fonctionner ou être désinstallé.
                    </td>
                    {else}
                    <td>
                        <a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>
                    </td>
                    <td>
                        {$plugin.version}
                    </td>
                    <td class="actions">
                        {if !empty($plugin.config)}
                            {linkbutton shape="settings" label="Configurer" href="!plugin/%s/config.php"|args:$plugin.id}
                        {/if}
                        {linkbutton shape="delete" href="!config/plugins.php?delete=%s"|args:$plugin.id label="Désinstaller"}
                    </td>
                    {/if}
                </tr>
                {/foreach}
            </tbody>
        </table>
    {else}
        <p class="help">
            Aucune extension n'est installée.
            Vous pouvez consulter <a href="{$garradin_website}">le site de Garradin</a> pour obtenir
            des extensions à télécharger.
        </p>
    {/if}

    {if !empty($liste_telecharges)}
    <form method="post" action="{$self_url}">

        <fieldset>
            <legend>Extensions à installer</legend>
            <dl>
                {foreach from=$liste_telecharges item="plugin" key="id"}
                <dt>
                    <input type="radio" name="plugin" value="{$id}" id="f_{$id}" />
                    <label for="f_{$id}">
                        {$plugin.nom}
                    </label>
                    <small>(version {$plugin.version})</small>
                </dt>
                <dd>[<a href="{$plugin.url}" onclick="return !window.open(this.href);">{$plugin.auteur}</a>] {$plugin.description}</dd>
                {/foreach}
            </dl>
        </fieldset>

        <p class="help">
            Attention : installer une extension non officielle peut présenter des risques de sécurité
            et de stabilité.
        </p>

        <p class="submit">
            {csrf_field key="install_plugin"}
            {button type="submit" name="install" label="Installer" shape="right" class="main"}
        </p>
    </form>
    {/if}
{/if}

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






















































































































































































































Deleted src/templates/admin/config/upgrade.tpl version [876a754ddf].

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
{include file="admin/_head.tpl" title="Mise à jour" current="config"}

{include file="admin/config/_menu.tpl" current="index"}

{form_errors}

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

{if !count($releases)}
	<p class="block alert">Aucune mise à jour n'est disponible.</p>
{elseif $downloaded && $verified === false}
	<p class="error block">Le fichier d'installation est corrompu.</p>
{elseif $downloaded}
	<fieldset>
		<legend>Mise à jour vers {$version}</legend>
		{if $verified === true}
		<p class="help">
			Le fichier d'installation a été correctement vérifié.
		</p>
		{else}
		<p class="block alert">
			L'intégrité du fichier d'installation n'a pas pu être vérifié automatiquement.
			{if !$can_verify}
			<br />(Cela est probablement dû au fait que votre installation ne dispose pas du module <em>GnuPG</em>.)
			{/if}
		</p>
		{/if}
		<details>
			<summary><h3>{$diff.delete|count} fichiers seront supprimés</h3></summary>
			<dl>
			{foreach from=$diff.delete key="file" item="path"}
				<dd>{$file}</dd>
			{/foreach}
			</dl>
		</details>
		<details>
			<summary><h3>{$diff.create|count} fichiers seront rajoutés</h3></summary>
			<dl>
			{foreach from=$diff.create key="file" item="path"}
				<dd>{$file}</dd>
			{/foreach}
			</dl>
		</details>
		<details>
			<summary><h3>{$diff.update|count} fichiers seront modifiés</h3></summary>
			<p class="alert block">
				Si vous aviez bidouillé ces fichiers, les modifications seront écrasées.
			</p>
			<dl>
			{foreach from=$diff.update key="file" item="path"}
				<dd>{$file}</dd>
			{/foreach}
			</dl>
		</details>
		<dl class="block error">
			{input type="checkbox" name="upgrade" value=$version label="Je confirme vouloir procéder à la mise à jour" help="Cette action peut casser votre installation !"}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Effectuer la mise à jour" shape="right" class="main"}
	</p>
{else}
	<fieldset>
		<legend>Mise à jour</legend>
		<dl>
		{foreach from=$releases key="version" item="release"}
			{input type="radio" name="download" value=$version label=$version}
			{if $version == $latest}
			<dd class="help">
				Dernière version stable, conseillée.
			</dd>
			{/if}
		{/foreach}
		</dl>
	</fieldset>

	<p class="alert block">N'oubliez pas d'aller {link href="%swiki/?name=Changelog"|args:$website target="_blank" label="lire le journal des changements"} avant d'effectuer la mise à jour&nbsp;!</p>
	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="next" label="Télécharger" shape="right" class="main"}
	</p>
{/if}

</form>

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


















































































































































































Deleted src/templates/admin/index.tpl version [03e37c3c4d].

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="admin/_head.tpl" title="Bonjour %s !"|args:$user.identite current="home"}

{$banner|raw}

<nav class="tabs">
	<ul>
		<li><a href="{$admin_url}me/">Mes informations personnelles</a></li>
		<li><a href="{$admin_url}me/services.php">Suivi de mes activités et cotisations</a></li>
	</ul>
</nav>

<nav class="home-buttons">
	{button id="homescreen-btn" label="Installer comme application sur l'écran d'accueil" class="hidden" shape="plus"}
</nav>

<aside class="describe">
	<h3>{$config.nom_asso}</h3>
	{if !empty($config.adresse_asso)}
	<p>
		{$config.adresse_asso|escape|nl2br}
	</p>
	{/if}
	{if !empty($config.telephone_asso)}
	<p>
		Tél. : <a href="tel:{$config.telephone_asso}">{$config.telephone_asso}</a>
	</p>
	{/if}
	{if !empty($config.email_asso)}
	<p>
		E-Mail : <a href="mailto:{$config.email_asso}">{$config.email_asso}</a>
	</p>
	{/if}
	{if !empty($config.site_asso)}
	<p>
		Web : <a href="{$config.site_asso}" target="_blank">{$config.site_asso}</a>
	</p>
	{/if}
</aside>

{if $homepage}
	<article class="web-content">
		{$homepage|raw}
	</article>
{/if}

<script type="text/javascript" src="{$admin_url}static/scripts/homescreen.js" defer="defer"></script>

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
































































































Deleted src/templates/admin/install.tpl version [57684a4a9c].

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="admin/_head.tpl" title="Garradin - Installation" menu=false}

<p class="help">
    Bienvenue dans Garradin !
    Veuillez remplir les quelques informations suivantes pour terminer
    l'installation.
</p>

{form_errors}

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

<fieldset>
    <legend>Informations sur l'association</legend>
    <dl>
        {input type="text" label="Nom de l'association" required=true name="name"}
    </dl>
</fieldset>

<fieldset>
    <legend>Création du compte administrateur</legend>
    <dl>
        {input type="text" label="Nom et prénom" required=true name="user_name"}
        {input type="email" label="Adresse E-Mail" required=true name="user_email"}
        {password_change label="Mot de passe" required=true name="user_password"}
    </dl>
</fieldset>

<p class="submit">
    {csrf_field key="install"}
    {button type="submit" name="save" label="Terminer l'installation" shape="right" class="main"}
</p>

<script type="text/javascript" src="{$admin_url}static/scripts/loader.js"></script>

<script type="text/javascript">
{literal}
g.script('scripts/password.js', () => {
    initPasswordField('user_password');
});

var form = $('form')[0];
form.onsubmit = function () {
    $('#f_submit').style.opacity = 0;
    var loader = document.createElement('div');
    loader.className = 'loader install';
    loader.innerHTML = '<b>Garradin est en cours d\'installation…</b>';
    $('#f_submit').parentNode.appendChild(loader);
    animatedLoader(loader, 5);
};
{/literal}
</script>

</form>


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


















































































































Deleted src/templates/admin/login.tpl version [f3e8752670].

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
{include file="admin/_head.tpl" title="Connexion"}

{form_errors}

{if $changed}
    <p class="block confirm">
        Votre mot de passe a bien été modifié.<br />
        Vous pouvez maintenant l'utiliser pour vous reconnecter.
    </p>
{/if}

{if !$ssl_enabled && $prefer_ssl}
    <p class="block alert">
        <strong>Message de sécurité</strong><br />
        Nous vous conseillons de vous connecter sur la version <a href="{$own_https_url}">chiffrée (HTTPS) de cette page</a>
        pour vous connecter.
    </p>
{/if}

<p class="block error" style="display: none;" id="old_browser">
    Le navigateur que vous utilisez n'est pas supporté. Des fonctionnalités peuvent ne pas fonctionner.<br />
    Merci d'utiliser un navigateur web moderne comme <a href="https://www.getfirefox.com/" target="_blank">Firefox</a> ou <a href="https://vivaldi.com/fr/" target="_blank">Vivaldi</a>.
</p>

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

    <fieldset>
        <legend>Connexion</legend>
        <dl>
            <dt><label for="f_id">{$id_field_name}</label></dt>
            <dd><input type="text" name="_id" id="f_id" value="{form_field name=_id}" /></dd>
            <dt><label for="f_passe">Mot de passe</label></dt>
            <dd><input type="password" name="password" id="f_passe" value="" autocomplete="current-password" />
                {if $ssl_enabled}
                    <b class="icn confirm" title="Connexion chiffrée">&#x1f512;</b>
                    <span class="confirm">Connexion sécurisée</span>
                {else}
                    <b class="icn error" title="Connexion non chiffrée">&#x1f513;</b>
                    {if $prefer_ssl}
                        <span class="error">Connexion non-sécurisée&nbsp;!</span>
                        <a href="{$own_https_url}">Se connecter en HTTPS (sécurisé)</a>
                    {else}
                        <span class="alert">Connexion non-sécurisée</span>
                    {/if}
                {/if}
            </dd>
            {input type="checkbox" name="permanent" value="1" label="Rester connecté⋅e" help="recommandé seulement sur ordinateur personnel"}
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="login"}
        {button type="submit" name="login" label="Se connecter" shape="right" class="main"}
    </p>

    <p class="help">
        <a href="{$admin_url}password.php">Première connexion ou mot de passe perdu ?</a>
    </p>

</form>

{literal}
<script type="text/javascript">
if (window.navigator.userAgent.match(/MSIE|Trident\/|Edge\//)) {
    document.getElementById('old_browser').style.display = 'block';
}

g.enhancePasswordField($('#f_passe'));
</script>
{/literal}

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
















































































































































Deleted src/templates/admin/login_otp.tpl version [0b5ffa4503].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{include file="admin/_head.tpl" title="Connexion — double facteur"}

{form_errors}
{show_error if=$fail message="Code incorrect. Vérifiez que votre téléphone est à l'heure."}

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

    <fieldset>
        <legend>Authentification à double facteur</legend>
        <dl>
            <dt><label for="f_code">Code TOTP</label></dt>
            <dd class="help">Entrez ici le code donné par l'application d'authentification double facteur.</dd>
            <dd><input type="text" name="code" id="f_code" value="{form_field name=code}" /></dd>
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="otp"}
        {button type="submit" name="login" label="Se connecter" shape="right" class="main"}
    </p>

</form>

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
















































Deleted src/templates/admin/membres/_details.tpl version [080d8f71af].

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
<?php
assert(isset($data, $champs, $show_message_button));
$user_files_path = (new Membres)->getAttachementsDirectory($data->id);
?>

<dl class="describe">
	{foreach from=$champs key="c" item="c_config"}
	<?php
	// Skip private fields from "my info" page
	if ($mode == 'user' && $c_config->private) {
		continue;
	}

	// Skip files from export
	if ($mode == 'export' && $c_config->type == 'file') {
		continue;
	}

	$value = $data->$c ?? null;
	?>
	<dt>{$c_config.title}</dt>
	<dd>
		{if $c_config.type == 'checkbox'}
			{if $value}Oui{else}Non{/if}
		{elseif $c_config.type == 'file'}
			<?php
			$edit = ($c_config->editable || $mode == 'edit');
			?>
			{include file="common/files/_context_list.tpl" path="%s/%s"|args:$user_files_path,$c}
		{elseif empty($value)}
			<em>(Non renseigné)</em>
		{elseif $c == $c_config.champ_identite}
			<strong>{$value}</strong>
		{elseif $c_config.type == 'email'}
			<a href="mailto:{$value|escape:'url'}">{$value}</a>
			{if $c == 'email' && $show_message_button}
				{linkbutton href="!membres/message.php?id=%d"|args:$data.id label="Envoyer un message" shape="mail"}
			{/if}
		{elseif $c_config.type == 'multiple'}
			<ul>
			{foreach from=$c_config.options key="b" item="name"}
				{if $value & (0x01 << $b)}
					<li>{$name}</li>
				{/if}
			{/foreach}
			</ul>
		{else}
			{$value|display_champ_membre:$c_config|raw}
		{/if}
	</dd>
		{if $c_config.type == 'email' && $value && ($email = Users\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
				<br/>{linkbutton target="_dialog" label="Rétablir l'envoi à cette adresse" href="emails.php?verify=%s"|args:$value shape="check"}
			{elseif $email.invalid}
				<b class="error">{icon shape="alert"} Adresse invalide</b>
			{elseif $email->hasReachedFailLimit()}
				<b class="error">{icon shape="alert"} Trop d'erreurs</b>
			{elseif $email.verified}
				<b class="confirm">{icon shape="check" class="confirm"}</b> Adresse vérifiée
			{else}
				Adresse non vérifiée
			{/if}
			{if $email.fail_log}
				<br /><span class="help">{$email.fail_log|escape|nl2br}</span>
			{/if}
		</dd>
		{/if}
	{/foreach}
</dl>
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































































































Deleted src/templates/admin/membres/_list_actions.tpl version [1ce77f5c93].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
		<tfoot>
			<tr>
				<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all2" /><label for="f_all2"></label></td>
				<td class="actions" colspan="{$colspan}">
					<em>Pour les membres cochés :</em>
					{csrf_field key="membres_action"}
					<select name="action">
						<option value="">— Choisir une action à effectuer —</option>
						<option value="move">Changer de catégorie</option>
						{if !isset($export) || $export != false}
						<option value="csv">Exporter en tableau CSV</option>
						<option value="ods">Exporter en classeur Office</option>
						{/if}
						{if empty($hide_delete)}
						<option value="delete">Supprimer le membre</option>
						{/if}
					</select>
					<noscript>
						<input type="submit" value="OK" />
					</noscript>
				</td>
			</tr>
		</tfoot>
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































Deleted src/templates/admin/membres/_nav.tpl version [bb7c40e577].

1
2
3
4
5
6
7
8
9
10
<nav class="tabs">
	<ul>
		<li{if $current == 'index'} class="current"{/if}><a href="{$admin_url}membres/">Liste des membres</a></li>
		<li{if $current == 'recherche'} class="current"{/if}><a href="{$admin_url}membres/recherche.php">Recherche avancée</a></li>
		<li{if $current == 'recherches'} class="current"{/if}><a href="{$admin_url}membres/recherches.php">Recherches enregistrées</a></li>
		{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			<li{if $current == 'import'} class="current"{/if}><a href="{$admin_url}membres/import.php">Import &amp; export</a></li>
		{/if}
	</ul>
</nav>
<
<
<
<
<
<
<
<
<
<




















Deleted src/templates/admin/membres/action.tpl version [3bdaad34d2].

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
{include file="admin/_head.tpl" title="Action collective sur les membres" current="membres"}

<p class="block alert">
    {$selected|count} membres sélectionnés
</p>

{form_errors}

<form method="post" action="{$self_url}">
    {foreach from=$selected item="id"}
        <input type="hidden" name="selected[]" value="{$id}" />
    {/foreach}

    </fieldset>

    {if $action == 'move'}
    <fieldset>
        <legend>Changer la catégorie des {$nb_selected} membres sélectionnés</legend>
        <dl>
            <dt><label for="f_cat">Nouvelle catégorie</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd>
                <select name="id_category" id="f_cat">
                {foreach from=$membres_cats key="id" item="nom"}
                    <option value="{$id}">{$nom}</option>
                {/foreach}
                </select>
            </dd>
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="membres_action"}
        <input type="hidden" name="action" value="move" />
        {button type="submit" name="confirm" label="Enregistrer" shape="right" class="main"}
    </p>

    {elseif $action == 'delete'}
    <fieldset>
        <legend>Supprimer les membres sélectionnés ?</legend>
        <h3 class="warning">
            Êtes-vous sûr de vouloir supprimer les {$nb_selected} membres sélectionnés ?
        </h3>
        <p class="block alert">
            <strong>Attention</strong> : cette action est irréversible et effacera toutes les
            données personnelles et l'historique de ces membres.
        </p>
        <p class="help">
            Alternativement, il est aussi possible de déplacer les membres qui ne font plus
            partie de l'association dans une catégorie «&nbsp;Anciens membres&nbsp;», plutôt
            que de les effacer complètement.
        </p>
    </fieldset>

    <p class="submit">
        {csrf_field key="membres_action"}
        <input type="hidden" name="action" value="delete" />
        {button type="submit" name="confirm" label="Oui, supprimer ces membres" shape="delete" class="main"}
    </p>
    {/if}

</form>

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






























































































































Deleted src/templates/admin/membres/ajouter.tpl version [8e6270090a].

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
{include file="admin/_head.tpl" title="Ajouter un membre" current="membres/ajouter"}

{form_errors}

<form method="post" action="{$self_url}" enctype="multipart/form-data">
    <!-- This is to avoid chrome autofill, Chrome developers you suck -->
    <input type="text" style="display: none;" name="email" />
    {if $id_field_name != 'email'}<input type="text" style="display: none;" name="{$id_field_name}" />{/if}
    <input type="password" style="display: none;" name="password" />

    <fieldset>
        <legend>Informations personnelles</legend>
        <dl>
            {foreach from=$champs item="champ" key="nom"}
                {html_champ_membre config=$champ name=$nom}
            {/foreach}
        </dl>
    </fieldset>

    <fieldset>
        <legend>Connexion</legend>
        <dl>
            <dt><label for="f_passe">Mot de passe</label> (minimum {$password_length} caractères) {if $champs.passe.mandatory} <b title="(Champ obligatoire)">obligatoire</b>{/if}</dt>
            <dd class="help">
                Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr 
                et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres.
            </dd>
            <dd class="help">
                Pas d'idée&nbsp;? Voici une suggestion choisie au hasard :
                <input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="pw_suggest" value="{$passphrase}" autocomplete="off" />
            </dd>
            <dd><input type="password" name="passe" id="f_passe" value="{form_field name=passe}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
            <dt><label for="f_repasse">Encore le mot de passe</label> (vérification)</dt>
            <dd><input type="password" name="passe_confirmed" id="f_repasse" value="{form_field name=passe_confirmed}" pattern="{$password_pattern}" autocomplete="new-password" /></dd>
        </dl>
    </fieldset>

    {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
    <fieldset>
        <legend>Général</legend>
        <dl>
            <dt><label for="f_cat">Catégorie du membre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd>
                <select name="id_category" id="f_cat">
                {foreach from=$membres_cats key="id" item="nom"}
                    <option value="{$id}"{if $current_cat == $id} selected="selected"{/if}>{$nom}</option>
                {/foreach}
                </select>
            </dd>
        </dl>
    </fieldset>
    {/if}

    <p class="submit">
        {csrf_field key="new_member"}
        {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
    </p>

</form>

<script type="text/javascript">
{literal}
g.script('scripts/password.js', () => {
    initPasswordField('pw_suggest', 'f_passe', 'f_repasse');
});
{/literal}
</script>


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












































































































































Deleted src/templates/admin/membres/emails.tpl version [b2294e48a8].

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

<nav class="tabs">
	<ul>
		<li><a href="message_collectif.php">Envoyer</a></li>
		<li class="current"><a href="emails.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>
{/if}

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

{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>
			<th><a href="fiche.php?id={$row.user_id}">{$row.identity}</a></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}
					{linkbutton target="_dialog" label="Rétablir" href="?verify=%s"|args:$row.email shape="check"}
				{/if}
			</td>
		</tr>

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

	{pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

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

{/if}

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








































































































































Deleted src/templates/admin/membres/emails_verification.tpl version [1618adab04].

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="admin/_head.tpl" title="Vérification d'adresse" current="membres/message"}

<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="admin/_foot.tpl"}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































Deleted src/templates/admin/membres/fiche.tpl version [5f9a4846f6].

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
{include file="admin/_head.tpl" title="%s (%s)"|args:$membre.identite:$category.name current="membres"}

<nav class="tabs">
    <ul>
        <li class="current"><a href="{$admin_url}membres/fiche.php?id={$membre.id}">{$membre.identite}</a></li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}<li><a href="{$admin_url}membres/modifier.php?id={$membre.id}">Modifier</a></li>{/if}
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $user.id != $membre.id}
            <li><a href="{$admin_url}membres/supprimer.php?id={$membre.id}">Supprimer</a></li>
        {/if}
    </ul>
</nav>

<dl class="cotisation">
    <dt>Activités et cotisations</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}
    <dd>
        {if count($services)}
            {linkbutton href="!services/user/?id=%d"|args:$membre.id label="Liste des inscriptions aux activités" shape="menu"}
        {/if}
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
            {linkbutton href="!services/user/subscribe.php?user=%d"|args:$membre.id label="Inscrire à une activité" shape="plus"}
        {/if}
    </dd>
    {if count($services)}
    <dd>
        {linkbutton shape="alert" label="Liste des rappels envoyés" href="!services/reminders/user.php?id=%d"|args:$membre.id}
    </dd>
    {/if}
    {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={$membre.id}">{$transactions_linked} écritures comptables liées à ce membre</a></dd>
        {/if}
        {if !empty($transactions_created)}
            <dt>Écritures comptables créées</dt>
            <dd><a href="{$admin_url}acc/transactions/creator.php?id={$membre.id}">{$transactions_created} écritures comptables créées par ce membre</a></dd>
        {/if}
    {/if}
</dl>

<aside class="describe">
	<dl class="describe">
		<dt>Catégorie</dt>
		<dd>{$category.name} <span class="permissions">{display_permissions permissions=$category}</span></dd>
		<dt>Inscription</dt>
		<dd>{$membre.date_inscription|date_short}</dd>
		<dt>Dernière connexion</dt>
		<dd>{if empty($membre.date_connexion)}Jamais{else}{$membre.date_connexion|date_short:true}{/if}</dd>
		<dt>Mot de passe</dt>
		<dd>
			{if empty($membre.passe)}
				Pas de mot de passe configuré
			{else}
				<b class="icn">☑</b> Oui
				{if !empty($membre.secret_otp)}
					(<b class="icn">🔒</b> avec second facteur)
				{else}
					(<b class="icn">🔓</b> sans second facteur)
				{/if}
		{/if}
		</dd>
	</dl>
</aside>

{include file="admin/membres/_details.tpl" champs=$champs data=$membre show_message_button=true mode="edit"}

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




































































































































































Deleted src/templates/admin/membres/import.tpl version [dc72822dd3].

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
{include file="admin/_head.tpl" title="Import & export des membres" current="membres"}

{include file="admin/membres/_nav.tpl" current="import"}

<nav class="tabs">
    <ul class="sub">
        <li class="current"><a href="{$admin_url}membres/import.php">Importer</a></li>
        <li><a href="{$admin_url}membres/import.php?export=csv">Exporter en CSV</a></li>
        <li><a href="{$admin_url}membres/import.php?export=ods">Exporter en classeur Office</a></li>
    </ul>
</nav>

{form_errors}

{if $ok}
    <p class="block confirm">
        L'import s'est bien déroulé.
    </p>
{/if}

<form method="post" action="{$self_url}" enctype="multipart/form-data">

    {if $csv->loaded()}

        {include file="common/_csv_match_columns.tpl"}

    {else}

    <fieldset>
        <legend>Importer depuis un fichier</legend>
        <dl>
            <dt><label for="f_file">Fichier à importer</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd class="help">La taille maximale du fichier est de {$max_upload_size|size_in_bytes}.</dd>
            <dd><input type="file" name="upload" id="f_file" required="required" /></dd>
            <dt><label for="f_type">Type de fichier</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd>
                <input type="radio" name="type" id="f_type" value="garradin" {form_field name=type checked="garradin" default="garradin"} />
                <label for="f_type">Fichier CSV de Garradin</label>
            </dd>
            <dd class="help">
                Export de la liste des membres au format CSV provenant de Garradin.
                Les lignes comportant un numéro de membre mettront à jour les fiches des membres ayant ce numéro (si le numéro existe),
                les lignes sans numéro ou avec un numéro inexistant créeront de nouveaux membres.
            </dd>
            <dd>
                <input type="radio" name="type" id="f_type_csv" value="custom" {form_field name=type checked="csv"} />
                <label for="f_type_csv">Fichier CSV générique</label>
            </dd>
            <dd class="help">
                Vous pourrez choisir la correspondance entre colonnes du CSV et champs des fiches membres
                dans le prochain écran.
            </dd>
        </dl>
    </fieldset>

    {/if}

    <p class="submit">
        {csrf_field key=$csrf_key}
        {if $csv->loaded()}{button type="submit" name="cancel" value="1" label="Annuler" shape="left"}{/if}
        {button type="submit" name="import" label="Importer" shape="upload" class="main"}
    </p>

</form>

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




































































































































Deleted src/templates/admin/membres/index.tpl version [99c33965df].

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
{include file="admin/_head.tpl" title="Liste des membres" current="membres"}

{include file="admin/membres/_nav.tpl" current="index"}

{if $sent}
    <p class="block confirm">Votre message a été envoyé.</p>
{/if}

{if !empty($categories)}
<form method="get" action="{$self_url}" class="shortFormRight">
    <fieldset>
        <legend>Filtrer par catégorie</legend>
        <select name="cat" id="f_cat" onchange="this.form.submit();">
            <option value="0" {if $current_cat == 0} selected="selected"{/if}>-- Toutes</option>
        {foreach from=$categories key="id" item="nom"}
            {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)
                || !array_key_exists($id, $hidden_categories)}
            <option value="{$id}"{if $current_cat == $id} selected="selected"{/if}>{$nom}</option>
            {/if}
        {/foreach}
        </select>
        <noscript><input type="submit" value="Filtrer &rarr;" /></noscript>
    </fieldset>
</form>
{/if}

<form method="get" action="{$admin_url}membres/recherche.php" class="shortFormLeft">
    <fieldset>
        <legend>Rechercher un membre</legend>
        <input type="text" name="qt" value="" />
        <input type="submit" value="Chercher &rarr;" />
    </fieldset>
</form>

<form method="post" action="action.php" class="memberList">

{if $list->count()}
    {pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}

    {include file="common/dynamic_list_head.tpl" check=$can_edit}

    {foreach from=$list->iterate() item="row"}
        <tr>
            {if $can_edit}
                <td class="check">{input type="checkbox" name="selected[]" value=$row._user_id}</td>
            {/if}
            {foreach from=$list->getHeaderColumns() key="key" item="value"}
                <?php $value = $row->$key; ?>
                {if $key == 'numero'}
                <td class="num">
                    <a href="{$admin_url}membres/fiche.php?id={$row._user_id}">{$value}</a>
                </td>
                {elseif $key == $id_field}
                <th><a href="{$admin_url}membres/fiche.php?id={$row._user_id}">{$value|raw|display_champ_membre:$key}</a></th>
                {else}
                <td>
                    {$value|raw|display_champ_membre:$key}
                </td>
                {/if}
            {/foreach}

            <td class="actions">
                {linkbutton label="Fiche membre" shape="user" href="!membres/fiche.php?id=%d"|args:$row._user_id}
                {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
                    {linkbutton label="Modifier" shape="edit" href="!membres/modifier.php?id=%d"|args:$row._user_id}
                {/if}
            </td>
        </tr>
    {/foreach}

    </tbody>

    {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
        {include file="admin/membres/_list_actions.tpl" colspan=count($list->getHeaderColumns())+$can_edit+1}
    {/if}

    </table>

    {pagination url=$list->paginationURL() page=$list.page bypage=$list.per_page total=$list->count()}
{else}
    <p class="block alert">
        Aucun membre trouvé.
    </p>
{/if}

</form>

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
















































































































































































Deleted src/templates/admin/membres/message.tpl version [29a83a20df].

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
{include file="admin/_head.tpl" title="Contacter un membre" current="membres"}

{form_errors}

<form method="post" action="{$self_url}">
    <fieldset class="mailing">
        <legend>Message</legend>
        <dl>
            <dt>Expéditeur</dt>
            <dd>{$user.identite} &lt;{$user.email}&gt;</dd>
            <dt>Destinataire</dt>
            <dd>{$membre.identite} ({$categorie.name})</dd>
            <dt><label for="f_subject">Sujet</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><input type="text" name="sujet" id="f_subject" value="{form_field name=sujet}" required="required" /></dd>
            <dt><label for="f_message">Message</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd><textarea name="message" id="f_message" cols="72" rows="25" required="required">{form_field name=message}</textarea></dd>
            <dd>
                <input type="checkbox" name="copie" id="f_copie" value="1" />
                <label for="f_copie">Recevoir par e-mail une copie du message envoyé</label>
            </dd>
        </dl>
    </fieldset>

    <p class="submit">
        {csrf_field key="send_message_"|cat:$membre.id}
        {button type="submit" name="save" label="Envoyer" shape="right" class="main"}
    </p>
</form>


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






























































Deleted src/templates/admin/membres/message_collectif.tpl version [0cc04e5843].

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
{include file="admin/_head.tpl" title="Envoyer un message collectif" current="membres/message" custom_css=["!web/css.php"]}

<nav class="tabs">
    <ul>
    	<li class="current"><a href="{$self_url}">Envoyer</a></li>
    	<li><a href="emails.php">Adresses rejetées</a></li>
    </ul>
</nav>

{if $sent}
	<p class="block confirm">Votre message a été envoyé.</p>
{/if}

{form_errors}

<form method="post" action="{$self_url_no_qs}">
	{if $preview}
		<fieldset class="mailing">
			<legend>Prévisualisation du message</legend>
			{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
			<nav class="menu">
				<b data-icon="↷" class="btn">Exporter la liste des destinataires</b>
				<span>
					{button type="submit" name="export" value="csv" shape="export" label="Export CSV"}
					{button type="submit" name="export" value="ods" shape="export" label="Export LibreOffice"}
					{if CALC_CONVERT_COMMAND}
						{button type="submit" name="export" value="xlsx" shape="export" label="Export Excel"}
					{/if}
				</span>
			</nav>
			{/if}
			<p class="help">
				Ce message sera envoyé à <strong>{$recipients_count}</strong> destinataires.<br />
				Voici un exemple du message pour un de ces destinataires.
			</p>
			<dl>
				<dt>Expéditeur</dt>
				<dd>{$preview.from}</dd>
				<dt>Destinataire</dt>
				<dd>
					{$preview.to}
				</dd>
				<dt>Sujet</dt>
				<dd>{$preview.subject}</dd>
				<dt>Message</dt>
				<dd class="preview">{$preview.html|raw}</dd>
			</dl>
		</fieldset>

		<p class="submit">
			{input type="hidden" name="subject"}
			{input type="hidden" name="message"}
			{input type="hidden" name="target"}
			{input type="hidden" name="send_copy"}
			{input type="hidden" name="render"}
			{csrf_field key=$csrf_key}
			{button type="submit" name="back" label="Retour à l'édition" shape="left"}
			{button type="submit" name="send" label="Envoyer" shape="right" class="main"}
		</p>

	{else}
	<fieldset class="mailing">
		<legend>Message</legend>
		<dl>
			<dt>Expéditeur</dt>
			<dd>{$config.nom_asso} &lt;{$config.email_asso}&gt;</dd>
			<dt><label for="f_target">Destinataires</label></dt>
			<dd>
				<select name="target" id="f_target" required="required">
					<option value="all_">Tous les membres (sauf ceux appartenant à une catégorie cachée)</option>
					<optgroup label="Catégorie de membres">
						{foreach from=$categories key="id" item="label"}
						<option value="category_{$id}" {form_field name="target" selected="category_%d"|args:$id}>{$label}</option>
						{/foreach}
					</optgroup>
					<optgroup label="Recherches enregistrées">
						{foreach from=$search_list item="s"}
						<option value="search_{$s.id}" {form_field name="target" selected="search_%d"|args:$s.id}>{$s.intitule}</option>
						{/foreach}
					</optgroup>
				</select>
			</dd>
			<dd class="help">
				Vous pouvez cibler précisément des membres en créant une <a href="{$admin_url}membres/recherche.php">recherche enregistrée</a>.
				Les recherches enregistrées apparaîtront dans ce formulaire.
			</dd>
			{input type="text" name="subject" required=true label="Sujet"}
			{input type="textarea" name="message" cols=35 rows=25 required=true label="Message"}
			{input type="checkbox" name="send_copy" value=1 label="Recevoir par e-mail une copie du message envoyé"}
			<dt><label for="f_render">Format de rendu</label></dt>
			<dd>
				{input type="select" name="render" options=$render_formats}
				{linkbutton shape="help" href="!web/_syntax_skriv.html" target="_dialog" label="Aide syntaxe SkrivML"}
				{linkbutton shape="help" href="!web/_syntax_markdown.html" target="_dialog" label="Aide syntaxe MarkDown"}
			</dd>
		</dl>
	</fieldset>

	<p class="submit">
		{csrf_field key=$csrf_key}
		{button type="submit" name="preview" label="Prévisualiser" shape="right" class="main"}
	</p>
	{/if}
</form>


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






















































































































































































































Deleted src/templates/admin/membres/modifier.tpl version [bf6707b31f].

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
{include file="admin/_head.tpl" title="Modifier un membre" current="membres"}

<nav class="tabs">
    <ul>
        <li><a href="{$admin_url}membres/fiche.php?id={$membre.id}">{$membre.identite}</a></li>
        <li class="current"><a href="{$admin_url}membres/modifier.php?id={$membre.id}">Modifier</a></li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $user.id != $membre.id}
            <li><a href="{$admin_url}membres/supprimer.php?id={$membre.id}">Supprimer</a></li>
        {/if}
    </ul>
</nav>

{form_errors}

<form method="post" action="{$self_url}">
    <!-- This is to avoid chrome autofill, Chrome developers you suck -->
    <input type="text" style="display: none;" name="email" />
    {if $id_field_name != 'email'}<input type="text" style="display: none;" name="{$id_field_name}" />{/if}
    <input type="password" style="display: none;" name="password" />

    <fieldset>
        <legend>Informations personnelles</legend>
        <dl>
            {foreach from=$champs item="champ" key="nom"}
                {html_champ_membre config=$champ name=$nom data=$membre}
            {/foreach}
        </dl>
    </fieldset>

    <fieldset>
        <legend>{if $membre.passe}Changer le mot de passe{else}Choisir un mot de passe{/if}</legend>
        <dl>
        {if $membre.passe}
            <dd>Ce membre a déjà un mot de passe, mais vous pouvez le changer si besoin.</dd>
        {else}
            <dd>Ce membre n'a pas encore de mot de passe et ne peut donc se connecter.</dd>
        {/if}
            <dt><label for="f_passe">Nouveau mot de passe</label> (minimum {$password_length} caractères) {if $champs.passe.mandatory} <b title="(Champ obligatoire)">obligatoire</b>{/if}</dt>
            <dd class="help">
                Astuce : un mot de passe de quatre mots choisis au hasard dans le dictionnaire est plus sûr 
                et plus simple à retenir qu'un mot de passe composé de 10 lettres et chiffres.
            </dd>
            <dd class="help">
                Pas d'idée&nbsp;? Voici une suggestion choisie au hasard :
                <input type="text" readonly="readonly" title="Cliquer pour utiliser cette suggestion comme mot de passe" id="pw_suggest" value="{$passphrase}" autocomplete="off" />
            </dd>
            <dd><input type="password" name="passe" id="f_passe" value="{form_field name=passe}" pattern="{$password_pattern}" autocomplete="off" /></dd>
            <dt><label for="f_repasse">Encore le mot de passe</label> (vérification){if $champs.passe.mandatory} <b title="(Champ obligatoire)">obligatoire</b>{/if}</dt>
            <dd><input type="password" name="passe_confirmed" id="f_repasse" value="{form_field name=passe_confirmed}" pattern="{$password_pattern}" autocomplete="off" /></dd>
        {if $membre.passe}
            <dd>
                {input type="checkbox" name="delete_password" label="Supprimer le mot de passe de ce membre" value=1}
            </dd>
        {/if}
        </dl>
    </fieldset>

    {if $membre.secret_otp || $membre.clef_pgp}
    <fieldset>
        <legend>Options de sécurité</legend>
        <dl>
        {if $membre.secret_otp}
            {input type="checkbox" name="clear_otp" value="1" label="Désactiver l'authentification à double facteur TOTP"}
        {/if}
        {if $membre.clef_pgp}
            {input type="checkbox" name="clear_pgp" value="1" label="Supprimer la clé PGP associée au membre"}
        {/if}
        </dl>
    </fieldset>
    {/if}

    {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $user.id != $membre.id}
    <fieldset>
        <legend>Général</legend>
        <dl>
            <dt><label for="f_cat">Catégorie du membre</label> <b title="(Champ obligatoire)">obligatoire</b></dt>
            <dd>
                <select name="id_category" id="f_cat">
                {foreach from=$membres_cats key="id" item="nom"}
                    <option value="{$id}"{if $current_cat == $id} selected="selected"{/if}>{$nom}</option>
                {/foreach}
                </select>
            </dd>
        </dl>
    </fieldset>
    {/if}

    <p class="submit">
        {csrf_field key="edit_member_"|cat:$membre.id}
        {button type="submit" name="save" label="Enregistrer" shape="right" class="main"}
    </p>

</form>

<script type="text/javascript">
{literal}
g.script('scripts/password.js', () => {
    initPasswordField('pw_suggest', 'f_passe', 'f_repasse');
});
{/literal}
</script>

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














































































































































































































Deleted src/templates/admin/membres/recherche.tpl version [27011add8f].

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
{include file="admin/_head.tpl" title="Recherche de membre" current="membres" custom_js=['lib/query_builder.min.js']}

{include file="admin/membres/_nav.tpl" current="recherche"}

{include file="common/search/advanced.tpl" action_url=$self_url}

{if !empty($result)}
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
		<form method="post" action="{$admin_url}membres/action.php" class="memberList">
	{/if}

	<p class="help">{$result|count} membres trouvés pour cette recherche.</p>
	<table class="list search">
		<thead>
			<tr>
				{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}<td class="check"><input type="checkbox" value="Tout cocher / décocher" id="f_all" /><label for="f_all"></label></td>{/if}
				{foreach from=$result_header item="label"}
					<td>{$label}</td>
				{/foreach}
				<td></td>
			</tr>
		</thead>
		<tbody>
			{foreach from=$result item="row"}
				<tr>
					{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}<td class="check">{if $row._user_id}{input type="checkbox" name="selected[]" value=$row._user_id}{/if}</td>{/if}
					{foreach from=$row key="key" item="value"}
						<?php $link = false; ?>
						{if isset($result_header[$key])}
							<td>
							{if !$link && $row._user_id}
								<a href="{$admin_url}membres/fiche.php?id={$row._user_id}">
							{/if}

							{$value|raw|display_champ_membre:$key}

							{if !$link}
								<?php $link = true; ?>
								</a>
							{/if}
							</td>
						{elseif substr($key, 0, 1) != '_'}
							<td>{$value}</td>
						{/if}
					{/foreach}
					<td class="actions">
						{if $row._user_id}
							{linkbutton shape="user" label="Fiche membre" href="!membres/fiche.php?id=%d"|args:$row._user_id}
							{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
								{linkbutton shape="edit" label="Modifier" href="!membres/modifier.php?id=%d"|args:$row._user_id}
							{/if}
						{/if}
					</td>
				</tr>
			{/foreach}
		</tbody>
	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN) && $row._user_id}
		{include file="admin/membres/_list_actions.tpl" colspan=count($result_header)+1}
	{/if}
	</table>

	{if $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)}
		</form>
	{/if}

{elseif $result !== null}

	<p class="block alert">
		Aucun membre trouvé.
	</p>

	</form>
{/if}


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
























































































































































Deleted src/templates/admin/membres/selector.tpl version [d560c1b60a].

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
{include file="admin/_head.tpl" title="Sélectionner un compte"}

<form method="post" action="{$self_url}">
	<h2 class="ruler">
		<input type="text" placeholder="Recherche rapide de membre" value="{$query}" name="q" />
		<input type="submit" value="Chercher &rarr;" />
	</h2>
</form>

<table class="list">
	<tbody>
    {foreach from=$list item="row"}
        <tr>
        	<td class="num">
        		{$row.numero}
        	</td>
            <th>
                {$row.identite}
            </th>
            <td class="actions">
				<button class="icn-btn" value="{$row.id}" data-label="{$row.numero} — {$row.identite}" data-icon="&rarr;">Sélectionner</button>
			</td>
		</tr>
	{/foreach}
	</tbody>
</table>

{literal}
<script type="text/javascript">
var buttons = document.querySelectorAll('button');

buttons.forEach((e) => {
	e.onclick = () => {
		window.parent.g.inputListSelected(e.value, e.getAttribute('data-label'));
	};
});

if (buttons.length) {
	buttons[0].focus();
}

var rows = document.querySelectorAll('table tbody tr');

if (rows.length == 1) {
	rows[0].querySelector('button').click();
}

rows.forEach((e) => {
	e.classList.add('clickable');

	e.onclick = (evt) => {
		if (evt.target.tagName && evt.target.tagName == 'BUTTON') {
			return;
		}

		e.querySelector('button').click();
	};
});

document.querySelector('input').focus();
</script>
{/literal}

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
































































































































Deleted src/templates/admin/membres/supprimer.tpl version [9188bf5497].

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="admin/_head.tpl" title="Supprimer un membre" current="membres"}

<nav class="tabs">
    <ul>
        <li><a href="{$admin_url}membres/fiche.php?id={$membre.id}">{$membre.identite}</a></li>
        <li><a href="{$admin_url}membres/modifier.php?id={$membre.id}">Modifier</a></li>
        {if $session->canAccess($session::SECTION_USERS, $session::ACCESS_ADMIN)}
            <li class="current"><a href="{$admin_url}membres/supprimer.php?id={$membre.id}">Supprimer</a></li>
        {/if}
    </ul>
</nav>

{form_errors}

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

    <fieldset>
        <legend>Supprimer ce membre ?</legend>
        <h3 class="warning">
            Êtes-vous sûr de vouloir supprimer le membre «&nbsp;{$membre.identite}&nbsp;» ?
        </h3>
        <p class="block alert">
            <strong>Attention</strong> : cette action est irréversible et effacera toutes les
            données personnelles et l'historique de ces membres.
        </p>
        <p class="help">
            Alternativement, il est aussi possible de déplacer les membres qui ne font plus
            partie de l'association dans une catégorie «&nbsp;Anciens membres&nbsp;», plutôt
            que de les effacer complètement.
        </p>
    </fieldset>

    <p class="submit">
        {csrf_field key="delete_membre_"|cat:$membre.id}
        {button type="submit" name="delete" label="Supprimer" shape="delete" class="main"}
    </p>

</form>

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
















































































Deleted src/templates/admin/optout.tpl version [c331cc3781].

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
{include file="admin/_head.tpl" title="Désinscription" transparent=true}

{if $verify === true}
	<p class="block confirm">
		Votre adresse e-mail a bien été vérifiée, merci !
	</p>
{elseif $verify === false}
	<p class="block error">
		Erreur de vérification de votre adresse e-mail.
	</p>
{elseif $ok}
	<p class="block confirm">
		Vous avez été bien désinscrit, vous ne recevrez plus aucun message de notre part.
	</p>

	<p class="help">
		Vous pouvez revenir sur cette page pour vous réinscrire à tout moment.
	</p>
{elseif $resub_ok}
	<p class="block confirm">
		Un e-mail vous a été envoyé, merci de cliquer sur le lien dans le message reçu pour confirmer.
	</p>
{elseif $email.optout}

	<p class="block alert">
		Votre adresse e-mail est déjà désinscrite. Pour demander à vous réinscrire, renseignez le formulaire ci-dessous.
	</p>

	{form_errors}

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

		<fieldset>
			<dl>
				{input type="email" required=true name="email" label="Adresse e-mail"}
				{input type="checkbox" name="confirm_resub" value="1" required=true label="Oui, je veux à nouveau recevoir les messages de l'association"}
			</dl>
		</fieldset>

		<p class="submit">
			{csrf_field key="optout"}
			{button type="submit" name="resub" label="Réinscrire mon adresse e-mail" shape="right" class="main"}
		</p>
	</form>
{else}

	{form_errors}

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

		<p class="help">
			En cliquant sur ce bouton vous confirmez ne plus vouloir recevoir de messages de notre part.
		</p>

		<p class="submit">
			{csrf_field key="optout"}
			{button type="submit" name="optout" label="Me désinscrire" shape="right" class="main"}
		</p>

	</form>
{/if}

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