Compare commits
1178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8135029174 | |||
| b265930e6f | |||
| 41738b60ef | |||
| 2a1b43a6fa | |||
| 03bf5686e0 | |||
| dadb11a164 | |||
| 6374ae1ec0 | |||
| 6252b65671 | |||
| 85b0dd0b39 | |||
| 986600d3ef | |||
| 30375a262f | |||
| f571627a6b | |||
| 43d8ba5098 | |||
| 17e9c33934 | |||
| d920e1ca8e | |||
| ca320bdc1f | |||
| 2efb5e067c | |||
| b077baf0a3 | |||
| b4930537a1 | |||
| f668db1419 | |||
| 1636db68ea | |||
| 9961bb19df | |||
| 8905f083ea | |||
| 70ca65e962 | |||
| ad293acd88 | |||
| ea95431eae | |||
| 906c6a7f15 | |||
| f84ca73b04 | |||
| a225dbb0ef | |||
| 34f10f2283 | |||
| 8b9433b7f2 | |||
| c0d8a40c92 | |||
| bee401430b | |||
| 31427a8b3b | |||
| e0828a7e13 | |||
| 770b7cbdf8 | |||
| f0a891b73f | |||
| 3034e911e7 | |||
| 0e0fb32222 | |||
| 2a22951829 | |||
| 301a8668b3 | |||
| 439b6f9cdc | |||
| 3a0d868e93 | |||
| a66af0190d | |||
| 940f0de0a7 | |||
| ac73f6efc1 | |||
| d2fa17761d | |||
| bb2aee85a2 | |||
| 10770c5d14 | |||
| cc412f459f | |||
| 03afb8347c | |||
| 34a8470792 | |||
| 8258b92f98 | |||
| 4e3c8517ad | |||
| 633462f18b | |||
| ca2f6746f8 | |||
| 0970803e5e | |||
| 7dea07a30f | |||
| c693992041 | |||
| db0550a170 | |||
| 13538a4f41 | |||
| adbaffe545 | |||
| f6132d41a2 | |||
| 490ecf940d | |||
| f63c08f381 | |||
| 8b3e72d20a | |||
| 341dd84686 | |||
| 87d59c2ce0 | |||
| 7d413ccda4 | |||
| 91aeca6f04 | |||
| 639c2eb986 | |||
| 0d2ad33d6c | |||
| 2454c8109f | |||
| 76c1096f4c | |||
| 94addf7fae | |||
| 0c22138e19 | |||
| 495fe153b1 | |||
| 9e0aa71910 | |||
| f603d98ddb | |||
| c9074462d6 | |||
| 0e1991683b | |||
| 10c1e45d71 | |||
| f2810640ba | |||
| 8d937d387a | |||
| 04125f91f8 | |||
| b4e778ab8c | |||
| 445c8666cf | |||
| 26de687ece | |||
| ebc4736025 | |||
| 6e9b451734 | |||
| 6dd7dca271 | |||
| f580459bd5 | |||
| 117ce9a4ab | |||
| 4e07c69a85 | |||
| cb205a89f3 | |||
| ea6998ac11 | |||
| 34e5065550 | |||
| d2e114da34 | |||
| a9d9594a31 | |||
| 5b7074529c | |||
| 3493dbba25 | |||
| ed288823c9 | |||
| 6b9cc412f7 | |||
| 6cea1fdc95 | |||
| 98cbcd9311 | |||
| 9bebc96c5d | |||
| cd29c0358b | |||
| 89cc754a95 | |||
| 4b3a2f5432 | |||
| 4c9bb87808 | |||
| d29c245f9d | |||
| a8ba261e7c | |||
| 71ad4dd1ca | |||
| ff78b86ad0 | |||
| 4dbe986310 | |||
| ec45dc05ec | |||
| 4f18cc07f2 | |||
| 697711f676 | |||
| 4f36022443 | |||
| 2318702e29 | |||
| 647d7a7759 | |||
| 9314da7ee6 | |||
| 0bfac680a5 | |||
| 7941b08d4a | |||
| e59e2e50b3 | |||
| c81533aa8e | |||
| 055b0edc7e | |||
| 8efd8db07e | |||
| fbea2ebd53 | |||
| 436906911b | |||
| 4c1c1cc987 | |||
| 97e1d0b5de | |||
| 2ce52d9eab | |||
| a7662a8561 | |||
| 33ea1205cf | |||
| 979874760d | |||
| 68f615370f | |||
| 7454d0db32 | |||
| 74c5de0bda | |||
| 91d3153e10 | |||
| 41236df0fe | |||
| d69ae9b8de | |||
| 24bc1c08c1 | |||
| 082d3e24b1 | |||
| 903b1e4dfd | |||
| 5a6bd59297 | |||
| dee6e37932 | |||
| ee98aa8186 | |||
| 11da1e9d8d | |||
| 47694f2e5d | |||
| fb4ffaa3e2 | |||
| ef1e4503c9 | |||
| e2d1082e92 | |||
| 9303c5e206 | |||
| ce9a79f420 | |||
| 571c56ef71 | |||
| e407f87529 | |||
| 6bc7540941 | |||
| 11cada2ffc | |||
| 722694bec9 | |||
| 9cf065e0db | |||
| 07bb4a91d0 | |||
| d0e80b6be5 | |||
| ab1efdd3b1 | |||
| 0950e5c375 | |||
| cc1caa9671 | |||
| 0137981201 | |||
| 25df072202 | |||
| 73b06c324a | |||
| 2376987982 | |||
| 2150ade42b | |||
| 92e30979d9 | |||
| ad028b9e6b | |||
| fb3292d41e | |||
| e8f74aa529 | |||
| 7ae5b75db9 | |||
| a2962c2163 | |||
| b04f97cb8c | |||
| 3645c098c2 | |||
| ee1f64a53b | |||
| 6610f2d73e | |||
| ee214677bd | |||
| 081811bea7 | |||
| ae98e2cec4 | |||
| 1cfb3e91e2 | |||
| 7664fe368d | |||
| 3e3ba7c4f8 | |||
| 349fb17c90 | |||
| 315c500005 | |||
| 3b744d87f0 | |||
| c5c63cb68e | |||
| e904ec4a7d | |||
| 6464baebd2 | |||
| cc2b34e84a | |||
| a24a96288d | |||
| 38743cfb95 | |||
| b89da05c84 | |||
| e44e2940fc | |||
| c748cb944e | |||
| c9ece32601 | |||
| 6256adf826 | |||
| 1551faaf0c | |||
| eb8f54dbf2 | |||
| 6739e4926b | |||
| 9d3b0dd068 | |||
| e763a0b878 | |||
| 86977bd37e | |||
| f1c28acc96 | |||
| a96f772d7a | |||
| 22dd49f337 | |||
| acb8d5e0d1 | |||
| d201b7a3c6 | |||
| 8b69fee31c | |||
| 21d3a30d8b | |||
| ad872d3b2c | |||
| f301c2ab0f | |||
| caf800e5e8 | |||
| d229b58f3b | |||
| cf23863a30 | |||
| 19991dd22f | |||
| 6bec76535c | |||
| cfcbfe9768 | |||
| 8eb5c06198 | |||
| 43122a7c12 | |||
| 348da79175 | |||
| 7cc1fadf0f | |||
| 5d56f8180a | |||
| f8d1e60519 | |||
| 96358423df | |||
| e483d7af4d | |||
| 2f2cd151fb | |||
| c4851ff0ae | |||
| fb62c28e10 | |||
| 0209ec0d5c | |||
| 9ea8bee940 | |||
| e820041cef | |||
| e678399fe7 | |||
| 2cbee97afb | |||
| 0b88a65414 | |||
| 14ae1a7bfb | |||
| 1159697c0e | |||
| ea20491e9c | |||
| 8a428ac1aa | |||
| d08f7aeeac | |||
| 1bdcaaf5d8 | |||
| a38b2d5e71 | |||
| 74afb7aef8 | |||
| 2ed560d4e8 | |||
| cd107a0213 | |||
| 9ff2373ccb | |||
| 126834c7d4 | |||
| 872fc33dd7 | |||
| 0ffe647657 | |||
| 179e6e436d | |||
| 64158e9080 | |||
| f52b2b7b71 | |||
| b077fd318d | |||
| 17c379f0b3 | |||
| a2c20adb4e | |||
| 8515ba8d84 | |||
| 190b8154b4 | |||
| a1bf0b381b | |||
| 63a7ff3d89 | |||
| 502d3b390a | |||
| 2e9888998a | |||
| 7184b7768a | |||
| 9e8ad6c74b | |||
| 41491159a3 | |||
| 64d4821fae | |||
| 9b124deeea | |||
| b46d5d8244 | |||
| d8faccab46 | |||
| 60b59d2d03 | |||
| 5a64f0f682 | |||
| 69a8b847ae | |||
| 8825e76101 | |||
| c4aa90b0d1 | |||
| f04e1b4c44 | |||
| 9a93ec986a | |||
| 78534000a0 | |||
| ce3c3a5e6f | |||
| 9bed4e3fb9 | |||
| 8161a8b8b4 | |||
| 5b9faa5a76 | |||
| 147c943cdf | |||
| 0403d5212e | |||
| 50e3299ffb | |||
| fb45c8e7d5 | |||
| d98e7313de | |||
| 04e6fca765 | |||
| 4b754c9d77 | |||
| b7ca9c1ffa | |||
| 0cfafedeef | |||
| d436fd4d9e | |||
| 5578a9deb0 | |||
| f39c54210f | |||
| dc30d889c4 | |||
| d2f23ae6c8 | |||
| b2806d6176 | |||
| 2d88217bbf | |||
| 7c77524a3a | |||
| 3d09b489b9 | |||
| 22fc0cbab2 | |||
| db148eef90 | |||
| 03023a6b8c | |||
| cb14b7c68e | |||
| 9d4280f69d | |||
| 8cd5bfe821 | |||
| f8112274cf | |||
| 98b4d13f94 | |||
| b5f529c789 | |||
| 5e5c5170a7 | |||
| 0eb2807630 | |||
| b6a95e5464 | |||
| 401bf5e204 | |||
| 8faeaa5804 | |||
| 2bdcfdeb1a | |||
| 57dd4d22eb | |||
| b25150faa3 | |||
| 8926c5b02c | |||
| b4f7e94800 | |||
| e229caf320 | |||
| 5bd7029066 | |||
| 1bd15f627f | |||
| 6e0722e2d7 | |||
| 89bcd9f161 | |||
| e932c66af0 | |||
| 5f500a1aa8 | |||
| d3025e323b | |||
| 3d1cb69463 | |||
| 4b1d8bf5c3 | |||
| 4f90184ddd | |||
| b7a79d8d86 | |||
| 007cb28b21 | |||
| b0543c0c03 | |||
| a18d43dae4 | |||
| 421750957f | |||
| 78aea4a69a | |||
| 3f12e6e210 | |||
| 3528924857 | |||
| 6d07dcb0bb | |||
| 06fad7ec2d | |||
| 3eec248dab | |||
| 2701eb0422 | |||
| b93aca9005 | |||
| 82999ef056 | |||
| 590b927e31 | |||
| 5a702ac77e | |||
| aac3753226 | |||
| 9f10f1c4de | |||
| d79e990c04 | |||
| d72cad90ec | |||
| 44031efcd5 | |||
| 8037f2733e | |||
| 6038b44fa8 | |||
| ccdf476c4f | |||
| ff7aaa54cd | |||
| dba8669f4b | |||
| e2124024e1 | |||
| 95f7d80b45 | |||
| cce4d9873a | |||
| 6144e3c18d | |||
| 558df9627b | |||
| 90261d4f31 | |||
| 4602b45f30 | |||
| f301a0a336 | |||
| 442192cb35 | |||
| ba5ad2c2ac | |||
| 816c6d67b0 | |||
| 9baf02810f | |||
| 6161a103b0 | |||
| ac3ba89c0a | |||
| 02df6c10a0 | |||
| 196a9db8c1 | |||
| 1453990f7a | |||
| ded5e66ccc | |||
| cae0a528f3 | |||
| 0234505932 | |||
| 108592f289 | |||
| efefc3f5b0 | |||
| 2a691e37b7 | |||
| 927f56cb1c | |||
| 48c0649a68 | |||
| 1d2a945aa1 | |||
| 3ad328103c | |||
| 555a87fdf7 | |||
| 3f1c39d5a6 | |||
| c3408ecc3c | |||
| c0927c718d | |||
| 162bc550c0 | |||
| e4c291a785 | |||
| 570f9f4153 | |||
| 99abdfed0b | |||
| f41ee64c32 | |||
| afe99ea79f | |||
| 7be0332f02 | |||
| 8830b2ac91 | |||
| b1c33c29ed | |||
| c99f4d970e | |||
| c228f7be55 | |||
| 4e1c610405 | |||
| cbce646fd8 | |||
| a6f60ce3c4 | |||
| f9aefb55d8 | |||
| d99bb00d6c | |||
| afdd3c8904 | |||
| ad0a220953 | |||
| f508c821c2 | |||
| 45526f7c7b | |||
| 5e64a4553d | |||
| 2e22fcabe9 | |||
| 3c0cf6bc07 | |||
| 9c5fc9257a | |||
| 0edca05df5 | |||
| 099fba58f7 | |||
| 817aeedc6f | |||
| 1d27d517f3 | |||
| a5e802d20a | |||
| e5ca7d62a3 | |||
| 35e72645bd | |||
| 39dfa22b0d | |||
| 96c0b0dd35 | |||
| 10ed9c4c4b | |||
| ce4b06fe3d | |||
| 2413ffb4f0 | |||
| cb1685d055 | |||
| 4be82ac76b | |||
| 83d38f8489 | |||
| ec2b8389c3 | |||
| 3e305845dd | |||
| 68bd398759 | |||
| 0ed807f98c | |||
| f94701207b | |||
| fc22eca661 | |||
| e591c5500f | |||
| 074716bc16 | |||
| ae8292d68d | |||
| 26455a7616 | |||
| 19e181734c | |||
| 8efd9b45eb | |||
| eab06eedae | |||
| 5586db23aa | |||
| a85baca408 | |||
| 2ce762b6cc | |||
| 826448d6be | |||
| e44af8add8 | |||
| 992f6d4f93 | |||
| 966d193ec4 | |||
| c264c36684 | |||
| 70ee819a51 | |||
| b87eb4328c | |||
| 7c9a95355b | |||
| 28709a162a | |||
| 9324a734d4 | |||
| 2a8e82ccd6 | |||
| f5db674e22 | |||
| 5166c88b8b | |||
| b0fbe89c2c | |||
| 441d36273a | |||
| ffaf200c07 | |||
| fede22f314 | |||
| d591d7f47a | |||
| b8d73c7f7b | |||
| cb93dbe05a | |||
| fa2709b38c | |||
| bba53d81e1 | |||
| 5a6aa31c86 | |||
| 42ba73cd36 | |||
| 21c65c34fb | |||
| afd6590c54 | |||
| 20f81b5732 | |||
| 0ebbfcd872 | |||
| 8a853b2384 | |||
| 1aea71be7f | |||
| 317b2b5cdb | |||
| c304f05d85 | |||
| 5341ca002e | |||
| ed32b0f66b | |||
| 4d3e8ac344 | |||
| ab74717679 | |||
| c9a97fe9d2 | |||
| b8db0cddf8 | |||
| 4c60fed0c2 | |||
| 070a91fed1 | |||
| e638ce45be | |||
| e55f3447db | |||
| 6cf213add5 | |||
| 80c27bf357 | |||
| 2e9b587613 | |||
| ad7e3e5b1f | |||
| 7d4df0e6ad | |||
| 8dc5287918 | |||
| 63f51d7b98 | |||
| 2f0cbada34 | |||
| c683034e2b | |||
| 985af0fdc7 | |||
| 912507b1d9 | |||
| 04c8bb8b6d | |||
| 6d2a95d775 | |||
| 6c591aeb08 | |||
| 7571b9a984 | |||
| 6f4e7f1853 | |||
| a05c869b0d | |||
| 686218c639 | |||
| f24d0ba168 | |||
| f12ada2fac | |||
| a3cfad3d8f | |||
| 4b67cde147 | |||
| ca44b99016 | |||
| 1d190e4086 | |||
| bc3b09e2a9 | |||
| 0363e89301 | |||
| 19d69e965f | |||
| 8fb1a2084d | |||
| 75e5aeaf40 | |||
| b8af6a17a1 | |||
| 0c9666204b | |||
| cd468a837c | |||
| 8a2f7f620c | |||
| 3a73cdf4b9 | |||
| 62eb4d98f0 | |||
| 6fdec32b29 | |||
| 1447a9fcaf | |||
| 9d7f33905f | |||
| 755b8c13ab | |||
| f4b57dbe5c | |||
| 0322c20513 | |||
| edbdeaa370 | |||
| 25746b4869 | |||
| 1a5f049b1a | |||
| bd6a4ff266 | |||
| 1839928e69 | |||
| a6049b3244 | |||
| d8ee6e5b37 | |||
| b437decf5f | |||
| 10f24dec30 | |||
| 18b5488051 | |||
| 7d8f8a7c19 | |||
| 93777cce6d | |||
| dadbb0aeff | |||
| f7b7954ed2 | |||
| fc4d965b20 | |||
| a715140af0 | |||
| c45d4e0ea1 | |||
| 0777153c82 | |||
| 6e670c97c9 | |||
| 522c467702 | |||
| 74ae1fb008 | |||
| e3a0fba4df | |||
| a382370767 | |||
| 653999693c | |||
| aaf54eef91 | |||
| b996eb97cc | |||
| 3e89dacff9 | |||
| eb8d7abe0d | |||
| a484984e40 | |||
| bea160aa00 | |||
| 8e603d2806 | |||
| 1d833bb800 | |||
| f81c897342 | |||
| 3532a9c381 | |||
| b4956c34d1 | |||
| 72d4881796 | |||
| 1e67d329d0 | |||
| 4e25ff5c85 | |||
| cdb6a70f7a | |||
| 74213d3844 | |||
| 95c90e319e | |||
| abc6390174 | |||
| 9e3f6765e0 | |||
| a906a40b7b | |||
| d4844140b4 | |||
| 40e593ffdc | |||
| 5151b32f3e | |||
| 20e7eed424 | |||
| af0efbe928 | |||
| 038858985c | |||
| 7b27c15515 | |||
| 88863415d7 | |||
| 5149582958 | |||
| 49bd1b3036 | |||
| 6b78abb7fd | |||
| ff9b3a6092 | |||
| 4d9a300e0c | |||
| e275f10dbd | |||
| 1c0fb0a960 | |||
| 8896726e09 | |||
| a02a9df081 | |||
| 7976257029 | |||
| 72fd495c5b | |||
| 92fe76d072 | |||
| 1a07e5ac0f | |||
| ae4a206de7 | |||
| 5c6e75e3d3 | |||
| 9ffa9c39f8 | |||
| 5a7a4da027 | |||
| 8342c6a0e6 | |||
| 6d22b7298a | |||
| e33e5da619 | |||
| ffeb708f1d | |||
| aa445b6f01 | |||
| 330ed4f0cb | |||
| da5a99b3af | |||
| 285ed37d79 | |||
| e44fe452b2 | |||
| 7aef7acd34 | |||
| 3ce6f224ae | |||
| c7c7c2c62c | |||
| ad58c1152a | |||
| 19449969d6 | |||
| b0d2aca4ff | |||
| 0abbd982ea | |||
| 18d07b5398 | |||
| ff425108b0 | |||
| 0c7ca04f8e | |||
| 89904a5bad | |||
| 4d0baebb75 | |||
| 5ccfec35e9 | |||
| 38f7543205 | |||
| 26169abc2f | |||
| 1eb579da57 | |||
| 1f5ffe2962 | |||
| 4c4b010f44 | |||
| 9d9760e596 | |||
| a7516d65e3 | |||
| 600567330f | |||
| af3b3464e4 | |||
| e4e1438220 | |||
| e352624c02 | |||
| accdfe1384 | |||
| da13ed7984 | |||
| 035e6b7ac3 | |||
| 71f21b733f | |||
| 09ddb0f485 | |||
| 342f7b53a9 | |||
| 88016d9517 | |||
| 5ecfb463f3 | |||
| 1655af375c | |||
| cba3128b8f | |||
| a31d996812 | |||
| e89311f3ab | |||
| 31b68079e0 | |||
| 9cf92563ae | |||
| 94ff13a66b | |||
| 4715db282e | |||
| 2d592842ab | |||
| cfd0da34cd | |||
| f31522734c | |||
| 077af6dd2e | |||
| 553e23dd98 | |||
| e83df64261 | |||
| 0c3b0265b4 | |||
| fd1a801ddb | |||
| 035d7f23fa | |||
| e75a69e259 | |||
| 9a19e5cba7 | |||
| 0d0c0d0a75 | |||
| ade9a20926 | |||
| 24e0cee2b1 | |||
| 9e8811e080 | |||
| 06186c1691 | |||
| 30b6874045 | |||
| 6e5f4011e5 | |||
| 1f48319a0f | |||
| 75005f43af | |||
| 08fc4dcfdc | |||
| ad3f2cf654 | |||
| 24bce6e2d6 | |||
| 72da1a15e2 | |||
| f4514bf06c | |||
| a54f78875e | |||
| 7b78e0a0ea | |||
| eecd75ac42 | |||
| b06f3c91e0 | |||
| 139b820b6b | |||
| bb2cde2fc5 | |||
| 3957ca1662 | |||
| 547accd7be | |||
| ff6b5d0d41 | |||
| a3dce84cc5 | |||
| e6bf671f16 | |||
| b34f63f711 | |||
| d6e14e2564 | |||
| dd63feeeac | |||
| 3b2cb15935 | |||
| 91ea9eccea | |||
| a89677604b | |||
| e5419bab9c | |||
| 4f9f31c4c7 | |||
| 28fdca2a06 | |||
| 771dcf0ea1 | |||
| dac9efee88 | |||
| 50c3947c04 | |||
| 9852d55a3d | |||
| 144e714235 | |||
| a9d3d96a5e | |||
| dbfc20921a | |||
| d8f1a884a3 | |||
| 0ac6c55d9e | |||
| 6a6be51455 | |||
| 52958ccbaa | |||
| 8a61d3268a | |||
| 32aa877797 | |||
| 858ab6c61a | |||
| b08d517f36 | |||
| 44d2d7d2c1 | |||
| f2af09bb35 | |||
| 24db748b6a | |||
| 1ffa995a4a | |||
| 9da3d9ce34 | |||
| 125c520c19 | |||
| 78d528f75b | |||
| c77511d4e8 | |||
| f2046d6ba7 | |||
| 26dd2aaf1a | |||
| f8fffb6fa4 | |||
| b4a42449ad | |||
| 051e908bd1 | |||
| 1fdcb12dd2 | |||
| 5d4c0b8438 | |||
| 958c3956ea | |||
| acb8500dc0 | |||
| e804b6bca2 | |||
| 3745f6df74 | |||
| bf1791635f | |||
| 1d88682f85 | |||
| d1a876ba9c | |||
| 3a70bbd9ea | |||
| 79a06f996e | |||
| 2f90a754ab | |||
| d50ec5b01e | |||
| c02fb995c5 | |||
| 6c778882f9 | |||
| e754a3a217 | |||
| 9629231e29 | |||
| ab3377ef60 | |||
| f531ce7091 | |||
| adc8457abc | |||
| 302d253e6e | |||
| f0f9d8cb3d | |||
| 4771d14744 | |||
| d9f97cf8b3 | |||
| c7d1a5ec73 | |||
| 4f19085b75 | |||
| 0db800fdab | |||
| 4619dd2708 | |||
| 367e5f64ff | |||
| adda58bada | |||
| 2b0b048520 | |||
| 5c43bfea78 | |||
| 15c5a800ed | |||
| 83119b86e9 | |||
| af89bb0630 | |||
| eb5be1972e | |||
| b303a8bea6 | |||
| 472fbb7cbb | |||
| 6891aabd4d | |||
| 3beedbcfe4 | |||
| 37e893d642 | |||
| 64fc40e5aa | |||
| cb885eeb90 | |||
| 61dbf237cf | |||
| 39af4bafbe | |||
| f480439985 | |||
| ad9b4f0fdf | |||
| 0532f6b0a7 | |||
| 6fdf01e5e1 | |||
| 15de2aaf17 | |||
| 55e2992559 | |||
| f16cca036d | |||
| deba0a244e | |||
| ef11184286 | |||
| edd52d471e | |||
| 7b3ea9c580 | |||
| bfc4065cbb | |||
| 41a832d7be | |||
| 1561cb3c9a | |||
| f1725a95f8 | |||
| b59afd1cbc | |||
| 75b42f4baf | |||
| 8f5d38ff78 | |||
| bd149ff11f | |||
| cd81aa9532 | |||
| 2a381f6dbb | |||
| e609bc3e32 | |||
| c3ebdb0057 | |||
| 55bb92468e | |||
| 965aef06f9 | |||
| 3ce37784dc | |||
| cee51d929c | |||
| b11aa20743 | |||
| 674639cf6e | |||
| 68a87985c7 | |||
| 776aa271f1 | |||
| 427eb111bf | |||
| fe7277ce26 | |||
| b68fa3872f | |||
| b7ff04c69c | |||
| ccee27baee | |||
| 47e17c48e7 | |||
| e3b57608f4 | |||
| f4f0d70864 | |||
| 10c69013f0 | |||
| c08e2fa143 | |||
| afd812c50d | |||
| 536d37d67c | |||
| f43bb14e23 | |||
| 5422f95ea7 | |||
| 29f6e5c2c2 | |||
| c311ed5f23 | |||
| 31d0a626e6 | |||
| 307e3379e0 | |||
| 1fb18bcf6f | |||
| 30e822d435 | |||
| a44aaf6df6 | |||
| 8c11a018a0 | |||
| a0d6222424 | |||
| 25115c305c | |||
| 9d9533b8a2 | |||
| e4f06acc56 | |||
| e0dc2687c4 | |||
| 274e06cde4 | |||
| f9aadede8b | |||
| d35862beb0 | |||
| ed6811266d | |||
| 06f658ccf1 | |||
| e4c6659205 | |||
| 434a659778 | |||
| e573fcccae | |||
| 84739df7cb | |||
| 1d241ca9b7 | |||
| 0264aae3b9 | |||
| fb5c37b34b | |||
| 639b342178 | |||
| e8534c8108 | |||
| f5d8026061 | |||
| 1eecc45e29 | |||
| 07bdcf232d | |||
| 6e992e0a6e | |||
| a76a14b030 | |||
| b3bd488882 | |||
| a385e999d3 | |||
| a4bda0483f | |||
| 9a1e5bee63 | |||
| 1c4fccac6e | |||
| 187feee544 | |||
| 965f24ab92 | |||
| 3aee2667d0 | |||
| 3d96a5e1b8 | |||
| 35928d7245 | |||
| ac42dde488 | |||
| ae366bf014 | |||
| bd64c397dc | |||
| 937e8be356 | |||
| e880eaff12 | |||
| 07a97293cc | |||
| ef97cea679 | |||
| c9fc2a01fc | |||
| 8002bd711a | |||
| 1ba78fe8df | |||
| f1e24794e6 | |||
| 9110b78f2a | |||
| 2ce6005c88 | |||
| 4234736656 | |||
| 39faeca4d3 | |||
| 82bd82944a | |||
| 92f30a963f | |||
| 267e2e15f8 | |||
| dd52bd5158 | |||
| d499906bbe | |||
| 2fc501efbb | |||
| 945f7974c8 | |||
| 1044379425 | |||
| 421eaa0772 | |||
| 3776b18ff5 | |||
| 6005b2fcb5 | |||
| fc99723b2e | |||
| c3e801a15a | |||
| 38ea8834af | |||
| efac172ac8 | |||
| 337f68f2fc | |||
| 2d43654c81 | |||
| 5bcd17e8b0 | |||
| ec28e89788 | |||
| 338311fc62 | |||
| 86b445a007 | |||
| 16a914a98a | |||
| 0bc82fae1e | |||
| 3c68317f9d | |||
| d8dcb04fbf | |||
| edfe6dd65e | |||
| 021361e2c1 | |||
| 04ec9438f1 | |||
| e6960dc16d | |||
| 2fb71e466c | |||
| b47463b638 | |||
| 84cf48182d | |||
| 0f11a575ff | |||
| c9459771ac | |||
| 99eef0eaa3 | |||
| 4719ef5754 | |||
| 032ca5554e | |||
| 90d66bcbb6 | |||
| 43104f611e | |||
| 24ef21761f | |||
| e0fa8464d2 | |||
| 0691329b04 | |||
| 359ba248ac | |||
| 19d7735306 | |||
| 43fd2df2c0 | |||
| 9ae6f29391 | |||
| 80a7e51ec4 | |||
| e216b42379 | |||
| 7f42ca67a2 | |||
| ec15852406 | |||
| 8c98a60c75 | |||
| 565a0cfd08 | |||
| 53919bff3e | |||
| 193cf5302c | |||
| fd2f87e382 | |||
| 59e363a1fc | |||
| fdeedde983 | |||
| 925d050c96 | |||
| 5d4ee692f3 | |||
| a51d2a83b4 | |||
| 6b133c21ab | |||
| 503ec33f43 | |||
| 6b6d2ca790 | |||
| fa84271759 | |||
| d8f5739168 | |||
| dd07351bdd | |||
| 4fb376d43d | |||
| 2335ae3d59 | |||
| 9e9770b1a0 | |||
| ce4570afad | |||
| 4c72c0e269 | |||
| 9a035ae431 | |||
| f511c4fcef | |||
| d58183beb5 | |||
| 18f8388df5 | |||
| ef39be3346 | |||
| 783fb88d0c | |||
| c19076ee85 | |||
| c44ef311c3 | |||
| 63622d83f5 | |||
| 5bfce2a1d9 | |||
| a3f8bd0981 | |||
| bb6c0dec26 | |||
| 459ca6a846 | |||
| 8637915eb2 | |||
| e187cf33aa | |||
| 2f3ce6a467 | |||
| 1310caef4d | |||
| efd9bd4238 | |||
| 39844071ad | |||
| 5c64177c6a | |||
| 1e85dbe562 | |||
| 6835f328b6 | |||
| a3eb48bacb | |||
| 1848c881ac | |||
| bbb04a4487 | |||
| e9faf80609 | |||
| f6a38a29bd | |||
| c6e096e42a | |||
| 4c6f63f723 | |||
| f3c8fd4481 | |||
| 5ca52fadd7 | |||
| ee91e7a91f | |||
| 392f862baa | |||
| 199476ec30 | |||
| c98a36f759 | |||
| 65e0f4c5f0 | |||
| bc7652f336 | |||
| 188383a882 | |||
| 7f3a0bcb21 | |||
| 3270abb7f6 | |||
| 2eb984a95f | |||
| 776126a481 | |||
| 59abf35a12 | |||
| a40eb64245 | |||
| acae1517cb | |||
| 9a9decde6e | |||
| a1ae962c73 | |||
| 0404aa0973 | |||
| effe01dedf | |||
| aaa70039d2 | |||
| 7dc5f09545 | |||
| 91e7f373ec | |||
| c7768ea8f1 | |||
| 816d0de324 | |||
| c2fab377d5 | |||
| d7cdf38cda | |||
| 85f0084b7b | |||
| 712dc5a7f8 | |||
| 84b94fd882 | |||
| 90e9bc3c8c | |||
| 442f1ded27 | |||
| e4dcc19e8b | |||
| 0b828c3d67 | |||
| bc930bf6c9 | |||
| 070e8c98b9 | |||
| 7c157d9d71 | |||
| 79d11b326e | |||
| 51c023a803 | |||
| 819f2e315f | |||
| 928f75f504 | |||
| 1d21cbb69d | |||
| ee2bae190d | |||
| ad6005259e | |||
| f01a991f4f | |||
| a44e1a37cb | |||
| 502f652791 | |||
| fdb38b3b9b | |||
| 4394f38e35 | |||
| ce9d23100d | |||
| 70c5b91e6d | |||
| 4895bfc5ac | |||
| d9db156626 | |||
| d00efcd500 | |||
| 41df3f6d8e | |||
| 0db814710b | |||
| c1955ac807 | |||
| 4081b369d9 | |||
| 8a63f72bee | |||
| cc05ac0d99 | |||
| 8d236ab8dc | |||
| 0e20b0012f | |||
| 5122bb7f3a | |||
| 4b8565dcf2 | |||
| 98b8699afb | |||
| 1c5868d75f | |||
| bfa181cefd | |||
| 944f207d9b | |||
| 8f335a52ea | |||
| e88992a470 | |||
| 9e5673c28f | |||
| 2fe92d8a14 | |||
| b88d1c9f93 | |||
| 59321fc80a | |||
| df04286a4a | |||
| 2fc319d1bc | |||
| 29428a8706 | |||
| dbc80ad94e | |||
| ec877669d4 | |||
| a41b70d5af | |||
| e41dbe772c | |||
| 5a3770027f | |||
| 5c90b5f350 | |||
| dd229a212f | |||
| 8bcf57ce83 | |||
| 0fea49109a | |||
| 8337f7cbf1 | |||
| 159d409237 | |||
| 1586f9b7e4 | |||
| 46dfc23d42 | |||
| 8c3642e5d6 | |||
| 0c99f037b5 | |||
| 929b836cb0 | |||
| 73a10e20a6 | |||
| 48da8cde5c | |||
| 36ea24278f | |||
| 000e4e89db | |||
| 3396986b2d | |||
| f3305d42cd | |||
| f56f5bfc94 | |||
| 5a9b585b44 | |||
| 89a419a8d2 | |||
| 90af1f6eb4 | |||
| e9db143f45 | |||
| 6a5b4aa2aa | |||
| 56b7a8a0a1 | |||
| 5bd519f9e5 | |||
| 5ca5fc300f | |||
| b93b290f97 | |||
| e8eba9ba66 | |||
| 41d3ca7539 | |||
| 636e0b059d | |||
| 87d657abf8 | |||
| 5a89f70d4c | |||
| 6e37e59c4c | |||
| 3f931313a6 | |||
| 815d25519f | |||
| cd45c364e6 | |||
| b423c01068 | |||
| 0729ebb23e | |||
| c682f16c40 | |||
| 94f9192760 | |||
| f490e099ea | |||
| 8a9f135662 | |||
| b12a5b72c2 | |||
| c2ab1f49de | |||
| 81526ae2b4 | |||
| e41351dfc1 | |||
| b854d77d2f | |||
| 3db5b804e4 | |||
| 89b0666e70 | |||
| 95bc323d63 | |||
| 8376295464 | |||
| 1e194f52a6 | |||
| ff1402d427 | |||
| 1ab5c639f0 | |||
| 637c35522f | |||
| 83744cea65 | |||
| 23932c8dd3 | |||
| 8e216124f1 | |||
| b0e440595a | |||
| 88f37c199a | |||
| 68b3072aa3 | |||
| 9f74aaea69 | |||
| 89f95904b8 | |||
| 8e7048490e | |||
| 59fb9e7b3a | |||
| 01e714dba5 | |||
| 859506c4aa | |||
| 4c6f38f41e | |||
| 1171708d9e | |||
| c66bf97572 | |||
| 804f6e7883 | |||
| 12d01bb3e0 | |||
| 9d37e0c59b | |||
| 5cd540a8d0 | |||
| 1717c118ab | |||
| 87f336a457 | |||
| 0e9597de54 | |||
| 972d7f55b2 | |||
| 17df388035 | |||
| 21f0be3265 | |||
| c8b2cc3683 | |||
| ad19744a47 | |||
| 59fe1df1e7 | |||
| 313a7ba230 | |||
| 5c9fd6b4ef | |||
| 74b122e75c | |||
| a9b37c1907 | |||
| 8ad69b4bae | |||
| 10ec2c6649 | |||
| fadb4b1930 | |||
| e595128c13 | |||
| f9b46eea36 | |||
| 5ed704a497 | |||
| 64001959c3 | |||
| ec8b109258 | |||
| cd25673a5f | |||
| 459984bcbf | |||
| a2f9b2dca0 | |||
| cd208ee57b | |||
| 5ec550373d | |||
| d1da7c321c | |||
| e4a670948d | |||
| 5563ff49f8 | |||
| 3b1f3c8c65 | |||
| 67ee8628da | |||
| c8cae37ff1 | |||
| 7082041853 | |||
| 60d204b061 | |||
| 75e09b0bc6 | |||
| 578041cb7f | |||
| 918236212c | |||
| 34c129bd18 | |||
| 55dca61a96 | |||
| fd0dc4643d | |||
| 2687705f29 | |||
| ddcb8df904 | |||
| 8b389515c4 | |||
| 2b17f64904 | |||
| a1aebfff4f | |||
| 2034577eb2 | |||
| 0f13869c20 | |||
| 388d2993cc | |||
| 8e91737a4b | |||
| d9420468ba | |||
| 950fb7865a | |||
| 98bb73a7aa | |||
| 282b30f515 | |||
| 91bac3f56d | |||
| 24f7c720e0 | |||
| 3e15e11ffa | |||
| e9f4f50d51 |
@@ -0,0 +1,51 @@
|
||||
name: Déploiement
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
jobs:
|
||||
deploy:
|
||||
# Trigger deploy workflow if
|
||||
# ...I clicked on "Run workflow" in Github actions, thus deploy main to production env (main)
|
||||
# or
|
||||
# ...I merged a PR or pushed on the dev branch, thus deploy to the test env (dev)
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.ref_name == 'main') ||
|
||||
((github.event_name == 'pull_request' || github.event_name == 'push') && github.ref_name == 'dev')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
env
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > /home/runner/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -p ${{ secrets.ARTIFACT_PORT }} ${{ secrets.ARTIFACT_HOST }} >> ~/.ssh/known_hosts
|
||||
sudo apt-get install sshpass python3 python3-pip
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.ARTIFACT_HOST }}
|
||||
username: ${{ secrets.USER }}
|
||||
port: ${{ secrets.ARTIFACT_PORT }}
|
||||
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
||||
passphrase: ${{ secrets.SSH_PSWD }}
|
||||
command_timeout: 5m
|
||||
script: |
|
||||
systemctl stop ${{ vars.APP_NAME }}
|
||||
cd /var/www/${{ vars.APP_NAME }}
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install .
|
||||
deactivate
|
||||
chown -R ${{ vars.APP_NAME }}:www-data *
|
||||
systemctl start ${{ vars.APP_NAME }}
|
||||
@@ -0,0 +1,14 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ColinMaudry] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Déploiement
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
jobs:
|
||||
deploy:
|
||||
# Trigger deploy workflow if
|
||||
# ...I clicked on "Run workflow" in Github actions, thus deploy main to production env (main)
|
||||
# or
|
||||
# ...I merged a PR or pushed on the dev branch, thus deploy to the test env (dev)
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.ref_name == 'main') ||
|
||||
((github.event_name == 'pull_request' || github.event_name == 'push') && github.ref_name == 'dev')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
env
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ARTIFACT_SSH_KEY }}" > /home/runner/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -p ${{ secrets.ARTIFACT_PORT }} ${{ secrets.ARTIFACT_HOST }} >> ~/.ssh/known_hosts
|
||||
sudo apt-get install sshpass python3 python3-pip
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.ARTIFACT_HOST }}
|
||||
username: ${{ secrets.USER }}
|
||||
port: ${{ secrets.ARTIFACT_PORT }}
|
||||
key: ${{ secrets.ARTIFACT_SSH_KEY }}
|
||||
passphrase: ${{ secrets.SSH_PSWD }}
|
||||
command_timeout: 5m
|
||||
script: |
|
||||
systemctl stop ${{ vars.APP_NAME }}
|
||||
cd /var/www/${{ vars.APP_NAME }}
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install .
|
||||
deactivate
|
||||
chown -R ${{ vars.APP_NAME }}:www-data *
|
||||
systemctl start ${{ vars.APP_NAME }}
|
||||
@@ -1 +1,33 @@
|
||||
*.db
|
||||
*.egg-info
|
||||
__pycache__
|
||||
.idea
|
||||
.venv
|
||||
.worktrees
|
||||
build
|
||||
.env
|
||||
|
||||
# DuckDB runtime artifacts (regenerated from decp_prod.parquet at startup)
|
||||
**/decp.duckdb
|
||||
**/decp.duckdb.tmp
|
||||
**/decp.duckdb.lock
|
||||
**/schema.cache.json
|
||||
|
||||
# Runtime databases (never commit)
|
||||
users.sqlite
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
!tests/*.sqlite
|
||||
!tests/**/*.sqlite
|
||||
# Copie jetable de tests/users.test.sqlite, générée par tests/conftest.py
|
||||
tests/users.runtime.sqlite
|
||||
|
||||
# Cache disque isolé pour pytest (CACHE_DIR, cf. pyproject.toml) : évite de
|
||||
# partager /tmp/colibre-cache avec le serveur dev (résultats de requêtes
|
||||
# faussés par les données de test après un rmtree() croisé, cf. src/app.py)
|
||||
tests/cache/
|
||||
|
||||
# LLM plugins
|
||||
.superpowers/
|
||||
.codegraph/
|
||||
.claude
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.5.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
files: \.(js|css|html|json|md)$
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [ "--fix" ]
|
||||
- id: ruff
|
||||
args: [ "check", "--select", "I", "--fix" ]
|
||||
- id: ruff-format
|
||||
@@ -0,0 +1,93 @@
|
||||
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
||||
DUCKDB_PATH=./colibre.duckdb
|
||||
PORT=8050
|
||||
DEVELOPMENT=True
|
||||
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||
|
||||
# Annonce dans l'en-tête du site
|
||||
ANNOUNCEMENTS=
|
||||
|
||||
# Chemin vers le schéma de données
|
||||
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
|
||||
DATA_SCHEMA_CACHE=./schema.cache.json
|
||||
|
||||
# Colonnes masquées par défaut
|
||||
DISPLAYED_COLUMNS="acheteur_nom, montant, objet, titulaire_nom, dateNotification, dureeMois, acheteur_departement_code"
|
||||
|
||||
# Formulaire de contact
|
||||
LOGIN_PASSWORD="" # mot de passe du serveur
|
||||
|
||||
# Matomo
|
||||
MATOMO_ID_SITE=
|
||||
MATOMO_BASE_URL=
|
||||
MATOMO_TOKEN=
|
||||
|
||||
# Widget de chat Chatwoot (essai, offre managée, issue #120). Laisser vide
|
||||
# pour désactiver le widget.
|
||||
CHATWOOT_WEBSITE_TOKEN=
|
||||
|
||||
# API privée
|
||||
API_AUTH_DISABLED="false"
|
||||
MATOMO_URL=https://analytics.maudry.com/matomo.php
|
||||
MATOMO_SITE_ID=14
|
||||
MATOMO_TRACKING_ENABLED=true
|
||||
|
||||
# Comptes utilisateurs
|
||||
USERS_DB_PATH=./users.sqlite
|
||||
# Sert aussi à chiffrer les jetons MCP stockés (fonction « Copier le jeton » sur
|
||||
# /compte/mcp). Sa rotation déconnecte les sessions ET rend les jetons existants
|
||||
# non ré-affichables (les utilisateurs devront en régénérer).
|
||||
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
|
||||
APP_BASE_URL=http://localhost:8050
|
||||
|
||||
# Panneau admin interne (accès à /admin, protégé par cette adresse)
|
||||
ADMIN_EMAIL=
|
||||
|
||||
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
|
||||
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
|
||||
# {APP_BASE_URL}/auth/linkedin/callback
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
|
||||
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
|
||||
BREVO_API_KEY=
|
||||
BREVO_TEMPLATE_VERIFY_ID=
|
||||
BREVO_TEMPLATE_RESET_ID=
|
||||
MAIL_FROM=noreply@colibre.fr # expéditeur (doit être un expéditeur vérifié dans Brevo)
|
||||
MAIL_FROM_NAME=colibre # nom d'expéditeur
|
||||
# Active/désactive le mode sandbox Brevo (X-Sib-Sandbox: drop) indépendamment de DEVELOPMENT.
|
||||
# Si non défini, suit DEVELOPMENT (sandbox actif par défaut en dev/tests).
|
||||
MAIL_SUPPRESS_SEND=
|
||||
|
||||
# Sauvegarde de users.sqlite vers un stockage S3-compatible
|
||||
S3_ENDPOINT_URL=https://s3.exemple.net
|
||||
S3_BUCKET=colibre-backups
|
||||
S3_BACKUP_PREFIX=backups
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
# Clé Fernet : générer avec
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# À CONSERVER HORS DU SERVEUR : sans elle, les sauvegardes sont irrécupérables.
|
||||
BACKUP_ENCRYPTION_KEY=
|
||||
|
||||
# Frisbii — gestion des abonnements (https://docs.frisbii.com)
|
||||
FRISBII_API_KEY= # clé PRIVÉE (priv_...), serveur uniquement
|
||||
FRISBII_API_BASE_URL=https://api.frisbii.com # base de l'API Frisbii (déf. https://api.reepay.com, à confirmer)
|
||||
FRISBII_PLAN_SIMPLE=abonnement-colibre # handle du plan "abonnement simple" (20 € HT/mois)
|
||||
FRISBII_PLAN_SOUTIEN=soutien-colibre # handle du plan "abonnement de soutien" (50 € HT/mois)
|
||||
FRISBII_WEBHOOK_SECRET= # secret de signature des webhooks
|
||||
|
||||
# Accès gratuit temporaire
|
||||
TOUS_ABONNES=false
|
||||
|
||||
# Active le serveur MCP (/_mcp) ET le connecteur d'abonné (scope B, #111).
|
||||
# À true, l'accès à /_mcp exige un jeton MCP (généré dans /compte/mcp) lié à un
|
||||
# abonnement actif. Laisser false tant que le connecteur n'est pas déployé.
|
||||
# Déploiement recommandé : activer d'abord sur test.colibre.fr (branche dev).
|
||||
DASH_MCP_ENABLED=false
|
||||
# Le connecteur MCP OAuth (Claude.ai, ChatGPT) requiert APP_BASE_URL en HTTPS
|
||||
# et que l'egress Anthropic 160.79.104.0/21 puisse joindre le serveur.
|
||||
# APP_BASE_URL sert d'issuer OAuth et à construire les URLs de découverte.
|
||||
|
||||
# Votes attribués chaque semaine aux abonné·es pour choisir les fonctionnalités prioritaires
|
||||
VOTES_PER_WEEK=4
|
||||
@@ -0,0 +1,262 @@
|
||||
### 3.0.0
|
||||
|
||||
**Fonctionnalités par abonnement**
|
||||
|
||||
- Abonnement payant à colibre : période d'essai gratuite, souscription et gestion du moyen de paiement, résiliation, historique de facturation ([#90](https://github.com/ColinMaudry/colibre/issues/90))
|
||||
- Sauvegarde de vues personnalisées (filtres, tris, colonnes) dans la page Tableau ([#95](https://github.com/ColinMaudry/colibre/issues/95))
|
||||
- Vote pour prioriser les fonctionnalités de la roadmap, réservé aux abonné·es une fois leur période d'essai terminée, avec une page roadmap publique en lecture seule ([#94](https://github.com/ColinMaudry/colibre/issues/94))
|
||||
- Accès aux données de colibre via un connecteur MCP (Model Context Protocol), pour interroger colibre directement depuis un agent IA (Claude, Mistral, Gemini, Cursor…) ([#111](https://github.com/ColinMaudry/colibre/issues/111))
|
||||
|
||||
**Autres améliorations**
|
||||
|
||||
- Nouvelle interface de tableau : plus d'options de filtres, plus performante ([#47](https://github.com/ColinMaudry/colibre/issues/47))
|
||||
- Ajout des codes et libellés NAF des titulaires, affichés sur leur page /titulaire
|
||||
- Export Excel mis en forme (styles) ([#83](https://github.com/ColinMaudry/colibre/issues/83))
|
||||
- Chat intégré 💬 pour répondre à vos questions et recueillir vos suggestions en direct ([#120](https://github.com/ColinMaudry/colibre/issues/120))
|
||||
- Réduction du nombre de colonnes affichées par défaut et ajout de la colonne "Marché" dans la page Tableau ([#84](https://github.com/ColinMaudry/colibre/issues/84))
|
||||
- Refonte et mise en page pleine largeur de la page À propos
|
||||
- Ajout des conditions générales d'utilisation (CGU) ([#93](https://github.com/ColinMaudry/colibre/issues/93))
|
||||
- Amélioration du référencement (sitemaps acheteurs/titulaires)
|
||||
- Amélioration des performances des pages acheteur et titulaire
|
||||
- decp.info devient **colibre** : nouveau nom, nouvelles icônes ([#57](https://github.com/ColinMaudry/colibre/issues/57))
|
||||
|
||||
##### 2.8.1 (25 juin 2026)
|
||||
|
||||
- Correction du bug dans la création de token d'API
|
||||
|
||||
#### 2.8.0 (23 juin 2026)
|
||||
|
||||
- Ajout des considérations sociales et environnementales à l'observatoire
|
||||
- Les filtres textuels dans les vues tableaux ne sont plus sensibles à l'accentuation
|
||||
- API privée tabulaire sur abonnement (filtres dynamiques, pagination, tri) avec documentation interactive Swagger UI à `/api/v1/swagger` (📨 <colin@colmo.tech> si intéressé)
|
||||
|
||||
##### 2.7.9 (9 juin 2026)
|
||||
|
||||
- Ajout d'une vue "étapes" (elle sera mieux intégrée dans le site à l'avenir)
|
||||
- Correction de petites erreurs qui polluent les logs
|
||||
|
||||
##### 2.7.8 (18 mai 2026)
|
||||
|
||||
- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr
|
||||
|
||||
##### 2.7.7 (11 mai 2026)
|
||||
|
||||
- Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour.
|
||||
|
||||
##### 2.7.6 (5 mai 2026)
|
||||
|
||||
- Correction du problème de filtre par date dans les tableaux
|
||||
- Retour des cartes dans les pages acheteur et titulaire
|
||||
- Possibilité de chercher un SIRET/SIREN avec des espaces dans les champs `SIRET acheteur` et `Identifiant titulaire`
|
||||
|
||||
##### 2.7.5 (24 avril 2026)
|
||||
|
||||
- Amélioration des permormances de l'observatoire
|
||||
- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau)
|
||||
- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire
|
||||
|
||||
##### 2.7.4 (22 avril 2026)
|
||||
|
||||
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/colibre/issues/72)
|
||||
|
||||
##### 2.7.3 (20 avril 2026)
|
||||
|
||||
- Mise en cache des vues tableau par ensemble de filtres et de tris
|
||||
- Résolution du bug d'écriture du fichier de vérouillage de la base de données
|
||||
|
||||
##### 2.7.2 (19 avril 2026)
|
||||
|
||||
- Chargement des données depuis une base DuckDB plutôt qu'en mémoire (plus de stabilité) ([#71](https://github.com/ColinMaudry/colibre/issues/71))
|
||||
- Mise en cache des vue sur l'observatoire pour un chargement plus rapide (remise à zéro quotidienne)
|
||||
- Correction de bug : la liste de colonnes par défaut est bien appliquée plutôt qu'afficher toutes les colonnes
|
||||
- Quelques corrections de bugs d'affichage
|
||||
- Refactorisation des fonctions utilitaire (`utils.py` approchait des 1 000 lignes)
|
||||
|
||||
##### 2.7.1 (23 mars 2026)
|
||||
|
||||
- Correction du partage de données filtrées entre dashboard et vue des données
|
||||
|
||||
#### 2.7.0 (23 mars 2026)
|
||||
|
||||
- Remplacement de la page Statistiques par l'observatoire
|
||||
- Généralisation de la grille dash (`dbc.Row`, `dbc.Col`)
|
||||
- Ajout de l'histogramme de distances aux pages acheteur et titulaire
|
||||
- Ajout de la colonne `acheteur_categorie` (commune, État, etc.)
|
||||
|
||||
##### 2.6.2 (22 février 2026)
|
||||
|
||||
- Correction du téléchargemnent buggé dans /tableau
|
||||
|
||||
##### 2.6.1 (17 février 2026)
|
||||
|
||||
- Corrections la création des liens canoniques (SEO)
|
||||
|
||||
#### 2.6.0 (5 février 2026)
|
||||
|
||||
- Suite de la refonte graphique
|
||||
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
|
||||
- Joli tableau pour choisir les colonnes à afficher
|
||||
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
|
||||
- Amélioration du SEO (liens canoniques)
|
||||
|
||||
##### 2.5.1 (29 janvier 2026)
|
||||
|
||||
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/colibre/issues/67), [#68](https://github.com/ColinMaudry/colibre/issues/68))
|
||||
|
||||
#### 2.5.0 (29 janvier 2026)
|
||||
|
||||
- Refonte graphique et amélioration des textes d'aide
|
||||
- Amélioration du filtrage du tableau à partir d'une URL
|
||||
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
|
||||
- Suppression de la dépendance à Google Fonts grâce à [Bunny Fonts](https://fonts.bunny.net) 🇪🇺 🇸🇮
|
||||
|
||||
##### 2.4.1 (22 janvier 2026)
|
||||
|
||||
- Meilleure gestion des colonnes absentes du schéma
|
||||
|
||||
#### 2.4.0 (22 janvier 2026)
|
||||
|
||||
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/colibre/issues/63))
|
||||
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
|
||||
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/colibre/issues/50))
|
||||
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
|
||||
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/colibre/issues/36))
|
||||
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
|
||||
|
||||
##### 2.3.1 (16 janvier 2026)
|
||||
|
||||
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
|
||||
|
||||
#### 2.3.0 (24 décembre 2025)
|
||||
|
||||
- Possibilité de filtrer, trier etc. dans les vues acheteur et titulaire
|
||||
- Possibilité de partager les filtres, tris et choix de colonnes via une adresse Web ([exemple](https://decp.info/tableau?filtres=%7Bobjet%7D+icontains+%22d%C3%A9corations+de+no%C3%ABl%22+%26%26+%7BdateNotification%7D+icontains+2025&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdateNotification%2Cdistance%2Cacheteur_departement_code))
|
||||
- Possibilité de filtrer une colonne avec plusieurs mots
|
||||
|
||||
##### 2.2.3 (4 décembre 2025)
|
||||
|
||||
- mise à jour de l'adresse email de contact (colmo.tech)
|
||||
- message sur l'indisponibilité des données MINEF
|
||||
|
||||
##### 2.2.2 (22 novembre 2025)
|
||||
|
||||
- Correction d'un bug dans le téléchargement Excel
|
||||
|
||||
##### 2.2.1 (15 novembre 2025)
|
||||
|
||||
- Le moteur de recherche ignore les tirets ("franche comté" trouve "Bourgogne-Franche-Comté)
|
||||
- Phrase "tagline" au-dessus du champ de recherche
|
||||
- Les infos de Contact rebasculent dans À propos
|
||||
- Police de caractère "Open Sans" généralisée
|
||||
|
||||
#### 2.2.0 (13 novembre 2025)
|
||||
|
||||
- Moteur de recherche (acheteurs et titulaires) en page d'accueil ([#58](https://github.com/ColinMaudry/colibre/issues/58))
|
||||
- Top acheteurs / titulaires par montant attribué/remporté (([#55](https://github.com/ColinMaudry/colibre/issues/55)))
|
||||
- Moins de colonnes affichées par défaut dans Tableau ([#54](https://github.com/ColinMaudry/colibre/issues/54))
|
||||
|
||||
##### 2.1.7 (11 novembre 2025)
|
||||
|
||||
- Remplacement du formulaire de contact par une adresse email
|
||||
|
||||
##### 2.1.6 (15 octobre 2025)
|
||||
|
||||
- Stabilisation de la vue marché
|
||||
|
||||
##### 2.1.5 (10 octobre 2025)
|
||||
|
||||
- réparation des filtres (notamment < > sur les montants)
|
||||
- remplacement des valeurs "null" dans les tableaux par des cellules vides
|
||||
|
||||
##### 2.1.4 (8 octobre 2025)
|
||||
|
||||
- possibilité de filtrer sur le champ "Source"
|
||||
- création automatique d'une release Github quand je push un tag
|
||||
|
||||
##### 2.1.3 (4 octobre 2025)
|
||||
|
||||
- tentative d'auto-release à chaque création de tag git
|
||||
- adaptation au format TableSchema
|
||||
|
||||
##### 2.1.2 (3 octobre 2025)
|
||||
|
||||
- dataframe global plutôt que lazyframe, pour plus de résilience et charger toutes les données en mémoire
|
||||
|
||||
##### 2.1.1 (1er octobre 2025)
|
||||
|
||||
- ajout d'une section dans À propos sur la qualité et l'exhaustivité des données ([#43](https://github.com/ColinMaudry/colibre/issues/43))
|
||||
- ajout du nombre de marchés en plus du nombre de lignes dans la vue Tableau
|
||||
|
||||
#### 2.1.0 (30 septembre 2025)
|
||||
|
||||
- Ajout des vues [acheteur](https://decp.info/acheteurs/24350013900189) ([#28](https://github.com/ColinMaudry/colibre/issues/28)), [titulaire](https://decp.info/titulaires/51903758414786) ([#35](https://github.com/ColinMaudry/colibre/issues/35)) et [marché](https://decp.info/marches/532239472000482025S00004) ([#40](https://github.com/ColinMaudry/colibre/issues/40)) 🔎
|
||||
- Ajout des balises HTML meta Open Graph et Twitter ([#39](https://github.com/ColinMaudry/colibre/issues/39)) pour de beaux aperçus de liens 🖼️
|
||||
- Formulaire de contact ([#48](https://github.com/ColinMaudry/colibre/issues/48)) 📨
|
||||
- Nom de colonnes plus_agréables ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 💅
|
||||
- Définition des colonnes quand vous passez votre souris sur les en-têtes ([#33](https://github.com/ColinMaudry/colibre/issues/33)) 📖
|
||||
- Affichage du numéro de version près du logo et lien vers ici 🤓
|
||||
- Variables globales uniquement en lecture (😁)
|
||||
|
||||
##### 2.0.1 (23 septembre 2025)
|
||||
|
||||
- Bloquage du bouton de téléchargement si trop de lignes (+ 65000) [#38](https://github.com/ColinMaudry/colibre/issues/38)
|
||||
- Amélioration du script de déploiement (deploy.sh)
|
||||
- Meilleures instructions d'installation et lancement
|
||||
- Coquilles 🐚
|
||||
|
||||
### 2.0.0 (23 septembre 2025)
|
||||
|
||||
- détails des sources de données
|
||||
- section "À propos" plus développée
|
||||
- correction de bugs dans les filtres de la data table
|
||||
|
||||
#### 2.0.0-alpha
|
||||
|
||||
- Data table fonctionnelle
|
||||
|
||||
### 1.5.0 (28/01/2023
|
||||
|
||||
- fixation des dépendances Python pour plus de stabilité en cas de réinstallation (Pipfile)
|
||||
|
||||
#### 1.4.1 (14/06/2021)
|
||||
|
||||
- ajout des traductions des opérations de filtrage à toutes les vues, pas seulement /db/decp
|
||||
|
||||
### 1.4.0 (14/06/2021)
|
||||
|
||||
- traduction des opérations de filtrage (ex : contains => contient)
|
||||
- élargissement des menus de filtrage
|
||||
- correction du titre de la page des notes de versions
|
||||
|
||||
### 1.3.0 (03/06/2021)
|
||||
|
||||
- utilisation de noms de colonnes plus lisibles dans l'application
|
||||
- suppression des références à la licence et aux données source sur la page d'accueil
|
||||
- correction des liens vers le code source
|
||||
- correction de l'indentation des puces dans les notes de version
|
||||
|
||||
### 1.2.0 (28/05/2021)
|
||||
|
||||
- ajout d'une page "Notes de version"
|
||||
- meilleur lien pour la documentation des champs
|
||||
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/colibre)
|
||||
|
||||
### 1.1.0 (25/05/2021)
|
||||
|
||||
- ajout de nouvelles vues :
|
||||
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
|
||||
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
|
||||
- amélioration de la page d'accueil
|
||||
- développement de la page "db" avec description des vues et liste des colonnes
|
||||
- les codes APE sont cliquables
|
||||
- ajout des mentions légales
|
||||
- ajout d'un formulatire d'inscription à une lettre d'information
|
||||
- correction de bugs :
|
||||
- correction du format de certaines dates dans les données
|
||||
|
||||
### 1.0.0
|
||||
|
||||
- publication sur <https://decp.info>
|
||||
- ajout d'une vue équivalente au format DECP réglementaire
|
||||
- personnalisation de datasette
|
||||
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
|
||||
@@ -0,0 +1,134 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**colibre** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
|
||||
|
||||
## Commands
|
||||
|
||||
### Setup
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
cp .template.env .env # then customize .env
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
uv run run.py # starts Dash app
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
gunicorn run:server
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # run all tests (some are Selenium-based integration tests)
|
||||
uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||
```
|
||||
|
||||
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
|
||||
|
||||
## Ajouts git
|
||||
|
||||
Avant d'ajouter des fichier dans git (`git add` ou `git commit -a`), exécute `pre-commit` pour que ruff formate les fichiers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Multi-page Dash app
|
||||
|
||||
- `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics
|
||||
- `src/pages/*.py` — each page registers itself with `@register_page()` and o.wns its own layout and callbacks
|
||||
- `run.py` — dev entry point; exports `server` (Flask) for gunicorn
|
||||
|
||||
### Module imports
|
||||
|
||||
- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.), NOT `utils.cache` or `pages.observatoire`.
|
||||
|
||||
### Key pages
|
||||
|
||||
| Page | URL | Purpose |
|
||||
| ----------------- | --------------- | -------------------------------------- |
|
||||
| `recherche.py` | `/` | Search homepage for buyers/contractors |
|
||||
| `acheteur.py` | `/acheteur` | Buyer detail with stats, charts, maps |
|
||||
| `titulaire.py` | `/titulaire` | Contractor detail |
|
||||
| `tableau.py` | `/tableau` | Filterable data table with exports |
|
||||
| `marche.py` | `/marche` | Individual contract detail |
|
||||
| `observatoire.py` | `/observatoire` | An interactive analytics dashboard |
|
||||
|
||||
### Data layer
|
||||
|
||||
- Data is stored as **Parquet** at rest, possibly in DuckDB, loaded in DuckDB, served from DuckDB for big queries and manipulated with **Polars** for the remaining steps
|
||||
- Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet`
|
||||
- `src/util/*.py` — helpers shared by other modules, search (`search_org`), link generation, geographic data loading
|
||||
- `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`)
|
||||
- `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering)
|
||||
- a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records)
|
||||
- the TableSchema of the dataset with the list of field and their definition is located at `../decp-processing/reference/base_schema.json`
|
||||
- `tests/test.parquet` is very small and may not contain all possible columns, only those necessary for testing
|
||||
|
||||
### UI stack
|
||||
|
||||
- **Dash 4.4** + **Dash Bootstrap Components** for layout
|
||||
- **Plotly Express** for charts
|
||||
- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
|
||||
- Custom CSS in `src/assets/css/`
|
||||
|
||||
### Environment
|
||||
|
||||
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
|
||||
- `.env` file is required at runtime (copy from `template.env`)
|
||||
|
||||
### Migrations de schéma SQLite
|
||||
|
||||
Les migrations sont gérées dans `src/migrations.py` via une liste `_MIGRATIONS` de tuples `(id, sql)`. Elles sont appliquées automatiquement au démarrage de l'app (via `init_subscriptions`).
|
||||
|
||||
Pour ajouter une migration :
|
||||
|
||||
```python
|
||||
# src/migrations.py
|
||||
_MIGRATIONS = [
|
||||
("0001_add_prix_ht_to_subscriptions", "ALTER TABLE subscriptions ADD COLUMN prix_ht REAL"),
|
||||
("0002_ma_nouvelle_migration", "ALTER TABLE ... "), # ajouter ici
|
||||
]
|
||||
```
|
||||
|
||||
- L'ID doit être unique et croissant (convention `NNNN_description`)
|
||||
- Les migrations appliquées sont tracées dans la table `schema_migrations`
|
||||
- `apply_pending()` est idempotent : sans effet si la migration est déjà enregistrée, et tolère le cas où la colonne existe déjà dans le schéma (DB fraîche)
|
||||
|
||||
### Deployment
|
||||
|
||||
- `main` branch → manual deploy to colibre.fr via GitHub Actions
|
||||
- `dev` branch → auto-deploy to test.colibre.fr via GitHub Actions
|
||||
|
||||
#### Sauvegarde de la base utilisateurs
|
||||
|
||||
`users.sqlite` est sauvegardée toutes les heures sur S3 par un timer systemd
|
||||
(voir `deploy/colibre-backup.{service,timer}`). Installation initiale (une fois,
|
||||
sur le serveur, en root) :
|
||||
|
||||
```bash
|
||||
cp deploy/colibre-backup.service deploy/colibre-backup.timer /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now colibre-backup.timer
|
||||
systemctl list-timers colibre-backup.timer # vérifier le prochain déclenchement
|
||||
```
|
||||
|
||||
Restauration manuelle :
|
||||
|
||||
```bash
|
||||
cd /var/www/colibre && source .venv/bin/activate
|
||||
python -m src.backup list
|
||||
systemctl stop colibre
|
||||
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
|
||||
systemctl start colibre
|
||||
```
|
||||
@@ -1,201 +1,16 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
decp.info is a web application that enable analysis and download of
|
||||
French public procurement data.
|
||||
Copyright (C) 2025 Colin Maudry
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
1. Definitions.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,51 +1,65 @@
|
||||
# decp.info
|
||||
# colibre
|
||||
|
||||
Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
> **Note:** Ce projet a été rebaptisé de **decp.info** à **colibre** en 2026.
|
||||
|
||||
=> https://decp.info
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
Dépôts de code connexes :
|
||||
=> [colibre.fr](https://colibre.fr)
|
||||
|
||||
- [decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) (traitement et publication des données)
|
||||
- [decp-table-schema](https://github.com/ColinMaudry/decp-table-schema) (schéma de données tabulaire)
|
||||
## Installation et lancement
|
||||
|
||||
```shell
|
||||
# Copie et personnalisation du .env
|
||||
cp template.env .env
|
||||
nano .env
|
||||
|
||||
# Pour la production
|
||||
uv run gunicorn app:server
|
||||
|
||||
# Pour avoir le debuggage et le hot reload
|
||||
uv run run.py
|
||||
```
|
||||
|
||||
## Déploiement
|
||||
|
||||
- **Production** (branche `main`, [colibre.fr](https://colibre.fr)) : déploiement manuel via un déclenchement de la Github Action [Déploiement](https://github.com/ColinMaudry/colibre/actions/workflows/deploy.yaml)
|
||||
- **Test** (branche `dev`, [test.colibre.fr](https://test.colibre.fr)) : déploiement automatique à chaque push sur la branche `dev`, via la même Github Action.
|
||||
|
||||
Ne pas oublier de mettre à jour les fichier .env.
|
||||
|
||||
### Sauvegarde de la base utilisateurs
|
||||
|
||||
`users.sqlite` est sauvegardée toutes les heures sur S3 via un timer systemd. Pour lister les sauvegardes disponibles :
|
||||
|
||||
```bash
|
||||
python -m src.backup list
|
||||
```
|
||||
|
||||
Pour restaurer une sauvegarde, arrêtez le service, restaurez la base, puis redémarrez :
|
||||
|
||||
```bash
|
||||
systemctl stop colibre
|
||||
python -m src.backup restore backups/users-YYYYMMDDTHHMMSSZ.sqlite.gz.enc
|
||||
systemctl start colibre
|
||||
```
|
||||
|
||||
## Migrations de base de données
|
||||
|
||||
Les migrations SQLite s'appliquent **automatiquement au démarrage de l'app** — aucune action manuelle requise. Il suffit de redémarrer le service après un déploiement.
|
||||
|
||||
Pour vérifier quelles migrations ont été appliquées :
|
||||
|
||||
```bash
|
||||
sqlite3 users.sqlite "SELECT id, applied_at FROM schema_migrations ORDER BY applied_at;"
|
||||
```
|
||||
|
||||
Pour ajouter une migration, voir les instructions dans `src/migrations.py`.
|
||||
|
||||
## Liens connexes
|
||||
|
||||
- [decp-processing](https://github.com/ColinMaudry/decp-processing) (traitement et publication des données)
|
||||
- [colin.maudry.com](https://colin.maudry.com) (blog)
|
||||
|
||||
## Notes de version
|
||||
|
||||
### 1.4.0 (14/06/2021)
|
||||
|
||||
- traduction des opérations de filtrage (ex : contains => contient)
|
||||
- élargissement des menus de filtrage
|
||||
- correction du titre de la page des notes de versions
|
||||
|
||||
### 1.3.0 (03/06/2021)
|
||||
|
||||
- utilisation de noms de colonnes plus lisibles dans l'application
|
||||
- suppression des références à la licence et aux données source sur la page d'accueil
|
||||
- correction des liens vers le code source
|
||||
- correction de l'indentation des puces dans les notes de version
|
||||
|
||||
### 1.2.0 (28/05/2021)
|
||||
|
||||
- ajout d'une page "Notes de version"
|
||||
- meilleur lien pour la documentation des champs
|
||||
- déplacement du code de decp.info depuis [ColinMaudry/decp-table-schema-utils](https://github.com/ColinMaudry/decp-table-schema-utils) vers [ColinMaudry/decp.info](https://github.com/ColinMaudry/decp.info)
|
||||
|
||||
### 1.1.0 (25/05/2021)
|
||||
|
||||
- ajout de nouvelles vues :
|
||||
- Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE
|
||||
- Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants
|
||||
- amélioration de la page d'accueil
|
||||
- développement de la page "db" avec description des vues et liste des colonnes
|
||||
- les codes APE sont cliquables
|
||||
- ajout des mentions légales
|
||||
- ajout d'un formulatire d'inscription à une lettre d'information
|
||||
- correction de bugs :
|
||||
- correction du format de certaines dates dans les données
|
||||
|
||||
### 1.0.0
|
||||
|
||||
- publication sur https://decp.info
|
||||
- ajout d'une vue équivalente au format DECP réglementaire
|
||||
- personnalisation de datasette
|
||||
- script de conversion quotidien basé sur [dataflows](https://github.com/datahq/dataflows)
|
||||
Voir [CHANGELOG](https://github.com/ColinMaudry/colibre/blob/main/CHANGELOG.md).
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contexte
|
||||
|
||||
Je souhate ajouter la possibilité pour les utilisateurs de créer un compte utilisateur. Les données des utilisateurs sont stockés dans une base de données sqlite située à la racine du projet. Les seules données demandées sont une adresse email et un mot de passe. Un lien dans le menu supérieur, tout à droite, permet d'accéder à une page qui permet soit de se connecter, soit de créer un compte.
|
||||
|
||||
Il s'agit des fondations des comptes utilisateurs, d'autres fonctionnalités viendront s'ajouter.
|
||||
|
||||
# Inscription
|
||||
|
||||
Données nécessaires :
|
||||
|
||||
- adresse email
|
||||
- mot de passe
|
||||
|
||||
Le mot de passe est hashé en base de données.
|
||||
|
||||
# Connexion
|
||||
|
||||
- adresse email
|
||||
- mot passe
|
||||
|
||||
Lien vers une page de réinitialisation du mot de passe.
|
||||
|
||||
# Page du compte
|
||||
|
||||
Possibilité de changer le mot de passe.
|
||||
|
||||
# Configuration
|
||||
|
||||
Ces fonctionnalités nécessitent d'envoyer des emails. Les options nécessaire à une connexion à un service SMTP sont fournies sous la forme de variables d'environnement.
|
||||
@@ -0,0 +1,261 @@
|
||||
code,libellé
|
||||
0000,Organisme de placement collectif en valeurs mobilières sans personnalité morale
|
||||
1000,Entrepreneur individuel
|
||||
2110,Indivision entre personnes physiques
|
||||
2120,Indivision avec personne morale
|
||||
2210,Société créée de fait entre personnes physiques
|
||||
2220,Société créée de fait avec personne morale
|
||||
2310,Société en participation entre personnes physiques
|
||||
2320,Société en participation avec personne morale
|
||||
2385,Société en participation de professions libérales
|
||||
2400,Fiducie
|
||||
2700,Paroisse hors zone concordataire
|
||||
2800,Assujetti unique à la TVA
|
||||
2900,Autre groupement de droit privé non doté de la personnalité morale
|
||||
3110,Représentation ou agence commerciale d'état ou organisme public étranger immatriculé au RCS
|
||||
3120,Société commerciale étrangère immatriculée au RCS
|
||||
3205,Organisation internationale
|
||||
3210,"État, collectivité ou établissement public étranger"
|
||||
3220,Société étrangère non immatriculée au RCS
|
||||
3290,Autre personne morale de droit étranger
|
||||
4110,Établissement public national à caractère industriel ou commercial doté d'un comptable public
|
||||
4120,Établissement public national à caractère industriel ou commercial non doté d'un comptable public
|
||||
4130,Exploitant public
|
||||
4140,Établissement public local à caractère industriel ou commercial
|
||||
4150,Régie d'une collectivité locale à caractère industriel ou commercial
|
||||
4160,Institution Banque de France
|
||||
5191,Société de caution mutuelle
|
||||
5192,Société coopérative de banque populaire
|
||||
5193,Caisse de crédit maritime mutuel
|
||||
5194,Caisse (fédérale) de crédit mutuel
|
||||
5195,Association coopérative inscrite (droit local Alsace Moselle)
|
||||
5196,Caisse d'épargne et de prévoyance à forme coopérative
|
||||
5202,Société en nom collectif
|
||||
5203,Société en nom collectif coopérative
|
||||
5306,Société en commandite simple
|
||||
5307,Société en commandite simple coopérative
|
||||
5308,Société en commandite par actions
|
||||
5309,Société en commandite par actions coopérative
|
||||
5310,Société en libre partenariat (SLP)
|
||||
5370,Société de Participations Financières de Profession Libérale Société en commandite par actions (SPFPL SCA)
|
||||
5385,Société d'exercice libéral en commandite par actions
|
||||
5410,SARL nationale
|
||||
5415,SARL d'économie mixte
|
||||
5422,SARL immobilière pour le commerce et l'industrie (SICOMI)
|
||||
5426,SARL immobilière de gestion
|
||||
5430,SARL d'aménagement foncier et d'équipement rural (SAFER)
|
||||
5431,SARL mixte d'intérêt agricole (SMIA)
|
||||
5432,SARL d'intérêt collectif agricole (SICA)
|
||||
5442,SARL d'attribution
|
||||
5443,SARL coopérative de construction
|
||||
5451,SARL coopérative de consommation
|
||||
5453,SARL coopérative artisanale
|
||||
5454,SARL coopérative d'intérêt maritime
|
||||
5455,SARL coopérative de transport
|
||||
5458,SARL coopérative de production (SCOP)
|
||||
5459,SARL union de sociétés coopératives
|
||||
5460,Autre SARL coopérative
|
||||
5470,Société de Participations Financières de Profession Libérale Société à responsabilité limitée (SPFPL SARL)
|
||||
5485,Société d'exercice libéral à responsabilité limitée
|
||||
5499,Société à responsabilité limitée (sans autre indication)
|
||||
5505,SA à participation ouvrière à conseil d'administration
|
||||
5510,SA nationale à conseil d'administration
|
||||
5515,SA d'économie mixte à conseil d'administration
|
||||
5520,Fonds à forme sociétale à conseil d'administration
|
||||
5522,SA immobilière pour le commerce et l'industrie (SICOMI) à conseil d'administration
|
||||
5525,SA immobilière d'investissement à conseil d'administration
|
||||
5530,SA d'aménagement foncier et d'équipement rural (SAFER) à conseil d'administration
|
||||
5531,Société anonyme mixte d'intérêt agricole (SMIA) à conseil d'administration
|
||||
5532,SA d'intérêt collectif agricole (SICA) à conseil d'administration
|
||||
5542,SA d'attribution à conseil d'administration
|
||||
5543,SA coopérative de construction à conseil d'administration
|
||||
5546,SA de HLM à conseil d'administration
|
||||
5547,SA coopérative de production de HLM à conseil d'administration
|
||||
5548,SA de crédit immobilier à conseil d'administration
|
||||
5551,SA coopérative de consommation à conseil d'administration
|
||||
5552,SA coopérative de commerçants-détaillants à conseil d'administration
|
||||
5553,SA coopérative artisanale à conseil d'administration
|
||||
5554,SA coopérative (d'intérêt) maritime à conseil d'administration
|
||||
5555,SA coopérative de transport à conseil d'administration
|
||||
5558,SA coopérative de production (SCOP) à conseil d'administration
|
||||
5559,SA union de sociétés coopératives à conseil d'administration
|
||||
5560,Autre SA coopérative à conseil d'administration
|
||||
5570,Société de Participations Financières de Profession Libérale Société anonyme à conseil d'administration (SPFPL SA à conseil d'administration)
|
||||
5585,Société d'exercice libéral à forme anonyme à conseil d'administration
|
||||
5599,SA à conseil d'administration (s.a.i.)
|
||||
5605,SA à participation ouvrière à directoire
|
||||
5610,SA nationale à directoire
|
||||
5615,SA d'économie mixte à directoire
|
||||
5620,Fonds à forme sociétale à directoire
|
||||
5622,SA immobilière pour le commerce et l'industrie (SICOMI) à directoire
|
||||
5625,SA immobilière d'investissement à directoire
|
||||
5630,Safer anonyme à directoire
|
||||
5631,SA mixte d'intérêt agricole (SMIA)
|
||||
5632,SA d'intérêt collectif agricole (SICA)
|
||||
5642,SA d'attribution à directoire
|
||||
5643,SA coopérative de construction à directoire
|
||||
5646,SA de HLM à directoire
|
||||
5647,Société coopérative de production de HLM anonyme à directoire
|
||||
5648,SA de crédit immobilier à directoire
|
||||
5651,SA coopérative de consommation à directoire
|
||||
5652,SA coopérative de commerçants-détaillants à directoire
|
||||
5653,SA coopérative artisanale à directoire
|
||||
5654,SA coopérative d'intérêt maritime à directoire
|
||||
5655,SA coopérative de transport à directoire
|
||||
5658,SA coopérative de production (SCOP) à directoire
|
||||
5659,SA union de sociétés coopératives à directoire
|
||||
5660,Autre SA coopérative à directoire
|
||||
5670,Société de Participations Financières de Profession Libérale Société anonyme à Directoire (SPFPL SA à directoire)
|
||||
5685,Société d'exercice libéral à forme anonyme à directoire
|
||||
5699,SA à directoire (s.a.i.)
|
||||
5710,"SAS, société par actions simplifiée"
|
||||
5770,Société de Participations Financières de Profession Libérale Société par actions simplifiée (SPFPL SAS)
|
||||
5785,Société d'exercice libéral par action simplifiée
|
||||
5800,Société européenne
|
||||
6100,Caisse d'Épargne et de Prévoyance
|
||||
6210,Groupement européen d'intérêt économique (GEIE)
|
||||
6220,Groupement d'intérêt économique (GIE)
|
||||
6316,Coopérative d'utilisation de matériel agricole en commun (CUMA)
|
||||
6317,Société coopérative agricole
|
||||
6318,Union de sociétés coopératives agricoles
|
||||
6411,Société d'assurance à forme mutuelle
|
||||
6511,Sociétés Interprofessionnelles de Soins Ambulatoires
|
||||
6521,Société civile de placement collectif immobilier (SCPI)
|
||||
6532,Société civile d'intérêt collectif agricole (SICA)
|
||||
6533,Groupement agricole d'exploitation en commun (GAEC)
|
||||
6534,Groupement foncier agricole
|
||||
6535,Groupement agricole foncier
|
||||
6536,Groupement forestier
|
||||
6537,Groupement pastoral
|
||||
6538,Groupement foncier et rural
|
||||
6539,Société civile foncière
|
||||
6540,Société civile immobilière
|
||||
6541,Société civile immobilière de construction-vente
|
||||
6542,Société civile d'attribution
|
||||
6543,Société civile coopérative de construction
|
||||
6544,Société civile immobilière d' accession progressive à la propriété
|
||||
6551,Société civile coopérative de consommation
|
||||
6554,Société civile coopérative d'intérêt maritime
|
||||
6558,Société civile coopérative entre médecins
|
||||
6560,Autre société civile coopérative
|
||||
6561,SCP d'avocats
|
||||
6562,SCP d'avocats aux conseils
|
||||
6563,SCP d'avoués d'appel
|
||||
6564,SCP d'huissiers
|
||||
6565,SCP de notaires
|
||||
6566,SCP de commissaires-priseurs
|
||||
6567,SCP de greffiers de tribunal de commerce
|
||||
6568,SCP de conseils juridiques
|
||||
6569,SCP de commissaires aux comptes
|
||||
6571,SCP de médecins
|
||||
6572,SCP de dentistes
|
||||
6573,SCP d'infirmiers
|
||||
6574,SCP de masseurs-kinésithérapeutes
|
||||
6575,SCP de directeurs de laboratoire d'analyse médicale
|
||||
6576,SCP de vétérinaires
|
||||
6577,SCP de géomètres experts
|
||||
6578,SCP d'architectes
|
||||
6585,Autre société civile professionnelle
|
||||
6589,Société civile de moyens
|
||||
6595,Caisse locale de crédit mutuel
|
||||
6596,Caisse de crédit agricole mutuel
|
||||
6597,Société civile d'exploitation agricole
|
||||
6598,Exploitation agricole à responsabilité limitée
|
||||
6599,Autre société civile
|
||||
6901,Autre personne de droit privé inscrite au registre du commerce et des sociétés
|
||||
7111,Autorité constitutionnelle
|
||||
7112,Autorité administrative ou publique indépendante
|
||||
7113,Ministère
|
||||
7120,Service central d'un ministère
|
||||
7150,Service du ministère de la Défense
|
||||
7160,Service déconcentré à compétence nationale d'un ministère (hors Défense)
|
||||
7171,Service déconcentré de l'État à compétence (inter) régionale
|
||||
7172,Service déconcentré de l'État à compétence (inter) départementale
|
||||
7179,(Autre) Service déconcentré de l'État à compétence territoriale
|
||||
7190,Ecole nationale non dotée de la personnalité morale
|
||||
7210,Commune et commune nouvelle
|
||||
7220,Département
|
||||
7225,Collectivité et territoire d'Outre Mer
|
||||
7229,(Autre) Collectivité territoriale
|
||||
7230,Région
|
||||
7312,Commune associée et commune déléguée
|
||||
7313,Section de commune
|
||||
7314,Ensemble urbain
|
||||
7321,Association syndicale autorisée
|
||||
7322,Association foncière urbaine
|
||||
7323,Association foncière de remembrement
|
||||
7331,Établissement public local d'enseignement
|
||||
7340,Pôle métropolitain
|
||||
7341,Secteur de commune
|
||||
7342,District urbain
|
||||
7343,Communauté urbaine
|
||||
7344,Métropole
|
||||
7345,Syndicat intercommunal à vocation multiple (SIVOM)
|
||||
7346,Communauté de communes
|
||||
7347,Communauté de villes
|
||||
7348,Communauté d'agglomération
|
||||
7349,Autre établissement public local de coopération non spécialisé ou entente
|
||||
7351,Institution interdépartementale ou entente
|
||||
7352,Institution interrégionale ou entente
|
||||
7353,Syndicat intercommunal à vocation unique (SIVU)
|
||||
7354,Syndicat mixte fermé
|
||||
7355,Syndicat mixte ouvert
|
||||
7356,Commission syndicale pour la gestion des biens indivis des communes
|
||||
7357,Pôle d'équilibre territorial et rural (PETR)
|
||||
7361,Centre communal d'action sociale
|
||||
7362,Caisse des écoles
|
||||
7363,Caisse de crédit municipal
|
||||
7364,Établissement d'hospitalisation
|
||||
7365,Syndicat inter hospitalier
|
||||
7366,Établissement public local social et médico-social
|
||||
7367,Centre Intercommunal d'action sociale (CIAS)
|
||||
7371,Office public d'habitation à loyer modéré (OPHLM)
|
||||
7372,Service départemental d'incendie et de secours (SDIS)
|
||||
7373,Établissement public local culturel
|
||||
7378,Régie d'une collectivité locale à caractère administratif
|
||||
7379,(Autre) Établissement public administratif local
|
||||
7381,Organisme consulaire
|
||||
7382,Établissement public national ayant fonction d'administration centrale
|
||||
7383,Établissement public national à caractère scientifique culturel et professionnel
|
||||
7384,Autre établissement public national d'enseignement
|
||||
7385,Autre établissement public national administratif à compétence territoriale limitée
|
||||
7389,Établissement public national à caractère administratif
|
||||
7410,Groupement d'intérêt public (GIP)
|
||||
7430,Établissement public des cultes d'Alsace-Lorraine
|
||||
7450,"Etablissement public administratif, cercle et foyer dans les armées "
|
||||
7470,Groupement de coopération sanitaire à gestion publique
|
||||
7490,Autre personne morale de droit administratif
|
||||
8110,Régime général de la Sécurité Sociale
|
||||
8120,Régime spécial de Sécurité Sociale
|
||||
8130,Institution de retraite complémentaire
|
||||
8140,Mutualité sociale agricole
|
||||
8150,Régime maladie des non-salariés non agricoles
|
||||
8160,Régime vieillesse ne dépendant pas du régime général de la Sécurité Sociale
|
||||
8170,Régime d'assurance chômage
|
||||
8190,Autre régime de prévoyance sociale
|
||||
8210,Mutuelle
|
||||
8250,Assurance mutuelle agricole
|
||||
8290,Autre organisme mutualiste
|
||||
8310,Comité social économique d’entreprise
|
||||
8311,Comité social économique d'établissement
|
||||
8410,Syndicat de salariés
|
||||
8420,Syndicat patronal
|
||||
8450,Ordre professionnel ou assimilé
|
||||
8470,Centre technique industriel ou comité professionnel du développement économique
|
||||
8490,Autre organisme professionnel
|
||||
8510,Institution de prévoyance
|
||||
8520,Institution de retraite supplémentaire
|
||||
9110,Syndicat de copropriété
|
||||
9150,Association syndicale libre
|
||||
9210,Association non déclarée
|
||||
9220,Association déclarée
|
||||
9221,Association déclarée d'insertion par l'économique
|
||||
9222,Association intermédiaire
|
||||
9223,Groupement d'employeurs
|
||||
9224,Association d'avocats à responsabilité professionnelle individuelle
|
||||
9230,"Association déclarée, reconnue d'utilité publique"
|
||||
9240,Congrégation
|
||||
9260,"Association de droit local (Bas-Rhin, Haut-Rhin et Moselle)"
|
||||
9300,Fondation
|
||||
9900,Autre personne morale de droit privé
|
||||
9970,Groupement de coopération sanitaire à gestion privée
|
||||
|
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"01": {
|
||||
"departement": "Ain",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"02": {
|
||||
"departement": "Aisne",
|
||||
"region": "Hauts-de-France"
|
||||
},
|
||||
"03": {
|
||||
"departement": "Allier",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"04": {
|
||||
"departement": "Alpes-de-Haute-Provence",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"05": {
|
||||
"departement": "Hautes-Alpes",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"06": {
|
||||
"departement": "Alpes-Maritimes",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"07": {
|
||||
"departement": "Ardèche",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"08": {
|
||||
"departement": "Ardennes",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"09": {
|
||||
"departement": "Ariège",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"10": {
|
||||
"departement": "Aube",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"11": {
|
||||
"departement": "Aude",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"12": {
|
||||
"departement": "Aveyron",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"13": {
|
||||
"departement": "Bouches-du-Rhône",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"14": {
|
||||
"departement": "Calvados",
|
||||
"region": "Normandie"
|
||||
},
|
||||
"15": {
|
||||
"departement": "Cantal",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"16": {
|
||||
"departement": "Charente",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"17": {
|
||||
"departement": "Charente-Maritime",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"18": {
|
||||
"departement": "Cher",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"19": {
|
||||
"departement": "Corrèze",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"21": {
|
||||
"departement": "Côte-d'Or",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"22": {
|
||||
"departement": "Côtes-d'Armor",
|
||||
"region": "Bretagne"
|
||||
},
|
||||
"23": {
|
||||
"departement": "Creuse",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"24": {
|
||||
"departement": "Dordogne",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"25": {
|
||||
"departement": "Doubs",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"26": {
|
||||
"departement": "Drôme",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"27": {
|
||||
"departement": "Eure",
|
||||
"region": "Normandie"
|
||||
},
|
||||
"28": {
|
||||
"departement": "Eure-et-Loir",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"29": {
|
||||
"departement": "Finistère",
|
||||
"region": "Bretagne"
|
||||
},
|
||||
"30": {
|
||||
"departement": "Gard",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"31": {
|
||||
"departement": "Haute-Garonne",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"32": {
|
||||
"departement": "Gers",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"33": {
|
||||
"departement": "Gironde",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"34": {
|
||||
"departement": "Hérault",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"35": {
|
||||
"departement": "Ille-et-Vilaine",
|
||||
"region": "Bretagne"
|
||||
},
|
||||
"36": {
|
||||
"departement": "Indre",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"37": {
|
||||
"departement": "Indre-et-Loire",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"38": {
|
||||
"departement": "Isère",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"39": {
|
||||
"departement": "Jura",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"40": {
|
||||
"departement": "Landes",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"41": {
|
||||
"departement": "Loir-et-Cher",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"42": {
|
||||
"departement": "Loire",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"43": {
|
||||
"departement": "Haute-Loire",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"44": {
|
||||
"departement": "Loire-Atlantique",
|
||||
"region": "Pays de la Loire"
|
||||
},
|
||||
"45": {
|
||||
"departement": "Loiret",
|
||||
"region": "Centre-Val de Loire"
|
||||
},
|
||||
"46": {
|
||||
"departement": "Lot",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"47": {
|
||||
"departement": "Lot-et-Garonne",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"48": {
|
||||
"departement": "Lozère",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"49": {
|
||||
"departement": "Maine-et-Loire",
|
||||
"region": "Pays de la Loire"
|
||||
},
|
||||
"50": {
|
||||
"departement": "Manche",
|
||||
"region": "Normandie"
|
||||
},
|
||||
"51": {
|
||||
"departement": "Marne",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"52": {
|
||||
"departement": "Haute-Marne",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"53": {
|
||||
"departement": "Mayenne",
|
||||
"region": "Pays de la Loire"
|
||||
},
|
||||
"54": {
|
||||
"departement": "Meurthe-et-Moselle",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"55": {
|
||||
"departement": "Meuse",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"56": {
|
||||
"departement": "Morbihan",
|
||||
"region": "Bretagne"
|
||||
},
|
||||
"57": {
|
||||
"departement": "Moselle",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"58": {
|
||||
"departement": "Nièvre",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"59": {
|
||||
"departement": "Nord",
|
||||
"region": "Hauts-de-France"
|
||||
},
|
||||
"60": {
|
||||
"departement": "Oise",
|
||||
"region": "Hauts-de-France"
|
||||
},
|
||||
"61": {
|
||||
"departement": "Orne",
|
||||
"region": "Normandie"
|
||||
},
|
||||
"62": {
|
||||
"departement": "Pas-de-Calais",
|
||||
"region": "Hauts-de-France"
|
||||
},
|
||||
"63": {
|
||||
"departement": "Puy-de-Dôme",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"64": {
|
||||
"departement": "Pyrénées-Atlantiques",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"65": {
|
||||
"departement": "Hautes-Pyrénées",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"66": {
|
||||
"departement": "Pyrénées-Orientales",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"67": {
|
||||
"departement": "Bas-Rhin",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"68": {
|
||||
"departement": "Haut-Rhin",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"69": {
|
||||
"departement": "Rhône",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"70": {
|
||||
"departement": "Haute-Saône",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"71": {
|
||||
"departement": "Saône-et-Loire",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"72": {
|
||||
"departement": "Sarthe",
|
||||
"region": "Pays de la Loire"
|
||||
},
|
||||
"73": {
|
||||
"departement": "Savoie",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"74": {
|
||||
"departement": "Haute-Savoie",
|
||||
"region": "Auvergne-Rhône-Alpes"
|
||||
},
|
||||
"75": {
|
||||
"departement": "Paris",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"76": {
|
||||
"departement": "Seine-Maritime",
|
||||
"region": "Normandie"
|
||||
},
|
||||
"77": {
|
||||
"departement": "Seine-et-Marne",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"78": {
|
||||
"departement": "Yvelines",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"79": {
|
||||
"departement": "Deux-Sèvres",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"80": {
|
||||
"departement": "Somme",
|
||||
"region": "Hauts-de-France"
|
||||
},
|
||||
"81": {
|
||||
"departement": "Tarn",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"82": {
|
||||
"departement": "Tarn-et-Garonne",
|
||||
"region": "Occitanie"
|
||||
},
|
||||
"83": {
|
||||
"departement": "Var",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"84": {
|
||||
"departement": "Vaucluse",
|
||||
"region": "Provence-Alpes-Côte d'Azur"
|
||||
},
|
||||
"85": {
|
||||
"departement": "Vendée",
|
||||
"region": "Pays de la Loire"
|
||||
},
|
||||
"86": {
|
||||
"departement": "Vienne",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"87": {
|
||||
"departement": "Haute-Vienne",
|
||||
"region": "Nouvelle-Aquitaine"
|
||||
},
|
||||
"88": {
|
||||
"departement": "Vosges",
|
||||
"region": "Grand Est"
|
||||
},
|
||||
"89": {
|
||||
"departement": "Yonne",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"90": {
|
||||
"departement": "Territoire de Belfort",
|
||||
"region": "Bourgogne-Franche-Comté"
|
||||
},
|
||||
"91": {
|
||||
"departement": "Essonne",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"92": {
|
||||
"departement": "Hauts-de-Seine",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"93": {
|
||||
"departement": "Seine-Saint-Denis",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"94": {
|
||||
"departement": "Val-de-Marne",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"95": {
|
||||
"departement": "Val-d'Oise",
|
||||
"region": "Île-de-France"
|
||||
},
|
||||
"971": {
|
||||
"departement": "Guadeloupe",
|
||||
"region": "Guadeloupe"
|
||||
},
|
||||
"972": {
|
||||
"departement": "Martinique",
|
||||
"region": "Martinique"
|
||||
},
|
||||
"973": {
|
||||
"departement": "Guyane",
|
||||
"region": "Guyane"
|
||||
},
|
||||
"974": {
|
||||
"departement": "La Réunion",
|
||||
"region": "La Réunion"
|
||||
},
|
||||
"975": {
|
||||
"departement": "Saint-Pierre-et-Miquelon",
|
||||
"region": "Saint-Pierre-et-Miquelon"
|
||||
},
|
||||
"976": {
|
||||
"departement": "Mayotte",
|
||||
"region": "Mayotte"
|
||||
},
|
||||
"977": {
|
||||
"departement": "Saint-Barthelemy",
|
||||
"region": "Saint-Barthelemy"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db": {
|
||||
"hash": "e7781c4da0df3983d9e8bfc09d513162302d787bff766e3cfee2dd5dcd272b85",
|
||||
"size": 121118720,
|
||||
"file": "datasette/db.db",
|
||||
"tables": {
|
||||
"decp": {
|
||||
"count": 292374
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
extra_css_urls:
|
||||
- "/static/custom.css"
|
||||
title: Exploration et téléchargement des données essentielles de la commande publique
|
||||
(format tabulaire)
|
||||
description: Ce site vous permet de filtrer et trier les données sur les marchés publics,
|
||||
et de télécharger le résultat sous la forme d'un fichier que vous pourrez ouvrir
|
||||
dans un logiciel de tableur (MS Excel, LibreOffice, OpenOffice).
|
||||
source: Données essentielles de la commande publique
|
||||
source_url: https://www.data.gouv.fr/fr/datasets/608c055b35eb4e6ee20eb325/
|
||||
license: Licence ouverte
|
||||
license_url: https://www.etalab.gouv.fr/wp-content/uploads/2014/05/Licence_Ouverte.pdf
|
||||
databases:
|
||||
db:
|
||||
tables:
|
||||
decp:
|
||||
title: Marchés et titulaires (= DECP)
|
||||
description_html: |-
|
||||
Marché publics et leurs titulaires, données équivalentes au <a href="https://139bercy.github.io/decp-docs/schemas/">format réglementaire</a>. Une ligne = un titulaire de marché, donc ces données ne sont pas adaptées pour travailler avec les montants de marché ou compter les marchés.
|
||||
size: 40
|
||||
download: https://www.data.gouv.fr/fr/datasets/r/8587fe77-fb31-4155-8753-f6a3c5e0f5c9
|
||||
column_labels:
|
||||
id: Identifiant
|
||||
uid: Identifiant unique
|
||||
acheteur.id: SIRET acheteur
|
||||
acheteur.nom: Nom de l'acheteur
|
||||
procedure: Procédure d'achat
|
||||
nature: Nature
|
||||
dureeMois: Durée du marché (mois)
|
||||
dateNotification: Date de notification
|
||||
datePublicationDonnees: Date de publication des données
|
||||
montant: Montant
|
||||
objet: Objet
|
||||
codeCPV: Code CPV
|
||||
formePrix: Forme du prix
|
||||
lieuExecution.code: Code du lieu d'exécution
|
||||
lieuExecution.typeCode: Code du lieu d'exécution (type)
|
||||
lieuExecution.nom: Nom du lieu d'exécution
|
||||
titulaire.id: Identifiant du titulaire
|
||||
titulaire.typeIdentifiant: Identifiant du titulaire (type)
|
||||
titulaire.denominationSociale: Nom du titulaire
|
||||
objetModification: Objet de la modification
|
||||
source: Source des données
|
||||
donneesActuelles: Données actuelles ?
|
||||
anomalies: Anomalies
|
||||
operation_labels:
|
||||
exact: est égal à
|
||||
not: est différent de
|
||||
contains: contient
|
||||
endswith: finit par
|
||||
startswith: commence par
|
||||
gt: est supérieur à (>)
|
||||
lt: est inférieur à (<)
|
||||
lte: est inférieur ou égal à (<=)
|
||||
gte: est supérieur ou égal à (>=)
|
||||
isnull__1: est vide
|
||||
notnull__1: n'est pas vide
|
||||
decp-sans-titulaires:
|
||||
title: Marchés publics sans leurs titulaires
|
||||
description_html: Marchés publics sans les titulaires (pas de colonnes titulaire). Une ligne = un marché, donc ces données sont adaptées pour travailler avec les montants de marchés et compter les marchés.
|
||||
size: 40
|
||||
download: https://www.data.gouv.fr/fr/datasets/r/834c14dd-037c-4825-958d-0a841c4777ae
|
||||
column_labels:
|
||||
id: Identifiant
|
||||
uid: Identifiant unique
|
||||
acheteur.id: SIRET acheteur
|
||||
acheteur.nom: Nom de l'acheteur
|
||||
procedure: Procédure d'achat
|
||||
nature: Nature
|
||||
dureeMois: Durée du marché (mois)
|
||||
dateNotification: Date de notification
|
||||
datePublicationDonnees: Date de publication des données
|
||||
montant: Montant
|
||||
objet: Objet
|
||||
codeCPV: Code CPV
|
||||
formePrix: Forme du prix
|
||||
lieuExecution.code: Code du lieu d'exécution
|
||||
lieuExecution.typeCode: Code du lieu d'exécution (type)
|
||||
lieuExecution.nom: Nom du lieu d'exécution
|
||||
objetModification: Objet de la modification
|
||||
source: Source des données
|
||||
donneesActuelles: Données actuelles ?
|
||||
anomalies: Anomalies
|
||||
decp-titulaires:
|
||||
title: Données sur les titulaires et géolocalisation
|
||||
description_html: Données détaillées sur les titulaires ayant un numéro SIRET, dont leur géolocalisation. Les colonnes <tt>formePrix</tt>, <tt>procedure</tt>, <tt>objetModification</tt> et <tt>datePublicationDonnees</tt> sont absentes. Une ligne = un titulaire de marché, donc ces données ne sont pas adaptées pour travailler avec les montants de marché ou compter les marchés.
|
||||
size: 40
|
||||
download: https://www.data.gouv.fr/fr/datasets/r/25fcd9e6-ce5a-41a7-b6c0-f140abb2a060
|
||||
column_labels:
|
||||
id: Identifiant
|
||||
uid: Identifiant unique
|
||||
acheteur.id: SIRET acheteur
|
||||
acheteur.nom: Nom de l'acheteur
|
||||
procedure: Procédure d'achat
|
||||
nature: Nature
|
||||
dureeMois: Durée du marché (mois)
|
||||
dateNotification: Date de notification
|
||||
datePublicationDonnees: Date de publication des données
|
||||
montant: Montant
|
||||
objet: Objet
|
||||
codeCPV: Code CPV
|
||||
formePrix: Forme du prix
|
||||
lieuExecution.code: Code du lieu d'exécution
|
||||
lieuExecution.typeCode: Code du lieu d'exécution (type)
|
||||
lieuExecution.nom: Nom du lieu d'exécution
|
||||
objetModification: Objet de la modification
|
||||
source: Source des données
|
||||
donneesActuelles: Données actuelles ?
|
||||
anomalies: Anomalies
|
||||
codeAPE: Code APE
|
||||
departement: Département
|
||||
categorie: Catégorie
|
||||
categorieJuridique: Catégorie juridique
|
||||
categorieJuridiqueLibelle1: Catégorie juridique niveau 1
|
||||
categorieJuridiqueLibelle2: Catégorie juridique niveau 2
|
||||
etatEtablissement: État établissement
|
||||
etatEntreprise: État entreprise
|
||||
longitude: Longitude
|
||||
latitude: Latitude
|
||||
titulaire.id: Identifiant du titulaire
|
||||
titulaire.typeIdentifiant: Identifiant du titulaire (type)
|
||||
titulaire.denominationSociale: Nom du titulaire
|
||||
@@ -1,12 +0,0 @@
|
||||
from datasette import hookimpl
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
return [
|
||||
{"href": "https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948", "label": "Présentation / FAQ"},
|
||||
{"href": "https://github.com/ColinMaudry/decp-table-schema/#documentation-du-sch%C3%A9ma", "label": "Documentation des champs"},
|
||||
{"href": "https://github.com/ColinMaudry/decp.info", "label": "Code source"},
|
||||
{"href": "/versions", "label": "Notes de version"},
|
||||
{"href": "/inscription", "label": "Lettre d'information"},
|
||||
{"href": "/mentions-legales", "label": "Mentions légales"}
|
||||
]
|
||||
@@ -1,75 +0,0 @@
|
||||
from datasette import hookimpl
|
||||
from datasette.utils.asgi import Response
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.writer.excel import save_virtual_workbook
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
|
||||
def render_spreadsheet(rows):
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet()
|
||||
ws = wb.active
|
||||
ws.title = "decp"
|
||||
|
||||
columnSpecs = {
|
||||
'acheteur.nom': {
|
||||
'width': 7,
|
||||
'wrapText': True
|
||||
},
|
||||
'objet': {
|
||||
'width': 7,
|
||||
'wrapText': True
|
||||
},
|
||||
'titulaire.denominationSociale': {
|
||||
'width': 7,
|
||||
'wrapText': True
|
||||
},
|
||||
}
|
||||
|
||||
columns = rows[0].keys()
|
||||
if columns[0] == "rowid":
|
||||
columns = columns[1:]
|
||||
|
||||
letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
headers = []
|
||||
index = 0
|
||||
for col in columns :
|
||||
c = WriteOnlyCell(ws, col)
|
||||
c.fill = PatternFill("solid", fgColor="DDEFFF")
|
||||
headers.append(c)
|
||||
if col in columnSpecs:
|
||||
ws.column_dimensions[letters[index]].width = columnSpecs[col]['width'] * 5
|
||||
else:
|
||||
ws.column_dimensions[letters[index]].bestFit = True
|
||||
index = index + 1
|
||||
ws.append(headers)
|
||||
|
||||
for row in rows:
|
||||
wsRow = []
|
||||
for col in columns:
|
||||
c = WriteOnlyCell(ws, row[col])
|
||||
if col in columnSpecs :
|
||||
c.alignment = Alignment(wrapText = columnSpecs[col]['wrapText'])
|
||||
wsRow.append(c)
|
||||
ws.append(wsRow)
|
||||
|
||||
with NamedTemporaryFile() as tmp:
|
||||
wb.save(tmp.name)
|
||||
tmp.seek(0)
|
||||
return Response(
|
||||
tmp.read(),
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename=decp.xlsx',
|
||||
'Content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_output_renderer():
|
||||
return {"extension": "xlsx",
|
||||
"render": render_spreadsheet,
|
||||
"can_render": lambda: False}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"sql_time_limit_ms": 10000,
|
||||
"max_returned_rows": 50000,
|
||||
"num_sql_threads": 6,
|
||||
"default_cache_ttl": 3600,
|
||||
"max_csv_mb": 0,
|
||||
"force_https_urls": 1,
|
||||
"allow_facet": "off",
|
||||
"suggest_facets": "off",
|
||||
"template_debug": 1,
|
||||
"hash_urls": 1
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
section.content > div.table-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
a, a:visited, a:focus, a:active {
|
||||
color: #276890;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #0c286d;
|
||||
}
|
||||
|
||||
a.explore {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
a.nodec {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
nav li {
|
||||
padding: 10px;
|
||||
border-top: solid 1px #fff;
|
||||
}
|
||||
|
||||
nav summary {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav .nav-menu-inner {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
table.rows-and-columns td > div {
|
||||
max-height: 100px;
|
||||
font-size: 0.8em;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
table.rows-and-columns tr:nth-child(even) {
|
||||
background-color: #DDEFFF;
|
||||
}
|
||||
|
||||
h4,.header4 {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table.rows-and-columns td.col-objet > div {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
table.rows-and-columns td.col-procedure > div {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
td.col-longitude,th.col-longitude,td.col-latitude,th.col-latitude {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sib-container form label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.db-table .columns {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.db-presentation {
|
||||
border-left: 10px solid #276890;
|
||||
padding-left: 10px;
|
||||
max-width: 1000px;
|
||||
margin-top: 30px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
.versions ul ul {
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
div.select-wrapper {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
div.select-wrapper.filter-op {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Flaticon icon font: Flaticon
|
||||
Creation date: 22/06/2016 15:35
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./font/Flaticon.eot");
|
||||
src: url("./font/Flaticon.eot?#iefix") format("embedded-opentype"),
|
||||
url("./font/Flaticon.woff") format("woff"),
|
||||
url("./font/Flaticon.ttf") format("truetype"),
|
||||
url("./font/Flaticon.svg#Flaticon") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./font/Flaticon.svg#Flaticon") format("svg");
|
||||
}
|
||||
}
|
||||
|
||||
[class^="flaticon-"]:before, [class*=" flaticon-"]:before,
|
||||
[class^="flaticon-"]:after, [class*=" flaticon-"]:after {
|
||||
font-family: Flaticon;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.flaticon-avatar:before { content: "\f100"; }
|
||||
.flaticon-avatar-1:before { content: "\f101"; }
|
||||
.flaticon-back:before { content: "\f102"; }
|
||||
.flaticon-book:before { content: "\f103"; }
|
||||
.flaticon-cancel:before { content: "\f104"; }
|
||||
.flaticon-chat:before { content: "\f105"; }
|
||||
.flaticon-chat-1:before { content: "\f106"; }
|
||||
.flaticon-chat-2:before { content: "\f107"; }
|
||||
.flaticon-copy:before { content: "\f108"; }
|
||||
.flaticon-dislike:before { content: "\f109"; }
|
||||
.flaticon-download:before { content: "\f10a"; }
|
||||
.flaticon-download-1:before { content: "\f10b"; }
|
||||
.flaticon-edit:before { content: "\f10c"; }
|
||||
.flaticon-envelope:before { content: "\f10d"; }
|
||||
.flaticon-folder:before { content: "\f10e"; }
|
||||
.flaticon-garbage:before { content: "\f10f"; }
|
||||
.flaticon-glasses:before { content: "\f110"; }
|
||||
.flaticon-hand:before { content: "\f111"; }
|
||||
.flaticon-headphones:before { content: "\f112"; }
|
||||
.flaticon-heart:before { content: "\f113"; }
|
||||
.flaticon-house:before { content: "\f114"; }
|
||||
.flaticon-like:before { content: "\f115"; }
|
||||
.flaticon-link:before { content: "\f116"; }
|
||||
.flaticon-logout:before { content: "\f117"; }
|
||||
.flaticon-magnifying-glass:before { content: "\f118"; }
|
||||
.flaticon-monitor:before { content: "\f119"; }
|
||||
.flaticon-musical-note:before { content: "\f11a"; }
|
||||
.flaticon-next:before { content: "\f11b"; }
|
||||
.flaticon-next-1:before { content: "\f11c"; }
|
||||
.flaticon-padlock:before { content: "\f11d"; }
|
||||
.flaticon-paper-plane:before { content: "\f11e"; }
|
||||
.flaticon-phone-call:before { content: "\f11f"; }
|
||||
.flaticon-photo-camera:before { content: "\f120"; }
|
||||
.flaticon-pie-chart:before { content: "\f121"; }
|
||||
.flaticon-piggy-bank:before { content: "\f122"; }
|
||||
.flaticon-placeholder:before { content: "\f123"; }
|
||||
.flaticon-printer:before { content: "\f124"; }
|
||||
.flaticon-reload:before { content: "\f125"; }
|
||||
.flaticon-settings:before { content: "\f126"; }
|
||||
.flaticon-settings-1:before { content: "\f127"; }
|
||||
.flaticon-share:before { content: "\f128"; }
|
||||
.flaticon-shopping-bag:before { content: "\f129"; }
|
||||
.flaticon-shopping-cart:before { content: "\f12a"; }
|
||||
.flaticon-shuffle:before { content: "\f12b"; }
|
||||
.flaticon-speaker:before { content: "\f12c"; }
|
||||
.flaticon-star:before { content: "\f12d"; }
|
||||
.flaticon-tag:before { content: "\f12e"; }
|
||||
.flaticon-upload:before { content: "\f12f"; }
|
||||
.flaticon-upload-1:before { content: "\f130"; }
|
||||
.flaticon-vector:before { content: "\f131"; }
|
||||
@@ -1,445 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<!--
|
||||
2016-6-22: Created.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>
|
||||
Created by FontForge 20120731 at Wed Jun 22 15:35:07 2016
|
||||
By root
|
||||
Created by root with FontForge 2.0 (http://fontforge.sf.net)
|
||||
</metadata>
|
||||
<defs>
|
||||
<font id="Flaticon" horiz-adv-x="512" >
|
||||
<font-face
|
||||
font-family="Flaticon"
|
||||
font-weight="500"
|
||||
font-stretch="normal"
|
||||
units-per-em="512"
|
||||
panose-1="2 0 6 3 0 0 0 0 0 0"
|
||||
ascent="448"
|
||||
descent="-64"
|
||||
bbox="0 -64 512.001 448"
|
||||
underline-thickness="25.6"
|
||||
underline-position="-51.2"
|
||||
unicode-range="U+0020-F131"
|
||||
/>
|
||||
<missing-glyph />
|
||||
<glyph glyph-name="space" unicode=" " horiz-adv-x="200"
|
||||
/>
|
||||
<glyph glyph-name="uniF113" unicode=""
|
||||
d="M470.645 375.274c26.8105 -26.8096 41.4639 -62.4121 41.3555 -100.294c0 -37.8809 -14.7617 -73.4834 -41.5723 -100.294l-204.278 -203.519c-2.82227 -2.82227 -6.62109 -4.2334 -10.3115 -4.2334s-7.48926 1.51953 -10.3115 4.3418l-203.845 203.845
|
||||
c-26.8105 26.8105 -41.6807 62.3037 -41.6807 100.186c-0.108398 37.8818 14.6533 73.4834 41.4639 100.294c26.7012 26.7012 62.3037 41.4639 100.077 41.4639c37.8809 0 73.5918 -14.8711 100.402 -41.6807l14.2188 -14.2197l14.002 14.002
|
||||
c26.8105 26.8105 62.4131 41.5723 100.295 41.5723c37.8809 0 73.375 -14.6533 100.185 -41.4639zM449.805 195.419c21.2744 21.2744 32.9971 49.4951 32.8887 79.5615c0 30.0664 -11.6143 58.2881 -32.8887 79.5625c-21.2744 21.166 -49.4961 32.8887 -79.4541 32.8887
|
||||
s-58.2871 -11.7227 -79.5615 -32.9971l-24.3145 -24.3135c-5.75195 -5.75293 -14.9785 -5.75293 -20.7314 0l-24.5303 24.5303c-21.2754 21.2744 -49.6045 32.9971 -79.6709 32.9971c-29.958 0 -58.1797 -11.7227 -79.3457 -32.8887
|
||||
c-21.2744 -21.166 -32.8887 -49.3867 -32.8887 -79.4531s11.7227 -58.2881 32.9971 -79.5625l193.533 -193.532z" />
|
||||
<glyph glyph-name="uniF131" unicode=""
|
||||
d="M504.751 279.223c9.02734 -9.02637 9.02734 -23.5117 0.105469 -32.3281l-25.6104 -25.6104c-4.30371 -4.30371 -10.0762 -6.71777 -16.1641 -6.71777s-11.8604 2.41406 -16.1641 6.71777l-25.6104 25.6104c-8.92188 8.92188 -8.92188 23.5117 0 32.4326l1.57422 1.5752
|
||||
c-59.7227 36.9463 -136.975 35.2666 -195.123 -4.93359c3.14941 -8.18652 1.46973 -17.8438 -5.14258 -24.4561l-25.6104 -25.6104c-4.30371 -4.30371 -10.0762 -6.71777 -16.1641 -6.71777c-2.93945 0 -5.66797 0.525391 -8.29199 1.5752
|
||||
c-40.2002 -58.1484 -41.7744 -135.4 -4.93359 -195.123l0.735352 0.734375c4.30273 4.30371 10.0762 6.71777 16.1641 6.71777c6.08691 0 11.8604 -2.41406 16.1641 -6.71777l25.6104 -25.6104c8.9209 -8.92188 8.9209 -23.5107 0 -32.4326l-25.6104 -25.6104
|
||||
c-4.30371 -4.30371 -10.0771 -6.71777 -16.1641 -6.71777c-6.08789 0 -11.8613 2.41406 -16.1641 6.71777l-25.6104 25.6104c-8.92188 8.92188 -8.92188 23.5107 0 32.4326l4.30273 4.30371c-22.4619 33.6924 -34.3223 72.8428 -34.4268 113.883
|
||||
c-0.105469 25.6104 4.5127 50.4863 13.3301 73.7871l-44.7139 -44.7129c7.55762 -15.7441 4.72363 -35.2676 -8.29199 -48.2822c-7.97656 -8.08203 -18.6826 -12.4902 -30.0186 -12.4902s-22.042 4.5127 -30.0186 12.4902c-16.584 16.4785 -16.584 43.4541 0 60.0381
|
||||
c7.97656 8.08203 18.6826 12.4902 30.0186 12.4902c6.40234 0 12.5957 -1.36523 18.2627 -4.09375l80.5059 80.5049l-2.72949 2.72949c-4.30273 4.30273 -6.71777 10.0762 -6.71777 16.1641s2.41504 11.8604 6.71777 16.1641l25.8203 25.7148
|
||||
c4.30371 4.30371 10.0762 6.71777 16.1641 6.71777s11.8604 -2.41406 16.1641 -6.71777l2.72949 -2.72852l80.5049 80.5049c-2.62402 5.5625 -4.09375 11.8604 -4.09375 18.2637c0 11.335 4.51367 21.9365 12.4902 30.0186c7.97754 8.08203 18.6836 12.4902 30.0195 12.4902
|
||||
c11.335 0 21.9365 -4.51367 30.0186 -12.4902c8.08203 -7.97754 12.4902 -18.6836 12.4902 -30.0186c0 -11.3359 -4.5127 -22.042 -12.4902 -30.0195c-7.97656 -8.08203 -18.6826 -12.4902 -30.0186 -12.4902c-6.40332 0 -12.5957 1.36426 -18.2637 4.09375
|
||||
l-44.7129 -44.7139c23.1963 8.81738 47.8623 13.3301 73.3672 13.3301h0.419922c41.04 -0.104492 80.1904 -11.9648 113.883 -34.4268l3.46387 3.46387c4.30371 4.30273 10.0762 6.7168 16.1641 6.7168s11.8604 -2.41406 16.1641 -6.7168zM202.358 -15.6133
|
||||
l-17.9482 17.9482l-17.9482 -17.9482l17.9482 -17.9482zM308.579 415.357c-2.62402 -2.72949 -4.09375 -6.19336 -4.09375 -9.97168s1.46973 -7.34766 4.09375 -9.97168v0v0c2.62402 -2.62402 6.19238 -4.09277 9.97168 -4.09277c3.77832 0 7.34668 1.46875 9.9707 4.09277
|
||||
s4.09375 6.19336 4.09375 9.97168s-1.46973 7.34766 -4.09375 9.97168s-6.19238 4.09277 -9.9707 4.09277c-3.7793 0 -7.34766 -1.46875 -9.97168 -4.09277zM52.8945 119.682c5.5625 5.45801 5.5625 14.4844 0 19.9424c-2.62402 2.62402 -6.19336 4.09375 -9.97168 4.09375
|
||||
s-7.34766 -1.46973 -9.97168 -4.09375c-5.5625 -5.45801 -5.5625 -14.3789 0 -19.9424c2.62402 -2.62402 6.19336 -4.09375 9.97168 -4.09375s7.34766 1.46973 9.97168 4.09375zM180.736 249.624l17.8438 18.0527l-8.92188 8.92188v0v0l-8.92188 8.92188l-17.9482 -17.9482z
|
||||
M462.977 245.11l17.9482 17.9482l-17.9482 17.9482l-17.9482 -17.9482z" />
|
||||
<glyph glyph-name="uniF12B" unicode=""
|
||||
d="M195.743 89.7715c6.51562 -4.8877 7.70996 -14.1182 2.93164 -20.5254c-14.334 -19.1123 -33.1211 -34.8584 -54.2969 -45.6094c-22.1543 -11.2939 -46.1533 -17.0498 -71.2393 -17.0498h-40.5059c-8.14453 0 -14.6602 6.51562 -14.6602 14.6602
|
||||
s6.51562 14.6611 14.6602 14.6611h40.3975c40.5059 0 77.7549 18.5693 102.188 50.9307c4.88672 6.51562 14.0088 7.81934 20.5244 2.93262zM237.118 295.885c-6.40723 4.99609 -7.49316 14.1172 -2.71484 20.6338c30.0811 38.7686 75.3652 60.9219 124.232 60.9219h85.248
|
||||
l-45.502 45.501c-5.75488 5.75586 -5.75488 14.9863 0 20.7422c5.75586 5.75488 14.9863 5.75488 20.7422 0l70.5869 -70.5869c5.75586 -5.75586 5.75586 -14.9863 0 -20.7422l-70.5869 -70.5869c-2.82324 -2.93164 -6.62402 -4.34375 -10.3164 -4.34375
|
||||
s-7.49316 1.52051 -10.3174 4.34375c-5.75488 5.75586 -5.75488 14.9863 0 20.7422l45.502 45.501h-85.2471c-39.7461 0 -76.5596 -18.0264 -100.994 -49.5195c-2.93164 -3.69238 -7.27539 -5.64648 -11.6191 -5.64648c-3.14941 0 -6.29883 0.976562 -9.01367 3.04004z
|
||||
M419.232 102.15l70.4785 -70.4775c5.75586 -5.75586 5.75586 -14.9863 0 -20.7422l-70.5869 -70.5869c-2.82324 -2.93164 -6.625 -4.34375 -10.3164 -4.34375c-3.69238 0 -7.49316 1.52051 -10.3174 4.34375c-5.75488 5.75586 -5.75488 14.9863 0 20.7422l45.502 45.501
|
||||
h-85.2471c-86.6592 0 -157.138 70.4785 -157.138 157.138v56.4697c0 70.4785 -57.3389 127.816 -127.816 127.816h-40.5068c-8.14453 0 -14.6602 6.51562 -14.6602 14.6602s6.51562 14.6611 14.6602 14.6611h40.5068c86.6582 0 157.137 -70.4785 157.137 -157.138v-56.4697
|
||||
c0 -70.4785 57.3389 -127.816 127.817 -127.816h85.2471l-45.502 45.501c-5.75488 5.75586 -5.75488 14.9863 0 20.7412c5.75586 5.75586 14.9863 5.75586 20.7422 0z" />
|
||||
<glyph glyph-name="uniF10F" unicode=""
|
||||
d="M469.474 374.315c7.89453 0 14.2109 -6.31543 14.2109 -14.21s-6.31641 -14.2109 -14.2109 -14.2109h-25.6846v-333.895c0 -41.8945 -34.1045 -76 -76 -76h-223.578c-41.8955 0 -76 34.1055 -76 76v333.895h-25.6846c-7.89453 0 -14.2109 6.31641 -14.2109 14.2109
|
||||
s6.31641 14.21 14.2109 14.21h106.526v17.3691c0 31.0527 25.2627 56.3154 56.3154 56.3154h101.264c31.0527 0 56.3154 -25.2627 56.3154 -56.3154v-17.3691h106.526zM177.474 391.685v-17.3691h157.053v17.3691c0 15.3682 -12.5264 27.8945 -27.8945 27.8945h-101.264
|
||||
c-15.3682 0 -27.8945 -12.5264 -27.8945 -27.8945zM415.368 12h0.105469v333.895h-318.842v-333.895c0 -26.2109 21.3682 -47.5791 47.5791 -47.5791h223.578c26.2109 0 47.5791 21.3682 47.5791 47.5791zM256 15.3682c-7.89453 0 -14.2109 6.4209 -14.2109 14.3164v251.053
|
||||
c0 7.89453 6.31641 14.21 14.2109 14.21s14.2109 -6.31543 14.2109 -14.21v-251.158c0 -7.89453 -6.31641 -14.2109 -14.2109 -14.2109zM163.263 31.0527c-7.78906 0 -14.21 6.31543 -14.21 14.21v219.896c0 7.89453 6.31543 14.21 14.21 14.21
|
||||
c7.89551 0 14.2109 -6.31543 14.2109 -14.21v-219.896c0 -7.89453 -6.31543 -14.21 -14.2109 -14.21zM348.737 31.0527c-7.89551 0 -14.2109 6.31543 -14.2109 14.21v219.896c0 7.89453 6.31543 14.21 14.2109 14.21c7.89453 0 14.21 -6.31543 14.21 -14.21v-219.896
|
||||
c0 -7.89453 -6.31543 -14.21 -14.21 -14.21z" />
|
||||
<glyph glyph-name="uniF114" unicode=""
|
||||
d="M507.273 215.844c5.79199 -5.26562 6.31836 -14.2168 1.26367 -20.1133c-2.84277 -3.16016 -6.73926 -4.73926 -10.6357 -4.73926c-3.37012 0 -6.73926 1.1582 -9.47754 3.58008l-43.3867 38.8584v-87.9316v-35.8037v-117.103
|
||||
c0 -25.168 -15.7959 -44.8604 -35.8047 -44.8604h-76.0312c-18.5342 0 -33.5938 15.0586 -33.5938 33.5928v127.738c0 2.84277 -2.31641 5.15918 -5.16016 5.15918h-76.7686c-2.84375 0 -5.16016 -2.31641 -5.16016 -5.15918v-127.738
|
||||
c0 -18.5342 -15.0586 -33.5928 -33.5928 -33.5928h-74.3477h-0.841797h-0.842773c-19.7979 0 -35.8047 16.1113 -35.8047 35.8047v131.213v112.468v7.58203l-43.3867 -38.8584c-2.7373 -2.42188 -6.10742 -3.58008 -9.47754 -3.58008
|
||||
c-3.89648 0 -7.79199 1.5791 -10.6357 4.73828c-5.16016 5.89746 -4.73926 14.8486 1.1582 20.1143l240.943 215.459c5.58105 4.94922 14.0059 4.73828 19.2715 -0.316406c0.526367 -0.31543 0.947266 -0.631836 1.36914 -1.05273zM416.499 145.604v0v113.311
|
||||
l-161.226 144.166l-159.856 -142.797v-32.9609v-112.469v-131.317c0 -4.10742 3.37012 -7.37207 7.37109 -7.37207h0.842773h0.842773h74.3467c2.84375 0 5.16016 2.31641 5.16016 5.16016v127.738c0 18.5332 15.0586 33.5928 33.5928 33.5928h76.7695
|
||||
c18.5342 0 33.5928 -15.0596 33.5928 -33.5928v-127.527c0 -2.84375 2.31641 -5.16016 5.16016 -5.16016h76.0312c2.31738 0 7.37207 6.21289 7.37207 16.4277v116.996v35.8047z" />
|
||||
<glyph glyph-name="uniF128" unicode=""
|
||||
d="M405.688 119.069c50.498 0 91.6416 -41.0361 91.6416 -91.5342c0 -50.499 -41.0371 -91.5352 -91.5352 -91.5352s-91.5352 41.1426 -91.5352 91.5352c0 10.6309 1.91406 20.9434 5.31543 30.5117l-140.544 78.5645c-16.7979 -21.9004 -43.1631 -36.04 -72.7178 -36.04
|
||||
c-50.499 0 -91.6416 41.0371 -91.6416 91.5352s41.1426 91.5352 91.5352 91.5352c29.5547 0 55.9199 -14.0332 72.6113 -35.9336l140.438 78.458c-3.40234 9.46191 -5.20898 19.668 -5.20898 30.2988c0 50.499 41.0361 91.5352 91.5342 91.5352
|
||||
c50.499 0 91.5352 -41.0361 91.5352 -91.5352c0 -50.498 -41.1426 -91.5342 -91.5352 -91.5342c-29.5547 0 -55.9199 14.1387 -72.6113 35.9326l-140.545 -78.458c3.40234 -9.56836 5.20996 -19.668 5.20996 -30.2988c0 -10.5254 -1.80762 -20.7314 -5.10352 -30.1924
|
||||
l140.651 -78.5654c16.7979 21.6875 43.0566 35.7207 72.5049 35.7207zM405.688 419.189c-34.5518 0 -62.8301 -28.1729 -62.8301 -62.8311c0 -34.6572 28.1729 -62.8301 62.8301 -62.8301c34.6582 0 62.8311 28.1729 62.8311 62.8301
|
||||
c0 34.6582 -28.1729 62.8311 -62.8311 62.8311zM106.312 129.275c34.5518 0 62.8301 28.1729 62.8301 62.8311c0 34.6572 -28.1729 62.8301 -62.8301 62.8301c-34.6582 0 -62.8311 -28.1729 -62.8311 -62.8301c0 -34.6582 28.1729 -62.8311 62.8311 -62.8311z
|
||||
M405.688 -35.1895c34.6582 0 62.8311 28.1729 62.8311 62.8311c0 34.6572 -28.1729 62.8301 -62.8311 62.8301c-34.6572 0 -62.8301 -28.1729 -62.8301 -62.8301c0 -34.6582 28.1729 -62.8311 62.8301 -62.8311z" />
|
||||
<glyph glyph-name="uniF102" unicode=""
|
||||
d="M155.784 192.014l231.12 -231.228c5.68652 -5.6875 5.68652 -14.8076 0 -20.4941c-2.79004 -2.89746 -6.54492 -4.29199 -10.1934 -4.29199s-7.40332 1.50195 -10.1934 4.29199l-241.528 241.421c-5.68652 5.68652 -5.68652 14.8066 0 20.4941l241.528 241.528
|
||||
c5.68652 5.68652 14.8076 5.68652 20.4941 0c5.68652 -5.6875 5.68652 -14.8076 0 -20.4941z" />
|
||||
<glyph glyph-name="uniF121" unicode=""
|
||||
d="M442.611 179.047c7.95508 0 14.5127 -6.55762 14.5127 -14.5117c0 -125.984 -102.551 -228.535 -228.535 -228.535s-228.535 102.551 -228.535 228.535s102.551 228.535 228.535 228.535c8.06152 0 14.5117 -6.4502 14.5117 -14.5127v-199.511h199.511zM228.589 -34.9766
|
||||
c105.13 0 191.557 81.6963 198.974 185h-198.974c-8.0625 0 -14.5117 6.44922 -14.5117 14.5117v198.974c-103.304 -7.41699 -185 -93.8438 -185 -198.974c0 -109.968 89.5439 -199.512 199.512 -199.512zM511.946 220.218v-0.75293
|
||||
c0 -8.06152 -6.4502 -14.5117 -14.5117 -14.5117v0h-214.023c-8.06152 0 -14.5117 6.4502 -14.5117 14.5117v214.023c0 8.0625 6.4502 14.5117 14.5117 14.5117c65.5723 0 127.92 -28.1641 171.348 -77.2891c36.6562 -41.6006 56.9727 -95.0254 57.1875 -150.493z
|
||||
M297.923 233.869h184.462c-3.22461 43.4287 -20.4238 84.707 -49.4473 117.492c-34.6133 39.2363 -83.2012 63.208 -135.015 66.9697v-184.462z" />
|
||||
<glyph glyph-name="uniF104" unicode=""
|
||||
d="M437.01 373.01c48.377 -48.377 74.9902 -112.592 74.9902 -181.01s-26.6133 -132.633 -74.9902 -181.01s-112.592 -74.9902 -181.01 -74.9902s-132.633 26.6133 -181.01 74.9902s-74.9902 112.592 -74.9902 181.01s26.6133 132.633 74.9902 181.01
|
||||
s112.592 74.9902 181.01 74.9902s132.633 -26.6133 181.01 -74.9902zM416.431 31.5693c88.458 88.458 88.458 232.403 0 320.861c-42.8818 42.8828 -99.8789 66.4785 -160.431 66.4785s-117.549 -23.5957 -160.431 -66.4785c-88.458 -88.458 -88.458 -232.403 0 -320.861
|
||||
c42.8818 -42.8828 99.8789 -66.4785 160.431 -66.4785s117.549 23.5957 160.431 66.4785zM368.808 304.809c5.71094 -5.71094 5.71094 -14.8691 0.000976562 -20.5801l-92.2295 -92.2285l92.0137 -92.2285c5.70996 -5.71094 5.70996 -14.8691 0 -20.5801
|
||||
c-2.80176 -2.9082 -6.46484 -4.30957 -10.2363 -4.30957c-3.66309 0 -7.43359 1.50879 -10.2354 4.30957l-92.2285 92.2295l-92.2295 -92.2295c-2.80078 -2.9082 -6.57227 -4.30957 -10.2354 -4.30957s-7.43457 1.50879 -10.2354 4.30957
|
||||
c-5.71094 5.71094 -5.71094 14.8691 0 20.5801l92.2285 92.2285l-92.2285 92.2285c-5.71094 5.71094 -5.71094 14.8691 0 20.5801c5.70996 5.70996 14.8682 5.70996 20.5791 0l92.2285 -92.2295l92.2285 92.2295c5.71094 5.70996 14.8691 5.70996 20.5791 0z" />
|
||||
<glyph glyph-name="uniF12E" unicode=""
|
||||
d="M502.712 222.068c12.3838 -16.8975 12.3838 -43.3447 0 -60.1367l-87.6338 -119.538c-11.4395 -15.7432 -34.8428 -27.6025 -54.3643 -27.6025h-319.573c-22.6689 0 -41.1406 18.4717 -41.1406 41.1406v272.137c0 22.6689 18.4717 41.1406 41.1406 41.1406h319.573
|
||||
c19.416 0 42.8203 -11.8594 54.3643 -27.6025zM479.833 178.514c5.14258 7.03223 5.14258 19.8359 0.104492 26.8672l-87.6328 119.539c-6.19238 8.29102 -21.2002 15.9521 -31.5908 15.9521h-319.573c-7.03223 0 -12.8037 -5.77246 -12.8037 -12.8037v-272.241
|
||||
c0 -7.03223 5.77148 -12.8037 12.8037 -12.8037h319.469c10.3896 0 25.5029 7.55566 31.5898 15.9521z" />
|
||||
<glyph glyph-name="uniF11E" unicode=""
|
||||
d="M507.617 443.548c4.36621 -4.25781 5.56738 -10.6982 3.16602 -16.2656l-209.041 -482.267c-2.29199 -5.13086 -7.0957 -8.51465 -12.6621 -8.8418h-0.874023c-5.12988 0 -10.042 2.83789 -12.6621 7.31348l-99.8809 169.962l-168.215 95.4053
|
||||
c-4.80371 2.72852 -7.75098 7.96875 -7.42285 13.5352c0.327148 5.56738 3.71094 10.4795 8.8418 12.7725l482.485 211.441c5.45801 2.40234 12.0078 1.20117 16.2646 -3.05566zM430.878 387.985l-383.259 -167.997l136.34 -77.2852zM286.241 -16.5605l167.015 385.225
|
||||
l-248.338 -246.701z" />
|
||||
<glyph glyph-name="uniF105" unicode=""
|
||||
d="M436.978 372.942c48.3584 -48.3584 74.9502 -112.693 74.9502 -180.996s-26.5918 -132.638 -74.9502 -180.996s-112.586 -74.9502 -180.996 -74.9502c-42.4609 0 -84.1719 10.5078 -121.271 30.4521c-27.5566 -18.9795 -60.582 -29.8086 -91.999 -29.8086v0
|
||||
c-6.32617 0 -12.4385 0.536133 -18.1211 1.39355c-10.4004 1.6084 -18.335 9.65039 -19.9434 20.0508c-1.50195 10.2939 3.75293 20.373 13.0811 24.9844c15.2256 7.50488 28.8438 23.1602 40.3164 46.6426c-83.0996 101.006 -76.3438 250.049 16.9414 343.335
|
||||
c48.3594 48.3584 112.587 74.9502 180.996 74.9502c68.4102 0 132.638 -26.6992 180.996 -75.0576zM416.498 31.4307c88.5674 88.46 88.5674 232.571 0 321.246c-42.8906 42.8906 -99.8271 66.4795 -160.517 66.4795c-60.582 0 -117.626 -23.5889 -160.516 -66.4795
|
||||
c-84.708 -84.708 -88.8896 -221.312 -9.54297 -310.953c3.75293 -4.28906 4.71777 -10.293 2.46582 -15.4404c-12.1162 -27.7705 -26.5918 -47.7148 -43.8545 -60.582c14.4746 0.322266 47.5 4.0752 80.3115 29.4873c2.57324 1.92969 5.79004 3.00195 8.89941 3.00195
|
||||
c2.46582 0 5.04004 -0.75 7.29102 -2.03711c34.6338 -20.373 74.415 -31.2021 114.945 -31.2021c60.583 0 117.626 23.5889 160.517 66.4795zM320.96 309.251c23.0537 0 44.8203 -9.00684 61.2256 -25.4131c16.2988 -16.4053 25.3057 -38.1719 25.3057 -61.332
|
||||
c0 -23.0537 -9.00684 -44.8203 -25.4131 -61.2256l-116.018 -115.589c-2.78711 -2.78809 -6.54004 -4.18164 -10.1855 -4.18164c-3.64648 0 -7.39941 1.50098 -10.1865 4.28906l-115.696 115.695c-16.2979 16.4053 -25.4121 38.0654 -25.4121 61.2256
|
||||
c-0.107422 23.0537 8.89941 44.8203 25.3047 61.2256c16.2988 16.2988 38.0654 25.3057 61.1182 25.3057c23.1611 0 44.9277 -9.00684 61.333 -25.4131l3.64551 -3.64551l3.64551 3.64551c16.4062 16.4062 38.1729 25.4131 61.333 25.4131zM361.599 181.76
|
||||
c10.9365 10.9375 16.9414 25.4121 16.9414 40.8525c0.107422 15.334 -5.89746 29.8086 -16.834 40.7461c-10.9375 10.9365 -25.4131 16.9414 -40.7461 16.9414c-15.4404 0 -29.916 -6.00488 -40.8525 -16.9414l-13.832 -13.832
|
||||
c-5.68262 -5.68262 -14.7969 -5.68262 -20.4805 0l-13.9385 13.9395c-10.9375 10.9365 -25.4131 16.9414 -40.8535 16.9414s-29.915 -6.00488 -40.7451 -16.835c-10.8301 -10.8291 -16.834 -25.3047 -16.834 -40.7451s6.00391 -29.916 16.9414 -40.8525l105.51 -105.51z" />
|
||||
<glyph glyph-name="uniF10A" unicode=""
|
||||
d="M169.312 237.642c5.55859 5.66504 14.751 5.66504 20.416 0l51.8418 -51.8408v247.77c0 8.0166 6.41309 14.4297 14.4297 14.4297s14.4297 -6.41309 14.4297 -14.4297v-247.77l51.8418 51.8408c5.66504 5.66504 14.751 5.66504 20.416 0s5.66504 -14.751 0 -20.416
|
||||
l-76.5332 -76.5322c-2.7793 -2.88672 -6.52051 -4.27539 -10.1543 -4.27539s-7.375 1.49609 -10.1543 4.27539l-76.5332 76.5322c-5.66504 5.66504 -5.66504 14.751 0 20.416zM491.905 119.422c0.426758 -1.17578 0.53418 -2.45801 0.53418 -3.84766v-75.4639
|
||||
c0 -57.3994 -46.7109 -104.11 -104.11 -104.11h-264.658c-57.3994 0 -104.11 46.7109 -104.11 104.11v75.7842c0 1.2832 0.213867 2.56543 0.53418 3.84863l50.2383 181.712c1.06934 4.16797 4.06152 7.48145 7.91016 9.29883l1.38965 0.641602
|
||||
c1.92383 0.855469 3.95508 1.28223 5.98535 1.28223h90.6426c8.0166 0 14.4297 -6.41309 14.4297 -14.4297s-6.41309 -14.4297 -14.4297 -14.4297h-80.8086l-42.6484 -154.028h107.851c8.01758 0 14.4307 -6.41309 14.4307 -14.4297v-13.04
|
||||
c0 -19.1338 15.499 -34.6328 34.6318 -34.6328h88.0771c19.1328 0 34.6318 15.499 34.6318 34.6328v13.04c0 8.0166 6.41309 14.4297 14.4307 14.4297h112.233l-42.4355 153.707h-80.8076c-8.0166 0 -14.4307 6.41309 -14.4307 14.4307
|
||||
c0 8.0166 6.41406 14.4297 14.4307 14.4297h90.5352c2.03027 0 4.06152 -0.427734 5.98535 -1.28223l1.38965 -0.641602c3.84863 -1.81738 6.73438 -5.13086 7.91016 -9.2998zM463.579 40.1104h0.213867v60.8203h-102.293
|
||||
c-0.748047 -34.3115 -28.9668 -61.9961 -63.4922 -61.9961h-88.0771c-34.5254 0 -62.7441 27.6846 -63.4922 61.9961h-98.0176v-60.8203c0 -41.4736 33.7773 -75.25 75.25 -75.25h264.658c41.4727 0 75.25 33.7764 75.25 75.25z" />
|
||||
<glyph glyph-name="uniF127" unicode=""
|
||||
d="M270.924 198.449c33.4727 -6.71582 58.6299 -36.3506 58.6299 -71.6348c0 -35.3916 -25.2637 -64.9199 -58.6299 -71.6357v-104.682c0 -7.99414 -6.39551 -14.3906 -14.3906 -14.3906s-14.3916 6.39648 -14.3916 14.3906v104.682
|
||||
c-33.3652 6.71582 -58.6299 36.2441 -58.6299 71.6357c0 35.3906 25.2646 64.9189 58.6299 71.6348v235.16c0 7.99414 6.39648 14.3906 14.3916 14.3906s14.3906 -6.39648 14.3906 -14.3906v-235.16zM256.533 82.5752c24.4111 0 44.2393 19.8271 44.2393 44.2393
|
||||
c0 24.4111 -19.8281 44.2383 -44.2393 44.2383s-44.2393 -19.8271 -44.2393 -44.2383c0 -24.4121 19.8281 -44.2393 44.2393 -44.2393zM94.7139 319.12c33.3662 -6.71582 58.6299 -36.1367 58.6299 -71.5283c0 -35.3906 -25.2637 -64.9189 -58.6299 -71.6348v-225.459
|
||||
c0 -7.99512 -6.39551 -14.3916 -14.3906 -14.3916s-14.3916 6.39648 -14.3916 14.3916v225.352c-33.3652 6.71582 -58.6299 36.2441 -58.6299 71.6357s25.2646 64.9189 58.6299 71.6348v114.489c0 7.99414 6.39648 14.3906 14.3916 14.3906
|
||||
s14.3906 -6.39648 14.3906 -14.3906v-114.489zM80.3232 203.246c24.3047 0 44.2393 19.8281 44.2393 44.2393s-19.8281 44.2393 -44.2393 44.2393s-44.2393 -19.8281 -44.2393 -44.2393s19.8281 -44.2393 44.2393 -44.2393zM446.068 319.12
|
||||
c33.4717 -6.71582 58.6299 -36.1367 58.6299 -71.5283c0 -35.3906 -25.2646 -64.9189 -58.6299 -71.6348v-225.566c0 -7.99414 -6.39648 -14.3906 -14.3916 -14.3906s-14.3906 6.39648 -14.3906 14.3906v225.459c-33.3662 6.71582 -58.6299 36.2441 -58.6299 71.6357
|
||||
s25.2637 64.9189 58.6299 71.6348v114.489c0 7.99414 6.39551 14.3906 14.3906 14.3906s14.3916 -6.39648 14.3916 -14.3906v-114.489zM431.677 203.246c24.4111 0 44.2393 19.8281 44.2393 44.2393s-19.8281 44.2393 -44.2393 44.2393s-44.2393 -19.8281 -44.2393 -44.2393
|
||||
s19.8281 -44.2393 44.2393 -44.2393z" />
|
||||
<glyph glyph-name="uniF101" unicode=""
|
||||
d="M452.09 -42.9824c2.16699 -4.55078 1.8418 -9.8584 -0.758789 -14.1924c-2.59961 -4.22461 -7.36621 -6.8252 -12.3506 -6.8252h-365.961c-4.98438 0 -9.64258 2.60059 -12.3506 6.8252c-2.60059 4.22559 -2.92578 9.53418 -0.758789 14.084
|
||||
c24.1592 50.1602 66.8438 89.3779 118.195 108.878v29.793c-30.876 1.2998 -56.9854 7.69238 -77.8936 18.959c-18.3096 9.8584 -25.8926 20.042 -27.1934 21.9922c-3.25 4.76758 -4.33301 10.9424 -1.4082 15.8174c2.81738 4.7666 7.25879 7.58398 12.7842 7.15039
|
||||
c1.625 0.433594 7.9082 2.59961 14.5166 13.542c8.01758 13.3252 17.334 41.0596 16.0342 97.7197c-1.2998 56.5518 9.31738 100.212 31.5264 129.68c19.0674 25.3506 41.6016 33.9092 53.7344 38.459l0.650391 0.216797c15.3838 5.95898 30.4424 8.88379 44.8516 8.88379
|
||||
c30.876 0 51.0264 -13.542 60.2354 -21.5586h2.81641c24.1592 0 44.2021 -8.66699 59.5859 -25.6768c14.1914 -15.8164 23.9424 -38.459 28.8174 -67.2764c4.22461 -25.1348 4.7666 -54.3857 1.5166 -86.8867c-7.47559 -76.2686 29.793 -89.0527 30.2256 -89.1611
|
||||
c4.9834 -1.4082 8.88379 -5.30859 10.1836 -10.292c1.30078 -4.9834 -0.108398 -10.292 -3.68262 -13.9756c-28.2764 -28.9258 -76.4863 -35.4258 -102.054 -36.834v-31.3096c52.002 -19.6094 94.9033 -58.502 118.737 -108.012zM97.9365 -34.6406v-0.216797h315.802
|
||||
c-23.292 35.3184 -58.502 62.1855 -99.6699 75.5107c-6.06641 1.9502 -10.1836 7.47559 -10.1836 13.8672v56.0098c0 4.00879 1.625 7.80078 4.44238 10.5088c2.81641 2.70898 6.7168 4.22559 10.6162 4.11719h2.16699c23.9424 0 55.6855 4.6582 78.0029 17.0088
|
||||
c-16.5762 14.9502 -35.751 45.7178 -29.6846 107.146c6.06641 60.8848 -2.38379 108.878 -22.9678 131.629c-9.75 10.834 -22.209 16.0342 -37.918 16.0342c-2.27441 0 -4.6582 -0.108398 -7.04102 -0.325195c-4.87598 -0.433594 -9.53418 1.4082 -12.5674 5.2002
|
||||
c-1.19238 1.4082 -15.4922 16.792 -43.4434 16.792c-10.7256 0 -22.3174 -2.38379 -34.4512 -6.93359l-0.649414 -0.216797c-20.584 -7.7998 -69.0107 -26.2168 -66.1943 -140.079c1.4082 -61.6436 -8.66699 -104.112 -30.0088 -126.321
|
||||
c-1.08398 -1.08301 -2.16699 -2.05762 -3.14258 -3.0332c13.8672 -8.2334 39.3262 -17.875 81.4697 -17.4424c3.90039 0.108398 7.58301 -1.5166 10.4004 -4.22461c2.81641 -2.70898 4.33301 -6.50098 4.33301 -10.4004v-54.4941
|
||||
c0 -6.2832 -4.11621 -11.917 -10.1836 -13.8662c-40.626 -13.1094 -75.7275 -40.3018 -99.1279 -76.2695z" />
|
||||
<glyph glyph-name="uniF106" unicode=""
|
||||
d="M436.939 372.958c48.3486 -48.3486 74.9355 -112.777 74.9355 -181.065s-26.5869 -132.609 -74.9355 -180.958c-48.3477 -48.3486 -112.562 -74.9346 -180.958 -74.9346c-42.4521 0 -84.1533 10.5059 -121.246 30.4453
|
||||
c-27.5508 -18.9746 -60.5693 -29.8018 -91.9795 -29.8018v0c-6.3252 0 -12.4355 0.536133 -18.1172 1.39355c-10.3994 1.60742 -18.332 9.64844 -19.9404 20.0469c-1.5 10.291 3.75293 20.3682 13.0791 24.9775c15.3301 7.50488 28.8379 23.1562 40.3086 46.6338
|
||||
c-83.082 100.984 -76.3291 249.996 16.9375 343.263c48.3486 48.3486 112.563 75.042 180.958 75.042c68.3955 0 132.61 -26.6934 180.958 -75.042zM416.464 31.5176c88.5498 88.4424 88.5498 232.522 0 321.18c-42.8809 42.8809 -99.9121 66.4648 -160.482 66.4648
|
||||
c-60.5693 0 -117.601 -23.584 -160.482 -66.4648c-84.6895 -84.6904 -88.8711 -221.267 -9.54102 -310.889c3.75195 -4.28809 4.7168 -10.291 2.46582 -15.4365c-12.1133 -27.7656 -26.5859 -47.7051 -43.8457 -60.5693c14.4727 0.321289 47.4912 4.07324 80.2949 29.4805
|
||||
c2.57227 1.92969 5.78906 3.00195 8.89746 3.00195c2.46582 0 5.03906 -0.750977 7.29004 -2.03711c34.626 -20.3691 74.3984 -31.1963 114.921 -31.1963c60.5703 0 117.602 23.585 160.482 66.4658zM363.185 276.476c7.93262 0 14.4727 -6.43164 14.4727 -14.4727
|
||||
c0 -8.04004 -6.43262 -14.4717 -14.4727 -14.4717h-214.405c-8.04004 0 -14.4727 6.43164 -14.4727 14.4717c0 8.04102 6.43262 14.4727 14.4727 14.4727h214.405zM363.185 206.473c7.93262 0 14.4727 -6.43262 14.4727 -14.4727s-6.43262 -14.4727 -14.4727 -14.4727
|
||||
h-214.405c-8.04004 0 -14.4727 6.43262 -14.4727 14.4727s6.43262 14.4727 14.4727 14.4727h214.405zM363.185 136.469c7.93262 0 14.4727 -6.43164 14.4727 -14.4717c0 -8.04102 -6.43262 -14.4727 -14.4727 -14.4727h-214.405
|
||||
c-8.04004 0 -14.4727 6.43164 -14.4727 14.4727c0 8.04004 6.43262 14.4717 14.4727 14.4717h214.405z" />
|
||||
<glyph glyph-name="uniF10B" unicode=""
|
||||
d="M497.331 197.922c8.14941 0 14.6689 -6.62793 14.6689 -14.6689v-133.433c0 -52.374 -42.5938 -95.0762 -95.0762 -95.0762h-321.848c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v135.605c0 8.14941 6.51953 14.6689 14.6689 14.6689
|
||||
s14.6689 -6.51953 14.6689 -14.6689v-135.605c0 -36.1836 29.4463 -65.7393 65.7383 -65.7393h321.848c36.1836 0 65.7383 29.4473 65.7383 65.7393v133.433c0 8.14941 6.51953 14.6689 14.6689 14.6689zM245.678 71.2256l-93.3379 93.2295
|
||||
c-5.75879 5.75879 -5.75879 14.9951 0 20.7539s14.9941 5.75879 20.7539 0l68.2373 -68.2383v297.617c0 8.14941 6.51953 14.6689 14.6689 14.6689s14.6689 -6.51953 14.6689 -14.6689v-297.617l68.1289 68.2383c5.75879 5.75879 14.9951 5.75879 20.7539 0
|
||||
s5.75879 -14.9951 0 -20.7539l-93.2295 -93.2295c-2.8252 -2.93359 -6.62793 -4.3457 -10.3223 -4.3457s-7.49707 1.52051 -10.3223 4.3457z" />
|
||||
<glyph glyph-name="uniF119" unicode=""
|
||||
d="M448.851 421.953c34.8701 0 63.1494 -28.2783 63.1494 -63.1494v-244.624c0 -34.8711 -28.3857 -63.1494 -63.1494 -63.1494h-178.498v-60.2793h88.9834c7.97266 0 14.3516 -6.37891 14.3516 -14.3525s-6.37891 -14.3516 -14.3516 -14.3516h-206.672
|
||||
c-7.97266 0 -14.3516 6.37793 -14.3516 14.3516s6.37891 14.3525 14.3516 14.3525h88.9834v60.2793h-178.498c-34.8701 0 -63.1494 28.3848 -63.1494 63.1494v244.624c0 34.8711 28.3857 63.1494 63.1494 63.1494h385.701zM63.1494 393.249
|
||||
c-19.0303 0 -34.4453 -15.415 -34.4453 -34.4453v-204.438h454.698v204.438c0 19.0303 -15.5225 34.4453 -34.4453 34.4453h-385.808zM448.851 79.7344c19.0303 0 34.4453 15.5215 34.4453 34.4453v11.4814h-454.592v-11.4814c0 -19.0303 15.5215 -34.4453 34.4453 -34.4453
|
||||
h385.701z" />
|
||||
<glyph glyph-name="uniF11B" unicode=""
|
||||
d="M506.858 221.764c3.25293 -2.72852 5.1416 -6.82031 5.1416 -11.0176s-1.88867 -8.18457 -5.1416 -10.9121l-226.013 -186.98c-4.30176 -3.46191 -10.0732 -4.19727 -15.1094 -1.88867c-5.03711 2.30859 -8.18457 7.34473 -8.18457 12.8018v97.8965
|
||||
c-91.3916 -0.314453 -154.452 -6.82031 -231.049 -141.861c-2.51855 -4.51172 -7.24023 -7.13477 -12.2773 -7.13477c-1.15332 0 -2.41309 0.104492 -3.56738 0.419922c-6.29492 1.67871 -10.5977 7.34473 -10.5977 13.7451v11.332
|
||||
c-0.314453 49.001 -0.628906 123.079 33.1572 185.616c39.2432 72.5039 114.686 111.223 224.439 115.21v98.2109c0 5.45703 3.14746 10.4932 8.0791 12.8018s10.8076 1.57324 15.0049 -1.88867zM285.987 53.8799l189.498 156.866l-189.604 156.446v-82.0527
|
||||
c0 -7.76465 -6.29492 -14.0605 -14.0596 -14.165c-107.761 -0.944336 -177.642 -33.9961 -213.736 -100.73c-22.7695 -42.0752 -28.2256 -91.8105 -29.4854 -133.467c31.374 45.6436 63.167 74.708 99.8906 91.8105c42.4961 19.832 86.25 21.4053 143.331 21.4053
|
||||
c3.67188 0 7.34473 -1.57324 9.96777 -4.19629c2.72852 -2.62402 4.19727 -6.19141 4.19727 -9.96875v-81.9482z" />
|
||||
<glyph glyph-name="uniF103" unicode=""
|
||||
d="M445.14 314.912c7.80371 0 14.0254 -6.32812 14.1309 -14.0264v-350.859c0 -7.69922 -6.22168 -14.0264 -14.0254 -14.0264h-311.946c-44.3975 0 -80.5693 36.1719 -80.5693 80.5703v350.859c0 44.3984 36.1719 80.5703 80.5693 80.5703h311.946
|
||||
c7.69824 0 14.0254 -6.22168 14.0254 -14.0264c0 -7.69824 -6.22168 -14.0254 -14.0254 -14.0254h-311.946c-29.001 0 -52.5176 -23.623 -52.5176 -52.5186c0 -29.001 23.6221 -52.5176 52.5176 -52.5176h311.841zM431.113 -35.9482v0v322.702h-297.814
|
||||
c-20.0371 0 -38.3867 7.38281 -52.5176 19.5098v-289.693c0 -29.001 23.6221 -52.5186 52.5176 -52.5186h297.814zM137.729 380.19h263.435c7.69824 0 14.0264 -6.32715 14.0264 -14.0264c0 -7.69824 -6.22266 -14.0254 -14.0264 -14.0254h-263.435
|
||||
c-7.69824 0 -14.0264 6.32715 -14.0264 14.0254c0 7.69922 6.22266 14.0264 14.0264 14.0264zM187.083 25.4287c-7.27637 2.63672 -11.0732 10.6514 -8.75293 18.0332l64.1182 179.174v0c0.105469 0.421875 0.316406 0.84375 0.527344 1.26562l0.316406 0.632812
|
||||
l0.316406 0.632812s0.316406 0.421875 0.421875 0.632812c0.105469 0.105469 0.210938 0.31543 0.316406 0.420898c0.210938 0.210938 0.316406 0.421875 0.527344 0.632812c0.210938 0.105469 0.316406 0.316406 0.421875 0.421875l0.527344 0.527344l0.527344 0.527344
|
||||
c0.105469 0.210938 0.316406 0.316406 0.421875 0.421875c0.210938 0.210938 0.421875 0.316406 0.632812 0.527344c0.105469 0.105469 0.316406 0.210938 0.421875 0.316406c0.316406 0.105469 0.527344 0.316406 0.738281 0.421875l0.632812 0.316406l0.632812 0.316406
|
||||
c0.421875 0.210938 0.84375 0.421875 1.26465 0.527344v0h0.105469c0.316406 0.105469 0.738281 0.210938 1.16016 0.316406c0.210938 0 0.316406 0.105469 0.527344 0.105469c0.316406 0.105469 0.527344 0.210938 0.84375 0.210938
|
||||
c0.210938 0.105469 0.421875 0.105469 0.632812 0.105469c0.210938 0.105469 0.527344 0.105469 0.738281 0.105469h0.738281h0.632812s0.527344 -0.105469 0.738281 -0.105469c0.210938 -0.105469 0.421875 -0.105469 0.632812 -0.105469
|
||||
c0.316406 -0.105469 0.527344 -0.105469 0.84375 -0.210938c0.210938 0 0.316406 -0.105469 0.527344 -0.105469l1.26562 -0.316406h0.105469v0c0.421875 -0.105469 0.842773 -0.316406 1.26465 -0.527344l0.632812 -0.316406l0.632812 -0.316406
|
||||
s0.421875 -0.316406 0.632812 -0.421875c0.105469 -0.105469 0.316406 -0.210938 0.421875 -0.316406c0.210938 -0.210938 0.421875 -0.316406 0.632812 -0.527344c0.105469 -0.210938 0.316406 -0.316406 0.421875 -0.421875l0.527344 -0.527344
|
||||
s0.421875 -0.316406 0.527344 -0.527344c0.210938 -0.105469 0.316406 -0.316406 0.421875 -0.421875c0.210938 -0.210938 0.421875 -0.421875 0.527344 -0.632812c0.105469 -0.104492 0.210938 -0.31543 0.316406 -0.420898
|
||||
c0.105469 -0.316406 0.316406 -0.527344 0.421875 -0.738281l0.316406 -0.632812l0.316406 -0.632812c0.210938 -0.421875 0.421875 -0.84375 0.527344 -1.26562v0l63.9072 -179.174c2.63672 -7.27637 -1.16016 -15.291 -8.43652 -17.9277
|
||||
c-1.58203 -0.527344 -3.16406 -0.84375 -4.74512 -0.84375c-5.69531 0 -11.1787 3.58594 -13.1826 9.28027l-17.084 47.8779c-0.527344 -0.105469 -1.16016 -0.105469 -1.6875 -0.105469h-65.8066l-17.084 -47.7725c-2.00391 -5.69434 -7.38184 -9.28027 -13.1816 -9.28027
|
||||
c-1.58203 0 -3.16406 0.316406 -4.74609 0.84375zM255.947 176.129l-23.834 -66.4395h47.5625z" />
|
||||
<glyph glyph-name="uniF12C" unicode=""
|
||||
d="M298.227 389.779c7.91797 0 14.252 -6.43945 14.252 -14.251v-367.057c0 -7.91699 -6.33398 -14.252 -14.252 -14.252c-35.3643 0 -69.9902 9.39551 -100.288 27.2363c-27.0254 15.9414 -49.9336 38.1104 -66.7188 64.502h-75.5859
|
||||
c-30.7197 0 -55.6338 24.9141 -55.6338 55.6338v100.816c0 30.7197 24.9141 55.6338 55.6338 55.6338h75.5859c16.7852 26.3916 39.6934 48.5605 66.7188 64.502c30.2979 17.8408 64.9238 27.2354 100.288 27.2354zM124.991 114.461v155.078h-69.3574
|
||||
c-14.9902 0 -27.1309 -12.1406 -27.1309 -27.1309v-100.816c0 -14.9902 12.1406 -27.1309 27.1309 -27.1309h69.3574zM283.976 23.251v337.393c-53.5225 -4.54004 -102.295 -34.626 -130.481 -80.8643v-175.664c28.1865 -46.2383 76.959 -76.3252 130.481 -80.8643z
|
||||
M368.323 302.37c36.1045 -25.125 57.7451 -66.7178 57.7451 -110.634s-21.6406 -85.1924 -57.7451 -110.424c-2.42773 -1.68848 -5.27832 -2.5332 -8.12891 -2.5332c-4.53906 0 -8.97266 2.11133 -11.7178 6.12305c-4.43359 6.43945 -2.85059 15.3076 3.58984 19.8467
|
||||
c28.5029 19.8467 45.499 52.4668 45.499 87.0928s-16.9961 67.2461 -45.499 87.0928c-6.5459 4.53906 -8.12891 13.4072 -3.58984 19.8467c4.53906 6.54492 13.4072 8.12891 19.8467 3.58887zM423.43 361.382c55.5283 -38.6367 88.5703 -101.978 88.5703 -169.435
|
||||
s-33.1484 -130.798 -88.5703 -169.436c-2.42871 -1.68848 -5.27832 -2.5332 -8.12891 -2.5332c-4.53906 0 -8.97363 2.11133 -11.7178 6.12305c-4.43457 6.43945 -2.85059 15.3066 3.58887 19.8467c47.8223 33.2529 76.3252 87.9375 76.3252 145.999
|
||||
s-28.5029 112.64 -76.3252 145.999c-6.54492 4.53906 -8.12891 13.4072 -3.58887 19.8467c4.53906 6.54492 13.4062 8.12891 19.8467 3.58887z" />
|
||||
<glyph glyph-name="uniF129" unicode=""
|
||||
d="M460.8 5.41797c0.104492 -0.417969 0.104492 -0.836914 0.104492 -1.25586c0 -37.5889 -34.4473 -68.1621 -76.8516 -68.1621h-256.105c-42.4043 0 -76.8516 30.5732 -76.8516 68.1621c0 0.418945 0.104492 0.837891 0.104492 1.25586l29.3164 330.131
|
||||
c0.628906 7.3291 6.70117 12.8779 14.0303 12.8779h60.3096c0.837891 55.0742 45.8604 99.5732 101.144 99.5732s100.306 -44.499 101.144 -99.5732h60.3096c7.22461 0 13.4014 -5.54883 14.0303 -12.8779zM256 419.73c-39.6826 0 -72.0361 -31.8301 -72.874 -71.3037
|
||||
h145.748c-0.837891 39.4736 -33.1914 71.3037 -72.874 71.3037zM384.053 -35.7305c26.4893 0 48.1631 17.5908 48.582 39.2646l-28.165 316.519h-47.3262v-42.9287c0 -7.85254 -6.28223 -14.1348 -14.1348 -14.1348s-14.1348 6.28223 -14.1348 14.1348v42.9287h-145.853
|
||||
v-42.9287c0 -7.85254 -6.28223 -14.1348 -14.1348 -14.1348s-14.1348 6.28223 -14.1348 14.1348v42.9287h-47.3262l-28.0605 -316.519c0.418945 -21.6738 21.9873 -39.2646 48.582 -39.2646h256.105z" />
|
||||
<glyph glyph-name="uniF109" unicode=""
|
||||
d="M489.891 284.098c10.4932 -13.4912 15.418 -27.7324 14.9902 -42.5088c-0.642578 -16.2744 -7.92383 -29.0166 -13.9199 -36.833c6.95996 -17.3457 9.74414 -44.543 -13.5986 -65.8506c-16.917 -15.5254 -45.9346 -22.4854 -85.9805 -20.5586
|
||||
c-28.1602 1.28516 -51.7168 6.53223 -52.6797 6.74609h-0.107422c-5.46094 0.963867 -11.1357 2.1416 -16.918 3.42676c-0.427734 -6.85352 0.75 -23.8779 13.3848 -62.2109c14.9902 -45.6133 14.0264 -80.5195 -2.78418 -103.861
|
||||
c-17.7744 -24.5195 -46.2559 -26.4473 -54.501 -26.4473c-8.1377 0 -15.418 3.31934 -20.665 9.42285c-11.7783 13.8125 -10.3857 39.2959 -8.99414 51.0742c-14.0264 37.9043 -53.6445 130.844 -87.2656 156.649c-0.642578 0.427734 -1.17773 0.962891 -1.71289 1.49902
|
||||
c-9.85059 10.3857 -16.4893 21.6289 -20.9863 31.4795c-6.31738 -3.42676 -13.4912 -5.35352 -21.2012 -5.35352h-65.3145c-24.5205 0 -44.543 19.915 -44.543 44.542v173.996c0 24.5195 19.916 44.542 44.543 44.542h65.3145c9.53027 0 18.417 -2.99805 25.6982 -8.1377
|
||||
l25.1621 2.99902c3.85449 0.535156 72.3818 9.20801 142.729 7.81641c34.9062 2.67676 63.709 1.71289 85.6592 -2.99902c32.7646 -6.95898 55.1436 -20.8789 66.6006 -41.4375c8.67285 -15.5254 8.67285 -31.1582 7.28125 -41.0088
|
||||
c21.3076 -19.2734 25.0547 -40.4746 24.3057 -55.5713c-0.536133 -8.56641 -2.46289 -15.9541 -4.49707 -21.415zM116.951 209.574c8.67383 0 15.6328 6.95996 15.6328 15.6328v173.995c0 8.56543 -6.95898 15.6328 -15.6328 15.6328h-65.3145
|
||||
c-8.56641 0 -15.6328 -6.95996 -15.6328 -15.6328v-173.995c0 -8.56641 6.95996 -15.6328 15.6328 -15.6328h65.3145zM461.408 199.188c-3.85449 5.99609 -2.89062 13.8125 2.46289 18.417c0.75 0.642578 11.5645 11.1357 11.9922 25.1621
|
||||
c0.321289 9.42285 -4.17578 19.0596 -13.3838 28.6963c-4.60449 4.81836 -5.46094 11.8848 -1.92773 17.4531c1.92773 3.42578 16.4893 31.1582 -15.2041 54.9287c-5.13965 3.85449 -7.17383 10.3867 -4.92578 16.3828c0.321289 1.28418 4.49707 14.8828 -3.10449 28.0527
|
||||
c-7.28125 12.7422 -23.1279 21.7363 -47.1133 26.876c-19.3799 4.17578 -45.6133 4.9248 -78.0566 2.35547h-1.49902c-68.8486 1.49902 -138.554 -7.49512 -139.196 -7.60254h-0.106445l-10.8154 -1.28418c0.642578 -2.99805 0.963867 -6.21094 0.963867 -9.42285v-173.889
|
||||
c0 -4.60352 -0.749023 -9.10059 -2.03418 -13.2764c1.92773 -7.1748 7.3877 -23.1279 19.916 -36.7266c48.0762 -38.1182 95.0811 -166.821 97.1162 -172.282c0.749023 -2.35547 0.962891 -4.81836 0.642578 -7.17383c-1.92773 -12.0996 -1.17871 -26.7686 1.3916 -31.0518
|
||||
c5.78223 0.107422 20.9863 1.71289 30.1943 14.4551c10.9219 15.0977 10.4941 42.0801 -1.28418 77.8428c-17.8818 54.501 -19.4873 83.1963 -5.24707 95.8311c7.06738 6.31738 16.4893 6.63867 23.3418 4.17578c6.4248 -1.3916 12.7422 -2.67676 18.6318 -3.74707
|
||||
c0.535156 -0.107422 0.962891 -0.213867 1.3916 -0.321289c32.8711 -7.28125 91.7627 -11.5645 112.214 7.06641c7.49512 6.85352 10.2783 15.0977 8.67285 25.0557c-1.28516 8.03027 -5.0332 13.9199 -5.0332 14.0264z" />
|
||||
<glyph glyph-name="uniF122" unicode=""
|
||||
d="M474.302 260.83c20.8955 0 37.9062 -16.9053 37.6963 -37.6963v-58.4873c0 -20.8955 -17.0107 -37.9062 -37.9062 -37.9062h-6.19531c-11.4453 -31.1865 -31.1865 -59.7471 -57.332 -83.1631c-0.209961 -0.314453 -0.419922 -0.524414 -0.735352 -0.735352
|
||||
l-0.209961 -0.209961c-0.945312 -0.839844 -1.99512 -1.67969 -2.93945 -2.51953c-19.8457 -18.0605 -21.5264 -38.9561 -21.6309 -43.3662v-19.1113c0 -14.9102 -12.0762 -26.9854 -26.9863 -26.9854h-78.2275c-14.9111 0 -26.9863 12.0752 -26.9863 26.9854v3.15039
|
||||
c-11.2354 -0.525391 -22.4707 -0.419922 -33.6006 0.419922v-3.57031c0 -14.9102 -12.0762 -26.9854 -26.9863 -26.9854h-78.2275c-13.4404 0 -24.6758 9.97461 -26.6709 22.9951c-0.209961 1.05078 -0.31543 2.10059 -0.31543 3.25586v0.734375v5.46094
|
||||
c-0.524414 11.1299 -4.2002 38.8506 -25.4111 60.6914c-15.3301 15.8555 -28.9805 36.4365 -40.4258 61.0078c-6.8252 13.5449 -12.1807 27.7207 -15.751 42.2109l-0.104492 0.525391c-0.105469 0.209961 -0.105469 0.524414 -0.209961 0.839844
|
||||
c-3.46582 14.3857 -5.14551 29.0859 -5.14551 43.7861c0 56.9121 25.2012 110.359 70.9824 150.365c45.2568 39.4814 105.214 61.3223 168.951 61.3223c9.97559 0 20.0557 -0.629883 29.9258 -1.67969c3.99023 -0.31543 25.3057 -1.89062 42.1064 -7.14062
|
||||
c13.9658 16.2754 37.2764 33.3916 75.6025 38.1162c1.15527 0.105469 2.41504 0.209961 3.57031 0.209961c10.1855 0 19.6357 -5.56445 24.6758 -14.5947c5.04004 -9.13574 4.72559 -19.8457 -0.839844 -28.7715c-9.97559 -15.751 -12.916 -30.9756 -9.03027 -45.0469
|
||||
c27.7207 -23.415 48.5117 -52.291 60.9023 -84.1074h7.45508zM391.139 405.104l-1.88965 14.0713l1.78418 -14.0713v0v0.105469l0.105469 -0.105469c-0.105469 0 -0.105469 0 0 0zM483.856 164.542h0.105469v58.1719c0 5.25 -4.30566 9.55469 -9.55566 9.55469h-17.2207
|
||||
h-0.209961c-6.08984 0 -11.5498 3.99023 -13.4404 9.76562c-10.5 32.0264 -31.0811 61.1123 -59.4316 84.0029c-3.15039 2.625 -5.14551 6.51074 -5.25 10.6055c-6.19531 21.9453 -1.99512 45.4668 12.1797 68.1475c-35.0703 -4.30566 -53.3418 -21.4209 -62.4766 -34.9658
|
||||
c-4.0957 -5.98535 -12.0752 -7.98047 -18.4805 -4.51562c-8.71582 4.72559 -30.5566 7.77051 -42.3164 8.50488c-0.210938 0.105469 -0.420898 0.105469 -0.630859 0.105469c-8.9248 0.945312 -18.0605 1.46973 -27.0908 1.46973
|
||||
c-116.659 0 -211.582 -82.2178 -211.582 -183.336c0 -12.6006 1.5752 -25.3057 4.51465 -37.6963v-0.209961c0 -0.105469 0.105469 -0.209961 0.105469 -0.31543c3.04492 -12.4951 7.66504 -24.6758 13.6504 -36.3311c0 -0.209961 0.105469 -0.31543 0.209961 -0.525391
|
||||
c10.0801 -21.8408 21.9463 -39.7959 35.1768 -53.4463c28.2451 -29.0859 32.7607 -65.3125 33.3906 -79.4883v-0.629883v-4.51465h75.498v17.9551c0 4.0957 1.78516 7.98047 4.83008 10.7109c3.04492 2.625 7.13965 3.88477 11.2354 3.35938
|
||||
c10.8154 -1.46973 21.8408 -2.20508 32.8662 -2.20508c8.50488 0 17.1152 0.419922 25.7256 1.36523c3.88477 0.419922 7.98047 -0.839844 10.9209 -3.57031c3.04492 -2.625 4.72461 -6.50977 4.72461 -10.5v-17.1152h75.6025v17.9551v0.31543
|
||||
c0.209961 8.61035 3.04492 38.5361 31.0811 63.9473c0.105469 0.104492 0.209961 0.209961 0.31543 0.314453c0.945312 0.839844 1.88965 1.68066 2.83496 2.52051l0.105469 0.104492c0.104492 0.105469 0.104492 0.105469 0.209961 0.209961
|
||||
c25.3057 22.5762 43.6807 50.5068 52.9209 80.748c1.78516 5.87988 7.35059 9.97559 13.5459 9.97559h16.3809c5.25 0 9.55469 4.30469 9.55469 9.55566z" />
|
||||
<glyph glyph-name="uniF12A" unicode=""
|
||||
d="M153.703 110.887c-10.8379 0 -21.0449 2.94629 -29.6738 7.99707v-38.5127c0 -16.3105 13.2588 -29.6738 29.6738 -29.6738h268.328c31.5684 0 57.3486 -25.7812 57.3486 -57.3486c0 -31.5684 -25.7803 -57.3486 -57.3486 -57.3486s-57.3486 25.6758 -57.3486 57.3486
|
||||
c0 10.5225 2.84082 20.4141 7.89258 28.9375h-127.745c5.05078 -8.41797 7.8916 -18.415 7.8916 -28.9375c0 -31.5684 -25.7803 -57.3486 -57.3486 -57.3486c-31.5674 0 -57.3486 25.6758 -57.3486 57.3486c0 10.7334 3.05176 20.835 8.20801 29.4629
|
||||
c-28.5166 3.68359 -50.6143 28.0957 -50.6143 57.5596v88.3906v69.9756v78.2881v10.207v64.3984l-68.8184 29.043c-7.26074 3.05176 -10.6279 11.3643 -7.57617 18.625s11.3643 10.6279 18.625 7.57617l77.5527 -32.6201
|
||||
c0.420898 -0.209961 0.946289 -0.420898 1.36719 -0.630859c0.105469 -0.105469 0.210938 -0.105469 0.316406 -0.210938c0.209961 -0.105469 0.525391 -0.31543 0.841797 -0.526367c0.209961 -0.209961 0.420898 -0.31543 0.630859 -0.525391
|
||||
c0.105469 -0.105469 0.31543 -0.210938 0.420898 -0.316406c0.31543 -0.209961 0.526367 -0.420898 0.736328 -0.630859l0.316406 -0.316406c0.209961 -0.209961 0.420898 -0.419922 0.630859 -0.630859l0.31543 -0.31543
|
||||
c0.105469 -0.210938 0.316406 -0.420898 0.420898 -0.631836c0.105469 -0.209961 0.210938 -0.31543 0.316406 -0.526367c0.104492 -0.209961 0.209961 -0.31543 0.31543 -0.525391c0.209961 -0.210938 0.31543 -0.420898 0.420898 -0.631836
|
||||
c0.105469 -0.105469 0.105469 -0.31543 0.209961 -0.420898c0.210938 -0.31543 0.316406 -0.526367 0.420898 -0.841797c0 -0.105469 0.105469 -0.31543 0.105469 -0.420898l0.316406 -0.947266c0 -0.209961 0.104492 -0.31543 0.104492 -0.525391
|
||||
c0.105469 -0.316406 0.105469 -0.526367 0.210938 -0.841797c0 -0.316406 0.105469 -0.631836 0.105469 -0.947266c0.104492 -0.210938 0.104492 -0.31543 0.104492 -0.420898c0.105469 -0.420898 0.105469 -0.947266 0.105469 -1.47363v-49.877l357.56 -49.3516
|
||||
c0.210938 0 0.316406 -0.105469 0.420898 -0.105469c0.316406 0 0.526367 0 0.841797 -0.104492c0.105469 0 0.316406 -0.105469 0.526367 -0.105469c0.210938 0 0.526367 -0.105469 0.737305 -0.210938c0.209961 -0.104492 0.419922 -0.104492 0.630859 -0.209961
|
||||
s0.420898 -0.210938 0.631836 -0.31543c0.209961 -0.105469 0.420898 -0.210938 0.630859 -0.316406c0.210938 -0.104492 0.420898 -0.209961 0.631836 -0.31543c0.209961 -0.209961 0.420898 -0.31543 0.630859 -0.420898
|
||||
c0.210938 -0.105469 0.31543 -0.209961 0.526367 -0.31543c0.209961 -0.105469 0.420898 -0.31543 0.630859 -0.420898c0.210938 -0.105469 0.316406 -0.31543 0.526367 -0.420898l0.526367 -0.526367c0.105469 -0.105469 0.31543 -0.209961 0.420898 -0.420898
|
||||
c0.105469 -0.209961 0.31543 -0.31543 0.420898 -0.526367c0.105469 -0.209961 0.31543 -0.31543 0.420898 -0.526367c0.209961 -0.209961 0.31543 -0.31543 0.420898 -0.525391c0.105469 -0.210938 0.31543 -0.420898 0.420898 -0.631836
|
||||
c0.105469 -0.209961 0.209961 -0.31543 0.31543 -0.526367c0.105469 -0.209961 0.210938 -0.420898 0.31543 -0.630859c0.105469 -0.105469 0.210938 -0.31543 0.316406 -0.526367c0.104492 -0.31543 0.209961 -0.526367 0.31543 -0.736328
|
||||
c0.105469 -0.210938 0.105469 -0.420898 0.209961 -0.631836c0.105469 -0.209961 0.105469 -0.420898 0.210938 -0.630859c0.105469 -0.31543 0.105469 -0.526367 0.209961 -0.736328c0 -0.210938 0.105469 -0.420898 0.105469 -0.631836
|
||||
c0 -0.209961 0.105469 -0.526367 0.105469 -0.736328c0.105469 -0.210938 0.105469 -0.420898 0.105469 -0.631836v-0.841797v-0.420898v-118.695c0 -31.9893 -26.0967 -58.085 -58.085 -58.085h-282.113zM224.416 -6.5459c0 15.9941 -13.0488 28.9365 -28.9375 28.9365
|
||||
c-15.8896 0 -28.9375 -13.0479 -28.9375 -28.9365c0 -15.8896 13.0479 -28.9375 28.9375 -28.9375c15.8887 0 28.9375 12.9424 28.9375 28.9375zM451.074 -6.5459c0 15.9941 -13.0488 28.9365 -28.9375 28.9365c-15.8896 0 -28.9375 -13.0479 -28.9375 -28.9365
|
||||
c0 -15.8896 13.0479 -28.9375 28.9375 -28.9375c15.8887 0 28.9375 12.9424 28.9375 28.9375zM435.816 139.298c16.3096 0 29.6738 13.2588 29.6738 29.7793v106.384l-341.461 47.0361v-5.26074v-78.2891v-69.9756c0 -16.3105 13.2588 -29.6738 29.6738 -29.6738h282.113z
|
||||
" />
|
||||
<glyph glyph-name="uniF124" unicode=""
|
||||
d="M423.66 343.054c41.7031 0 75.6592 -33.957 75.6592 -75.6602v-122.03c0 -41.7031 -33.9561 -75.6602 -75.6592 -75.6602h-13.2646v-95.9268c0 -20.7979 -16.9775 -37.7764 -37.7764 -37.7764h-234.618c-20.7979 0 -37.7764 16.9785 -37.7764 37.7764v95.9268h-11.8848
|
||||
c-41.7031 0 -75.6592 33.957 -75.6592 75.6602v122.03c0 41.7031 33.9561 75.6602 75.6592 75.6602h11.9912v29.2871c0 41.7031 33.9561 75.6592 75.6592 75.6592h158.853c41.7031 0 75.6592 -33.9561 75.6592 -75.6592v-29.2871h13.1582zM128.875 372.341v-29.2871h252.764
|
||||
v29.2871c0 25.8916 -21.1162 47.0088 -47.0078 47.0088h-158.747c-25.8916 0 -47.0088 -21.1172 -47.0088 -47.0088zM381.745 -26.4355v158.216h-252.87v-158.216c0 -4.9873 4.13867 -9.12598 9.12598 -9.12598h234.618c4.9873 0 9.12598 4.13867 9.12598 9.12598z
|
||||
M470.669 145.257v122.031c0 25.8916 -21.1172 47.0078 -47.0088 47.0078h-335.32c-25.8916 0 -47.0088 -21.1162 -47.0088 -47.0078v-122.031c0 -25.8916 21.1172 -47.0088 47.0088 -47.0088h11.9912v33.5322h-21.1172c-7.95801 0 -14.3252 6.36719 -14.3252 14.3252
|
||||
c0 7.95898 6.36719 14.3252 14.3252 14.3252h350.177c7.95801 0 14.3252 -6.36621 14.3252 -14.3252c0 -7.95801 -6.36719 -14.3252 -14.3252 -14.3252h-18.8887v-33.5322h13.1582c25.8916 0 47.0088 21.1172 47.0088 47.0088zM163.575 61.3203
|
||||
c-7.85254 0 -14.3262 6.36719 -14.3262 14.3262c0 7.95801 6.36719 14.3252 14.3262 14.3252h182.409c7.95898 0 14.3262 -6.36719 14.3262 -14.3252c0 -7.95898 -6.36719 -14.3262 -14.3262 -14.3262h-182.409zM347.152 31.3965
|
||||
c7.85156 0 14.2188 -6.36719 14.2188 -14.3252c0 -7.95898 -6.36719 -14.3252 -14.3252 -14.3252h-182.41c-7.95801 0 -14.3252 6.36621 -14.3252 14.3252c0 7.95801 6.36719 14.3252 14.3252 14.3252h182.517zM423.342 286.812
|
||||
c7.95898 0 14.3252 -6.36621 14.3252 -14.3252c0 -7.95801 -6.36621 -14.3252 -14.3252 -14.3252h-29.0752c-7.95898 0 -14.3252 6.36719 -14.3252 14.3252c0 7.95898 6.36621 14.3252 14.3252 14.3252h29.0752z" />
|
||||
<glyph glyph-name="uniF12D" unicode=""
|
||||
d="M511.298 251.688c1.68164 -5.14941 0.31543 -10.8232 -3.7832 -14.6064l-115.174 -112.968l27.6377 -158.153c0.31543 -1.05078 0.420898 -2.10156 0.420898 -3.25781c0 -7.77637 -6.41016 -14.1865 -14.1865 -14.1865v0c-2.20703 0 -4.41406 0.525391 -6.51562 1.57617
|
||||
l-143.021 74.7158l-142.602 -75.5566c-2.10156 -1.15527 -4.30859 -1.68066 -6.62012 -1.68066c-2.94238 0 -5.88477 0.945312 -8.30176 2.73145c-4.41309 3.15332 -6.62012 8.5127 -5.6748 13.8721l26.7969 159.1l-115.909 112.336
|
||||
c-3.8877 3.67773 -5.35938 9.35254 -3.67773 14.502c1.68066 5.14941 6.09473 8.82715 11.4541 9.66797l159.73 23.749l70.9326 144.913c2.41699 4.93945 7.35547 7.98633 12.7148 7.98633v0c5.35938 0 10.2988 -3.04688 12.7158 -7.88086l71.8779 -144.493l159.73 -22.6982
|
||||
c5.35938 -0.84082 9.77246 -4.51855 11.4541 -9.66797zM367.331 139.142l100.146 98.0449l-138.713 19.7568c-4.62402 0.735352 -8.61719 3.57227 -10.7188 7.77539l-62.4209 125.473l-61.5801 -125.787c-1.99707 -4.2041 -5.99023 -7.04102 -10.6143 -7.77637
|
||||
l-138.607 -20.5967l100.462 -97.5195c3.3623 -3.25781 4.83398 -7.88184 4.09863 -12.5059l-23.2246 -138.188l123.791 65.5742c4.20312 2.10156 9.14258 2.20605 13.2412 0l124.211 -64.8379l-24.0645 137.977c-0.84082 4.62402 0.629883 9.35352 3.99316 12.6104z" />
|
||||
<glyph glyph-name="uniF11A" unicode=""
|
||||
d="M486.939 335.223v-0.107422v-238.504c0 -18.7754 -9.33398 -36.0488 -26.2861 -48.8164c-15.7715 -11.8018 -36.3701 -18.3467 -58.2578 -18.3467c-21.8867 0 -42.4863 6.54492 -58.2578 18.3467c-16.9512 12.7676 -26.2852 30.041 -26.2852 48.8164
|
||||
s9.33398 36.0488 26.2852 48.8164c15.7715 11.8018 36.4785 18.3467 58.2578 18.3467c20.5996 0 40.2334 -5.79395 55.5762 -16.415v168.015l-263.824 -83.9004v-228.311c0 -18.7754 -9.33398 -36.0498 -26.2852 -48.8164
|
||||
c-15.7715 -11.8018 -36.3711 -18.3467 -58.2578 -18.3467s-42.4863 6.54492 -58.2578 18.3467c-16.9521 12.7666 -26.2861 30.041 -26.2861 48.8164s9.33398 36.0488 26.2861 48.8164c15.7715 11.8018 36.4775 18.3467 58.2578 18.3467
|
||||
c20.5996 0 40.2334 -5.79395 55.5752 -16.416v188.078v0.107422v98.3838c0 6.33008 4.07715 11.9092 10.085 13.8398l292.792 93.0195c4.39844 1.39453 9.22656 0.537109 12.9824 -2.14551c3.64746 -2.68262 5.90039 -7.08105 5.90039 -11.6943v-98.2764zM109.604 -35.0322
|
||||
c30.1475 0 55.5752 17.3809 55.5752 38.1953c0 20.707 -25.4277 38.1943 -55.5752 38.1943c-30.1484 0 -55.5762 -17.4883 -55.5762 -38.1943c0 -20.707 25.4277 -38.1953 55.5762 -38.1953zM194.147 261.943l263.824 83.793v68.0215l-263.824 -83.793v-68.0215z
|
||||
M402.503 58.417c30.041 0 55.4688 17.4873 55.5762 38.1943c0 20.707 -25.4277 38.1953 -55.5762 38.1953s-55.5752 -17.4883 -55.5752 -38.1953s25.4268 -38.1943 55.5752 -38.1943z" />
|
||||
<glyph glyph-name="uniF118" unicode=""
|
||||
d="M507.516 -37.8887c5.90332 -5.9043 5.90332 -15.667 0 -21.5703c-2.95117 -3.06543 -6.9248 -4.54102 -10.7852 -4.54102c-3.85938 0 -7.83301 1.58984 -10.7842 4.54102l-124.425 124.424c-38.3711 -33.376 -88.4355 -53.584 -143.155 -53.584
|
||||
c-120.337 0 -218.31 97.9727 -218.31 218.31s97.8594 218.31 218.31 218.31c120.337 0 218.31 -97.8594 218.31 -218.31c0 -54.7197 -20.207 -104.784 -53.584 -143.155zM30.5947 229.69c0 -103.535 84.2363 -187.771 187.658 -187.771
|
||||
c103.535 0 187.657 84.3496 187.657 187.771s-84.1221 187.657 -187.657 187.657c-103.422 0 -187.658 -84.1221 -187.658 -187.657z" />
|
||||
<glyph glyph-name="uniF10D" unicode=""
|
||||
d="M449.496 386.82c34.5361 0 62.6094 -28.1797 62.5039 -62.7148v-264.316c0 -34.5361 -28.0732 -62.6094 -62.6094 -62.6094h-386.781c-34.5361 0 -62.6094 28.0732 -62.6094 62.6094v264.422c0 34.5361 28.0732 62.6094 62.6094 62.6094h386.887zM483.503 59.7891
|
||||
h-0.106445v264.316c0 18.751 -15.2549 34.0059 -34.0059 34.0059h-386.781c-18.751 0 -34.0059 -15.2549 -34.0059 -34.0059v-264.316c0 -18.751 15.2549 -34.0059 34.0059 -34.0059h386.887c18.751 0 34.0068 15.2549 34.0068 34.0059zM322.9 195.284l125.748 -119.393
|
||||
c5.7207 -5.40332 5.93262 -14.5137 0.530273 -20.2344c-2.86035 -2.96582 -6.56836 -4.44922 -10.3818 -4.44922c-3.49609 0 -7.09766 1.27148 -9.85254 3.91992l-127.338 120.981l-35.7012 -31.9932c-2.64844 -2.43652 -6.14453 -3.70801 -9.53418 -3.70801
|
||||
c-3.39062 0 -6.78027 1.16504 -9.53516 3.60156l-34.7471 31.04l-128.08 -119.816c-2.75391 -2.54199 -6.25 -3.81348 -9.74609 -3.81348c-3.81348 0 -7.62793 1.58887 -10.4883 4.55566c-5.40234 5.82617 -5.08496 14.8311 0.635742 20.2334l126.173 118.122
|
||||
l-126.702 113.248c-5.93262 5.29688 -6.46191 14.3018 -1.16504 20.2334c5.29688 5.93262 14.3018 6.46289 20.2344 1.16602l137.084 -122.677c0.847656 -0.635742 1.58887 -1.27148 2.33008 -2.0127c0 -0.106445 0.106445 -0.211914 0.211914 -0.318359l33.6885 -30.0859
|
||||
l172.68 154.987c5.93262 5.29688 14.9375 4.76758 20.2344 -1.05957c5.29688 -5.93164 4.7666 -14.9365 -1.05957 -20.2334z" />
|
||||
<glyph glyph-name="uniF111" unicode=""
|
||||
d="M480.354 190.193c0.985352 -5.36719 25.0801 -131.861 -34.9365 -204.801c-26.9414 -32.7461 -66.1494 -49.3926 -116.527 -49.3926v0h-1.75293c-36.9072 0.328125 -75.6777 11.1709 -115.323 32.1982c-31.1035 16.5371 -62.8633 39.4268 -94.4053 68.0117
|
||||
c-53.2256 48.4072 -87.1768 95.9385 -88.6006 97.9092c-4.05176 5.69531 -3.61426 13.3613 1.0957 18.5088c16.7559 18.3994 36.5791 27.8174 58.9209 27.8174c33.0742 0 59.7969 -21.5752 63.6299 -24.8604l19.7139 -15.2227l1.20508 182.349v74.6914
|
||||
c0 0.547852 0.109375 0.985352 0.109375 1.5332c1.42383 27.5986 23.875 49.0645 51.6924 49.0645c28.1465 0 51.1455 -22.7803 51.6934 -50.8164l15.4414 -126.933c4.27148 -0.985352 8.76172 -1.86133 13.3613 -2.84766
|
||||
c35.7031 -7.44727 84.5488 -17.7422 167.783 -67.3535c3.61426 -2.19043 6.13281 -5.69531 6.89941 -9.85645zM422.528 4.01074c44.2461 53.7744 33.623 148.727 29.8994 174.135c-75.3496 44.0273 -118.28 53.1172 -152.889 60.3447
|
||||
c-8.65234 1.75293 -16.7559 3.50488 -24.6416 5.58594c-5.80469 1.5332 -10.0762 6.46191 -10.8428 12.4854l-16.5371 137.884c-0.109375 0.547852 -0.109375 1.20508 -0.109375 1.75195c0 12.1572 -9.85645 22.123 -22.123 22.123
|
||||
c-12.0469 0 -21.7939 -9.41895 -22.1221 -21.4658v-0.4375v-73.9258l-1.42383 -212.247c-0.109375 -5.58496 -3.28613 -10.7324 -8.32324 -13.1426c-5.14746 -2.51855 -11.0615 -1.86133 -15.5518 1.53418l-43.6982 33.6221
|
||||
c-0.21875 0.21875 -0.547852 0.4375 -0.766602 0.657227c-0.109375 0.21875 -20.6992 17.8516 -44.4648 17.8516c-10.2949 0 -19.6035 -3.28613 -28.2559 -9.9668c12.7041 -16.4277 40.4121 -49.9404 77.1016 -83.2344c45.998 -41.7266 116.309 -91.5576 189.577 -92.1045
|
||||
h1.5332c41.0693 0 72.6104 12.9229 93.6377 38.5498z" />
|
||||
<glyph glyph-name="uniF11D" unicode=""
|
||||
d="M419.897 224.875c15.2705 0 27.6787 -12.4082 27.6787 -27.6787v-178.267c0 -45.707 -37.2236 -82.9297 -82.9297 -82.9297h-217.293c-45.7061 0 -82.9297 37.2227 -82.9297 82.9297v178.267c0 15.2715 12.4082 27.6787 27.6787 27.6787h291.632v66.7041
|
||||
c0 70.5225 -57.3711 127.788 -127.787 127.788c-70.5225 0 -127.788 -57.3721 -127.788 -127.788c0 -7.95312 -6.36328 -14.3164 -14.3164 -14.3164c-7.9541 0 -14.3164 6.36328 -14.3164 14.3164c0 86.3232 70.2031 156.421 156.421 156.421
|
||||
c86.3232 0 156.421 -70.2041 156.421 -156.421v-66.7041h7.5293zM418.942 18.9297v0v177.312h-325.779v-177.312c0 -30.0117 24.3916 -54.2969 54.2969 -54.2969h217.187c30.0117 0 54.2959 24.3916 54.2959 54.2969zM256.053 24.7627
|
||||
c-29.5869 0 -53.5537 24.0723 -53.5537 53.5537s23.9668 53.5547 53.5537 53.5547c29.4814 0 53.5547 -23.9668 53.5547 -53.5547c0 -29.4814 -23.9668 -53.5537 -53.5547 -53.5537zM256.053 103.344c-13.7861 0 -24.9209 -11.3467 -24.9209 -25.0273
|
||||
c0 -13.6797 11.1348 -24.9209 24.9209 -24.9209s24.9219 11.3467 24.9219 25.0273c0 13.6797 -11.1357 24.9209 -24.9219 24.9209z" />
|
||||
<glyph glyph-name="uniF116" unicode=""
|
||||
d="M270.635 226.041c5.62012 5.62109 14.7402 5.62109 20.2549 0c34.3594 -34.3594 34.3594 -90.3525 0 -124.712l-121.955 -121.955c-17.1797 -17.1797 -39.7676 -25.7695 -62.3564 -25.7695c-22.5879 0 -45.1768 8.58984 -62.3564 25.7695l-18.3457 18.3457
|
||||
c-16.7559 16.6494 -25.876 38.8135 -25.876 62.3564s9.22656 45.707 25.876 62.3564l121.955 121.955c5.62012 5.62012 14.6348 5.62012 20.2549 0c5.62109 -5.62012 5.62109 -14.6348 0 -20.2549l-121.955 -121.955c-23.2246 -23.2246 -23.2246 -60.9785 0 -84.2021
|
||||
l18.3467 -18.3467c23.2246 -23.2246 60.9775 -23.2246 84.2021 0l121.955 121.955c23.2246 23.2246 23.2246 60.9775 0 84.2021c-5.62109 5.62109 -5.62109 14.6348 0 20.2549zM486.23 386.28c16.6494 -16.6494 25.7695 -38.8135 25.7695 -62.3564
|
||||
s-9.22656 -45.707 -25.876 -62.3564l-121.955 -121.955c-2.75684 -2.86328 -6.46875 -4.24219 -10.0742 -4.24219s-7.31738 1.48535 -10.0752 4.24219c-5.62012 5.62012 -5.62012 14.6348 0 20.2549l121.956 121.955c23.2236 23.2246 23.2236 60.9785 0 84.2021
|
||||
l-18.3467 18.3467c-23.2246 23.2246 -60.9775 23.2246 -84.2021 0l-121.955 -121.955c-23.2246 -23.2246 -23.2246 -60.9775 0 -84.2021c5.62012 -5.62109 5.62012 -14.6348 0 -20.2549c-5.62109 -5.62109 -14.6348 -5.62109 -20.2559 0
|
||||
c-34.3594 34.3594 -34.3594 90.3525 0 124.712l121.956 121.955c34.3594 34.3604 90.3525 34.3604 124.712 0z" />
|
||||
<glyph glyph-name="uniF120" unicode=""
|
||||
d="M504.441 288.794c4.72363 -2.41504 7.55859 -7.24414 7.55859 -12.4932v-166.922c0 -66.9795 -54.4863 -121.36 -121.36 -121.36c-7.87305 0 -14.1719 6.29883 -14.1719 14.1729s6.29883 14.1729 14.1719 14.1729c51.3369 0 93.0146 41.7832 93.0146 93.0146v157.999
|
||||
c-17.1113 7.76855 -62.1494 26.1406 -126.504 37.2686c-5.56348 0.944336 -10.0781 5.14355 -11.3379 10.708l-5.24902 22.5713c-5.24902 22.3613 -24.8809 37.8984 -47.7666 37.8984h-73.5938c-22.9902 0 -42.5176 -15.6426 -47.7666 -37.8984l-5.24902 -22.5713
|
||||
c-1.25977 -5.45898 -5.77441 -9.76367 -11.3379 -10.708c-64.5645 -11.1279 -109.497 -29.5 -126.504 -37.2686v-157.999c0 -51.3369 41.7822 -93.0146 93.0137 -93.0146c7.87402 0 14.1729 -6.29883 14.1729 -14.1729s-6.29883 -14.1729 -14.1729 -14.1729
|
||||
c-66.9785 0 -121.359 54.4863 -121.359 121.36v166.922c0 5.24902 2.93945 10.0781 7.55859 12.4932c2.09961 1.15527 51.2314 26.7705 133.118 42.0977l3.14941 13.4385c8.29395 35.2734 39.2637 59.8398 75.377 59.8398h73.5938
|
||||
c36.2188 0 67.1885 -24.6709 75.377 -59.8398l3.14941 -13.4385c81.8867 -15.4316 131.019 -40.9424 133.118 -42.0977zM46.4023 317.77c-5.87891 0 -11.4434 3.7793 -13.543 9.7627c-2.41406 7.34863 1.5752 15.4326 9.02832 17.8477
|
||||
c15.748 5.14355 32.2305 9.7627 48.9219 13.752c7.55957 1.78516 15.2227 -2.83398 17.0078 -10.498c1.78418 -7.55859 -2.83496 -15.2227 -10.499 -17.0068c-15.957 -3.7793 -31.5996 -8.18848 -46.5068 -13.123c-1.46973 -0.524414 -2.93945 -0.734375 -4.40918 -0.734375
|
||||
zM256.053 306.221c89.9697 0 163.143 -73.2773 163.143 -163.248c0 -89.9697 -73.1729 -163.143 -163.143 -163.143c-89.9707 0 -163.144 73.2783 -163.144 163.248s73.1729 163.143 163.144 163.143zM256.053 8.17578c74.3271 0 134.797 60.5742 134.797 134.902
|
||||
s-60.4697 134.798 -134.797 134.798c-74.3281 0 -134.798 -60.5752 -134.798 -134.903c0 -74.3271 60.4697 -134.797 134.798 -134.797z" />
|
||||
<glyph glyph-name="uniF108" unicode=""
|
||||
d="M329.502 358.455c22.2295 0 40.3691 -18.1396 40.4736 -40.4736v-341.508c0 -22.334 -18.1396 -40.4736 -40.4736 -40.4736h-238.018c-22.333 0 -40.4727 18.1396 -40.4727 40.4736v341.508c0 22.334 18.1396 40.4736 40.4727 40.4736h238.018zM341.561 -23.4219v341.403
|
||||
c0 6.71094 -5.45215 12.1631 -12.1631 12.1631h-238.018c-6.71094 0 -12.1631 -5.45215 -12.1631 -12.1631v-341.403c0 -6.70996 5.45215 -12.1631 12.1631 -12.1631h238.018c6.71094 0 12.1631 5.45312 12.1631 12.1631zM420.516 448
|
||||
c22.333 0 40.4727 -18.1396 40.4727 -40.4736v-341.508c0 -22.334 -18.1396 -40.4736 -40.4727 -40.4736c-7.86426 0 -14.1553 6.29102 -14.1553 14.1553s6.29102 14.1553 14.1553 14.1553c6.70996 0 12.1631 5.45215 12.1631 12.1631v341.508
|
||||
c0 6.71094 -5.45312 12.1631 -12.1631 12.1631h-238.018c-6.71094 0 -12.1631 -5.45215 -12.1631 -12.1631c0 -7.86426 -6.29199 -14.1553 -14.1553 -14.1553c-7.86426 0 -14.1553 6.29102 -14.1553 14.1553c0 22.334 18.1396 40.4736 40.4736 40.4736h238.018z" />
|
||||
<glyph glyph-name="uniF123" unicode=""
|
||||
d="M255.893 448c113.134 0 205.229 -92.0957 205.444 -205.229c0 -12.8799 -1.28809 -25.8682 -3.64941 -38.5342c-0.107422 -0.750977 -0.537109 -3.00488 -1.39551 -6.86914c-3.11328 -13.9541 -7.72852 -27.4785 -13.7393 -40.4668
|
||||
c-22.1113 -52.0586 -70.8428 -132.024 -177.429 -217.681c-2.68359 -2.14648 -5.9043 -3.21973 -9.12402 -3.21973s-6.44043 1.07324 -9.12402 3.21973c-106.693 85.6562 -155.317 165.622 -177.429 217.681c-6.01074 12.9883 -10.626 26.6201 -13.7393 40.4668
|
||||
c-0.858398 3.86426 -1.28809 6.11816 -1.39551 6.86914c-2.46875 12.666 -3.64941 25.6543 -3.64941 38.5342c0 113.134 92.0957 205.229 205.229 205.229zM428.921 209.281c2.14648 10.9482 3.21973 22.2188 3.21973 33.5967c0 97.1406 -79.1074 176.248 -176.248 176.248
|
||||
s-176.248 -79.1074 -176.248 -176.248c0 -11.2705 1.07324 -22.4336 3.21973 -33.4893c0 -0.322266 0.107422 -0.537109 0.107422 -0.750977c0.107422 -0.537109 0.322266 -2.14746 0.96582 -4.61621v-0.214844c2.79102 -12.0215 6.7627 -23.8281 11.915 -34.9912
|
||||
c0.107422 -0.107422 0.107422 -0.322266 0.214844 -0.429688c20.0723 -47.5508 64.0801 -120.11 159.825 -199.219c95.7451 79.1084 139.754 151.668 159.825 199.219c0.107422 0.107422 0.107422 0.322266 0.214844 0.429688
|
||||
c5.25977 11.2705 9.23145 22.9697 11.915 34.9912v0.214844c0.536133 2.46875 0.858398 4.0791 0.96582 4.61621c0 0.213867 0.107422 0.428711 0.107422 0.643555zM255.893 370.824c71.8086 0 130.308 -58.499 130.308 -130.308s-58.499 -130.308 -130.308 -130.308
|
||||
s-130.308 58.499 -130.308 130.308s58.499 130.308 130.308 130.308zM255.893 139.189c55.9229 0 101.327 45.5117 101.327 101.327s-45.5117 101.326 -101.327 101.326s-101.326 -45.5107 -101.326 -101.326s45.4033 -101.327 101.326 -101.327z" />
|
||||
<glyph glyph-name="uniF10E" unicode=""
|
||||
d="M429.752 343.887c45.3271 0 82.248 -36.8145 82.248 -82.1406v-207.269c0 -45.3271 -36.9209 -82.248 -82.248 -82.248h-347.504c-45.3271 0 -82.248 36.9209 -82.248 82.248v275.045c0 45.3271 36.9209 82.248 82.248 82.248h158.324
|
||||
c7.97949 0 14.3633 -6.38379 14.3633 -14.3643c0 -29.5791 24.0469 -53.5195 53.5195 -53.5195h121.297zM483.271 54.584v0v207.056c0 29.5791 -24.0459 53.5195 -53.5195 53.5195h-121.297c-40.4316 0 -74.1611 29.3662 -80.9707 67.8828h-145.236
|
||||
c-29.5801 0 -53.5195 -24.0459 -53.5195 -53.5195v-274.938c0 -29.5801 24.0459 -53.5195 53.5195 -53.5195h347.504c29.5801 0 53.5195 24.0459 53.5195 53.5195z" />
|
||||
<glyph glyph-name="uniF107" unicode=""
|
||||
d="M436.857 372.986c48.3857 -48.3857 75.1221 -112.575 75.1221 -180.986s-26.6279 -132.601 -75.0137 -180.986c-49.3604 -49.3594 -114.957 -75.0137 -181.203 -75.0137c-41.2412 0 -82.8076 9.95898 -120.802 30.417
|
||||
c-34.0967 -24.5713 -67.4365 -29.876 -90.0596 -29.876c-8.44336 0 -15.3711 0.757812 -20.2422 1.51562c-10.3916 1.62402 -18.4014 9.74219 -20.0254 20.1338c-1.62305 10.3916 3.68066 20.458 13.0977 25.1123c19.5928 9.63477 32.7988 28.9023 41.0254 45.4639
|
||||
c-83.8906 100.992 -77.3955 250.695 16.1279 344.22c48.3857 48.3857 112.575 75.0137 180.986 75.0137s132.601 -26.6279 180.986 -75.0137zM416.183 31.6885c88.4365 88.4365 88.4365 232.187 -0.108398 320.623c-88.3281 88.4365 -232.187 88.4365 -320.622 0
|
||||
c-84.6484 -84.6484 -88.7617 -221.037 -9.52637 -310.556c2.05664 -2.38184 3.24805 -5.30469 3.57227 -8.22656c0.757812 -3.03125 0.541016 -6.27832 -0.757812 -9.41797c-8.11816 -18.5098 -21.9736 -42.3232 -43.9473 -58.3438h0.108398
|
||||
c19.0508 0 47.8438 4.7627 77.1787 27.4941c0.324219 0.325195 0.757812 0.649414 1.19043 0.866211c4.65527 4.6543 11.9072 5.73633 17.7529 2.27246c88.9775 -52.2822 202.094 -37.7773 275.159 35.2881zM237.902 192c0 11.9792 5.98958 17.9688 17.9688 17.9688
|
||||
c11.9792 0 17.9688 -5.98958 17.9688 -17.9688s-5.98959 -17.9688 -17.9688 -17.9688c-11.9792 0 -17.9688 5.98958 -17.9688 17.9688zM330.561 192c0 11.9792 5.98959 17.9688 17.9688 17.9688s17.9688 -5.98958 17.9688 -17.9688s-5.98959 -17.9688 -17.9688 -17.9688
|
||||
s-17.9688 5.98958 -17.9688 17.9688zM145.244 192c0 11.9792 5.98958 17.9688 17.9688 17.9688s17.9688 -5.98958 17.9688 -17.9688s-5.98958 -17.9688 -17.9688 -17.9688s-17.9688 5.98958 -17.9688 17.9688z" />
|
||||
<glyph glyph-name="uniF115" unicode=""
|
||||
d="M489.917 99.9219c2.03418 -5.46094 3.96191 -12.8486 4.38965 -21.5215c0.75 -14.9893 -2.99805 -36.2959 -24.3047 -55.5684c1.39258 -9.84961 1.39258 -25.375 -7.28027 -41.0068c-11.3486 -20.4502 -33.7266 -34.3691 -66.4893 -41.3281
|
||||
c-13.9189 -2.99805 -30.6211 -4.49707 -49.7871 -4.49707c-11.1348 0 -23.126 0.535156 -35.8672 1.49902c-70.3438 -1.3916 -138.867 7.28027 -142.722 7.81641l-25.1611 2.99707c-7.28125 -5.13867 -16.168 -8.13672 -25.6963 -8.13672h-65.3115
|
||||
c-24.5186 0 -44.541 19.915 -44.541 44.54v173.986c0 24.5186 19.915 44.54 44.541 44.54h65.3115c7.70898 0 14.8818 -1.92773 21.1992 -5.35352c4.49707 9.85059 11.1348 21.0928 20.9854 31.4785c0.535156 0.535156 1.07031 1.07031 1.71289 1.49902
|
||||
c33.5127 25.8027 73.1279 118.738 87.2607 156.641c-1.49902 11.7773 -2.89062 37.2598 8.99414 51.0713c5.24609 6.10254 12.6338 9.42188 20.6641 9.42188c8.35059 0 36.7236 -1.92773 54.4971 -26.4463c16.917 -23.3408 17.7734 -58.2451 2.78418 -103.855
|
||||
c-12.6338 -38.3311 -13.8115 -55.3545 -13.3838 -62.207c5.78223 1.28516 11.4561 2.46289 16.8096 3.42676h0.107422c0.963867 0.213867 24.5186 5.45996 52.6777 6.74512c40.043 1.92676 68.9521 -5.03223 85.9756 -20.5576
|
||||
c23.2334 -21.1992 20.5566 -48.502 13.5977 -65.8467c5.99609 -7.81543 13.2764 -20.5566 13.9189 -36.8311c0.642578 -14.7754 -4.38965 -29.123 -14.8828 -42.5059zM51.6875 -30.916v0.107422h65.3115c8.56543 0 15.6318 6.95898 15.6318 15.6318v173.985
|
||||
c0 8.56543 -6.95996 15.6318 -15.6318 15.6318h-65.3115c-8.56543 0 -15.6318 -6.95898 -15.6318 -15.6318v-174.093c0 -8.56543 6.95898 -15.6318 15.6318 -15.6318zM462.508 112.448c9.31445 9.63672 13.8115 19.3799 13.3838 28.9082
|
||||
c-0.536133 14.2402 -11.7783 24.8398 -11.8848 24.9473c-5.35352 4.81836 -6.53125 12.6338 -2.56934 18.6299c1.3916 2.24805 13.7041 23.2334 -3.64062 39.0801c-20.4502 18.6299 -79.3379 14.2402 -112.208 7.06641
|
||||
c-0.427734 -0.107422 -0.962891 -0.213867 -1.3916 -0.321289c-5.88867 -0.963867 -12.0986 -2.24902 -18.6299 -3.74707c-6.85254 -2.46289 -16.2744 -2.1416 -23.3408 4.1748c-14.2402 12.6348 -12.7412 41.3291 5.24609 95.8262
|
||||
c11.7783 35.7607 12.2061 62.7422 1.28516 77.8389c-9.20801 12.7412 -24.5186 14.3467 -30.1934 14.4541c-2.56934 -4.38965 -3.21191 -19.0576 -1.3916 -31.0498c0.427734 -2.46289 0.213867 -4.9248 -0.642578 -7.17383
|
||||
c-2.03418 -5.56738 -49.0371 -134.156 -97.1104 -172.272c-12.6348 -13.5977 -17.9883 -29.5508 -19.915 -36.7246c1.28516 -4.1748 2.03418 -8.67188 2.03418 -13.2764v-174.093c0 -3.21191 -0.321289 -6.42383 -0.963867 -9.42188l10.8145 -1.28516h0.106445
|
||||
c0.75 -0.106445 70.3438 -9.10059 139.189 -7.60156h1.49805c32.335 -2.56934 58.6738 -1.82031 78.0527 2.35547c24.1982 5.13965 40.1514 14.3477 47.3242 27.1953c7.49512 13.3838 2.8916 27.5166 2.8916 27.624c-2.1416 5.99512 -0.107422 12.6338 4.9248 16.4883
|
||||
c14.1328 10.5996 20.8779 23.0195 20.1289 37.0459c-0.535156 10.2783 -4.92578 17.7734 -4.92578 17.8799c-3.42578 5.56738 -2.56934 12.7412 1.92773 17.4521z" />
|
||||
<glyph glyph-name="uniF126" unicode=""
|
||||
d="M485.793 245.746c15.2939 -2.56738 26.418 -15.7227 26.2041 -31.2314v-45.0283c0 -15.5088 -11.124 -28.6641 -26.418 -31.2314l-35.9375 -5.98926c-3.63672 -11.9785 -8.55664 -23.6367 -14.4385 -34.8672l21.1768 -29.7334
|
||||
c8.98438 -12.6211 7.4873 -29.8408 -3.42285 -40.751l-31.8721 -31.8721c-5.99023 -5.99023 -13.9043 -9.30566 -22.3545 -9.30566v0c-6.63086 0 -13.0479 2.03223 -18.3965 5.88281l-29.626 21.1768c-11.124 -5.88184 -22.7822 -10.6953 -34.8682 -14.4385
|
||||
l-6.0957 -35.9375c-2.56738 -15.2949 -15.7227 -26.418 -31.2314 -26.418h-45.1357c-15.5078 0 -28.6641 11.124 -31.2305 26.418l-5.99023 35.9375c-11.6582 3.63672 -22.9951 8.23535 -33.7979 13.9043l-30.0547 -21.3916
|
||||
c-5.34766 -3.85059 -11.6572 -5.88184 -18.2891 -5.88184c-8.44922 0 -16.3643 3.31543 -22.3535 9.30469l-31.873 31.873c-11.0166 10.9092 -12.4062 28.1289 -3.42285 40.75l21.0703 29.3057c-5.98926 11.123 -10.9092 22.7812 -14.6523 34.7607l-36.3652 6.20312
|
||||
c-15.2949 2.56738 -26.418 15.7227 -26.418 31.2314v45.0283c0 15.5088 11.123 28.6641 26.418 31.2305l35.5098 5.99023c3.63574 12.0859 8.3418 23.7441 14.2246 34.9746l-21.3906 30.0537c-8.98438 12.6211 -7.4873 29.8408 3.42188 40.751l31.9805 31.8721
|
||||
c5.88184 5.99023 13.9033 9.30566 22.3535 9.30566c6.63086 0 13.0488 -2.03223 18.3965 -5.88281l29.4121 -21.1768c11.124 5.98926 22.7822 10.9092 34.7607 14.6523l6.2041 36.3652c2.56641 15.2949 15.7217 26.418 31.2305 26.418h45.0283
|
||||
c15.5088 0 28.6641 -11.123 31.2314 -26.418l5.98926 -35.5098c12.5137 -3.74316 24.5996 -8.77051 36.1514 -14.8662l29.7334 21.1768c5.34766 3.85059 11.6582 5.88281 18.2891 5.88281c8.4502 0 16.3643 -3.31543 22.3545 -9.30566l31.8721 -31.8721
|
||||
c11.0166 -10.9102 12.4072 -28.1299 3.42285 -40.75l-21.1768 -29.627c5.88184 -11.124 10.6953 -22.7822 14.4385 -34.8682zM483.333 169.486h0.106445v45.0283c0 1.39062 -0.962891 2.56738 -2.35254 2.78125l-44.9219 7.59375
|
||||
c-5.56152 0.855469 -10.1611 5.02637 -11.5508 10.5889c-4.06445 15.7217 -10.2676 30.8027 -18.6104 44.8145c-2.8877 4.91992 -2.67383 11.123 0.641602 15.7217l26.418 37.1143c0.749023 1.17578 0.641602 2.67383 -0.321289 3.63574l-31.8721 31.873
|
||||
c-0.749023 0.749023 -1.60449 0.855469 -2.03223 0.855469c-0.535156 0 -1.06934 -0.213867 -1.60449 -0.53418l-36.8994 -26.5254c-4.70605 -3.31543 -10.8027 -3.5293 -15.7227 -0.641602c-14.332 8.44922 -29.7334 14.7598 -45.8838 18.8242
|
||||
c-5.5625 1.39062 -9.7334 5.98926 -10.6963 11.6582l-7.48633 44.6006c-0.213867 1.39062 -1.39062 2.35254 -2.78125 2.35254h-45.0283c-1.39062 0 -2.56641 -0.961914 -2.78027 -2.35254l-7.70117 -45.2422c-0.962891 -5.56152 -5.13379 -10.0537 -10.5889 -11.5518
|
||||
c-15.7227 -4.06348 -30.8027 -10.4814 -44.707 -18.8242c-4.91992 -2.8877 -11.124 -2.67383 -15.8301 0.641602l-36.8994 26.3115c-0.427734 0.321289 -0.962891 0.535156 -1.60449 0.535156c-0.53418 0 -1.2832 -0.107422 -2.03223 -0.856445l-31.8721 -31.8721
|
||||
c-0.962891 -0.962891 -1.07031 -2.56738 -0.321289 -3.63672l26.6318 -37.4346c3.31543 -4.59863 3.63672 -10.6953 0.749023 -15.6152c-8.23535 -14.0117 -14.332 -29.1992 -18.2891 -44.9219c-1.39062 -5.56152 -5.99023 -9.73242 -11.6582 -10.6953l-44.6006 -7.4873
|
||||
c-1.39062 -0.213867 -2.35352 -1.38965 -2.35352 -2.78027v-45.0283c0 -1.39062 0.962891 -2.56738 2.35352 -2.78125l45.2422 -7.7002c5.56152 -0.962891 10.0537 -5.13477 11.5508 -10.5889c4.06445 -15.7227 10.4814 -30.8037 18.8242 -44.708
|
||||
c2.8877 -4.91992 2.67383 -11.123 -0.641602 -15.8291l-26.3115 -36.8994c-0.748047 -1.17676 -0.641602 -2.67383 0.321289 -3.63672l31.873 -31.873c0.748047 -0.748047 1.60449 -0.855469 2.03223 -0.855469c0.53418 0 1.06934 0.213867 1.60352 0.535156
|
||||
l37.4346 26.6318c2.45996 1.81836 5.45508 2.67383 8.34277 2.67383c2.45996 0 5.02734 -0.641602 7.27344 -1.9248c13.7969 -7.91504 28.5566 -14.0117 43.8516 -17.9688c5.45508 -1.39062 9.62598 -5.88281 10.5889 -11.5518l7.48633 -44.9209
|
||||
c0.213867 -1.39062 1.39062 -2.35352 2.78125 -2.35352h45.0283c1.39062 0 2.56641 0.962891 2.78027 2.35352l7.59473 44.9209c0.855469 5.56152 5.02637 10.1611 10.5879 11.5518c15.7227 4.06445 30.8037 10.2676 44.8145 18.6104
|
||||
c4.91992 2.8877 11.124 2.67383 15.7227 -0.641602l37.1143 -26.4189c0.427734 -0.320312 0.961914 -0.53418 1.60352 -0.53418c0.535156 0 1.28418 0.106445 2.03223 0.855469l31.873 31.873c0.962891 0.961914 1.06934 2.56641 0.321289 3.63574l-26.4189 37.2207
|
||||
c-3.31543 4.70605 -3.5293 10.8027 -0.641602 15.7227c8.34277 14.0117 14.5459 29.0918 18.6104 44.8145c1.39062 5.45508 5.88281 9.62598 11.5518 10.5889l44.9209 7.48633c1.39062 0.214844 2.35352 1.39062 2.35352 2.78125zM256.052 302.539
|
||||
c60.9648 0 110.485 -49.5205 110.485 -110.485s-49.5205 -110.485 -110.485 -110.485s-110.485 49.5205 -110.485 110.485s49.5205 110.485 110.485 110.485zM256.052 110.447c45.0283 0 81.6074 36.5781 81.6074 81.6064s-36.5791 81.6074 -81.6074 81.6074
|
||||
s-81.6074 -36.5791 -81.6074 -81.6074s36.5791 -81.6064 81.6074 -81.6064z" />
|
||||
<glyph glyph-name="uniF10C" unicode=""
|
||||
d="M478.836 414.833c21.3818 -21.2725 33.1641 -49.6367 33.1641 -79.8555c0 -30.2178 -11.7822 -58.582 -33.1641 -79.9639l-293.02 -292.91c-2.72754 -2.83691 -6.54492 -4.36328 -10.3633 -4.36328s-7.63672 1.63574 -10.3643 4.36328l-118.037 118.037
|
||||
l-15.1631 -112.146l77.6729 10.4727c8.07324 1.09082 15.4912 -4.58105 16.582 -12.6543c1.09082 -8.07227 -4.58203 -15.4912 -12.6543 -16.582l-96.874 -13.0908c-0.654297 -0.109375 -1.30859 -0.109375 -1.96289 -0.109375
|
||||
c-3.81836 0 -7.63672 1.63672 -10.3643 4.36426c-3.16309 3.16309 -4.7998 7.74512 -4.14551 12.3271l21.71 160.801c0.435547 3.16309 1.85449 6.10938 4.14551 8.39941l293.02 292.911c44.0723 44.1816 115.746 44.1816 159.818 0zM324.69 378.833l-267.384 -267.384
|
||||
l49.2002 -49.2002l267.384 267.384zM175.562 -6.69629l267.384 267.383l-48.1094 48.1094l-267.384 -267.383zM463.454 281.741c12.3271 14.9453 19.0908 33.4912 19.0908 53.2363c0 22.3643 -8.61816 43.4189 -24.4365 59.1279
|
||||
c-15.8174 15.709 -36.7637 24.4365 -59.127 24.4365c-19.6367 0 -38.291 -6.76367 -53.2373 -19.0908z" />
|
||||
<glyph glyph-name="uniF112" unicode=""
|
||||
d="M512 189.698c0 -0.986328 -0.109375 -1.97266 -0.329102 -2.95898c0.219727 -0.985352 0.329102 -1.97168 0.329102 -2.95801v-121.206c0 -68.4932 -55.7812 -124.273 -124.274 -124.273c-8.21875 0 -14.7939 6.5752 -14.7939 14.7939v218.85
|
||||
c0 8.21875 6.5752 14.7949 14.7939 14.7949c37.918 0 71.8906 -16.9863 94.6855 -43.8359v40.877c0 0.986328 0.109375 1.97266 0.328125 2.95898c-0.21875 0.985352 -0.328125 1.97168 -0.328125 2.95801c0 124.822 -101.589 226.411 -226.411 226.411
|
||||
s-226.411 -101.589 -226.411 -226.411v-40.876c22.7949 26.8486 56.7676 43.835 94.6855 43.835c8.21875 0 14.7939 -6.5752 14.7939 -14.7939v-218.85c0 -8.21875 -6.5752 -14.7949 -14.7939 -14.7949c-68.4932 0 -124.274 55.7812 -124.274 124.274v121.205
|
||||
c0 68.3838 26.6299 132.713 74.959 181.041c48.3291 48.3291 112.657 74.959 181.041 74.959s132.712 -26.6299 181.041 -74.959c48.3291 -48.3281 74.959 -112.657 74.959 -181.041zM109.37 161.973c-45.1514 -7.12305 -79.7812 -46.3564 -79.8906 -93.4795
|
||||
c0 -47.123 34.7393 -86.3564 79.8906 -93.4795v186.959zM402.63 -30.9043c45.1514 7.12305 79.7812 46.2471 79.8906 93.4795c0 47.123 -34.7393 86.3564 -79.8906 93.4795v-186.959z" />
|
||||
<glyph glyph-name="uniF12F" unicode=""
|
||||
d="M416.401 305.023c55.1689 -14.5293 95.4932 -67.0664 95.5986 -126.132c0 -32.6377 -12.1074 -64.0127 -34.3232 -88.4385c-22.3203 -24.6367 -52.3262 -39.377 -84.4385 -41.6934h-1.05273h-78.1211c-7.89648 0 -14.2129 6.31738 -14.2129 14.2139
|
||||
s6.31641 14.2129 14.2129 14.2129h77.5947c50.7471 3.89551 91.9141 49.3789 91.9141 101.705c0 49.3789 -35.376 92.6514 -82.2275 100.652c-6.31738 1.05371 -11.1602 6.21191 -11.792 12.6348c-5.89551 61.6963 -57.1699 108.232 -119.288 108.232
|
||||
c-46.2197 0 -88.7549 -26.9531 -108.443 -68.751c-3.1582 -6.63281 -10.8438 -9.79102 -17.793 -7.26465c-5.68555 2.10547 -11.792 3.1582 -18.0029 3.1582c-29.0586 0 -52.748 -23.6885 -52.748 -52.7471c0 -6.42285 1.05273 -12.4238 3.1582 -18.1094
|
||||
c2.31641 -6.52734 -0.209961 -13.792 -6.21191 -17.2666c-25.7939 -15.0557 -41.7979 -42.8506 -41.7979 -72.6465c0 -46.1143 36.2178 -87.0703 79.2793 -89.7021h90.124c7.89648 0 14.2139 -6.31738 14.2139 -14.2139s-6.31738 -14.2139 -14.2139 -14.2139h-90.5449
|
||||
h-0.841797c-28.6377 1.58008 -55.4854 15.1611 -75.8057 38.1133c-19.793 22.4258 -30.6377 50.7471 -30.6377 79.9111c0 36.1133 17.583 70.0146 46.5361 91.0713c-1.05273 5.47559 -1.68457 11.0557 -1.68457 16.8457c0 44.7461 36.4287 81.1748 81.1748 81.1748
|
||||
c5.26367 0 10.5283 -0.526367 15.582 -1.47363c11.6865 20.4248 28.2158 37.7969 48.1152 50.6416c23.8994 15.4766 51.7998 23.6895 80.4375 23.6895c37.0596 0 72.6465 -13.793 100.021 -38.8506c24.6367 -22.5312 40.7451 -52.2207 46.2197 -84.7539zM341.333 153.202
|
||||
c-5.58008 -5.58008 -14.5293 -5.58008 -20.0039 0l-51.0635 51.0635v-234.68c0 -7.89648 -6.31641 -14.2139 -14.2129 -14.2139s-14.2139 6.31738 -14.2139 14.2139v234.68l-51.0625 -51.0635c-2.7373 -2.84277 -6.42285 -4.21094 -10.002 -4.21094
|
||||
c-3.58008 0 -7.26465 1.47363 -10.0029 4.21094c-5.58008 5.58008 -5.58008 14.5293 0 20.1094l75.2793 75.2793c2.63184 2.7373 6.31641 4.21094 10.002 4.21094c3.68457 0 7.37012 -1.5791 10.002 -4.21094l75.2783 -75.2793
|
||||
c5.58008 -5.58008 5.58008 -14.5293 0 -20.1094z" />
|
||||
<glyph glyph-name="uniF130" unicode=""
|
||||
d="M497.331 197.922c8.14941 0 14.6689 -6.62793 14.6689 -14.6689v-133.433c0 -52.374 -42.5938 -95.0762 -95.0762 -95.0762h-321.848c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v135.605c0 8.14941 6.51953 14.6689 14.6689 14.6689
|
||||
s14.6689 -6.51953 14.6689 -14.6689v-135.605c0 -36.1836 29.4463 -65.7393 65.7383 -65.7393h321.848c36.1836 0 65.7383 29.4473 65.7383 65.7393v133.433c0 8.14941 6.51953 14.6689 14.6689 14.6689zM173.094 310.927c-5.65039 -5.65039 -14.9951 -5.65039 -20.6455 0
|
||||
c-5.75879 5.75879 -5.75879 14.9951 0 20.7539l93.2295 93.2295c2.71582 2.8252 6.41016 4.34668 10.3223 4.34668c3.80273 0 7.60645 -1.62988 10.3223 -4.34668l93.2295 -93.2295c5.75879 -5.75879 5.75879 -14.9951 0 -20.7539
|
||||
c-2.8252 -2.93359 -6.51953 -4.3457 -10.3223 -4.3457c-3.69434 0 -7.49805 1.52051 -10.3232 4.3457l-68.2373 68.2383v-297.616c0 -8.14941 -6.51953 -14.6689 -14.6689 -14.6689s-14.6689 6.51953 -14.6689 14.6689v297.616z" />
|
||||
<glyph glyph-name="uniF125" unicode=""
|
||||
d="M117.261 346.469c-55.5166 -50.1846 -78.9365 -126.611 -61.3721 -199.379c1.88184 -7.52832 -2.82227 -15.1602 -10.3506 -17.042c-1.14941 -0.313477 -2.19531 -0.418945 -3.3457 -0.418945c-6.37695 0 -12.1279 4.28711 -13.6963 10.7695
|
||||
c-20.0732 82.8047 6.69141 169.791 69.8408 226.98c45.8984 41.6123 105.388 62.8359 167.282 59.6992c49.9756 -2.50879 96.71 -20.4922 134.662 -51.5439l-2.19531 57.8174c-0.313477 7.84082 5.75 14.3232 13.5918 14.6367
|
||||
c7.73633 0.313477 14.3232 -5.75 14.6367 -13.5918l3.4502 -92.2139v0v-0.104492v-0.523438v-1.04492c0 -7.63281 -5.95898 -13.8008 -13.5908 -14.1143l-93.2607 -3.4502h-0.522461c-7.52734 0 -13.8008 5.95898 -14.1143 13.5918
|
||||
c-0.313477 7.84082 5.75 14.3232 13.5918 14.6367l60.7441 2.2998c-76.6357 62.418 -189.761 61.3721 -265.352 -7.00488zM483.505 243.591c20.0732 -82.9102 -6.69141 -169.792 -69.8408 -226.981c-43.0752 -38.8936 -97.8604 -60.0127 -155.468 -60.0127
|
||||
c-3.86816 0 -7.8418 0.104492 -11.8145 0.313477c-49.6621 2.50977 -96.1875 20.3877 -134.14 51.126l5.01855 -56.5625c0.731445 -7.8418 -5.01855 -14.6377 -12.7549 -15.3691c-0.418945 -0.104492 -0.836914 -0.104492 -1.25488 -0.104492
|
||||
c-7.21387 0 -13.3828 5.54102 -14.0098 12.8594l-8.36426 92.9463c-0.732422 7.8418 5.01855 14.6377 12.7549 15.3691l92.9463 8.36426c7.8418 0.732422 14.6377 -4.91406 15.3691 -12.7549c0.732422 -7.8418 -5.01855 -14.6377 -12.7549 -15.3691l-61.4766 -5.54102
|
||||
c37.4297 -31.1562 83.8506 -46.9443 130.376 -46.9443c48.7207 0 97.6504 17.3555 136.648 52.5898c55.5166 50.1846 79.041 126.611 61.3721 199.38c-1.88184 7.52734 2.82227 15.1592 10.3506 17.042c7.52734 1.88184 15.1602 -2.82324 17.042 -10.3506z" />
|
||||
<glyph glyph-name="uniF11C" unicode=""
|
||||
d="M387.058 202.207c5.58008 -5.58008 5.58008 -14.8076 -0.107422 -20.3867l-241.528 -241.528c-2.78906 -2.89746 -6.54492 -4.29199 -10.1934 -4.29199c-3.64746 0 -7.40332 1.50195 -10.1934 4.29199c-5.68652 5.68652 -5.68652 14.8066 0 20.4941l231.228 231.228
|
||||
l-231.228 231.228c-5.68652 5.68652 -5.68652 14.8066 0 20.4941c5.6875 5.68652 14.8076 5.68652 20.4941 0z" />
|
||||
<glyph glyph-name="uniF100" unicode=""
|
||||
d="M254.145 172.12c-31.0654 0.530273 -56.1934 11.4512 -74.6426 32.3379c-40.8203 46.0156 -34.0342 124.898 -33.292 132.427c2.65039 56.9355 29.793 83.9727 52.0586 96.5898c22.1602 12.6172 45.2734 14.5254 57.042 14.5254h0.636719h0.423828h0.742188
|
||||
c21.417 -0.423828 40.6084 -5.51367 57.042 -14.9492c22.0537 -12.6172 48.7725 -39.6543 51.4229 -96.166c0.742188 -7.52832 7.52734 -86.4111 -33.292 -132.427c-18.5547 -20.8867 -43.6826 -31.8076 -74.749 -32.3379h-1.05957h-0.636719h-1.69629zM174.519 334.234
|
||||
c-0.105469 -0.636719 -7.31543 -73.0527 26.2949 -110.798c13.2529 -15.0557 31.1719 -22.4775 54.4971 -22.6895h0.530273v0h0.530273c23.4316 0.211914 41.2441 7.63379 54.6035 22.6895c33.7158 37.958 26.2949 110.056 26.1885 110.798
|
||||
c-0.106445 0.211914 -0.106445 0.529297 -0.106445 0.847656c-3.28613 71.8857 -51.9521 83.5488 -80.5791 84.1846h-0.848633h-0.423828c-23.1143 0 -77.0811 -8.16406 -80.5801 -84.1846c0 -0.211914 -0.106445 -0.530273 -0.106445 -0.847656zM473.725 41.2842
|
||||
c0.106445 -5.51367 0.211914 -33.8223 -5.08887 -47.9238c-1.06055 -2.75684 -3.0752 -5.08984 -5.51367 -6.67969c-3.18066 -2.01465 -79.4141 -50.6807 -206.963 -50.6807c-127.55 0 -203.782 48.5596 -206.963 50.6807
|
||||
c-2.54492 1.58984 -4.45312 3.92285 -5.51367 6.67969c-5.61914 14.207 -5.51367 42.5166 -5.40723 48.0293v0.318359c0.106445 0.848633 0.106445 1.69629 0.106445 2.65039c0.635742 20.8877 2.01367 69.9775 48.0293 85.7754
|
||||
c0.318359 0.106445 0.636719 0.211914 1.06055 0.318359c48.0293 12.1924 87.5771 39.7598 88.002 40.0771c6.4668 4.55957 15.373 2.96875 19.9326 -3.49805c4.55859 -6.46777 2.96875 -15.374 -3.49902 -19.9336c-1.80273 -1.16602 -44.001 -30.6416 -96.8018 -44.2129
|
||||
c-24.7041 -8.7998 -27.4609 -35.2002 -28.2031 -59.374c0 -0.954102 -0.105469 -1.80273 -0.105469 -2.65137c-0.212891 -9.54199 0.529297 -24.2793 2.22656 -32.7617c17.2822 -9.75391 85.0322 -43.5762 187.029 -43.5762c102.422 0 169.748 33.7158 186.924 43.4707
|
||||
c1.69727 8.48145 2.33301 23.2188 2.22656 32.7617c-0.105469 0.847656 -0.105469 1.69629 -0.105469 2.65039c-0.742188 24.1738 -3.49902 50.5742 -28.2031 59.375c-52.8008 13.5713 -94.999 42.9404 -96.8018 44.2129
|
||||
c-6.46777 4.55859 -8.05762 13.4648 -3.49902 19.9326c4.55957 6.46777 13.4658 8.05762 19.9326 3.49902c0.424805 -0.318359 40.1846 -27.8848 88.002 -40.0781c0.318359 -0.105469 0.742188 -0.211914 1.06055 -0.318359
|
||||
c46.0156 -15.6914 47.3936 -64.7812 48.0293 -85.7744c0 -0.954102 0.106445 -1.80273 0.106445 -2.65137v-0.317383z" />
|
||||
<glyph glyph-name="uniF117" unicode=""
|
||||
d="M247.328 -34.6621c8.04102 0 14.6689 -6.51953 14.6689 -14.6689s-6.51953 -14.6689 -14.6689 -14.6689h-133.542c-52.373 0 -95.0762 42.5938 -95.0762 95.0762v321.848c0 52.373 42.5938 95.0762 95.0762 95.0762h135.715c8.14941 0 14.6689 -6.51953 14.6689 -14.6689
|
||||
s-6.51953 -14.6689 -14.6689 -14.6689h-135.715c-36.1836 0 -65.7383 -29.4463 -65.7383 -65.7383v-321.848c0 -36.1836 29.4463 -65.7383 65.7383 -65.7383h133.542zM488.985 202.322c5.75879 -5.65039 5.75879 -14.9941 -0.109375 -20.8623l-93.2285 -93.2295
|
||||
c-2.8252 -2.93359 -6.62891 -4.3457 -10.3232 -4.3457s-7.49707 1.52148 -10.3223 4.3457c-5.75879 5.75977 -5.75879 14.9951 0 20.7539l68.2373 68.2383h-297.616c-8.14941 0 -14.6689 6.51953 -14.6689 14.6689s6.51953 14.6689 14.6689 14.6689h297.616
|
||||
l-68.2373 68.2373c-5.75879 5.75879 -5.75879 14.9951 0 20.7539s14.9951 5.75879 20.7539 0z" />
|
||||
<glyph glyph-name="uniF11F" unicode=""
|
||||
d="M404.642 130.834l59.3252 -59.542c10.9141 -10.8057 16.6416 -23.665 16.6416 -37.0645c0 -13.292 -5.61914 -26.2588 -16.3174 -37.3896c-4.64648 -4.8623 -9.40137 -9.50879 -14.0479 -13.9395c-6.80762 -6.5918 -13.292 -12.751 -18.9111 -19.5586
|
||||
c-0.107422 -0.216797 -0.324219 -0.324219 -0.431641 -0.541016c-16.4258 -17.8301 -37.3896 -26.7988 -62.3516 -26.7988c-2.16113 0 -4.53809 0.108398 -6.80762 0.21582c-37.0654 2.37793 -70.2402 16.3174 -95.0938 28.0957
|
||||
c-65.0527 31.4463 -122.001 76.0752 -169.332 132.592c-39.0098 46.8984 -65.1611 90.6631 -82.667 137.886c-7.13184 19.2344 -16.4248 48.627 -13.8311 80.1816c1.72852 19.5586 9.61719 36.416 23.4492 50.248l36.7402 37.0645
|
||||
c0.108398 0.108398 0.108398 0.108398 0.21582 0.216797c10.8066 10.374 23.5576 15.8848 36.7412 15.8848s25.7188 -5.51074 36.2002 -15.8848c7.02441 -6.48438 13.832 -13.3994 20.3154 -20.0996c3.24219 -3.45801 6.7002 -6.91602 10.0498 -10.2656l29.501 -29.501
|
||||
c22.6924 -22.6924 22.6924 -52.085 0 -74.7783c-3.02539 -3.02539 -6.05176 -6.15918 -9.07715 -9.18457c-7.99707 -8.10547 -16.209 -16.5342 -25.0703 -24.6387c6.5918 -14.5879 15.6689 -28.96 29.1768 -46.0342c27.8799 -34.2549 56.9482 -60.7295 88.9346 -81.0449
|
||||
c3.13281 -1.94531 6.80762 -3.78223 10.6973 -5.72754c2.70215 -1.40527 5.51172 -2.80957 8.32129 -4.32227l34.1475 34.0391c10.9141 10.9141 23.665 16.6416 36.9561 16.6416c13.4004 0 26.043 -5.83594 36.5254 -16.75zM443.327 17.0459
|
||||
c11.0225 11.4541 11.0225 22.8008 -0.108398 33.8232l-59.541 59.541l-0.216797 0.216797c-3.34961 3.67383 -8.86035 7.88867 -15.5605 7.88867c-6.80762 0 -12.6426 -4.43066 -16.3174 -8.10547l-36.957 -36.9561c-1.83691 -1.83789 -7.45605 -7.45703 -16.4248 -7.45703
|
||||
c-3.56641 0 -7.02441 0.865234 -10.6982 2.70215c-0.431641 0.21582 -0.864258 0.540039 -1.29688 0.755859c-3.13379 1.94531 -6.80762 3.78223 -10.6982 5.72754c-4.32227 2.16113 -8.86035 4.43066 -13.291 7.24023c-34.6875 21.9365 -66.1338 50.5723 -95.958 87.3135
|
||||
l-0.108398 0.107422c-16.4248 20.8564 -27.2314 38.3623 -34.9033 56.9482c-0.108398 0.324219 -0.216797 0.648438 -0.324219 0.972656c-1.94531 6.16016 -3.6748 15.2373 5.61914 24.5303c0.107422 0.21582 0.324219 0.324219 0.540039 0.540039
|
||||
c9.40137 8.42871 18.0459 17.0742 27.123 26.3672c3.13379 3.02539 6.16016 6.15918 9.29395 9.29297c11.4541 11.4551 11.4541 22.0449 0 33.499l-29.501 29.501c-3.45801 3.56543 -6.91602 7.02344 -10.2656 10.4814c-6.5918 6.80859 -12.8594 13.1836 -19.4512 19.3428
|
||||
c-0.108398 0.108398 -0.21582 0.216797 -0.324219 0.324219c-3.45801 3.45801 -8.96875 7.56445 -15.7773 7.56445c-5.51074 0 -11.1299 -2.70117 -16.4248 -7.67188l-36.8486 -36.8486c-8.96973 -8.96973 -13.832 -19.4512 -14.9131 -32.0947
|
||||
c-1.62109 -19.7754 2.05371 -40.7393 12.1035 -67.6465c16.3164 -44.0889 40.9551 -85.1514 77.8037 -129.564c44.6289 -53.2744 98.3359 -95.3105 159.498 -124.919c22.3691 -10.6982 52.1934 -23.2334 84.2881 -25.2861
|
||||
c1.62012 -0.108398 3.34961 -0.108398 4.9707 -0.108398c16.8574 0 29.7168 5.51074 40.6309 17.1816c6.69922 7.88867 13.832 14.9121 20.8555 21.6123c4.64648 4.53906 8.96973 8.64453 13.1836 13.1836c-0.108398 -0.108398 -0.108398 0 0 0zM276.697 326.209
|
||||
c-7.99707 1.29688 -13.1836 8.96875 -11.7793 16.8574c1.40527 7.88867 8.86133 13.1836 16.8574 11.8867c34.2559 -5.83594 65.4854 -22.0449 90.2314 -46.791s40.9551 -55.9756 46.79 -90.2305c1.29688 -7.99707 -3.99805 -15.5605 -11.9941 -16.8574
|
||||
c-0.864258 -0.108398 -1.62109 -0.216797 -2.48535 -0.216797c-7.02441 0 -13.1836 4.9707 -14.3721 12.1035c-4.86328 28.3115 -18.1543 54.0303 -38.6865 74.5615c-20.5312 20.5322 -46.25 33.9316 -74.5615 38.6865zM511.405 222.146
|
||||
c1.40527 -7.8877 -3.99805 -15.4521 -11.8867 -16.6416c-0.864258 -0.107422 -1.62012 -0.21582 -2.48535 -0.21582c-7.02344 0 -13.1826 4.9707 -14.3721 12.1025c-8.53613 50.3574 -32.3096 96.2832 -68.835 132.808c-36.5244 36.417 -82.4502 60.2979 -132.807 68.835
|
||||
c-7.99707 1.4043 -13.292 8.86133 -11.9951 16.8574c1.40527 7.88867 8.86133 13.292 16.75 11.8867c56.4072 -9.61719 107.736 -36.2002 148.584 -77.0479c40.8467 -40.8467 67.4297 -92.1758 77.0469 -148.584z" />
|
||||
<glyph glyph-name="uniF110" unicode=""
|
||||
d="M504.439 137.178c4.93359 -12.2803 7.66211 -25.7158 7.55762 -39.7803c0 -58.4629 -47.6523 -106.115 -106.115 -106.115c-53.0049 0 -97.0879 39.1504 -104.855 90.0566h-89.7412c-6.92676 -51.8506 -51.4307 -91.9453 -105.17 -91.9453
|
||||
c-58.4629 0 -106.115 47.6514 -106.115 106.114c0 13.96 2.83398 27.3955 7.76758 39.6758c0.104492 0.629883 0.208984 1.1543 0.418945 1.78418l67.5947 215.483c9.65625 31.3838 42.9287 49.1221 74.3125 39.5703c7.45215 -2.2041 11.7549 -10.1816 9.44629 -17.6338
|
||||
c-2.2041 -7.45215 -10.1816 -11.7549 -17.6338 -9.44629c-16.4785 5.03809 -33.9023 -4.30273 -38.9404 -20.7822v-0.104492l-49.1211 -156.182c15.4287 8.71191 33.2725 13.75 52.2705 13.75c53.7393 0 98.2432 -40.0947 105.17 -91.9453h89.2168
|
||||
c6.08789 52.6904 51.0107 93.835 105.38 93.835c18.8926 0 36.6309 -4.93359 52.0605 -13.6445l-48.4922 154.187v0.104492c-5.03809 16.4795 -22.4609 25.8203 -38.9404 20.7822c-7.45215 -2.30859 -15.3242 1.99414 -17.6328 9.44629
|
||||
c-2.30957 7.45215 1.99414 15.3242 9.44629 17.6338c31.3828 9.55176 64.7607 -8.18652 74.3125 -39.5703zM106.22 17.7324c42.8242 0 77.7754 34.8467 77.7754 77.7754c0 42.9297 -34.8467 77.7764 -77.7754 77.7764s-77.7754 -34.8467 -77.7754 -77.7764
|
||||
c0 -42.9287 34.8467 -77.7754 77.7754 -77.7754zM405.987 19.6221c42.9287 0 77.7754 34.8467 77.7754 77.7754s-34.9521 77.7754 -77.7754 77.7754c-42.8242 0 -77.7764 -34.8467 -77.7764 -77.7754s34.8467 -77.7754 77.7764 -77.7754z" />
|
||||
</font>
|
||||
</defs></svg>
|
||||
|
Before Width: | Height: | Size: 82 KiB |
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
Flaticon icon font: Flaticon
|
||||
Creation date: 22/06/2016 15:35
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./Flaticon.eot");
|
||||
src: url("./Flaticon.eot?#iefix") format("embedded-opentype"),
|
||||
url("./Flaticon.woff") format("woff"),
|
||||
url("./Flaticon.ttf") format("truetype"),
|
||||
url("./Flaticon.svg#Flaticon") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./Flaticon.svg#Flaticon") format("svg");
|
||||
}
|
||||
}
|
||||
|
||||
.fi:before{
|
||||
display: inline-block;
|
||||
font-family: "Flaticon";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
line-height: 1;
|
||||
text-decoration: inherit;
|
||||
text-rendering: optimizeLegibility;
|
||||
text-transform: none;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.flaticon-avatar:before { content: "\f100"; }
|
||||
.flaticon-avatar-1:before { content: "\f101"; }
|
||||
.flaticon-back:before { content: "\f102"; }
|
||||
.flaticon-book:before { content: "\f103"; }
|
||||
.flaticon-cancel:before { content: "\f104"; }
|
||||
.flaticon-chat:before { content: "\f105"; }
|
||||
.flaticon-chat-1:before { content: "\f106"; }
|
||||
.flaticon-chat-2:before { content: "\f107"; }
|
||||
.flaticon-copy:before { content: "\f108"; }
|
||||
.flaticon-dislike:before { content: "\f109"; }
|
||||
.flaticon-download:before { content: "\f10a"; }
|
||||
.flaticon-download-1:before { content: "\f10b"; }
|
||||
.flaticon-edit:before { content: "\f10c"; }
|
||||
.flaticon-envelope:before { content: "\f10d"; }
|
||||
.flaticon-folder:before { content: "\f10e"; }
|
||||
.flaticon-garbage:before { content: "\f10f"; }
|
||||
.flaticon-glasses:before { content: "\f110"; }
|
||||
.flaticon-hand:before { content: "\f111"; }
|
||||
.flaticon-headphones:before { content: "\f112"; }
|
||||
.flaticon-heart:before { content: "\f113"; }
|
||||
.flaticon-house:before { content: "\f114"; }
|
||||
.flaticon-like:before { content: "\f115"; }
|
||||
.flaticon-link:before { content: "\f116"; }
|
||||
.flaticon-logout:before { content: "\f117"; }
|
||||
.flaticon-magnifying-glass:before { content: "\f118"; }
|
||||
.flaticon-monitor:before { content: "\f119"; }
|
||||
.flaticon-musical-note:before { content: "\f11a"; }
|
||||
.flaticon-next:before { content: "\f11b"; }
|
||||
.flaticon-next-1:before { content: "\f11c"; }
|
||||
.flaticon-padlock:before { content: "\f11d"; }
|
||||
.flaticon-paper-plane:before { content: "\f11e"; }
|
||||
.flaticon-phone-call:before { content: "\f11f"; }
|
||||
.flaticon-photo-camera:before { content: "\f120"; }
|
||||
.flaticon-pie-chart:before { content: "\f121"; }
|
||||
.flaticon-piggy-bank:before { content: "\f122"; }
|
||||
.flaticon-placeholder:before { content: "\f123"; }
|
||||
.flaticon-printer:before { content: "\f124"; }
|
||||
.flaticon-reload:before { content: "\f125"; }
|
||||
.flaticon-settings:before { content: "\f126"; }
|
||||
.flaticon-settings-1:before { content: "\f127"; }
|
||||
.flaticon-share:before { content: "\f128"; }
|
||||
.flaticon-shopping-bag:before { content: "\f129"; }
|
||||
.flaticon-shopping-cart:before { content: "\f12a"; }
|
||||
.flaticon-shuffle:before { content: "\f12b"; }
|
||||
.flaticon-speaker:before { content: "\f12c"; }
|
||||
.flaticon-star:before { content: "\f12d"; }
|
||||
.flaticon-tag:before { content: "\f12e"; }
|
||||
.flaticon-upload:before { content: "\f12f"; }
|
||||
.flaticon-upload-1:before { content: "\f130"; }
|
||||
.flaticon-vector:before { content: "\f131"; }
|
||||
|
||||
$font-Flaticon-avatar: "\f100";
|
||||
$font-Flaticon-avatar-1: "\f101";
|
||||
$font-Flaticon-back: "\f102";
|
||||
$font-Flaticon-book: "\f103";
|
||||
$font-Flaticon-cancel: "\f104";
|
||||
$font-Flaticon-chat: "\f105";
|
||||
$font-Flaticon-chat-1: "\f106";
|
||||
$font-Flaticon-chat-2: "\f107";
|
||||
$font-Flaticon-copy: "\f108";
|
||||
$font-Flaticon-dislike: "\f109";
|
||||
$font-Flaticon-download: "\f10a";
|
||||
$font-Flaticon-download-1: "\f10b";
|
||||
$font-Flaticon-edit: "\f10c";
|
||||
$font-Flaticon-envelope: "\f10d";
|
||||
$font-Flaticon-folder: "\f10e";
|
||||
$font-Flaticon-garbage: "\f10f";
|
||||
$font-Flaticon-glasses: "\f110";
|
||||
$font-Flaticon-hand: "\f111";
|
||||
$font-Flaticon-headphones: "\f112";
|
||||
$font-Flaticon-heart: "\f113";
|
||||
$font-Flaticon-house: "\f114";
|
||||
$font-Flaticon-like: "\f115";
|
||||
$font-Flaticon-link: "\f116";
|
||||
$font-Flaticon-logout: "\f117";
|
||||
$font-Flaticon-magnifying-glass: "\f118";
|
||||
$font-Flaticon-monitor: "\f119";
|
||||
$font-Flaticon-musical-note: "\f11a";
|
||||
$font-Flaticon-next: "\f11b";
|
||||
$font-Flaticon-next-1: "\f11c";
|
||||
$font-Flaticon-padlock: "\f11d";
|
||||
$font-Flaticon-paper-plane: "\f11e";
|
||||
$font-Flaticon-phone-call: "\f11f";
|
||||
$font-Flaticon-photo-camera: "\f120";
|
||||
$font-Flaticon-pie-chart: "\f121";
|
||||
$font-Flaticon-piggy-bank: "\f122";
|
||||
$font-Flaticon-placeholder: "\f123";
|
||||
$font-Flaticon-printer: "\f124";
|
||||
$font-Flaticon-reload: "\f125";
|
||||
$font-Flaticon-settings: "\f126";
|
||||
$font-Flaticon-settings-1: "\f127";
|
||||
$font-Flaticon-share: "\f128";
|
||||
$font-Flaticon-shopping-bag: "\f129";
|
||||
$font-Flaticon-shopping-cart: "\f12a";
|
||||
$font-Flaticon-shuffle: "\f12b";
|
||||
$font-Flaticon-speaker: "\f12c";
|
||||
$font-Flaticon-star: "\f12d";
|
||||
$font-Flaticon-tag: "\f12e";
|
||||
$font-Flaticon-upload: "\f12f";
|
||||
$font-Flaticon-upload-1: "\f130";
|
||||
$font-Flaticon-vector: "\f131";
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
Flaticon icon font: Flaticon
|
||||
Creation date: 22/06/2016 15:35
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./Flaticon.eot");
|
||||
src: url("./Flaticon.eot?#iefix") format("embedded-opentype"),
|
||||
url("./Flaticon.woff") format("woff"),
|
||||
url("./Flaticon.ttf") format("truetype"),
|
||||
url("./Flaticon.svg#Flaticon") format("svg");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: "Flaticon";
|
||||
src: url("./Flaticon.svg#Flaticon") format("svg");
|
||||
}
|
||||
}
|
||||
|
||||
[class^="flaticon-"]:before, [class*=" flaticon-"]:before,
|
||||
[class^="flaticon-"]:after, [class*=" flaticon-"]:after {
|
||||
font-family: Flaticon;
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.flaticon-avatar:before { content: "\f100"; }
|
||||
.flaticon-avatar-1:before { content: "\f101"; }
|
||||
.flaticon-back:before { content: "\f102"; }
|
||||
.flaticon-book:before { content: "\f103"; }
|
||||
.flaticon-cancel:before { content: "\f104"; }
|
||||
.flaticon-chat:before { content: "\f105"; }
|
||||
.flaticon-chat-1:before { content: "\f106"; }
|
||||
.flaticon-chat-2:before { content: "\f107"; }
|
||||
.flaticon-copy:before { content: "\f108"; }
|
||||
.flaticon-dislike:before { content: "\f109"; }
|
||||
.flaticon-download:before { content: "\f10a"; }
|
||||
.flaticon-download-1:before { content: "\f10b"; }
|
||||
.flaticon-edit:before { content: "\f10c"; }
|
||||
.flaticon-envelope:before { content: "\f10d"; }
|
||||
.flaticon-folder:before { content: "\f10e"; }
|
||||
.flaticon-garbage:before { content: "\f10f"; }
|
||||
.flaticon-glasses:before { content: "\f110"; }
|
||||
.flaticon-hand:before { content: "\f111"; }
|
||||
.flaticon-headphones:before { content: "\f112"; }
|
||||
.flaticon-heart:before { content: "\f113"; }
|
||||
.flaticon-house:before { content: "\f114"; }
|
||||
.flaticon-like:before { content: "\f115"; }
|
||||
.flaticon-link:before { content: "\f116"; }
|
||||
.flaticon-logout:before { content: "\f117"; }
|
||||
.flaticon-magnifying-glass:before { content: "\f118"; }
|
||||
.flaticon-monitor:before { content: "\f119"; }
|
||||
.flaticon-musical-note:before { content: "\f11a"; }
|
||||
.flaticon-next:before { content: "\f11b"; }
|
||||
.flaticon-next-1:before { content: "\f11c"; }
|
||||
.flaticon-padlock:before { content: "\f11d"; }
|
||||
.flaticon-paper-plane:before { content: "\f11e"; }
|
||||
.flaticon-phone-call:before { content: "\f11f"; }
|
||||
.flaticon-photo-camera:before { content: "\f120"; }
|
||||
.flaticon-pie-chart:before { content: "\f121"; }
|
||||
.flaticon-piggy-bank:before { content: "\f122"; }
|
||||
.flaticon-placeholder:before { content: "\f123"; }
|
||||
.flaticon-printer:before { content: "\f124"; }
|
||||
.flaticon-reload:before { content: "\f125"; }
|
||||
.flaticon-settings:before { content: "\f126"; }
|
||||
.flaticon-settings-1:before { content: "\f127"; }
|
||||
.flaticon-share:before { content: "\f128"; }
|
||||
.flaticon-shopping-bag:before { content: "\f129"; }
|
||||
.flaticon-shopping-cart:before { content: "\f12a"; }
|
||||
.flaticon-shuffle:before { content: "\f12b"; }
|
||||
.flaticon-speaker:before { content: "\f12c"; }
|
||||
.flaticon-star:before { content: "\f12d"; }
|
||||
.flaticon-tag:before { content: "\f12e"; }
|
||||
.flaticon-upload:before { content: "\f12f"; }
|
||||
.flaticon-upload-1:before { content: "\f130"; }
|
||||
.flaticon-vector:before { content: "\f131"; }
|
||||
@@ -1,705 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Flaticon icon font: Flaticon
|
||||
Creation date: 22/06/2016 15:35
|
||||
-->
|
||||
<html>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Flaticon WebFont</title>
|
||||
<link href="http://fonts.googleapis.com/css?family=Varela+Round" rel="stylesheet" type="text/css" />
|
||||
<link rel="stylesheet" type="text/css" href="flaticon.css">
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
font: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article, aside, details, figcaption, figure,
|
||||
footer, header, hgroup, menu, nav, section {
|
||||
display: block;
|
||||
}
|
||||
body {
|
||||
line-height: 1;
|
||||
}
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
blockquote, q {
|
||||
quotes: none;
|
||||
}
|
||||
blockquote:before, blockquote:after,
|
||||
q:before, q:after {
|
||||
content: '';
|
||||
content: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'Varela Round', Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
a {
|
||||
color: #333;
|
||||
border-bottom: 1px solid #a9fd00;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
* {
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
[class^="flaticon-"]:before, [class*=" flaticon-"]:before, [class^="flaticon-"]:after, [class*=" flaticon-"]:after {
|
||||
font-family: Flaticon;
|
||||
font-size: 30px;
|
||||
font-style: normal;
|
||||
margin-left: 20px;
|
||||
color: #333;
|
||||
}
|
||||
.wrapper {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
padding: 0 1em;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
padding: 1em;
|
||||
}
|
||||
header .logo {
|
||||
width: 210px;
|
||||
height: 38px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 1em;
|
||||
border: none;
|
||||
}
|
||||
header strong {
|
||||
font-size: 1.95em;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
margin-top: 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
.demo {
|
||||
margin: 2em auto;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.demo ul li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.demo ul li .num {
|
||||
color: #222;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
width: 26px;
|
||||
padding: 3px;
|
||||
height: 26px;
|
||||
text-align: center;
|
||||
margin-right: 0.5em;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
.demo ul li code {
|
||||
background-color: #222;
|
||||
border-radius: 4px;
|
||||
padding: 0.25em 0.5em;
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
|
||||
font-weight: lighter;
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
word-break: break-all;
|
||||
}
|
||||
.demo ul li code.big {
|
||||
padding: 1em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.demo ul li code .red {
|
||||
color: #EF3159;
|
||||
}
|
||||
.demo ul li code .green {
|
||||
color: #ACFF65;
|
||||
}
|
||||
.demo ul li code .yellow {
|
||||
color: #FFFF99;
|
||||
}
|
||||
.demo ul li code .blue {
|
||||
color: #99D3FF;
|
||||
}
|
||||
.demo ul li code .purple {
|
||||
color: #A295FF;
|
||||
}
|
||||
.demo ul li code .dots {
|
||||
margin-top: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
#glyphs {
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 2em 0;
|
||||
text-align: center;
|
||||
}
|
||||
.glyph {
|
||||
display: inline-block;
|
||||
width: 9em;
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
background: #FFF;
|
||||
}
|
||||
.glyph .glyph-icon {
|
||||
padding: 10px;
|
||||
display: block;
|
||||
font-family:"Flaticon";
|
||||
font-size: 64px;
|
||||
line-height: 1;
|
||||
}
|
||||
.glyph .glyph-icon:before {
|
||||
font-size: 64px;
|
||||
color: #222;
|
||||
margin-left: 0;
|
||||
}
|
||||
.class-name {
|
||||
font-size: 0.65em;
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 0.5em;
|
||||
color: #FFFF99;
|
||||
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
|
||||
}
|
||||
.author-name {
|
||||
font-size: 0.6em;
|
||||
background-color: #fcfcfd;
|
||||
border: 1px solid #DEDEE4;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 0.5em;
|
||||
}
|
||||
.class-name:last-child {
|
||||
font-size: 10px;
|
||||
color:#888;
|
||||
}
|
||||
.class-name:last-child a {
|
||||
font-size: 10px;
|
||||
color:#555;
|
||||
}
|
||||
.class-name:last-child a:hover {
|
||||
color:#a9fd00;
|
||||
}
|
||||
.glyph > input {
|
||||
display: block;
|
||||
width: 100px;
|
||||
margin: 5px auto;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
cursor: text;
|
||||
}
|
||||
.glyph > input.icon-input {
|
||||
font-family:"Flaticon";
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.attribution .title {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.attribution textarea {
|
||||
background-color: #fcfcfd;
|
||||
padding: 1em;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border: 1px solid #DEDEE4;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
font-size: 0.8em;
|
||||
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.iconsuse {
|
||||
margin: 2em auto;
|
||||
text-align: center;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.iconsuse:after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.iconsuse .image {
|
||||
float: left;
|
||||
width: 25%;
|
||||
padding: 0 1em;
|
||||
}
|
||||
.iconsuse .image p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.iconsuse .image span {
|
||||
display: block;
|
||||
font-size: 0.65em;
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 0.5em;
|
||||
color: #FFFF99;
|
||||
margin-top: 1em;
|
||||
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace;
|
||||
}
|
||||
#footer {
|
||||
text-align: center;
|
||||
background-color: #4C5B5C;
|
||||
color: #7c9192;
|
||||
padding: 1em;
|
||||
}
|
||||
#footer a {
|
||||
border: none;
|
||||
color: #a9fd00;
|
||||
font-weight: normal;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.iconsuse .image {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.iconsuse .image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="characters-off">
|
||||
|
||||
<header>
|
||||
<a href="http://www.flaticon.com" target="_blank" class="logo">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" viewBox="0 0 560.875 102.036" enable-background="new 0 0 560.875 102.036" xml:space="preserve">
|
||||
<defs>
|
||||
</defs>
|
||||
<g>
|
||||
<g class="letters">
|
||||
<path fill="#ffffff" d="M141.596,29.675c0-3.777,2.985-6.767,6.764-6.767h34.438c3.426,0,6.15,2.728,6.15,6.15
|
||||
c0,3.43-2.724,6.149-6.15,6.149h-27.674v13.091h23.719c3.429,0,6.151,2.724,6.151,6.15c0,3.43-2.723,6.149-6.151,6.149h-23.719
|
||||
v17.574c0,3.773-2.986,6.761-6.764,6.761c-3.779,0-6.764-2.989-6.764-6.761V29.675z"></path>
|
||||
<path fill="#ffffff" d="M193.844,29.149c0-3.781,2.985-6.767,6.764-6.767c3.776,0,6.763,2.985,6.763,6.767v42.957h25.039
|
||||
c3.426,0,6.149,2.726,6.149,6.153c0,3.425-2.723,6.15-6.149,6.15h-31.802c-3.779,0-6.764-2.986-6.764-6.768V29.149z"></path>
|
||||
<path fill="#ffffff" d="M241.891,75.71l21.438-48.407c1.492-3.341,4.215-5.357,7.906-5.357h0.792
|
||||
c3.686,0,6.323,2.017,7.815,5.357l21.439,48.407c0.436,0.967,0.701,1.845,0.701,2.723c0,3.602-2.809,6.501-6.414,6.501
|
||||
c-3.161,0-5.269-1.845-6.499-4.655l-4.132-9.661h-27.059l-4.301,10.102c-1.144,2.631-3.426,4.214-6.237,4.214
|
||||
c-3.517,0-6.24-2.81-6.24-6.325C241.1,77.64,241.451,76.677,241.891,75.71z M279.932,58.666l-8.521-20.297l-8.526,20.297H279.932
|
||||
z"></path>
|
||||
<path fill="#ffffff" d="M314.864,35.387H301.86c-3.429,0-6.239-2.813-6.239-6.238c0-3.429,2.811-6.24,6.239-6.24h39.533
|
||||
c3.426,0,6.237,2.811,6.237,6.24c0,3.425-2.811,6.238-6.237,6.238h-13.001v42.785c0,3.773-2.99,6.761-6.764,6.761
|
||||
c-3.779,0-6.764-2.989-6.764-6.761V35.387z"></path>
|
||||
<path fill="#A9FD00" d="M352.615,29.149c0-3.781,2.985-6.767,6.767-6.767c3.774,0,6.761,2.985,6.761,6.767v49.024
|
||||
c0,3.773-2.987,6.761-6.761,6.761c-3.781,0-6.767-2.989-6.767-6.761V29.149z"></path>
|
||||
<path fill="#A9FD00" d="M374.132,53.836v-0.179c0-17.481,13.178-31.801,32.065-31.801c9.22,0,15.459,2.458,20.557,6.238
|
||||
c1.402,1.054,2.637,2.985,2.637,5.357c0,3.692-2.985,6.59-6.681,6.59c-1.845,0-3.071-0.702-4.044-1.319
|
||||
c-3.776-2.813-7.729-4.393-12.562-4.393c-10.364,0-17.831,8.611-17.831,19.154v0.173c0,10.542,7.291,19.329,17.831,19.329
|
||||
c5.715,0,9.492-1.756,13.359-4.834c1.049-0.874,2.458-1.491,4.039-1.491c3.429,0,6.325,2.813,6.325,6.236
|
||||
c0,2.106-1.056,3.78-2.282,4.834c-5.539,4.834-12.036,7.733-21.878,7.733C387.572,85.464,374.132,71.493,374.132,53.836z"></path>
|
||||
<path fill="#A9FD00" d="M433.009,53.836v-0.179c0-17.481,13.79-31.801,32.766-31.801c18.981,0,32.592,14.143,32.592,31.628v0.173
|
||||
c0,17.483-13.785,31.807-32.769,31.807C446.625,85.464,433.009,71.32,433.009,53.836z M484.224,53.836v-0.179
|
||||
c0-10.539-7.725-19.326-18.626-19.326c-10.893,0-18.449,8.611-18.449,19.154v0.173c0,10.542,7.73,19.329,18.626,19.329
|
||||
C476.676,72.986,484.224,64.378,484.224,53.836z"></path>
|
||||
<path fill="#A9FD00" d="M506.233,29.321c0-3.774,2.99-6.763,6.767-6.763h1.401c3.252,0,5.183,1.583,7.029,3.953l26.093,34.265
|
||||
V29.059c0-3.692,2.99-6.677,6.681-6.677c3.683,0,6.671,2.985,6.671,6.677v48.934c0,3.78-2.987,6.765-6.764,6.765h-0.436
|
||||
c-3.257,0-5.188-1.581-7.034-3.953l-27.056-35.492v32.944c0,3.687-2.985,6.676-6.678,6.676c-3.683,0-6.673-2.989-6.673-6.676
|
||||
V29.321z"></path>
|
||||
</g>
|
||||
<g class="insignia">
|
||||
<path fill="#ffffff" d="M48.372,56.137h12.517l11.156-18.537H37.186L25.688,18.539h57.825L94.668,0H9.271
|
||||
C5.925,0,2.842,1.801,1.198,4.716c-1.644,2.907-1.593,6.482,0.134,9.343l50.38,83.501c1.678,2.781,4.689,4.476,7.938,4.476
|
||||
c3.246,0,6.257-1.695,7.935-4.476l2.898-4.804L48.372,56.137z"></path>
|
||||
<g class="i">
|
||||
<path fill="#A9FD00" d="M93.575,18.539h0.031v0.004l21.652,0.004l2.705-4.488c1.727-2.861,1.778-6.436,0.133-9.343
|
||||
C116.454,1.801,113.371,0,110.026,0h-5.294L93.575,18.539z"></path>
|
||||
<polygon fill="#A9FD00" points="88.291,27.356 64.725,66.486 75.519,84.404 109.942,27.356"></polygon>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
<strong>Font Demo</strong>
|
||||
</header>
|
||||
|
||||
|
||||
<section class="demo wrapper">
|
||||
|
||||
<p class="title">Instructions</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<span class="num">1</span>Copy the "Fonts" files and CSS files to your website CSS folder.
|
||||
</li>
|
||||
<li>
|
||||
<span class="num">2</span>Add the CSS link to your website source code on header.
|
||||
<code class="big">
|
||||
<<span class="red">head</span>>
|
||||
<br/><span class="dots">...</span>
|
||||
<br/><<span class="red">link</span> <span class="green">rel</span>=<span class="yellow">"stylesheet"</span> <span class="green">type</span>=<span class="yellow">"text/css"</span> <span class="green">href</span>=<span class="yellow">"your_website_domain/css_root/flaticon.css"</span>>
|
||||
<br/><span class="dots">...</span>
|
||||
<br/></<span class="red">head</span>>
|
||||
</code>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>
|
||||
<span class="num">3</span>Use the icon class on <code>"<span class="blue">display</span>:<span class="purple"> inline</span>"</code> elements:
|
||||
<br />
|
||||
Use example: <code><<span class="red">i</span> <span class="green">class</span>=<span class="yellow">"flaticon-airplane49"</span>></<span class="red">i</span>></code> or <code><<span class="red">span</span> <span class="green">class</span>=<span class="yellow">"flaticon-airplane49"</span>></<span class="red">span</span>></code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<section id="glyphs">
|
||||
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-avatar"></div>
|
||||
<div class="class-name">.flaticon-avatar</div>
|
||||
<div class="author-name">Author: <a data-file="avatar" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-avatar-1"></div>
|
||||
<div class="class-name">.flaticon-avatar-1</div>
|
||||
<div class="author-name">Author: <a data-file="avatar-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-back"></div>
|
||||
<div class="class-name">.flaticon-back</div>
|
||||
<div class="author-name">Author: <a data-file="back" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-book"></div>
|
||||
<div class="class-name">.flaticon-book</div>
|
||||
<div class="author-name">Author: <a data-file="book" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-cancel"></div>
|
||||
<div class="class-name">.flaticon-cancel</div>
|
||||
<div class="author-name">Author: <a data-file="cancel" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-chat"></div>
|
||||
<div class="class-name">.flaticon-chat</div>
|
||||
<div class="author-name">Author: <a data-file="chat" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-chat-1"></div>
|
||||
<div class="class-name">.flaticon-chat-1</div>
|
||||
<div class="author-name">Author: <a data-file="chat-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-chat-2"></div>
|
||||
<div class="class-name">.flaticon-chat-2</div>
|
||||
<div class="author-name">Author: <a data-file="chat-2" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-copy"></div>
|
||||
<div class="class-name">.flaticon-copy</div>
|
||||
<div class="author-name">Author: <a data-file="copy" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-dislike"></div>
|
||||
<div class="class-name">.flaticon-dislike</div>
|
||||
<div class="author-name">Author: <a data-file="dislike" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-download"></div>
|
||||
<div class="class-name">.flaticon-download</div>
|
||||
<div class="author-name">Author: <a data-file="download" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-download-1"></div>
|
||||
<div class="class-name">.flaticon-download-1</div>
|
||||
<div class="author-name">Author: <a data-file="download-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-edit"></div>
|
||||
<div class="class-name">.flaticon-edit</div>
|
||||
<div class="author-name">Author: <a data-file="edit" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-envelope"></div>
|
||||
<div class="class-name">.flaticon-envelope</div>
|
||||
<div class="author-name">Author: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-folder"></div>
|
||||
<div class="class-name">.flaticon-folder</div>
|
||||
<div class="author-name">Author: <a data-file="folder" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-garbage"></div>
|
||||
<div class="class-name">.flaticon-garbage</div>
|
||||
<div class="author-name">Author: <a data-file="garbage" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-glasses"></div>
|
||||
<div class="class-name">.flaticon-glasses</div>
|
||||
<div class="author-name">Author: <a data-file="glasses" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-hand"></div>
|
||||
<div class="class-name">.flaticon-hand</div>
|
||||
<div class="author-name">Author: <a data-file="hand" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-headphones"></div>
|
||||
<div class="class-name">.flaticon-headphones</div>
|
||||
<div class="author-name">Author: <a data-file="headphones" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-heart"></div>
|
||||
<div class="class-name">.flaticon-heart</div>
|
||||
<div class="author-name">Author: <a data-file="heart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-house"></div>
|
||||
<div class="class-name">.flaticon-house</div>
|
||||
<div class="author-name">Author: <a data-file="house" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-like"></div>
|
||||
<div class="class-name">.flaticon-like</div>
|
||||
<div class="author-name">Author: <a data-file="like" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-link"></div>
|
||||
<div class="class-name">.flaticon-link</div>
|
||||
<div class="author-name">Author: <a data-file="link" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-logout"></div>
|
||||
<div class="class-name">.flaticon-logout</div>
|
||||
<div class="author-name">Author: <a data-file="logout" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-magnifying-glass"></div>
|
||||
<div class="class-name">.flaticon-magnifying-glass</div>
|
||||
<div class="author-name">Author: <a data-file="magnifying-glass" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-monitor"></div>
|
||||
<div class="class-name">.flaticon-monitor</div>
|
||||
<div class="author-name">Author: <a data-file="monitor" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-musical-note"></div>
|
||||
<div class="class-name">.flaticon-musical-note</div>
|
||||
<div class="author-name">Author: <a data-file="musical-note" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-next"></div>
|
||||
<div class="class-name">.flaticon-next</div>
|
||||
<div class="author-name">Author: <a data-file="next" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-next-1"></div>
|
||||
<div class="class-name">.flaticon-next-1</div>
|
||||
<div class="author-name">Author: <a data-file="next-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-padlock"></div>
|
||||
<div class="class-name">.flaticon-padlock</div>
|
||||
<div class="author-name">Author: <a data-file="padlock" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-paper-plane"></div>
|
||||
<div class="class-name">.flaticon-paper-plane</div>
|
||||
<div class="author-name">Author: <a data-file="paper-plane" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-phone-call"></div>
|
||||
<div class="class-name">.flaticon-phone-call</div>
|
||||
<div class="author-name">Author: <a data-file="phone-call" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-photo-camera"></div>
|
||||
<div class="class-name">.flaticon-photo-camera</div>
|
||||
<div class="author-name">Author: <a data-file="photo-camera" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-pie-chart"></div>
|
||||
<div class="class-name">.flaticon-pie-chart</div>
|
||||
<div class="author-name">Author: <a data-file="pie-chart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-piggy-bank"></div>
|
||||
<div class="class-name">.flaticon-piggy-bank</div>
|
||||
<div class="author-name">Author: <a data-file="piggy-bank" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-placeholder"></div>
|
||||
<div class="class-name">.flaticon-placeholder</div>
|
||||
<div class="author-name">Author: <a data-file="placeholder" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-printer"></div>
|
||||
<div class="class-name">.flaticon-printer</div>
|
||||
<div class="author-name">Author: <a data-file="printer" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-reload"></div>
|
||||
<div class="class-name">.flaticon-reload</div>
|
||||
<div class="author-name">Author: <a data-file="reload" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-settings"></div>
|
||||
<div class="class-name">.flaticon-settings</div>
|
||||
<div class="author-name">Author: <a data-file="settings" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-settings-1"></div>
|
||||
<div class="class-name">.flaticon-settings-1</div>
|
||||
<div class="author-name">Author: <a data-file="settings-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-share"></div>
|
||||
<div class="class-name">.flaticon-share</div>
|
||||
<div class="author-name">Author: <a data-file="share" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-shopping-bag"></div>
|
||||
<div class="class-name">.flaticon-shopping-bag</div>
|
||||
<div class="author-name">Author: <a data-file="shopping-bag" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-shopping-cart"></div>
|
||||
<div class="class-name">.flaticon-shopping-cart</div>
|
||||
<div class="author-name">Author: <a data-file="shopping-cart" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-shuffle"></div>
|
||||
<div class="class-name">.flaticon-shuffle</div>
|
||||
<div class="author-name">Author: <a data-file="shuffle" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-speaker"></div>
|
||||
<div class="class-name">.flaticon-speaker</div>
|
||||
<div class="author-name">Author: <a data-file="speaker" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-star"></div>
|
||||
<div class="class-name">.flaticon-star</div>
|
||||
<div class="author-name">Author: <a data-file="star" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-tag"></div>
|
||||
<div class="class-name">.flaticon-tag</div>
|
||||
<div class="author-name">Author: <a data-file="tag" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-upload"></div>
|
||||
<div class="class-name">.flaticon-upload</div>
|
||||
<div class="author-name">Author: <a data-file="upload" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-upload-1"></div>
|
||||
<div class="class-name">.flaticon-upload-1</div>
|
||||
<div class="author-name">Author: <a data-file="upload-1" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
<div class="glyph"><div class="glyph-icon flaticon-vector"></div>
|
||||
<div class="class-name">.flaticon-vector</div>
|
||||
<div class="author-name">Author: <a data-file="vector" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a> </div>
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section class="attribution wrapper" style="text-align:center;">
|
||||
|
||||
<div class="title">License and attribution:</div><div class="attrDiv">Font generated by <a href="http://www.flaticon.com">flaticon.com</a>. <div><p>Under <a href="http://creativecommons.org/licenses/by/3.0/">CC</a>: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a></p> </div>
|
||||
</div>
|
||||
<div class="title">Copy the Attribution License:</div>
|
||||
|
||||
<textarea onclick="this.focus();this.select();">Font generated by <a href="http://www.flaticon.com">flaticon.com</a>. <p>Under <a href="http://creativecommons.org/licenses/by/3.0/">CC</a>: <a data-file="envelope" href="http://www.flaticon.com/authors/gregor-cresnar">Gregor Cresnar</a></p>
|
||||
</textarea>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="iconsuse">
|
||||
|
||||
<div class="title">Examples:</div>
|
||||
|
||||
<div class="image">
|
||||
<p>
|
||||
<i class="glyph-icon flaticon-avatar"></i>
|
||||
<span><i class="flaticon-avatar"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<p>
|
||||
<i class="glyph-icon flaticon-avatar-1"></i>
|
||||
<span><i class="flaticon-avatar-1"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<p>
|
||||
<i class="glyph-icon flaticon-back"></i>
|
||||
<span><i class="flaticon-back"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<p>
|
||||
<i class="glyph-icon flaticon-book"></i>
|
||||
<span><i class="flaticon-book"></i></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div id="footer">
|
||||
<div>Generated by <a href="http://www.flaticon.com">flaticon.com</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"theme_color": "#276890",
|
||||
"background_color": "#276890",
|
||||
"display": "browser",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"lang": "fr",
|
||||
"name": "decp.info",
|
||||
"short_name": "decp.info",
|
||||
"description": "Outil d'exploration et de t\u00e9l\u00e9chargemetn des donn\u00e9es de la commande publique.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<script>
|
||||
document.body.addEventListener('click', (ev) => {
|
||||
/* Close any open details elements that this click is outside of */
|
||||
var target = ev.target;
|
||||
var detailsClickedWithin = null;
|
||||
while (target && target.tagName != 'DETAILS') {
|
||||
target = target.parentNode;
|
||||
}
|
||||
if (target && target.tagName == 'DETAILS') {
|
||||
detailsClickedWithin = target;
|
||||
}
|
||||
Array.from(document.getElementsByTagName('details')).filter(
|
||||
(details) => details.open && details != detailsClickedWithin
|
||||
).forEach(details => details.open = false);
|
||||
});
|
||||
</script>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/codemirror-5.57.0.min.css" />
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0-sql.min.js"></script>
|
||||
<script src="{{ base_url }}-/static/cm-resize-1.0.1.min.js"></script>
|
||||
<style>
|
||||
.CodeMirror { height: auto; min-height: 70px; width: 80%; border: 1px solid #ddd; }
|
||||
.cm-resize-handle {
|
||||
background: url("data:image/svg+xml,%3Csvg%20aria-labelledby%3D%22cm-drag-to-resize%22%20role%3D%22img%22%20fill%3D%22%23ccc%22%20stroke%3D%22%23ccc%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%2216%22%20height%3D%2216%22%3E%0A%20%20%3Ctitle%20id%3D%22cm-drag-to-resize%22%3EDrag%20to%20resize%3C%2Ftitle%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M1%202.75A.75.75%200%20011.75%202h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%202.75zm0%205A.75.75%200%20011.75%207h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%207.75zM1.75%2012a.75.75%200%20100%201.5h12.5a.75.75%200%20100-1.5H1.75z%22%3E%3C%2Fpath%3E%0A%3C%2Fsvg%3E");
|
||||
background-repeat: no-repeat;
|
||||
box-shadow: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script>
|
||||
window.onload = () => {
|
||||
const sqlFormat = document.querySelector("button#sql-format");
|
||||
const readOnly = document.querySelector("pre#sql-query");
|
||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (sqlFormat && !readOnly) {
|
||||
sqlFormat.hidden = false;
|
||||
}
|
||||
if (sqlInput) {
|
||||
var editor = CodeMirror.fromTextArea(sqlInput, {
|
||||
lineNumbers: true,
|
||||
mode: "text/x-sql",
|
||||
lineWrapping: true,
|
||||
});
|
||||
editor.setOption("extraKeys", {
|
||||
"Shift-Enter": function() {
|
||||
document.getElementsByClassName("sql")[0].submit();
|
||||
},
|
||||
Tab: false
|
||||
});
|
||||
if (sqlFormat) {
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
editor.setValue(sqlFormatter.format(editor.getValue()));
|
||||
})
|
||||
}
|
||||
cmResize(editor, {resizableWidth: false});
|
||||
}
|
||||
if (sqlFormat && readOnly) {
|
||||
const formatted = sqlFormatter.format(readOnly.innerHTML);
|
||||
if (formatted != readOnly.innerHTML) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
readOnly.innerHTML = formatted;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
{% if metadata.description_html or metadata.description %}
|
||||
<div class="metadata-description">
|
||||
{% if metadata.description_html %}
|
||||
{{ metadata.description_html|safe }}
|
||||
{% else %}
|
||||
{{ metadata.description }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}
|
||||
<p>
|
||||
{% if metadata.license or metadata.license_url %}Licence des données :
|
||||
{% if metadata.license_url %}
|
||||
<a href="{{ metadata.license_url }}">{{ metadata.license or metadata.license_url }}</a>
|
||||
{% else %}
|
||||
{{ metadata.license }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if metadata.source or metadata.source_url %}{% if metadata.license or metadata.license_url %}·{% endif %}
|
||||
Source des données : {% if metadata.source_url %}
|
||||
<a href="{{ metadata.source_url }}">
|
||||
{% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
{% if metadata.about or metadata.about_url %}{% if metadata.license or metadata.license_url or metadata.source or metadata.source_url %}·{% endif %}
|
||||
Plus d'informations : {% if metadata.about_url %}
|
||||
<a href="{{ metadata.about_url }}">
|
||||
{% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -1,23 +0,0 @@
|
||||
Propulsé par <a href="https://datasette.io/" title="Datasette v{{ datasette_version }}">Datasette</a>
|
||||
<!-- {% if query_ms %}· Durée de la requ {{ query_ms|round(3) }}ms{% endif %} -->
|
||||
{% if metadata %}
|
||||
{% if metadata.license or metadata.license_url %}· Licence des données :
|
||||
{% if metadata.license_url %}
|
||||
<a href="{{ metadata.license_url }}">{{ metadata.license or metadata.license_url }}</a>
|
||||
{% else %}
|
||||
{{ metadata.license }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if metadata.source or metadata.source_url %}·
|
||||
Source des données : {% if metadata.source_url %}
|
||||
<a href="{{ metadata.source_url }}">
|
||||
{% endif %}{{ metadata.source or metadata.source_url }}{% if metadata.source_url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
· Code source : <a href="https://github.com/ColinMaudry/decp.info">Github</a>
|
||||
{% if metadata.about or metadata.about_url %}·
|
||||
Plus d'informations : {% if metadata.about_url %}
|
||||
<a href="{{ metadata.about_url }}">
|
||||
{% endif %}{{ metadata.about or metadata.about_url }}{% if metadata.about_url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
· <a href="/mentions-legales">Mentions légales</a>
|
||||
{% endif %}
|
||||
@@ -1,34 +0,0 @@
|
||||
{% if display_rows %}
|
||||
<div class="table-wrapper">
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in display_columns %}
|
||||
<th class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
|
||||
{% if not column.sortable %}
|
||||
{{ metadata.column_labels[column.name] }}
|
||||
{% else %}
|
||||
{% if column.name == sort %}
|
||||
<a href="{{ path_with_replaced_args(request, {'_sort_desc': column.name, '_sort': None, '_next': None}) }}" rel="nofollow">{{ metadata.column_labels[column.name] }} ▼</a>
|
||||
{% else %}
|
||||
<a href="{{ path_with_replaced_args(request, {'_sort': column.name, '_sort_desc': None, '_next': None}) }}" rel="nofollow">{{ metadata.column_labels[column.name] }}{% if column.name == sort_desc %} ▲{% endif %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}"><div>{% if cell.column == "titulaire.id" or cell.column == "acheteur.id" %}<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ cell.value }}" target="_blank">{{ cell.value }}</a>{% elif cell.column == "codeAPE" %} <a href="https://www.insee.fr/fr/metadonnees/nafr2/sousClasse/{{ cell.value }}?champRecherche=true" target="_blank">{{ cell.value }}</a> {% else %}{{ cell.value }}{% endif %}</div></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="zero-results">0 lignes</p>
|
||||
{% endif %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
<h1>Coucou</h1>
|
||||
@@ -1,58 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug allow rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
p.message-warning {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Debug allow rules</h1>
|
||||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<form action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<p><label>Actor</label></p>
|
||||
<textarea name="actor">{{ actor_input }}</textarea>
|
||||
</div>
|
||||
<div style="margin-top: 1em;">
|
||||
<input type="submit" value="Apply allow block to actor">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}<p class="message-warning">{{ error }}</p>{% endif %}
|
||||
|
||||
{% if result == "True" %}<p class="message-info">Result: allow</p>{% endif %}
|
||||
|
||||
{% if result == "False" %}<p class="message-error">Result: deny</p>{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,94 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<meta name="twitter:card" content="summary"></meta>
|
||||
<meta name="twitter:creator" content="@col1m" />
|
||||
<meta name="author" content="Colin Maudry" />
|
||||
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:title" content="decp.info : exploration et téléchargement des données des marchés publics" />
|
||||
<meta name="description" property="og:description" content="decp.info vous permet de filtrer et trier les données sur les marchés publics français et de télécharger le résultat sous la forme d'un fichier que vous pourrez ouvrir dans un logiciel de tableur. Outil gratuit et libre." />
|
||||
<meta name="image" property="og:image" content="https://decp.info/static/images/capture.png" />
|
||||
|
||||
<link rel="shortcut icon" href="/static/icons/icon-192x192.png" />
|
||||
|
||||
<!-- Matomo -->
|
||||
<script type="text/javascript">
|
||||
var _paq = window._paq = window._paq || [];
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u="//analytics.maudry.com/";
|
||||
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||
_paq.push(['setSiteId', '14']);
|
||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
</script>
|
||||
<!-- End Matomo Code -->
|
||||
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<header><nav>{% block nav %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
<details class="nav-menu">
|
||||
<summary>Menu</summary>
|
||||
<div class="nav-menu-inner">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<button class="button-as-link">Déconnexion</button>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
{% if actor %}
|
||||
<div class="actor">
|
||||
<strong>{{ display_actor(actor) }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}</nav></header>
|
||||
|
||||
{% block messages %}
|
||||
{% if show_messages %}
|
||||
{% for message, message_type in show_messages() %}
|
||||
<p class="message-{% if message_type == 1 %}info{% elif message_type == 2 %}warning{% elif message_type == 3 %}error{% endif %}">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<section class="content">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
|
||||
|
||||
{% include "_close_open_menus.html" %}
|
||||
|
||||
{% for body_script in body_scripts %}
|
||||
<script{% if body_script.module %} type="module"{% endif %}>{{ body_script.script }}</script>
|
||||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,113 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
{% include "_codemirror.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
<!-- <div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = database_actions() %}{% if links %}
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div> -->
|
||||
|
||||
<!-- {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} -->
|
||||
|
||||
|
||||
{% if attached_databases %}
|
||||
<div class="message-info">
|
||||
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
|
||||
<ul class="bullets">
|
||||
{% for db_name in attached_databases %}
|
||||
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="db-presentation">decp.info vous permet d'explorer les données à travers différentes vues. Selon que vous soyez plutôt intéressé·e par le montant, les titulaires ou les caractériques d'un marché public, une vue vous sera plus utile qu'une autre.</p>
|
||||
|
||||
{% for table in tables %}
|
||||
{% if show_hidden or not table.hidden %}
|
||||
<div class="db-table">
|
||||
<h2><a href="{{ urls.table(database, table.name) }}">{{ metadata.tables[table.name].title }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2>
|
||||
<p>Description : {{ metadata.tables[table.name].description_html | safe }} (<a target="_blank" href="{{ metadata.tables[table.name].download }}">CSV</a>)</p>
|
||||
<p class="columns">Colonnes : {% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</p>
|
||||
<p>{% if table.count is none %}{% else %}{{ "{:,}".format(table.count) }} ligne{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if hidden_count and not show_hidden %}
|
||||
<p>... and <a href="{{ urls.database(database) }}?_show_hidden=1">{{ "{:,}".format(hidden_count) }} hidden table{% if hidden_count == 1 %}{% else %}s{% endif %}</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if views %}
|
||||
<h2 id="views">Views</h2>
|
||||
<ul class="bullets">
|
||||
{% for view in views %}
|
||||
<li><a href="{{ urls.database(database) }}/{{ view.name|urlencode }}">{{ view.name }}</a>{% if view.private %} 🔒{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if queries %}
|
||||
<h2 id="queries">Queries</h2>
|
||||
<ul class="bullets">
|
||||
{% for query in queries %}
|
||||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql" action="{{ urls.database(database) }}" method="get">
|
||||
<h3>Requête SQL personnalisée</h3>
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Formater le SQL</button>
|
||||
<input type="submit" value="Exécuter">
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if allow_download %}
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if title %}{{ title }}{% else %}Error {{ status }}{% endif %}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>{% if title %}{{ title }}{% else %}Erreur {{ status }}{% endif %}</h1>
|
||||
|
||||
<div style="padding: 1em; margin: 1em 0; border: 3px solid red;">{{ error }}</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
|
||||
|
||||
{% block body_class %}index{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
<!-- {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
-->
|
||||
<p><a class="explore nodec" href="/db"><i class="flaticon-magnifying-glass"></i></a> <a class="explore" href="/db">Explorer les données</a></p>
|
||||
|
||||
<p><a class="explore nodec" href="https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948"><i class="flaticon-chat-2"></i></a> <a href="https://teamopendata.org/t/decp-info-les-donnees-de-la-commande-publique-pour-tous-questions-reponses-discussions/2948">Présentation / FAQ / discussions</a></p>
|
||||
|
||||
<p><a class="explore nodec" href="/versions"><i class="flaticon-copy"></i></a> <a href="/versions">Notes de version</a></p>
|
||||
|
||||
<p><a class="explore nodec" href="/inscription"><i class="flaticon-paper-plane"></i></a> <a href="/inscription">Se tenir informé des nouveautés de decp.info</a></p>
|
||||
|
||||
<p><a class="explore nodec" href="mailto:colin+decp@maudry.com"><i class="flaticon-envelope"></i></a> <a href="mailto:colin+decp@maudry.com">colin+decp@maudry.com</a></p>
|
||||
|
||||
<!-- {% for database in databases %}
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.color }}">
|
||||
<a href="{{ urls.database(database.name) }}">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>
|
||||
<p>
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.hidden_tables_count -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||
{% endif -%}
|
||||
{% if database.views_count -%}
|
||||
{% if database.tables_count or database.hidden_tables_count %}, {% endif -%}
|
||||
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>{% for table in database.tables_and_views_truncated %}<a href="{{ urls.table(database.name, table.name) }}"{% if table.count %} title="{{ table.count }} rows"{% endif %}>{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, <a href="{{ urls.database(database.name) }}">...</a>{% endif %}</p>
|
||||
{% endfor %} -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log out{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ base_url }}">home</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Log out</h1>
|
||||
|
||||
<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>
|
||||
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Log out">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,27 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug messages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Debug messages</h1>
|
||||
|
||||
<p>Set a message:</p>
|
||||
|
||||
<form action="{{ urls.path('-/messages') }}" method="post">
|
||||
<div>
|
||||
<input type="text" name="message" style="width: 40%">
|
||||
<div class="select-wrapper">
|
||||
<select name="message_type">
|
||||
<option>INFO</option>
|
||||
<option>WARNING</option>
|
||||
<option>ERROR</option>
|
||||
<option>all</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Add message">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,240 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Se tenir informé{% endblock %}
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h1>Se tenir informé des nouveautés de decp.info</h1>
|
||||
|
||||
<p><a href="/mentions-legales">Mentions légales et politique de confidentialité</a></p>
|
||||
|
||||
<!-- Begin Sendinblue Form -->
|
||||
<!-- START - We recommend to place the below code in head tag of your website html -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-display: block;
|
||||
font-family: Roboto;
|
||||
src: url(https://assets.sendinblue.com/font/Roboto/Latin/normal/normal/7529907e9eaf8ebb5220c5f9850e3811.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/normal/normal/25c678feafdc175a70922a116c9be3e7.woff) format("woff")
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: fallback;
|
||||
font-family: Roboto;
|
||||
font-weight: 600;
|
||||
src: url(https://assets.sendinblue.com/font/Roboto/Latin/medium/normal/6e9caeeafb1f3491be3e32744bc30440.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/medium/normal/71501f0d8d5aa95960f6475d5487d4c2.woff) format("woff")
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: fallback;
|
||||
font-family: Roboto;
|
||||
font-weight: 700;
|
||||
src: url(https://assets.sendinblue.com/font/Roboto/Latin/bold/normal/3ef7cf158f310cf752d5ad08cd0e7e60.woff2) format("woff2"), url(https://assets.sendinblue.com/font/Roboto/Latin/bold/normal/ece3a1d82f18b60bcce0211725c476aa.woff) format("woff")
|
||||
}
|
||||
|
||||
#sib-container input:-ms-input-placeholder {
|
||||
text-align: left;
|
||||
font-family: "Helvetica", sans-serif;
|
||||
color: #c0ccda;
|
||||
}
|
||||
|
||||
#sib-container input::placeholder {
|
||||
text-align: left;
|
||||
font-family: "Helvetica", sans-serif;
|
||||
color: #c0ccda;
|
||||
}
|
||||
|
||||
#sib-container textarea::placeholder {
|
||||
text-align: left;
|
||||
font-family: "Helvetica", sans-serif;
|
||||
color: #c0ccda;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://sibforms.com/forms/end-form/build/sib-styles.css">
|
||||
<!-- END - We recommend to place the above code in head tag of your website html -->
|
||||
|
||||
<!-- START - We recommend to place the below code where you want the form in your website html -->
|
||||
<div class="sib-form" style="text-align: center;
|
||||
background-color: #EFF2F7; ">
|
||||
<div id="sib-form-container" class="sib-form-container">
|
||||
<div id="error-message" class="sib-form-message-panel" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;max-width:540px;">
|
||||
<div class="sib-form-message-panel__text sib-form-message-panel__text--center">
|
||||
<svg viewBox="0 0 512 512" class="sib-icon sib-notification__icon">
|
||||
<path d="M256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm-11.49 120h22.979c6.823 0 12.274 5.682 11.99 12.5l-7 168c-.268 6.428-5.556 11.5-11.99 11.5h-8.979c-6.433 0-11.722-5.073-11.99-11.5l-7-168c-.283-6.818 5.167-12.5 11.99-12.5zM256 340c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sib-form-message-panel__inner-text">
|
||||
Il y a eu un problème lors de votre inscription.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<div id="success-message" class="sib-form-message-panel" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#085229; background-color:#F8FAFB; border-radius:3px; border-color:#13ce66;max-width:540px;">
|
||||
<div class="sib-form-message-panel__text sib-form-message-panel__text--center">
|
||||
<svg viewBox="0 0 512 512" class="sib-icon sib-notification__icon">
|
||||
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 464c-118.664 0-216-96.055-216-216 0-118.663 96.055-216 216-216 118.664 0 216 96.055 216 216 0 118.663-96.055 216-216 216zm141.63-274.961L217.15 376.071c-4.705 4.667-12.303 4.637-16.97-.068l-85.878-86.572c-4.667-4.705-4.637-12.303.068-16.97l8.52-8.451c4.705-4.667 12.303-4.637 16.97.068l68.976 69.533 163.441-162.13c4.705-4.667 12.303-4.637 16.97.068l8.451 8.52c4.668 4.705 4.637 12.303-.068 16.97z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sib-form-message-panel__inner-text">
|
||||
Votre inscription à la lettre d'information de decp.info est confirmée !
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<div id="sib-container" class="sib-container--large sib-container--vertical" style="text-align:center; background-color:rgba(255,255,255,1); max-width:540px; border-radius:3px; border-width:1px; border-color:#C0CCD9; border-style:solid;">
|
||||
<form id="sib-form" method="POST" action="https://6254d9a3.sibforms.com/serve/MUIEAFh0B8R5fTSPBmKbkiziso8TBxEbUSvq4FaHCK9EL8CT6H41du2IXKnJZhVBXFYxxZgnOVqO7hribE8qPWpY5IE5yraVm7d7uKdlGqurkl0XgHUy4fILW8aPuGAcn60ZsZzJf7gxINtPUpSlrOsjZPcdl4vvWgsaAU51rM9cXCC8Mm_MrlvOkuZk_uah-YUNMjj6qLONk1WR"
|
||||
data-type="subscription">
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-form-block" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#3C4858; background-color:transparent;">
|
||||
<div class="sib-text-form-block">
|
||||
<p>Inscrivez-vous à la lettre d'information de decp.info afin d'être informé·e de l'ajout de nouvelles fonctionnalités (2 mails par mois maximum).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-input sib-form-block">
|
||||
<div class="form__entry entry_block">
|
||||
<div class="form__label-row ">
|
||||
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:"Helvetica", sans-serif; color:#3c4858;" for="EMAIL" data-required="*">
|
||||
Votre adresse email :
|
||||
</label>
|
||||
|
||||
<div class="entry__field">
|
||||
<input class="input" type="text" id="EMAIL" name="EMAIL" autocomplete="off" data-required="true" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-input sib-form-block">
|
||||
<div class="form__entry entry_block">
|
||||
<div class="form__label-row ">
|
||||
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:"Helvetica", sans-serif; color:#3c4858;" for="NOM">
|
||||
Votre nom :
|
||||
</label>
|
||||
|
||||
<div class="entry__field">
|
||||
<input class="input" maxlength="200" type="text" id="NOM" name="NOM" autocomplete="off" placeholder="Optionnel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-input sib-form-block">
|
||||
<div class="form__entry entry_block">
|
||||
<div class="form__label-row ">
|
||||
<label class="entry__label" style="font-size:16px; text-align:left; font-weight:700; font-family:"Helvetica", sans-serif; color:#3c4858;" for="ORGANISME">
|
||||
Votre organisme :
|
||||
</label>
|
||||
|
||||
<div class="entry__field">
|
||||
<input class="input" maxlength="200" type="text" id="ORGANISME" name="ORGANISME" autocomplete="off" placeholder="Optionnel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-optin sib-form-block">
|
||||
<div class="form__entry entry_mcq">
|
||||
<div class="form__label-row ">
|
||||
<div class="entry__choice">
|
||||
<label>
|
||||
<input type="checkbox" class="input_replaced" value="1" id="OPT_IN" name="OPT_IN" />
|
||||
<span class="checkbox checkbox_tick_positive"></span><span style="font-size:14px; text-align:left; font-family:"Helvetica", sans-serif; color:#3C4858; background-color:transparent;"><p>J'accepte de recevoir vos e-mails et confirme avoir pris connaissance de votre politique de confidentialité et mentions légales.</p></span> </label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="entry__error entry__error--primary" style="font-size:16px; text-align:left; font-family:"Helvetica", sans-serif; color:#661d1d; background-color:#ffeded; border-radius:3px; border-color:#ff4949;">
|
||||
</label>
|
||||
<label class="entry__specification" style="font-size:12px; text-align:left; font-family:"Helvetica", sans-serif; color:#8390A4;">
|
||||
Vous pouvez vous désinscrire à tout moment en cliquant sur le lien présent dans nos emails.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-form__declaration">
|
||||
<div class="declaration-block-icon">
|
||||
<svg class="icon__SVG" width="0" height="0" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<symbol id="svgIcon-sphere" viewBox="0 0 63 63">
|
||||
<path class="path1" d="M31.54 0l1.05 3.06 3.385-.01-2.735 1.897 1.05 3.042-2.748-1.886-2.738 1.886 1.044-3.05-2.745-1.897h3.393zm13.97 3.019L46.555 6.4l3.384.01-2.743 2.101 1.048 3.387-2.752-2.1-2.752 2.1 1.054-3.382-2.745-2.105h3.385zm9.998 10.056l1.039 3.382h3.38l-2.751 2.1 1.05 3.382-2.744-2.091-2.743 2.091 1.054-3.381-2.754-2.1h3.385zM58.58 27.1l1.04 3.372h3.379l-2.752 2.096 1.05 3.387-2.744-2.091-2.75 2.092 1.054-3.387-2.747-2.097h3.376zm-3.076 14.02l1.044 3.364h3.385l-2.743 2.09 1.05 3.392-2.744-2.097-2.743 2.097 1.052-3.377-2.752-2.117 3.385-.01zm-9.985 9.91l1.045 3.364h3.393l-2.752 2.09 1.05 3.393-2.745-2.097-2.743 2.097 1.05-3.383-2.751-2.1 3.384-.01zM31.45 55.01l1.044 3.043 3.393-.008-2.752 1.9L34.19 63l-2.744-1.895-2.748 1.891 1.054-3.05-2.743-1.9h3.384zm-13.934-3.98l1.036 3.364h3.402l-2.752 2.09 1.053 3.393-2.747-2.097-2.752 2.097 1.053-3.382-2.743-2.1 3.384-.01zm-9.981-9.91l1.045 3.364h3.398l-2.748 2.09 1.05 3.392-2.753-2.1-2.752 2.096 1.053-3.382-2.743-2.102 3.384-.009zM4.466 27.1l1.038 3.372H8.88l-2.752 2.097 1.053 3.387-2.743-2.09-2.748 2.09 1.053-3.387L0 30.472h3.385zm3.069-14.025l1.045 3.382h3.395L9.23 18.56l1.05 3.381-2.752-2.09-2.752 2.09 1.053-3.381-2.744-2.1h3.384zm9.99-10.056L18.57 6.4l3.393.01-2.743 2.1 1.05 3.373-2.754-2.092-2.751 2.092 1.053-3.382-2.744-2.1h3.384zm24.938 19.394l-10-4.22a2.48 2.48 0 00-1.921 0l-10 4.22A2.529 2.529 0 0019 24.75c0 10.47 5.964 17.705 11.537 20.057a2.48 2.48 0 001.921 0C36.921 42.924 44 36.421 44 24.75a2.532 2.532 0 00-1.537-2.336zm-2.46 6.023l-9.583 9.705a.83.83 0 01-1.177 0l-5.416-5.485a.855.855 0 010-1.192l1.177-1.192a.83.83 0 011.177 0l3.65 3.697 7.819-7.916a.83.83 0 011.177 0l1.177 1.191a.843.843 0 010 1.192z"
|
||||
fill="#0092FF"></path>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg class="svgIcon-sphere" style="width:63px; height:63px;">
|
||||
<use xlink:href="#svgIcon-sphere"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<p style="font-size:14px; text-align:left; font-family:"Helvetica", sans-serif; color:#687484; background-color:transparent;">
|
||||
Nous utilisons Sendinblue en tant que plateforme marketing. En soumettant ce formulaire, vous reconnaissez que les informations que vous allez fournir seront transmises à Sendinblue en sa qualité de processeur de données; et ce conformément à ses
|
||||
<a target="_blank" class="clickable_link" href="https://fr.sendinblue.com/legal/termsofuse/">conditions générales d'utilisation</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div style="padding: 8px 0;">
|
||||
<div class="sib-form-block" style="text-align: left">
|
||||
<button class="sib-form-block__button sib-form-block__button-with-loader" style="font-size:16px; text-align:left; font-weight:700; font-family:"Helvetica", sans-serif; color:#FFFFFF; background-color:#3E4857; border-radius:3px; border-width:0px;"
|
||||
form="sib-form" type="submit">
|
||||
<svg class="icon clickable__icon progress-indicator__icon sib-hide-loader-icon" viewBox="0 0 512 512">
|
||||
<path d="M460.116 373.846l-20.823-12.022c-5.541-3.199-7.54-10.159-4.663-15.874 30.137-59.886 28.343-131.652-5.386-189.946-33.641-58.394-94.896-95.833-161.827-99.676C261.028 55.961 256 50.751 256 44.352V20.309c0-6.904 5.808-12.337 12.703-11.982 83.556 4.306 160.163 50.864 202.11 123.677 42.063 72.696 44.079 162.316 6.031 236.832-3.14 6.148-10.75 8.461-16.728 5.01z"
|
||||
/>
|
||||
</svg>
|
||||
Je m'inscris
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="text" name="email_address_check" value="" class="input--hidden">
|
||||
<input type="hidden" name="locale" value="fr">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END - We recommend to place the below code where you want the form in your website html -->
|
||||
|
||||
<!-- START - We recommend to place the below code in footer or bottom of your website html -->
|
||||
<script>
|
||||
window.REQUIRED_CODE_ERROR_MESSAGE = 'Veuillez choisir un code pays';
|
||||
|
||||
window.EMAIL_INVALID_MESSAGE = window.SMS_INVALID_MESSAGE = "Les informations que vous avez fournies ne sont pas valides. Veuillez vérifier votre adresse email et réessayer.";
|
||||
|
||||
window.REQUIRED_ERROR_MESSAGE = "Ce champ est obligatoire pour vous inscrire. ";
|
||||
|
||||
window.GENERIC_INVALID_MESSAGE = "Les informations que vous avez fournies ne sont pas valides. Veuillez vérifier votre adresse email et réessayer.";
|
||||
|
||||
|
||||
|
||||
|
||||
window.translation = {
|
||||
common: {
|
||||
selectedList: '{quantity} liste sélectionnée',
|
||||
selectedLists: '{quantity} listes sélectionnées'
|
||||
}
|
||||
};
|
||||
|
||||
var AUTOHIDE = Boolean(0);
|
||||
</script>
|
||||
<script src="https://sibforms.com/forms/end-form/build/main.js"></script>
|
||||
|
||||
|
||||
<!-- END - We recommend to place the above code in footer or bottom of your website html -->
|
||||
<!-- End Sendinblue Form -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Mentions légales{% endblock %}
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div style="max-width: 700px;margin:auto">
|
||||
|
||||
<h1 id="mentionslgales">Mentions légales</h1>
|
||||
|
||||
<p>Le site decp.info est édité par Colin Maudry, inscrit au répertoire SIRENE sous le numéro 812 231 132, dont le siège social est situé à Rennes, et dont l'adresse email est <a href="mailto:colin@maudry.com">colin@maudry.com</a>.</p>
|
||||
<p>Le site est hébergé en France par :</p>
|
||||
|
||||
<p>Scaleway par ONLINE, SAS, au capital de 214 410,50 Euros<br/>
|
||||
Siège social : 8 rue de la ville l'Evêque-75008 PARIS<br/>
|
||||
RCS Paris B 433 115 904, TVA FR35433115904<br/>
|
||||
<a href="https://www.scaleway.com" rel="nofollow">https://www.scaleway.com</a></p>
|
||||
|
||||
<p>Les icônes de la page d'accueil ont été dessinées par <a href="https://www.flaticon.com/authors/gregor-cresnar" title="Gregor Cresnar">Gregor Cresnar</a> de <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a></p>
|
||||
|
||||
|
||||
<h1 id="vieprive">Vie privée</h1>
|
||||
|
||||
<h2>Les cookies</h2>
|
||||
|
||||
|
||||
<p>Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez (<a href="https://fr.wikipedia.org/wiki/Cookie_(informatique)">Wikipédia</a>). Cela me permet de mesurer le nombre de visites et de distinguer les nouveaux visiteurs des utilisateurs réguliers.</p>
|
||||
|
||||
<div style="background-color: #ccc;"><iframe style="border: 0; height: 200px; width: 100%;" title="Opt-out du cookie de suivi" src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOut&language=fr&backgroundColor=&fontColor=&fontSize=&fontFamily="></iframe></div>
|
||||
|
||||
<h3 id="cesitenaffichepasdebanniredeconsentementauxcookiespourquoi">Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?</h3>
|
||||
|
||||
<p>C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moitié de la page pour dire que vous êtes d’accord avec le dépôt de cookies.</p>
|
||||
|
||||
<p>Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.</p>
|
||||
|
||||
<p>J’utilise pour cela <a href="https://matomo.org/">Matomo</a>, un outil <a href="https://matomo.org/free-software/">libre</a>, paramétré pour être en conformité avec la <a href="https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience">recommandation « Cookies »</a> de la <a href="http://sigl.es/cnil">CNIL</a>. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne.</p>
|
||||
<h2>La lettre d'information et conformité RGPD</h2>
|
||||
|
||||
Ce site <a href="/inscription">vous propose</a> de vous inscrire à une lettre d'information. Pour ce faire, vous devez donner votre accord par deux fois : au moment de remplir le formulaire, et en cliquant sur un lien dans le mail de confirmation.
|
||||
|
||||
Tous les emails qui vous sont envoyés dans le cadre de cette lettre d'information comportent un lien de désinscription.
|
||||
|
||||
Votre adresse email est stockée en France chez Sendinblue, entreprise française.
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,91 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notes de version{% endblock %}
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div style="max-width: 700px;margin:auto" class="versions">
|
||||
<h1>Notes de version</h1>
|
||||
|
||||
<h3 id="14014062021">1.4.0 (14/06/2021)</h3>
|
||||
|
||||
<ul>
|
||||
<li>traduction des opérations de filtrage (ex : contains => contient)</li>
|
||||
|
||||
<li>élargissement des menus de filtrage</li>
|
||||
|
||||
<li>correction du titre de la page des notes de versions</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="13003062021">1.3.0 (03/06/2021)</h3>
|
||||
|
||||
<ul>
|
||||
<li>utilisation de noms de colonnes plus lisibles dans l'application</li>
|
||||
|
||||
<li>suppression des références à la licence et aux données source sur la page d'accueil</li>
|
||||
|
||||
<li>correction des liens vers le code source</li>
|
||||
|
||||
<li>correction de l'indentation des puces dans les notes de version</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<h3 id="12028052021">1.2.0 (28/05/2021)</h3>
|
||||
|
||||
<ul>
|
||||
<li>ajout d'une page "Notes de version"</li>
|
||||
|
||||
<li>meilleur lien pour la documentation des champs</li>
|
||||
|
||||
<li>déplacement du code de decp.info depuis <a href="https://github.com/ColinMaudry/decp-table-schema-utils">ColinMaudry/decp-table-schema-utils</a> vers <a href="https://github.com/ColinMaudry/decp.info">ColinMaudry/decp.info</a></li>
|
||||
</ul>
|
||||
|
||||
<h3 id="11025052021">1.1.0 (25/05/2021)</h3>
|
||||
|
||||
<ul>
|
||||
<li>ajout de nouvelles vues :
|
||||
|
||||
|
||||
<ul>
|
||||
<li>Marchés publics sans leurs titulaires : vue dédiée aux titulaires de marchés avec des données provenant du répertoire SIRENE</li>
|
||||
|
||||
<li>Données sur les titulaires et géolocalisation : vue sans les titulaires pour analyser les nombres de marchés et les montants</li></ul>
|
||||
</li>
|
||||
|
||||
<li>amélioration de la page d'accueil</li>
|
||||
|
||||
<li>développement de la page "db" avec description des vues et liste des colonnes</li>
|
||||
|
||||
<li>les codes APE sont cliquables</li>
|
||||
|
||||
<li>ajout des mentions légales</li>
|
||||
|
||||
<li>ajout d'un formulatire d'inscription à une lettre d'information</li>
|
||||
|
||||
<li>correction de bugs :
|
||||
|
||||
|
||||
<ul>
|
||||
<li>correction du format de certaines dates dans les données</li></ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="100">1.0.0</h3>
|
||||
|
||||
<ul>
|
||||
<li>publication sur https://decp.info</li>
|
||||
|
||||
<li>ajout d'une vue équivalente au format DECP réglementaire</li>
|
||||
|
||||
<li>personnalisation de datasette</li>
|
||||
|
||||
<li>script de conversion quotidien basé sur <a href="https://github.com/datahq/dataflows">dataflows</a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,495 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Datasette: Pattern Portfolio</title>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/app.css?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex">
|
||||
<style></style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header><nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a>
|
||||
</p>
|
||||
<details class="nav-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16" width="16" height="16">
|
||||
<title id="nav-menu-svg-title">Menu</title>
|
||||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
<ul>
|
||||
<li><a href="/-/databases">Databases</a></li>
|
||||
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||
<li><a href="/-/versions">Version info</a></li>
|
||||
</ul>
|
||||
<form action="/-/logout" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<div class="actor">
|
||||
<strong>root</strong>
|
||||
</div>
|
||||
</nav></header>
|
||||
|
||||
<section class="content">
|
||||
<h1>Pattern Portfolio</h1>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<h2 class="pattern-heading">Header for /database/table/row and Messages</h2>
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<p class="crumbs">
|
||||
<a href="/">home</a> /
|
||||
<a href="/fixtures">fixtures</a> /
|
||||
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||
</p>
|
||||
<div class="actor">
|
||||
<strong>testuser</strong>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<p class="message-info">Example message</p>
|
||||
<p class="message-warning">Example message</p>
|
||||
<p class="message-error">Example message</p>
|
||||
|
||||
<h2 class="pattern-heading">.bd for /</h2>
|
||||
<section class="content">
|
||||
<h1>Datasette Fixtures</h1>
|
||||
<div class="metadata-description">
|
||||
An example SQLite database demonstrating Datasette
|
||||
</div>
|
||||
<p>
|
||||
Data license:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
|
||||
·
|
||||
Data source:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
|
||||
tests/fixtures.py</a>
|
||||
·
|
||||
About:
|
||||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</p>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #9403e5"><a href="/fixtures">fixtures</a></h2>
|
||||
<p>
|
||||
1,258 rows in 24 tables, 206 rows in 5 hidden tables, 4 views
|
||||
</p>
|
||||
<p><a href="/fixtures/compound_three_primary_keys" title="1001 rows">compound_three_primary_keys</a>, <a href="/fixtures/sortable" title="201 rows">sortable</a>, <a href="/fixtures/facetable" title="15 rows">facetable</a>, <a href="/fixtures/roadside_attraction_characteristics" title="5 rows">roadside_attraction_characteristics</a>, <a href="/fixtures/simple_primary_key" title="4 rows">simple_primary_key</a>, <a href="/fixtures">...</a></p>
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #8d777f"><a href="/data">data</a></h2>
|
||||
<p>
|
||||
6 rows in 2 tables
|
||||
</p>
|
||||
<p><a href="/data/names" title="6 rows">names</a>, <a href="/data/foo">foo</a></p>
|
||||
</section>
|
||||
|
||||
<h2 class="pattern-heading">.bd for /database</h2>
|
||||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>fixtures</h1>
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
<ul>
|
||||
<li><a href="#">Database action</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="metadata-description">
|
||||
Test tables description
|
||||
</div>
|
||||
<p>
|
||||
Data license:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
|
||||
·
|
||||
Data source:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
|
||||
tests/fixtures.py</a>
|
||||
·
|
||||
About:
|
||||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</p>
|
||||
<form class="sql" action="/fixtures" method="get">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">select * from [123_starts_with_digits]</textarea></p>
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||
<input type="submit" value="Run SQL">
|
||||
</p>
|
||||
</form>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/123_starts_with_digits">123_starts_with_digits</a></h2>
|
||||
<p><em>content</em></p>
|
||||
<p>0 rows</p>
|
||||
</div>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/Table+With+Space+In+Name">Table With Space In Name</a></h2>
|
||||
<p><em>pk, content</em></p>
|
||||
<p>0 rows</p>
|
||||
</div>
|
||||
<div class="db-table">
|
||||
<h2><a href="/fixtures/attraction_characteristic">attraction_characteristic</a></h2>
|
||||
<p><em>pk, name</em></p>
|
||||
<p>2 rows</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 class="pattern-heading">.bd for /database/table</h2>
|
||||
|
||||
<section class="content">
|
||||
<div class="page-header" style="border-color: #ff0000">
|
||||
<h1>roadside_attraction_characteristics</h1>
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
<ul>
|
||||
<li><a href="#">Table action</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Data license:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
|
||||
·
|
||||
Data source:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
|
||||
tests/fixtures.py</a>
|
||||
·
|
||||
About:
|
||||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</p>
|
||||
<h3>3 rows
|
||||
where characteristic_id = 2
|
||||
</h3>
|
||||
<form class="filters" action="/fixtures/roadside_attraction_characteristics" method="get">
|
||||
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value=""></div>
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_1">
|
||||
<option value="">- remove filter -</option>
|
||||
<option>rowid</option>
|
||||
<option>attraction_id</option>
|
||||
<option selected>characteristic_id</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper filter-op">
|
||||
<select name="_filter_op_1">
|
||||
<option value="exact" selected>=</option>
|
||||
<option value="not">!=</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="endswith">ends with</option>
|
||||
<option value="startswith">starts with</option>
|
||||
<option value="gt">></option>
|
||||
<option value="gte">≥</option>
|
||||
<option value="lt"><</option>
|
||||
<option value="lte">≤</option>
|
||||
<option value="like">like</option>
|
||||
<option value="notlike">not like</option>
|
||||
<option value="glob">glob</option>
|
||||
<option value="in">in</option>
|
||||
<option value="notin">not in</option>
|
||||
<option value="arraycontains">array contains</option>
|
||||
<option value="date">date</option>
|
||||
<option value="isnull__1">is null</option>
|
||||
<option value="notnull__1">is not null</option>
|
||||
<option value="isblank__1">is blank</option>
|
||||
<option value="notblank__1">is not blank</option>
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value_1" class="filter-value" value="2">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- column -</option>
|
||||
<option>rowid</option>
|
||||
<option>attraction_id</option>
|
||||
<option>characteristic_id</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper filter-op">
|
||||
<select name="_filter_op">
|
||||
<option value="exact">=</option>
|
||||
<option value="not">!=</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="endswith">ends with</option>
|
||||
<option value="startswith">starts with</option>
|
||||
<option value="gt">></option>
|
||||
<option value="gte">≥</option>
|
||||
<option value="lt"><</option>
|
||||
<option value="lte">≤</option>
|
||||
<option value="like">like</option>
|
||||
<option value="notlike">not like</option>
|
||||
<option value="glob">glob</option>
|
||||
<option value="in">in</option>
|
||||
<option value="notin">not in</option>
|
||||
<option value="arraycontains">array contains</option>
|
||||
<option value="date">date</option>
|
||||
<option value="isnull__1">is null</option>
|
||||
<option value="notnull__1">is not null</option>
|
||||
<option value="isblank__1">is blank</option>
|
||||
<option value="notblank__1">is not blank</option>
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Sort...</option>
|
||||
<option value="rowid" selected>Sort by rowid</option>
|
||||
<option value="attraction_id">Sort by attraction_id</option>
|
||||
<option value="characteristic_id">Sort by characteristic_id</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"> descending</label>
|
||||
<input type="submit" value="Apply">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="extra-wheres">
|
||||
<h3>2 extra where clauses</h3>
|
||||
<ul>
|
||||
|
||||
<li><code>planet_int=1</code> [<a href="/fixtures/facetable?_where=state%3D%27CA%27">remove</a>]</li>
|
||||
|
||||
<li><code>state='CA'</code> [<a href="/fixtures/facetable?_where=planet_int%3D1">remove</a>]</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><a class="not-underlined" title="select rowid, attraction_id, characteristic_id from roadside_attraction_characteristics where "characteristic_id" = :p0 order by rowid limit 101" href="/fixtures?sql=select+rowid%2C+attraction_id%2C+characteristic_id+from+roadside_attraction_characteristics+where+%22characteristic_id%22+%3D+%3Ap0+order+by+rowid+limit+101&p0=2">✎ <span class="underlined">View and edit SQL</span></a></p>
|
||||
|
||||
<p class="export-links">This data as <a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">json</a>, <a href="/fixtures/roadside_attraction_characteristics.csv?characteristic_id=2&_labels=on&_size=max">CSV</a> (<a href="#export">advanced</a>)</p>
|
||||
|
||||
<p class="suggested-facets">
|
||||
Suggested facets: <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet=tags#facet-tags">tags</a>, <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_date=created#facet-created">created</a> (date), <a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet=complex_array&_facet_array=tags#facet-tags">tags</a> (array)
|
||||
</p>
|
||||
|
||||
<div class="facet-results">
|
||||
|
||||
<div class="facet-info facet-fixtures-facetable-tags" id="facet-tags">
|
||||
<p class="facet-info-name">
|
||||
<strong>tags (array)</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&tags__arraycontains=tag1">tag1</a> 2</li>
|
||||
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&tags__arraycontains=tag2">tag2</a> 1</li>
|
||||
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&tags__arraycontains=tag3">tag3</a> 1</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="facet-info facet-fixtures-facetable-created" id="facet-created">
|
||||
<p class="facet-info-name">
|
||||
<strong>created</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet_array=tags" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&created=2019-01-14+08%3A00%3A00">2019-01-14 08:00:00</a> 4</li>
|
||||
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&created=2019-01-15+08%3A00%3A00">2019-01-15 08:00:00</a> 4</li>
|
||||
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&created=2019-01-16+08%3A00%3A00">2019-01-16 08:00:00</a> 2</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="facet-info facet-fixtures-facetable-city_id" id="facet-city_id">
|
||||
<p class="facet-info-name">
|
||||
<strong>city_id</strong>
|
||||
|
||||
<a href="/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=created&_facet_array=tags" class="cross">✖</a>
|
||||
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&city_id=1">San Francisco</a> 6</li>
|
||||
|
||||
|
||||
|
||||
<li><a href="http://latest.datasette.io/fixtures/facetable?_where=planet_int%3D1&_where=state%3D%27CA%27&_facet=city_id&_facet=created&_facet_array=tags&city_id=2">Los Angeles</a> 4</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-Link" scope="col">
|
||||
Link
|
||||
</th>
|
||||
<th class="col-rowid" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort_desc=rowid" rel="nofollow">rowid ▼</a>
|
||||
</th>
|
||||
<th class="col-attraction_id" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=attraction_id" rel="nofollow">attraction_id</a>
|
||||
</th>
|
||||
<th class="col-characteristic_id" scope="col">
|
||||
<a href="/fixtures/roadside_attraction_characteristics?characteristic_id=2&_sort=characteristic_id" rel="nofollow">characteristic_id</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/1">1</a></td>
|
||||
<td class="col-rowid">1</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/1">The Mystery Spot</a> <em>1</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/2">2</a></td>
|
||||
<td class="col-rowid">2</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/2">Winchester Mystery House</a> <em>2</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-Link"><a href="/fixtures/roadside_attraction_characteristics/3">3</a></td>
|
||||
<td class="col-rowid">3</td>
|
||||
<td class="col-attraction_id"><a href="/fixtures/roadside_attractions/4">Bigfoot Discovery Museum</a> <em>4</em></td>
|
||||
<td class="col-characteristic_id"><a href="/fixtures/attraction_characteristic/2">Paranormal</a> <em>2</em></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="export" class="advanced-export">
|
||||
<h3>Advanced export</h3>
|
||||
<p>JSON shape:
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on">default</a>,
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array">array</a>,
|
||||
<a href="/fixtures/roadside_attraction_characteristics.json?characteristic_id=2&_labels=on&_shape=array&_nl=on">newline-delimited</a>
|
||||
</p>
|
||||
<form action="/fixtures/roadside_attraction_characteristics.csv" method="get">
|
||||
<p>
|
||||
CSV options:
|
||||
<label><input type="checkbox" name="_dl"> download file</label>
|
||||
<label><input type="checkbox" name="_labels" checked> expand labels</label>
|
||||
<input type="submit" value="Export CSV">
|
||||
<input type="hidden" name="characteristic_id" value="2">
|
||||
<input type="hidden" name="_size" value="max">
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<pre class="wrapped-sql">CREATE TABLE roadside_attraction_characteristics (
|
||||
attraction_id INTEGER REFERENCES roadside_attractions(pk),
|
||||
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
|
||||
);</pre>
|
||||
</section>
|
||||
|
||||
<h2 class="pattern-heading">.bd for /database/table/row</h2>
|
||||
<section class="content">
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #ff0000">roadside_attractions: 2</h1>
|
||||
<p>This data as <a href="/fixtures/roadside_attractions/2.json">json</a></p>
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-pk" scope="col">
|
||||
pk
|
||||
</th>
|
||||
<th class="col-name" scope="col">
|
||||
name
|
||||
</th>
|
||||
<th class="col-address" scope="col">
|
||||
address
|
||||
</th>
|
||||
<th class="col-latitude" scope="col">
|
||||
latitude
|
||||
</th>
|
||||
<th class="col-longitude" scope="col">
|
||||
longitude
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="col-pk">2</td>
|
||||
<td class="col-name">Winchester Mystery House</td>
|
||||
<td class="col-address">525 South Winchester Boulevard, San Jose, CA 95128</td>
|
||||
<td class="col-latitude">37.3184</td>
|
||||
<td class="col-longitude">-121.9511</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Links from other tables</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/fixtures/roadside_attraction_characteristics?attraction_id=2">
|
||||
1 row</a>
|
||||
from attraction_id in roadside_attraction_characteristics
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<h2 class="pattern-heading">.ft</h2>
|
||||
|
||||
<footer class="ft">Powered by <a href="https://datasette.io/" title="Datasette v0+unknown">Datasette</a>
|
||||
· Data license:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/LICENSE">Apache License 2.0</a>
|
||||
·
|
||||
Data source:
|
||||
<a href="https://github.com/simonw/datasette/blob/main/tests/fixtures.py">
|
||||
tests/fixtures.py</a>
|
||||
·
|
||||
About:
|
||||
<a href="https://github.com/simonw/datasette">
|
||||
About Datasette</a>
|
||||
</footer>
|
||||
|
||||
{% include "_close_open_menus.html" %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ base_url }}">home</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
{% for check in permission_checks %}
|
||||
<div class="check">
|
||||
<h2>
|
||||
<span class="check-action">{{ check.action }}</span>
|
||||
checked at
|
||||
<span class="check-when">{{ check.when }}</span>
|
||||
{% if check.result %}
|
||||
<span class="check-result check-result-true">✓</span>
|
||||
{% else %}
|
||||
<span class="check-result check-result-false">✗</span>
|
||||
{% endif %}
|
||||
{% if check.used_default %}
|
||||
<span class="check-used-default">(used default)</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p><strong>Actor:</strong> {{ check.actor|tojson }}</p>
|
||||
{% if check.resource %}
|
||||
<p><strong>Resource:</strong> {{ check.resource }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,87 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}{% if query and query.sql %}: {{ query.sql }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
{% if columns %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column|escape_css_string }}"; }
|
||||
{% endfor %}
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
{% include "_codemirror.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query db-{{ database|to_css_class }}{% if canned_query %} query-{{ canned_query|to_css_class }}{% endif %}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">home</a> /
|
||||
<a href="{{ urls.database(database) }}">{{ database }}</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
||||
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
||||
{% if not hide_sql %}
|
||||
{% if editable and allow_execute_sql %}
|
||||
<p><textarea id="sql-editor" name="sql">{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}</textarea></p>
|
||||
{% else %}
|
||||
<pre id="sql-query">{% if query %}{{ query.sql }}{% endif %}</pre>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<input type="hidden" name="sql" value="{% if query and query.sql %}{{ query.sql }}{% else %}select * from {{ tables[0].name|escape_sqlite }}{% endif %}">
|
||||
<input type="hidden" name="_hide_sql" value="1">
|
||||
{% endif %}
|
||||
{% if named_parameter_values %}
|
||||
<h3>Query parameters</h3>
|
||||
{% for name, value in named_parameter_values.items() %}
|
||||
<p><label for="qp{{ loop.index }}">{{ name }}</label> <input type="text" id="qp{{ loop.index }}" name="{{ name }}" value="{{ value }}"></p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||
{% if canned_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
||||
<input type="submit" value="Run SQL">
|
||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% if display_rows %}
|
||||
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}, <a href="{{ url_csv }}">CSV</a></p>
|
||||
<div class="table-wrapper"><table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in columns %}<th class="col-{{ column|to_css_class }}" scope="col">{{ column }}</th>{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
{% for column, td in zip(columns, row) %}
|
||||
<td class="col-{{ column|to_css_class }}">{{ td }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
{% if not canned_write %}
|
||||
<p class="zero-results">0 results</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,49 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column|escape_css_string }}"; }
|
||||
{% endfor %}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a> /
|
||||
<a href="{{ urls.database(database) }}">{{ database }}</a> /
|
||||
<a href="{{ urls.table(database, table) }}">{{ table }}</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ table }}: {{ ', '.join(primary_key_values) }}</h1>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
<p>Télcharger ces données au format {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}</p>
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if foreign_key_tables %}
|
||||
<h2>Liens depuis d'autres tables</h2>
|
||||
<ul>
|
||||
{% for other in foreign_key_tables %}
|
||||
<li>
|
||||
<a href="{{ urls.table(database, other.other_table) }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
|
||||
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
|
||||
from {{ other.other_column }} in {{ other.other_table }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ filename }}{% endblock %}
|
||||
|
||||
{% block body_class %}show-json{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">home</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ filename }}</h1>
|
||||
|
||||
<pre>{{ data_json }}</pre>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,221 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}: {% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} ligne{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}où {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in display_columns -%}
|
||||
.rows-and-columns td:nth-of-type({{ loop.index }}):before { content: "{{ column.name|escape_css_string }}"; }
|
||||
{% endfor %}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block body_class %}table db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
<p class="crumbs">
|
||||
<a href="{{ urls.instance() }}">accueil</a> /
|
||||
<a href="{{ urls.database(database) }}">{{ database }}</a>
|
||||
</p>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<h1>{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = table_actions() %}{% if links %}
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div>
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if filtered_table_rows_count or human_description_en %}
|
||||
<h4>{% if filtered_table_rows_count or filtered_table_rows_count == 0 %}{{ "{:,}".format(filtered_table_rows_count) }} ligne{% if filtered_table_rows_count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
<!-- {% if human_description_en %}{{ human_description_en }}{% endif %} -->
|
||||
</h4>
|
||||
{% endif %}
|
||||
|
||||
<form class="filters" action="{{ urls.table(database, table) }}" method="get">
|
||||
{% if supports_search %}
|
||||
<div class="search-row"><label for="_search">Rechercher :</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
|
||||
{% endif %}
|
||||
{% for column, lookup, value in filters.selections() %}
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column_{{ loop.index }}">
|
||||
<option value="">- supprimer le filtre -</option>
|
||||
{% for c in filter_columns %}
|
||||
<option{% if c == column %} selected{% endif %} value="{{ c }}">{{ metadata.column_labels[c] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><div class="select-wrapper filter-op">
|
||||
<select name="_filter_op_{{ loop.index }}">
|
||||
{% for key, display, no_argument in filters.lookups() %}
|
||||
{% if metadata.operation_labels[key] %}
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ metadata.operation_labels[key] }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value_{{ loop.index }}" class="filter-value" value="{{ value }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="filter-row">
|
||||
<div class="select-wrapper">
|
||||
<select name="_filter_column">
|
||||
<option value="">- colonne -</option>
|
||||
{% for column in filter_columns %}
|
||||
<option value="{{ column }}">{{ metadata.column_labels[column] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><div class="select-wrapper filter-op">
|
||||
<select name="_filter_op">
|
||||
{% for key, display, no_argument in filters.lookups() %}
|
||||
{% if metadata.operation_labels[key] %}
|
||||
<option value="{{ key }}{% if no_argument %}__1{% endif %}"{% if key == lookup %} selected{% endif %}>{{ metadata.operation_labels[key] }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div><input type="text" name="_filter_value" class="filter-value">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
{% if is_sortable %}
|
||||
<div class="select-wrapper small-screen-only">
|
||||
<select name="_sort" id="sort_by">
|
||||
<option value="">Trier...</option>
|
||||
{% for column in display_columns %}
|
||||
{% if column.sortable %}
|
||||
<option value="{{ column.name }}"{% if column.name == sort or column.name == sort_desc %} selected{% endif %}>Trier par {{ column.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="sort_by_desc small-screen-only"><input type="checkbox" name="_sort_by_desc"{% if sort_desc %} checked{% endif %}> décroissant</label>
|
||||
{% endif %}
|
||||
{% for key, value in form_hidden_args %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<input type="submit" value="Filtrer">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if extra_wheres_for_ui %}
|
||||
<div class="extra-wheres">
|
||||
<h3>{{ extra_wheres_for_ui|length }} extra where clause{% if extra_wheres_for_ui|length != 1 %}s{% endif %}</h3>
|
||||
<ul>
|
||||
{% for extra_where in extra_wheres_for_ui %}
|
||||
<li><code>{{ extra_where.text }}</code> [<a href="{{ extra_where.remove_url }}">supprimer</a>]</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<p class="export-links">Télécharger ces données au format <a href="{{ url_csv | replace('.csv','.xlsx')}}&_dl=1">Excel</a> ou <a href="{{ url_csv }}&_dl=1">CSV</a>{% if filtered_table_rows_count > 50000 %} (50 000 premières lignes){% endif %}.
|
||||
|
||||
{% if query.sql and allow_execute_sql %}
|
||||
- <a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&{{ query.params|urlencode|safe }}{% endif %}">✎ <span class="underlined">Voir et éditer le SQL</span></a>
|
||||
{% endif %}
|
||||
{% if table == "decp-titulaires" %}Veuillez patienter pendant le chargement de la carte ci-dessous...{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- {% if suggested_facets %}
|
||||
<p class="suggested-facets">
|
||||
Facettes suggérées : {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.type %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if facets_timed_out %}
|
||||
<p class="facets-timed-out">Ces facettes ont pris trop temps à être générées : {{ ", ".join(facets_timed_out) }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if facet_results %}
|
||||
<div class="facet-results">
|
||||
{% for facet_info in sorted_facet_results %}
|
||||
<div class="facet-info facet-{{ database|to_css_class }}-{{ table|to_css_class }}-{{ facet_info.name|to_css_class }}" id="facet-{{ facet_info.name|to_css_class }}">
|
||||
<p class="facet-info-name">
|
||||
<strong>{{ facet_info.name }}{% if facet_info.type != "column" %} ({{ facet_info.type }}){% endif %}</strong>
|
||||
{% if facet_info.hideable %}
|
||||
<a href="{{ facet_info.toggle_url }}" class="cross">✖</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<ul class="tight-bullets">
|
||||
{% for facet_value in facet_info.results %}
|
||||
{% if not facet_value.selected %}
|
||||
<li><a href="{{ facet_value.toggle_url }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
{% else %}
|
||||
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if facet_info.truncated %}
|
||||
<li>...</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}-->
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
<p><a href="{{ next_url }}">Page suivante</a></p>
|
||||
{% endif %}
|
||||
|
||||
<!-- {% if display_rows %}
|
||||
<div id="export" class="advanced-export">
|
||||
<h3>Export avancé</h3>
|
||||
<p>JSON shape:
|
||||
<a href="{{ renderers['json'] }}">par défaut</a>,
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=array') }}">array</a>,
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=array&_nl=on') }}">newline-delimited</a>{% if primary_keys %},
|
||||
<a href="{{ append_querystring(renderers['json'], '_shape=object') }}">objet</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<form action="{{ url_csv_path }}" method="get">
|
||||
<p>
|
||||
Options CSV :
|
||||
<label><input type="checkbox" name="_dl"> télécharger le fichier</label>
|
||||
{% if expandable_columns %}<label><input type="checkbox" name="_labels" checked> récupérer les libellés</label>{% endif %}
|
||||
{% if next_url and config.allow_csv_stream %}<label><input type="checkbox" name="_stream"> stream de lignes</label>{% endif %}
|
||||
<input type="submit" value="Exporter le CSV">
|
||||
{% for key, value in url_csv_hidden_args %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %} -->
|
||||
|
||||
<!-- {% if table_definition %}
|
||||
<pre class="wrapped-sql">{{ table_definition }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if view_definition %}
|
||||
<pre class="wrapped-sql">{{ view_definition }}</pre>
|
||||
{% endif %} -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
|
||||
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
|
||||
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
|
||||
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
|
||||
File "/var/www/decpinfo/run.py", line 1, in <module>
|
||||
from src.app import app
|
||||
File "/var/www/decpinfo/src/app.py", line 30, in <module>
|
||||
app: Dash = Dash(
|
||||
^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
|
||||
self.init_app()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
|
||||
self.enable_pages()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
|
||||
_import_layouts_from_pages(self.config.pages_folder)
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
|
||||
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
|
||||
from src.db import schema
|
||||
File "/var/www/decpinfo/src/db.py", line 126, in <module>
|
||||
DB_PATH = _ensure_database()
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
|
||||
if should_rebuild(db_path, parquet_path):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
|
||||
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
|
||||
return os.stat(self, follow_symlinks=follow_symlinks)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
|
||||
[2026-05-01 08:00:30 +0200] [2216551] [INFO] Worker exiting (pid: 2216551)
|
||||
[2026-05-01 08:00:30 +0200] [2216552] [ERROR] Exception in worker process
|
||||
Traceback (most recent call last):
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/arbiter.py", line 608, in spawn_worker
|
||||
worker.init_process()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 135, in init_process
|
||||
self.load_wsgi()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/workers/base.py", line 147, in load_wsgi
|
||||
self.wsgi = self.app.wsgi()
|
||||
^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/base.py", line 66, in wsgi
|
||||
self.callable = self.load()
|
||||
^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 57, in load
|
||||
return self.load_wsgiapp()
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/app/wsgiapp.py", line 47, in load_wsgiapp
|
||||
return util.import_app(self.app_uri)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/gunicorn/util.py", line 370, in import_app
|
||||
mod = importlib.import_module(module)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.11/importlib/__init__.py", line 126, in import_module
|
||||
return _bootstrap._gcd_import(name[level:], package, level)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "<frozen importlib._bootstrap>", line 1206, in _gcd_import
|
||||
File "<frozen importlib._bootstrap>", line 1178, in _find_and_load
|
||||
File "<frozen importlib._bootstrap>", line 1149, in _find_and_load_unlocked
|
||||
File "<frozen importlib._bootstrap>", line 690, in _load_unlocked
|
||||
File "<frozen importlib._bootstrap_external>", line 940, in exec_module
|
||||
File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
|
||||
File "/var/www/decpinfo/run.py", line 1, in <module>
|
||||
from src.app import app
|
||||
File "/var/www/decpinfo/src/app.py", line 30, in <module>
|
||||
app: Dash = Dash(
|
||||
^^^^^
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 639, in __init__
|
||||
self.init_app()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 752, in init_app
|
||||
self.enable_pages()
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/dash.py", line 2535, in enable_pages
|
||||
_import_layouts_from_pages(self.config.pages_folder)
|
||||
File "/var/www/decpinfo/.venv/lib/python3.11/site-packages/dash/_pages.py", line 442, in _import_layouts_from_pages
|
||||
spec.loader.exec_module(page_module) # type: ignore[reportOptionalMemberAccess]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/pages/observatoire.py", line 19, in <module>
|
||||
from src.db import schema
|
||||
File "/var/www/decpinfo/src/db.py", line 126, in <module>
|
||||
DB_PATH = _ensure_database()
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/db.py", line 119, in _ensure_database
|
||||
if should_rebuild(db_path, parquet_path):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/var/www/decpinfo/src/db.py", line 23, in should_rebuild
|
||||
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.11/pathlib.py", line 1014, in stat
|
||||
return os.stat(self, follow_symlinks=follow_symlinks)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
FileNotFoundError: [Errno 2] No such file or directory: '/srv/shared/decp/prod/dist/decp.parquet'
|
||||
[2026-05-01 08:00:30 +0200] [2216552] [INFO] Worker exiting (pid: 2216552)
|
||||
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216549) exited with code 3
|
||||
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216550) was sent SIGTERM!
|
||||
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216552) was sent SIGTERM!
|
||||
[2026-05-01 08:00:30 +0200] [2216548] [ERROR] Worker (pid:2216551) was sent SIGTERM!
|
||||
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Shutting down: Master
|
||||
[2026-05-01 08:00:31 +0200] [2216548] [ERROR] Reason: Worker failed to boot.
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Sauvegarde horaire de users.sqlite (colibre) vers S3
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=colibre
|
||||
WorkingDirectory=/var/www/colibre
|
||||
EnvironmentFile=/var/www/colibre/.env
|
||||
ExecStart=/var/www/colibre/.venv/bin/python -m src.backup backup
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Déclenche la sauvegarde horaire de users.sqlite (colibre)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,114 @@
|
||||
# Configuration nginx de référence pour colibre.fr + migration decp.info.
|
||||
#
|
||||
# Installation (sur le serveur, en root) :
|
||||
# cp deploy/nginx-colibre.conf /etc/nginx/sites-available/colibre
|
||||
# ln -s /etc/nginx/sites-available/colibre /etc/nginx/sites-enabled/colibre
|
||||
# nginx -t && systemctl reload nginx
|
||||
#
|
||||
# Les certificats TLS sont gérés par certbot (Let's Encrypt) :
|
||||
# certbot --nginx -d colibre.fr -d www.colibre.fr -d decp.info -d www.decp.info
|
||||
# Obtenir/installer les certificats AVANT d'activer les redirections 301 :
|
||||
# une 301 vers un https cassé est mise en cache par les navigateurs/Google.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Migration decp.info → colibre.fr (301 permanentes, chemin préservé)
|
||||
# La structure d'URL est identique entre les deux domaines (rebranding),
|
||||
# donc $request_uri suffit : /acheteurs/123 → https://colibre.fr/acheteurs/123.
|
||||
# On NE redirige PAS vers la home : chaque ancienne URL garde son équivalent,
|
||||
# ce qui transfère l'autorité SEO page par page.
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name decp.info www.decp.info;
|
||||
# Laisser certbot répondre aux challenges ACME avant la redirection.
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name decp.info www.decp.info;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
return 301 https://colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1b. Migration test.decp.info → test.colibre.fr (302 temporaire, chemin
|
||||
# préservé). Contrairement à la prod (section 1), on reste en 302 tant
|
||||
# que l'environnement de test est susceptible de bouger, pour ne pas
|
||||
# risquer un cache navigateur/moteur sur une redirection qui n'est pas
|
||||
# encore définitive.
|
||||
# Nécessite un certificat couvrant test.colibre.fr (et test.decp.info
|
||||
# pour le vhost 443 ci-dessous), par ex. :
|
||||
# certbot --nginx -d test.colibre.fr -d test.decp.info
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name test.decp.info;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 302 https://test.colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name test.decp.info;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/test.colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/test.colibre.fr/privkey.pem;
|
||||
|
||||
return 302 https://test.colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. www.colibre.fr → colibre.fr (canonicalisation : un seul hôte indexé)
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name colibre.fr www.colibre.fr;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://colibre.fr$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name www.colibre.fr;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
return 301 https://colibre.fr$request_uri;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Hôte canonique : https://colibre.fr → application Dash (gunicorn)
|
||||
# ---------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name colibre.fr;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/colibre.fr/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/colibre.fr/privkey.pem;
|
||||
|
||||
# Le sitemap acheteurs/titulaires peut être volumineux : autoriser de
|
||||
# gros corps de réponse en proxy et des timeouts généreux.
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
location / {
|
||||
# gunicorn app:server — adapter le bind à l'unit systemd colibre.
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
# Observatoire Link from Search & Tableau Results — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let users jump from search/tableau results to the observatoire page, pre-filtered for a given organization, via a 📊 link in the `_nom` columns.
|
||||
|
||||
**Architecture:** Modify `add_links()` in `src/utils.py` to append an observatoire link to `_nom` columns. Add two callbacks to `src/pages/observatoire.py` for bidirectional URL ↔ filter sync using the existing `dcc.Location(id="dashboard_url")`. Add a share URL input and clipboard button to the observatoire layout.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Polars, `urllib.parse`, `dcc.Location`, `dcc.Clipboard`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add observatoire link to `acheteur_nom` in `add_links()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils.py:82-91` (the `acheteur_` block inside `add_links()`)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The `add_links()` function loops over column names. The `if col.startswith("acheteur_")` block (lines 82-91) currently wraps both `acheteur_nom` and `acheteur_id` in a detail page link. We must only append the observatoire link when `col == "acheteur_nom"`.
|
||||
|
||||
- [ ] **Step 1: Write a unit test for the observatoire link in acheteur_nom**
|
||||
|
||||
In `tests/test_main.py`, add a test that calls `add_links()` on a minimal DataFrame and checks the `acheteur_nom` column contains both the detail link and the observatoire link, while `acheteur_id` does NOT contain the observatoire link.
|
||||
|
||||
```python
|
||||
def test_004_add_links_observatoire_acheteur():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"acheteur_id": ["a1"],
|
||||
"acheteur_nom": ["ACHETEUR 1"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["acheteur_nom"][0]
|
||||
id_value = result["acheteur_id"][0]
|
||||
|
||||
# acheteur_nom should contain detail link + observatoire link
|
||||
assert "/acheteurs/a1" in nom_value
|
||||
assert "ACHETEUR 1" in nom_value
|
||||
assert '/observatoire?acheteur_id=a1' in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# acheteur_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||
Expected: FAIL — `'/observatoire?acheteur_id=a1'` not found in the output string.
|
||||
|
||||
- [ ] **Step 3: Implement the observatoire link for acheteur_nom**
|
||||
|
||||
In `src/utils.py`, modify the `if col.startswith("acheteur_")` block (lines 82-91). Gate the observatoire link append on `col == "acheteur_nom"`:
|
||||
|
||||
```python
|
||||
if col.startswith("acheteur_"):
|
||||
detail_link = (
|
||||
'<a href = "/acheteurs/'
|
||||
+ pl.col("acheteur_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "acheteur_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?acheteur_id='
|
||||
+ pl.col("acheteur_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(detail_link.alias(col))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Update `test_001` to account for the new emoji in cell text**
|
||||
|
||||
The existing `test_001` asserts `result_table.find_element(...).text == name` for `acheteur_nom`. The cell text now includes "📊" from the observatoire link. Update the assertion in `tests/test_main.py` to use `startswith` instead of exact match:
|
||||
|
||||
```python
|
||||
assert result_table.find_element(
|
||||
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||
).text.startswith(
|
||||
name
|
||||
), f"The search result should have the right {org_type} name"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run `test_001` to verify it still passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_001_logo_and_search -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils.py tests/test_main.py
|
||||
git commit -m "Ajout du lien observatoire dans acheteur_nom via add_links() #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add observatoire link to `titulaire_nom` in `add_links()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils.py:64-81` (the `titulaire_` block inside `add_links()`)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The `titulaire_` block (lines 64-81) uses a `pl.when().then().otherwise()` pattern because it guards on `titulaire_typeIdentifiant` being SIRET or null. The observatoire link must be appended inside the `.then()` branch, and only when `col == "titulaire_nom"`. Note: this block requires `titulaire_typeIdentifiant` to be present in the DataFrame.
|
||||
|
||||
- [ ] **Step 1: Write a unit test for the observatoire link in titulaire_nom**
|
||||
|
||||
```python
|
||||
def test_005_add_links_observatoire_titulaire():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"titulaire_id": ["t1"],
|
||||
"titulaire_nom": ["TITULAIRE 1"],
|
||||
"titulaire_typeIdentifiant": ["SIRET"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["titulaire_nom"][0]
|
||||
id_value = result["titulaire_id"][0]
|
||||
|
||||
# titulaire_nom should contain detail link + observatoire link
|
||||
assert "/titulaires/t1" in nom_value
|
||||
assert "TITULAIRE 1" in nom_value
|
||||
assert '/observatoire?titulaire_id=t1' in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# titulaire_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: FAIL — `'/observatoire?titulaire_id=t1'` not found.
|
||||
|
||||
- [ ] **Step 3: Implement the observatoire link for titulaire_nom**
|
||||
|
||||
In `src/utils.py`, modify the `if col.startswith("titulaire_")` block (lines 64-81). The `.then()` branch must build the link differently when `col == "titulaire_nom"`:
|
||||
|
||||
```python
|
||||
if col.startswith("titulaire_"):
|
||||
detail_link = (
|
||||
'<a href = "/titulaires/'
|
||||
+ pl.col("titulaire_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "titulaire_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?titulaire_id='
|
||||
+ pl.col("titulaire_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(
|
||||
pl.when(
|
||||
pl.Expr.or_(
|
||||
pl.col("titulaire_typeIdentifiant").is_null(),
|
||||
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||
)
|
||||
)
|
||||
.then(detail_link)
|
||||
.otherwise(pl.col(col))
|
||||
.alias(col)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run all tests so far to check for regressions**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: both PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils.py tests/test_main.py
|
||||
git commit -m "Ajout du lien observatoire dans titulaire_nom via add_links() #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Observatoire Callback A — URL → Inputs (page load)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py` (add import + new callback after line 281)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The existing `dcc.Location(id="dashboard_url")` is in the observatoire layout. A new callback reads `dashboard_url.search` on page load, parses query params, and sets `dashboard_acheteur_id.value` and/or `dashboard_titulaire_id.value`. It also clears `dashboard_url.search` to `""` to prevent re-triggering. Two imports must be added: `import urllib.parse` at the top of the file, and `no_update` to the existing `from dash import ...` line (currently: `from dash import ALL, Input, Output, State, callback, ctx, dcc, html, register_page` — add `no_update` to this).
|
||||
|
||||
- [ ] **Step 1: Write a Selenium test for URL → Input sync**
|
||||
|
||||
This test navigates to `/observatoire?acheteur_id=a1` and verifies the SIRET input gets populated.
|
||||
|
||||
```python
|
||||
def test_006_observatoire_url_to_input(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
dash_duo.wait_for_text_to_equal(
|
||||
"#dashboard_acheteur_id", "", timeout=4
|
||||
) # Wait for callback
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
assert acheteur_input.get_attribute("value") == "a1", (
|
||||
"acheteur_id input should be populated from URL param"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||
Expected: FAIL — the input value is empty because no callback reads URL params yet.
|
||||
|
||||
- [ ] **Step 3: Implement Callback A**
|
||||
|
||||
Add `import urllib.parse` to the imports at the top of `src/pages/observatoire.py` (after line 1). Also add `no_update` to the existing dash import line:
|
||||
|
||||
```python
|
||||
from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update, register_page
|
||||
```
|
||||
|
||||
Add the callback after the `layout` list ends, before existing callbacks:
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("dashboard_acheteur_id", "value"),
|
||||
Output("dashboard_titulaire_id", "value"),
|
||||
Output("dashboard_url", "search"),
|
||||
Input("dashboard_url", "search"),
|
||||
)
|
||||
def restore_filters_from_url(search):
|
||||
if not search:
|
||||
return no_update, no_update, no_update
|
||||
|
||||
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||||
|
||||
acheteur_id = params.get("acheteur_id", [None])[0] or no_update
|
||||
titulaire_id = params.get("titulaire_id", [None])[0] or no_update
|
||||
|
||||
return acheteur_id, titulaire_id, ""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py tests/test_main.py
|
||||
git commit -m "Callback URL → filtres sur la page observatoire #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Observatoire Callback B — Inputs → shareable URL + layout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py` (add layout components + new callback)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** Following the tableau.py pattern (lines 237-238 for layout, lines 399-450 for callback), add a hidden `share-url` input and a `copy-container` div to the observatoire layout. The callback listens to the ID inputs and builds a shareable URL. Component IDs must be unique across the app, so use `observatoire-share-url` and `observatoire-copy-container` to avoid collisions with tableau's `share-url` and `copy-container`.
|
||||
|
||||
- [ ] **Step 1: Write a test for the shareable URL generation**
|
||||
|
||||
```python
|
||||
def test_007_observatoire_share_url(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
|
||||
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
share_url_input = dash_duo.find_element("#observatoire-share-url")
|
||||
share_url_value = share_url_input.get_attribute("value")
|
||||
|
||||
assert "acheteur_id=a1" in share_url_value, (
|
||||
f"Share URL should contain acheteur_id param, got: {share_url_value}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||
Expected: FAIL — `#observatoire-share-url` element does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add layout components to observatoire**
|
||||
|
||||
In `src/pages/observatoire.py`, add the share URL input and copy container inside the filters column (after the download button, before the closing `]` of the `id="filters"` children list, around line 264):
|
||||
|
||||
```python
|
||||
dcc.Input(
|
||||
id="observatoire-share-url",
|
||||
readOnly=True,
|
||||
style={"display": "none"},
|
||||
),
|
||||
html.Div(id="observatoire-copy-container"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement Callback B**
|
||||
|
||||
Add after Callback A in `src/pages/observatoire.py`:
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("observatoire-share-url", "value"),
|
||||
Output("observatoire-copy-container", "children"),
|
||||
Input("dashboard_acheteur_id", "value"),
|
||||
Input("dashboard_titulaire_id", "value"),
|
||||
State("dashboard_url", "href"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def sync_observatoire_share_url(acheteur_id, titulaire_id, href):
|
||||
if not href:
|
||||
return no_update, no_update
|
||||
|
||||
base_url = href.split("?")[0]
|
||||
|
||||
params = {}
|
||||
if acheteur_id:
|
||||
params["acheteur_id"] = acheteur_id
|
||||
if titulaire_id:
|
||||
params["titulaire_id"] = titulaire_id
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||
|
||||
copy_button = dcc.Clipboard(
|
||||
id="btn-copy-observatoire-url",
|
||||
target_id="observatoire-share-url",
|
||||
title="Copier l'URL de cette vue",
|
||||
style={
|
||||
"display": "inline-block",
|
||||
"fontSize": 20,
|
||||
"verticalAlign": "top",
|
||||
"cursor": "pointer",
|
||||
},
|
||||
className="fa fa-link",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Partager",
|
||||
className="btn btn-primary mt-2",
|
||||
title="Copier l'adresse de cette vue filtrée pour la partager.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return full_url, copy_button
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Run all tests to check for regressions**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||
Expected: all tests PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py tests/test_main.py
|
||||
git commit -m "URL partageable pour la page observatoire #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: End-to-end integration test
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** Verify the full flow: search for an organization on the homepage, see the 📊 link in results, click it, arrive on the observatoire with the correct input populated.
|
||||
|
||||
- [ ] **Step 1: Write end-to-end test**
|
||||
|
||||
```python
|
||||
def test_008_search_to_observatoire(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Search for an acheteur
|
||||
search_bar = dash_duo.find_element("#search")
|
||||
search_bar.send_keys("ACHETEUR 1")
|
||||
search_bar.send_keys(Keys.ENTER)
|
||||
|
||||
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
|
||||
|
||||
# Find the observatoire link in acheteur_nom column
|
||||
observatoire_link = dash_duo.find_element(
|
||||
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
|
||||
)
|
||||
assert "📊" in observatoire_link.text
|
||||
|
||||
# Click the observatoire link
|
||||
observatoire_link.click()
|
||||
|
||||
# Wait for observatoire page to load
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "a1", (
|
||||
"acheteur_id input should be populated after navigating from search"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run end-to-end test**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_008_search_to_observatoire -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||
Expected: all tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_main.py
|
||||
git commit -m "Test e2e : recherche → observatoire #65"
|
||||
```
|
||||
@@ -0,0 +1,318 @@
|
||||
# Observatoire localStorage Filter Persistence Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Persist all 10 observatoire filter values in `localStorage` so they survive navigation and page reloads; URL params (`acheteur_id` / `titulaire_id`) override stored values.
|
||||
|
||||
**Architecture:** Add `dcc.Store(storage_type="local", id="observatoire-filters")` to the observatoire layout. A new save callback writes all 10 filter values to the store on any change (`prevent_initial_call=True` to avoid wiping localStorage on page mount). The existing `restore_filters_from_url` callback is expanded: it now outputs all 10 filters, accepts the store as `State`, and prioritises URL params over stored values.
|
||||
|
||||
**Tech Stack:** Dash 3.4 `dcc.Store` with `storage_type="local"`, Dash callbacks (Input / Output / State), pytest + Selenium (`DashComposite`).
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | File |
|
||||
| ------ | --------------------------------------------------------------------- |
|
||||
| Modify | `src/pages/observatoire.py` — layout + 2 callbacks |
|
||||
| Modify | `tests/test_main.py` — add `test_009_observatoire_filter_persistence` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Write a failing integration test
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_main.py`
|
||||
|
||||
- [ ] **Step 1: Add test_009 at the end of `tests/test_main.py`**
|
||||
|
||||
```python
|
||||
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Clear localStorage to start from a clean state
|
||||
dash_duo.driver.execute_script("localStorage.clear()")
|
||||
|
||||
# Navigate to observatoire without URL params
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
# Set the acheteur_id text input
|
||||
# dcc.Input without debounce fires the save callback on every keystroke,
|
||||
# so the value is written to localStorage as soon as typing finishes.
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
dash_duo.clear_input(acheteur_input)
|
||||
acheteur_input.send_keys("999000000000")
|
||||
|
||||
import time
|
||||
time.sleep(0.3) # allow the save callback to complete
|
||||
|
||||
# Navigate away
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/")
|
||||
|
||||
# Navigate back without URL params
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
import time
|
||||
time.sleep(0.5) # allow callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "999000000000", (
|
||||
"acheteur_id should be restored from localStorage after navigating back"
|
||||
)
|
||||
|
||||
# Also verify URL params still override localStorage
|
||||
dash_duo.wait_for_page(
|
||||
f"{dash_duo.server_url}/observatoire?acheteur_id=123"
|
||||
)
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
time.sleep(0.5)
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "123", (
|
||||
"URL param acheteur_id should override the value stored in localStorage"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```bash
|
||||
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
|
||||
```
|
||||
|
||||
Expected: **FAIL** — the input value after navigating back will be `""`, not `"999000000000"`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_main.py
|
||||
git commit -m "test: failing test for observatoire localStorage filter persistence #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `dcc.Store` to the layout and debounce the text inputs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py`
|
||||
|
||||
- [ ] **Step 1: Add the Store component to the layout**
|
||||
|
||||
In `src/pages/observatoire.py`, add this line immediately after the existing `dcc.Location` (line 123):
|
||||
|
||||
```python
|
||||
dcc.Store(id="observatoire-filters", storage_type="local"),
|
||||
```
|
||||
|
||||
So the top of the `layout` list becomes:
|
||||
|
||||
```python
|
||||
layout = [
|
||||
dcc.Location(id="dashboard_url", refresh="callback-nav"),
|
||||
dcc.Store(id="observatoire-filters", storage_type="local"),
|
||||
dbc.Modal(
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `debounce=True` to both text inputs**
|
||||
|
||||
`dcc.Input` without debounce fires on every keystroke, which would trigger the save callback (and the existing heavy `udpate_dashboard_cards` callback) on every character typed. `debounce=True` delays the callback until the user presses Enter or moves focus away.
|
||||
|
||||
Change `dashboard_acheteur_id` (around line 178):
|
||||
|
||||
```python
|
||||
dcc.Input(
|
||||
id="dashboard_acheteur_id",
|
||||
placeholder="SIRET",
|
||||
debounce=True,
|
||||
style={"width": "100%"},
|
||||
),
|
||||
```
|
||||
|
||||
Change `dashboard_titulaire_id` (around line 209):
|
||||
|
||||
```python
|
||||
dcc.Input(
|
||||
id="dashboard_titulaire_id",
|
||||
placeholder="SIRET",
|
||||
debounce=True,
|
||||
style={"width": "100%"},
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test — still fails (restore not implemented yet)**
|
||||
|
||||
```bash
|
||||
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
|
||||
```
|
||||
|
||||
Expected: still **FAIL**.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py
|
||||
git commit -m "feat: add dcc.Store and debounce text inputs on observatoire page #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add a save-to-localStorage callback
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py`
|
||||
|
||||
- [ ] **Step 1: Add the save callback after the existing `restore_filters_from_url` callback**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("observatoire-filters", "data"),
|
||||
Input("dashboard_year", "value"),
|
||||
Input("dashboard_acheteur_id", "value"),
|
||||
Input("dashboard_acheteur_categorie", "value"),
|
||||
Input("dashboard_acheteur_departement_code", "value"),
|
||||
Input("dashboard_titulaire_id", "value"),
|
||||
Input("dashboard_titulaire_categorie", "value"),
|
||||
Input("dashboard_titulaire_departement_code", "value"),
|
||||
Input("dashboard_marche_type", "value"),
|
||||
Input("dashboard_marche_considerationsSociales", "value"),
|
||||
Input("dashboard_marche_considerationsEnvironnementales", "value"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def save_filters_to_storage(
|
||||
year,
|
||||
acheteur_id,
|
||||
acheteur_categorie,
|
||||
acheteur_departement_code,
|
||||
titulaire_id,
|
||||
titulaire_categorie,
|
||||
titulaire_departement_code,
|
||||
marche_type,
|
||||
considerations_sociales,
|
||||
considerations_environnementales,
|
||||
):
|
||||
return {
|
||||
"year": year,
|
||||
"acheteur_id": acheteur_id,
|
||||
"acheteur_categorie": acheteur_categorie,
|
||||
"acheteur_departement_code": acheteur_departement_code,
|
||||
"titulaire_id": titulaire_id,
|
||||
"titulaire_categorie": titulaire_categorie,
|
||||
"titulaire_departement_code": titulaire_departement_code,
|
||||
"marche_type": marche_type,
|
||||
"considerations_sociales": considerations_sociales,
|
||||
"considerations_environnementales": considerations_environnementales,
|
||||
}
|
||||
```
|
||||
|
||||
**Why `prevent_initial_call=True`:** Without it, this callback fires on every page load with all-`None` values (the component defaults), immediately overwriting any saved filters. Setting `prevent_initial_call=True` skips that first call; subsequent user-driven changes still fire normally.
|
||||
|
||||
- [ ] **Step 2: Run the test — still fails (restore callback not updated yet)**
|
||||
|
||||
```bash
|
||||
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
|
||||
```
|
||||
|
||||
Expected: still **FAIL**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py
|
||||
git commit -m "feat: save observatoire filters to localStorage on change #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update the restore callback to read from localStorage
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py`
|
||||
|
||||
This is the core change. Replace the existing `restore_filters_from_url` callback entirely.
|
||||
|
||||
- [ ] **Step 1: Replace the existing callback**
|
||||
|
||||
Remove the current `restore_filters_from_url` function and its `@callback` decorator (lines 302–323) and replace with:
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("dashboard_year", "value"),
|
||||
Output("dashboard_acheteur_id", "value"),
|
||||
Output("dashboard_acheteur_categorie", "value"),
|
||||
Output("dashboard_acheteur_departement_code", "value"),
|
||||
Output("dashboard_titulaire_id", "value"),
|
||||
Output("dashboard_titulaire_categorie", "value"),
|
||||
Output("dashboard_titulaire_departement_code", "value"),
|
||||
Output("dashboard_marche_type", "value"),
|
||||
Output("dashboard_marche_considerationsSociales", "value"),
|
||||
Output("dashboard_marche_considerationsEnvironnementales", "value"),
|
||||
Input("dashboard_url", "search"),
|
||||
Input("dashboard_url", "pathname"),
|
||||
State("observatoire-filters", "data"),
|
||||
)
|
||||
def restore_filters(search, _pathname, stored_filters):
|
||||
# URL params take absolute priority: clear all other filters and apply only
|
||||
# the values present in the URL (acheteur_id and/or titulaire_id).
|
||||
if search:
|
||||
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||||
acheteur_id = (params.get("acheteur_id") or [None])[0] or None
|
||||
titulaire_id = (params.get("titulaire_id") or [None])[0] or None
|
||||
if acheteur_id or titulaire_id:
|
||||
return None, acheteur_id, None, None, titulaire_id, None, None, None, None, None
|
||||
|
||||
# No URL params: restore from localStorage if available.
|
||||
if stored_filters:
|
||||
return (
|
||||
stored_filters.get("year"),
|
||||
stored_filters.get("acheteur_id"),
|
||||
stored_filters.get("acheteur_categorie"),
|
||||
stored_filters.get("acheteur_departement_code"),
|
||||
stored_filters.get("titulaire_id"),
|
||||
stored_filters.get("titulaire_categorie"),
|
||||
stored_filters.get("titulaire_departement_code"),
|
||||
stored_filters.get("marche_type"),
|
||||
stored_filters.get("considerations_sociales"),
|
||||
stored_filters.get("considerations_environnementales"),
|
||||
)
|
||||
|
||||
return (no_update,) * 10
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
- `Input("dashboard_url", "pathname")` added alongside `search`: guarantees the callback fires on every navigation to the observatoire page, even when `search` stays `""` (no query string) across two navigations. The `_pathname` value itself is unused in the body; it exists only as a trigger.
|
||||
- `State("observatoire-filters", "data")` (not `Input`): the store triggers no re-run when the save callback writes to it, avoiding an infinite loop.
|
||||
- When URL params are present, _all_ non-URL filters are cleared (`None`), which causes the save callback to overwrite localStorage with only the URL-provided values.
|
||||
- `(no_update,) * 10` when there is nothing to restore: leaves defaults intact.
|
||||
|
||||
- [ ] **Step 2: Run the target test**
|
||||
|
||||
```bash
|
||||
pytest tests/test_main.py::test_009_observatoire_filter_persistence -v
|
||||
```
|
||||
|
||||
Expected: **PASS**.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Expected: all tests **PASS**. Pay special attention to `test_006` and `test_007` (URL → input / share URL), which exercise the code path that was just rewritten.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py
|
||||
git commit -m "feat: restore observatoire filters from localStorage on page load #65"
|
||||
```
|
||||
@@ -0,0 +1,838 @@
|
||||
# Tableau prepare_table_data Cache Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make page navigation, sort changes, and repeated filter visits in the `/tableau` page near-instant by memoizing the expensive filter+sort+post-process pipeline inside `prepare_table_data`.
|
||||
|
||||
**Architecture:** Extract a memoized inner function `_load_filter_sort_postprocess(filter_query, sort_by_key)` that performs the heavy work (load full data, filter, sort, collect, cast-to-string, fill-null, add HTML links, format values) and returns a fully post-processed Polars DataFrame. The outer `prepare_table_data` becomes a thin wrapper that handles non-deterministic side effects (`track_search`, `uuid.uuid4()` for cleanup trigger, `data_timestamp + 1`) and pagination. The memoized helper only runs when no `data` argument is passed (i.e., the Tableau path). Other callers (`acheteur`, `titulaire`, `observatoire`) keep the current uncached path because they pass an externally-provided LazyFrame that is not safely hashable for cache keys.
|
||||
|
||||
**Tech Stack:** Polars (LazyFrame, DataFrame), Flask-Caching (`@cache.memoize()` on `FileSystemCache` already configured in `src/app.py:38`), pytest for unit tests.
|
||||
|
||||
**Git**: the issue id is #72, add the reference in commit messages.
|
||||
|
||||
---
|
||||
|
||||
## Background and constraints
|
||||
|
||||
Read these before starting; they explain why the design takes the shape it does.
|
||||
|
||||
1. **Cache infrastructure is already wired.** `src/cache.py` defines `cache = Cache()`. `src/app.py:38-48` initializes it with `FileSystemCache`, default 24h timeout, `CACHE_THRESHOLD=300`. The cache directory is wiped on every restart (`rmtree` at `src/app.py:36`), so cache always starts empty.
|
||||
|
||||
2. **Existing pattern to mirror.** `src/pages/observatoire.py:650-660` already uses `@cache.memoize()` plus a `_normalize_filter_params` helper that converts a dict of filters into a hashable tuple. This plan applies the same idiom to `sort_by` (which is a `list[dict]` from Dash DataTable).
|
||||
|
||||
3. **Non-deterministic outputs that MUST stay outside the memoized function:**
|
||||
|
||||
- `data_timestamp + 1` (increments each call; would freeze if cached)
|
||||
- `trigger_cleanup = str(uuid.uuid4())` (intentionally unique per call to fire the clientside filter-cleanup callback)
|
||||
- `track_search(filter_query, source_table)` — Matomo HTTP POST, currently called inside `filter_table_data` at `src/utils/table.py:214`. Must fire on every user action including cache hits.
|
||||
|
||||
4. **Tracking call site move.** `track_search` must move OUT of `filter_table_data` and into each caller, otherwise cache hits would silently skip Matomo tracking. Current callers of `filter_table_data` to update:
|
||||
|
||||
- `src/utils/table.py:402` (inside `prepare_table_data`)
|
||||
- `src/pages/tableau.py:325` (`download_data` callback)
|
||||
- `src/pages/acheteur.py:427` (`download_data_acheteur` callback)
|
||||
- `src/pages/titulaire.py:443` (`download_data_titulaire` callback)
|
||||
|
||||
5. **Why Tableau-only caching.** `prepare_table_data` is also called from `acheteur.py`, `titulaire.py`, `observatoire.py`. Those callers pass a pre-filtered LazyFrame or list-of-dicts as `data`. Hashing arbitrary LazyFrames or large lists for memoization is impractical. The fix gates on `data is None` (the Tableau path) and leaves the other paths byte-for-byte identical.
|
||||
|
||||
6. **Cache key composition.** The memoized function takes only `(filter_query, sort_by_key)`. `page_current` and `page_size` are intentionally NOT in the key — pagination happens in the outer wrapper after retrieving the cached, fully post-processed frame. This means every page click and page-size change is a cache hit (the whole point of the change).
|
||||
|
||||
7. **Pickling.** Flask-Caching pickles arguments to form keys and pickles return values to disk. Polars `DataFrame` pickles cleanly. `LazyFrame` does not — so the memoized function must `.collect()` before returning.
|
||||
|
||||
8. **File path expectations.** All paths below are relative to repo root `/home/colin/git/decp.info`. Run all commands from there.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/utils/table.py` — extract memoized helper, refactor `prepare_table_data`, remove `track_search` call from `filter_table_data`.
|
||||
- **Modify** `src/pages/tableau.py` — add explicit `track_search` call in `download_data`.
|
||||
- **Modify** `src/pages/acheteur.py` — add explicit `track_search` call in `download_data_acheteur`.
|
||||
- **Modify** `src/pages/titulaire.py` — add explicit `track_search` call in `download_data_titulaire`.
|
||||
- **Create** `tests/test_table.py` — unit tests for new helpers and refactored `prepare_table_data`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Set up unit tests for table.py
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_table.py`
|
||||
|
||||
This task scaffolds a non-Selenium pytest module so subsequent tasks can do TDD without booting a Dash server. The conftest already writes a small `tests/test.parquet` fixture (see `tests/conftest.py:10`); reuse it.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/test_table.py` with:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_lff():
|
||||
"""Small LazyFrame with the columns needed by add_links / format_values."""
|
||||
return pl.LazyFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"id": "u1",
|
||||
"acheteur_id": "12345678900011",
|
||||
"acheteur_nom": "Mairie de Test",
|
||||
"titulaire_id": "98765432100022",
|
||||
"titulaire_nom": "Entreprise Test",
|
||||
"titulaire_typeIdentifiant": "SIRET",
|
||||
"objet": "Travaux divers",
|
||||
"montant": 12500.0,
|
||||
"dateNotification": "2025-03-15",
|
||||
"codeCPV": "45000000",
|
||||
"dureeRestanteMois": 6,
|
||||
"titulaire_distance": 42.0,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_table_module_imports():
|
||||
from src.utils import table
|
||||
|
||||
assert hasattr(table, "prepare_table_data")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes (sanity check)**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v`
|
||||
Expected: PASS for `test_table_module_imports`. (Selenium is not invoked because no `dash_duo` fixture is used.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_table.py
|
||||
git commit -m "test: scaffold unit tests for table utilities"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Move track_search out of filter_table_data
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py:210-274` (remove `track_search` import usage at line 214)
|
||||
- Modify: `src/pages/tableau.py:317-334` (`download_data` callback)
|
||||
- Modify: `src/pages/acheteur.py:425-430` area (`download_data_acheteur` callback)
|
||||
- Modify: `src/pages/titulaire.py:441-446` area (`download_data_titulaire` callback)
|
||||
- Modify: `tests/test_table.py` (add a test that confirms `filter_table_data` no longer calls Matomo)
|
||||
|
||||
`track_search` must move out so that the soon-to-be-memoized helper does not swallow tracking on cache hits. We do this BEFORE introducing caching so that the diff is small and verifiable on its own.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `tests/test_table.py`:
|
||||
|
||||
```python
|
||||
def test_filter_table_data_does_not_call_track_search(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
|
||||
|
||||
result = table.filter_table_data(
|
||||
sample_lff, "{objet} icontains travaux", "tableau"
|
||||
).collect()
|
||||
|
||||
assert calls == []
|
||||
assert result.height == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py::test_filter_table_data_does_not_call_track_search -v`
|
||||
Expected: FAIL (`assert calls == []` fails because `filter_table_data` currently calls `track_search` at line 214).
|
||||
|
||||
- [ ] **Step 3: Remove the track_search call from filter_table_data**
|
||||
|
||||
Edit `src/utils/table.py` — find this block:
|
||||
|
||||
```python
|
||||
def filter_table_data(
|
||||
lff: pl.LazyFrame, filter_query: str, filter_source: str
|
||||
) -> pl.LazyFrame:
|
||||
_schema = lff.collect_schema()
|
||||
track_search(filter_query, filter_source)
|
||||
filtering_expressions = filter_query.split(" && ")
|
||||
```
|
||||
|
||||
Remove the `track_search(filter_query, filter_source)` line. Result:
|
||||
|
||||
```python
|
||||
def filter_table_data(
|
||||
lff: pl.LazyFrame, filter_query: str, filter_source: str
|
||||
) -> pl.LazyFrame:
|
||||
_schema = lff.collect_schema()
|
||||
filtering_expressions = filter_query.split(" && ")
|
||||
```
|
||||
|
||||
The `filter_source` parameter remains in the signature (avoids changing all callers in this task). It becomes unused; that is acceptable since callers will pass it again later if needed. Do NOT remove the `from src.utils.tracking import track_search` import yet — `prepare_table_data` will use it in Task 5.
|
||||
|
||||
- [ ] **Step 4: Add explicit track_search calls in download callbacks**
|
||||
|
||||
In `src/pages/tableau.py`, find:
|
||||
|
||||
```python
|
||||
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
|
||||
# Les colonnes masquées sont supprimées
|
||||
if hidden_columns:
|
||||
lff = lff.drop(hidden_columns)
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query, "tab download")
|
||||
```
|
||||
|
||||
Insert a `track_search` call so behavior is preserved. First add the import at the top of `src/pages/tableau.py` next to other `src.utils` imports:
|
||||
|
||||
```python
|
||||
from src.utils.tracking import track_search
|
||||
```
|
||||
|
||||
Then change the body:
|
||||
|
||||
```python
|
||||
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
|
||||
# Les colonnes masquées sont supprimées
|
||||
if hidden_columns:
|
||||
lff = lff.drop(hidden_columns)
|
||||
|
||||
if filter_query:
|
||||
track_search(filter_query, "tab download")
|
||||
lff = filter_table_data(lff, filter_query, "tab download")
|
||||
```
|
||||
|
||||
Repeat the same pattern in `src/pages/acheteur.py` (search for `filter_table_data(lff, filter_query, "ach download")`):
|
||||
|
||||
Add import:
|
||||
|
||||
```python
|
||||
from src.utils.tracking import track_search
|
||||
```
|
||||
|
||||
Wrap the call:
|
||||
|
||||
```python
|
||||
if filter_query:
|
||||
track_search(filter_query, "ach download")
|
||||
lff = filter_table_data(lff, filter_query, "ach download")
|
||||
```
|
||||
|
||||
Repeat in `src/pages/titulaire.py` (search for `filter_table_data(lff, filter_query, "titu download")`):
|
||||
|
||||
Add import:
|
||||
|
||||
```python
|
||||
from src.utils.tracking import track_search
|
||||
```
|
||||
|
||||
Wrap the call:
|
||||
|
||||
```python
|
||||
if filter_query:
|
||||
track_search(filter_query, "titu download")
|
||||
lff = filter_table_data(lff, filter_query, "titu download")
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py::test_filter_table_data_does_not_call_track_search -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run full unit test file to verify no regressions**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v`
|
||||
Expected: All tests in `test_table.py` PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py tests/test_table.py
|
||||
git commit -m "refactor: move track_search out of filter_table_data into callers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add normalize_sort_by helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py` (add helper near other utility functions, e.g. after `dates_to_strings`)
|
||||
- Modify: `tests/test_table.py` (add tests)
|
||||
|
||||
A cache key must be hashable. Dash DataTable's `sort_by` is a `list[dict]` like `[{"column_id": "montant", "direction": "asc"}, ...]`, which is not hashable. We mirror the `_normalize_filter_params` idiom from `src/pages/observatoire.py:650-657`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_table.py`:
|
||||
|
||||
```python
|
||||
def test_normalize_sort_by_handles_empty():
|
||||
from src.utils.table import normalize_sort_by
|
||||
|
||||
assert normalize_sort_by(None) == ()
|
||||
assert normalize_sort_by([]) == ()
|
||||
|
||||
|
||||
def test_normalize_sort_by_returns_hashable_tuple():
|
||||
from src.utils.table import normalize_sort_by
|
||||
|
||||
sort_by = [
|
||||
{"column_id": "montant", "direction": "desc"},
|
||||
{"column_id": "dateNotification", "direction": "asc"},
|
||||
]
|
||||
key = normalize_sort_by(sort_by)
|
||||
|
||||
assert key == (("montant", "desc"), ("dateNotification", "asc"))
|
||||
# Must be hashable so that flask-caching can build a cache key from it
|
||||
hash(key)
|
||||
|
||||
|
||||
def test_normalize_sort_by_preserves_order():
|
||||
"""Order matters for sort: [A, B] != [B, A]."""
|
||||
from src.utils.table import normalize_sort_by
|
||||
|
||||
a_then_b = normalize_sort_by(
|
||||
[{"column_id": "a", "direction": "asc"}, {"column_id": "b", "direction": "asc"}]
|
||||
)
|
||||
b_then_a = normalize_sort_by(
|
||||
[{"column_id": "b", "direction": "asc"}, {"column_id": "a", "direction": "asc"}]
|
||||
)
|
||||
assert a_then_b != b_then_a
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v -k normalize_sort_by`
|
||||
Expected: FAIL with `ImportError` for `normalize_sort_by`.
|
||||
|
||||
- [ ] **Step 3: Implement normalize_sort_by**
|
||||
|
||||
Edit `src/utils/table.py`. Add this function immediately after the `dates_to_strings` function (around line 148):
|
||||
|
||||
```python
|
||||
def normalize_sort_by(sort_by) -> tuple:
|
||||
"""Convert Dash DataTable sort_by (list[dict]) into a hashable tuple
|
||||
suitable for use as a cache key. Order is preserved because it determines
|
||||
sort precedence."""
|
||||
if not sort_by:
|
||||
return ()
|
||||
return tuple((entry["column_id"], entry["direction"]) for entry in sort_by)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v -k normalize_sort_by`
|
||||
Expected: 3 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py tests/test_table.py
|
||||
git commit -m "feat: add normalize_sort_by hashable cache-key helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extract memoized post-process helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py` (add `_load_filter_sort_postprocess`, decorate with `@cache.memoize()`, import `cache`)
|
||||
- Modify: `tests/test_table.py` (add tests)
|
||||
|
||||
Introduce the function whose result will live in the FileSystemCache. Inputs: `(filter_query, sort_by_key)`. Output: a fully post-processed, unpaginated Polars DataFrame ready to slice and convert to dicts.
|
||||
|
||||
This task does NOT yet wire the helper into `prepare_table_data` — that happens in Task 5. Splitting these tasks keeps each diff small and testable.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_table.py`:
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_cache():
|
||||
"""Ensure the flask-caching backend is empty between tests so that
|
||||
cache-hit assertions are meaningful. Falls back to no-op when no
|
||||
Flask app context is active (NullCache)."""
|
||||
from utils.cache import cache
|
||||
|
||||
try:
|
||||
cache.clear()
|
||||
except RuntimeError:
|
||||
# No app context — cache is NullCache, nothing to clear
|
||||
pass
|
||||
yield
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_returns_dataframe(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
|
||||
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
|
||||
|
||||
assert isinstance(df, pl.DataFrame)
|
||||
assert df.height == 1
|
||||
# All values must be strings after post-processing
|
||||
for col in df.columns:
|
||||
assert df.schema[col] == pl.String
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_applies_filter(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
|
||||
df = table._load_filter_sort_postprocess(
|
||||
filter_query="{objet} icontains travaux", sort_by_key=()
|
||||
)
|
||||
assert df.height == 1
|
||||
|
||||
df_empty = table._load_filter_sort_postprocess(
|
||||
filter_query="{objet} icontains nonexistent", sort_by_key=()
|
||||
)
|
||||
assert df_empty.height == 0
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_adds_links(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
|
||||
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
|
||||
# add_links injects an <a href> wrapper around uid, acheteur_nom, titulaire_nom
|
||||
assert "<a href" in df["uid"][0]
|
||||
assert "<a href" in df["acheteur_nom"][0]
|
||||
assert "<a href" in df["titulaire_nom"][0]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v -k load_filter_sort_postprocess`
|
||||
Expected: FAIL with `AttributeError: module 'src.utils.table' has no attribute '_load_filter_sort_postprocess'`.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
Edit `src/utils/table.py`. Add this import near the top, with the other `src.` imports:
|
||||
|
||||
```python
|
||||
from utils.cache import cache
|
||||
```
|
||||
|
||||
Then add the helper function. Place it ABOVE `prepare_table_data` (around line 370, just before `def prepare_table_data`):
|
||||
|
||||
```python
|
||||
@cache.memoize()
|
||||
def _load_filter_sort_postprocess(filter_query, sort_by_key):
|
||||
"""Memoized core of the Tableau page pipeline.
|
||||
|
||||
Loads the full marchés dataset, applies filter and sort, materializes,
|
||||
then runs the per-row post-processing (cast to string, fill nulls, add
|
||||
HTML links, format values). Returns an unpaginated Polars DataFrame.
|
||||
|
||||
Inputs MUST be hashable: filter_query is str|None, sort_by_key is the
|
||||
tuple produced by normalize_sort_by(). Pagination intentionally lives
|
||||
in the outer wrapper so that page changes are cache hits.
|
||||
"""
|
||||
logger.debug(f"Cache miss — recomputing for filter={filter_query!r} sort={sort_by_key!r}")
|
||||
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query, "tableau")
|
||||
|
||||
if sort_by_key:
|
||||
sort_by = [
|
||||
{"column_id": col, "direction": direction}
|
||||
for col, direction in sort_by_key
|
||||
]
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
|
||||
# The remaining steps are cheap per-row operations that we run ONCE here
|
||||
# so that pagination in the outer function is a pure slice + to_dicts.
|
||||
lff = lff.cast(pl.String)
|
||||
lff = lff.fill_null("")
|
||||
|
||||
dff: pl.DataFrame = lff.collect()
|
||||
|
||||
dff = add_links(dff)
|
||||
if "sourceFile" in dff.columns:
|
||||
dff = add_resource_link(dff)
|
||||
if dff.height > 0:
|
||||
dff = format_values(dff)
|
||||
|
||||
return dff
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v -k load_filter_sort_postprocess`
|
||||
Expected: 3 PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full test_table.py to catch regressions**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v`
|
||||
Expected: All PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py tests/test_table.py
|
||||
git commit -m "feat: add memoized _load_filter_sort_postprocess helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire the memoized helper into prepare_table_data
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py` — replace the body of `prepare_table_data` so the Tableau path uses the cache
|
||||
- Modify: `tests/test_table.py` — add tests covering the new flow
|
||||
|
||||
The outer function keeps its signature unchanged so callers in `acheteur.py`, `titulaire.py`, `observatoire.py`, `tableau.py` need no updates. When `data is None` (the Tableau case), use the memoized helper; otherwise fall through to the original logic.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_table.py`:
|
||||
|
||||
```python
|
||||
def test_prepare_table_data_returns_expected_tuple(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
|
||||
result = table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=5,
|
||||
filter_query=None,
|
||||
page_current=0,
|
||||
page_size=20,
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
|
||||
# Same arity as before: 9 outputs
|
||||
assert len(result) == 9
|
||||
dicts, columns, tooltip, ts, nb_rows, dl_disabled, dl_text, dl_title, cleanup = result
|
||||
assert isinstance(dicts, list)
|
||||
assert ts == 6 # data_timestamp + 1 must still increment
|
||||
assert "1 lignes" in nb_rows
|
||||
|
||||
|
||||
def test_prepare_table_data_calls_track_search_on_filter(monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
|
||||
|
||||
table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
filter_query="{objet} icontains travaux",
|
||||
page_current=0,
|
||||
page_size=20,
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
|
||||
assert calls == [("{objet} icontains travaux", "tableau")]
|
||||
|
||||
|
||||
def test_prepare_table_data_paginates_without_recomputing(monkeypatch, sample_lff):
|
||||
"""Two calls with same filter+sort but different pages must invoke
|
||||
the inner heavy work only once."""
|
||||
from src.utils import table
|
||||
|
||||
call_count = {"n": 0}
|
||||
real_query = sample_lff.collect()
|
||||
|
||||
def counting_query():
|
||||
call_count["n"] += 1
|
||||
return real_query
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", counting_query)
|
||||
|
||||
# First call: cache miss
|
||||
table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
filter_query=None,
|
||||
page_current=0,
|
||||
page_size=10,
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
first_count = call_count["n"]
|
||||
|
||||
# Second call, different page: cache hit, query_marches must NOT fire again
|
||||
table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
filter_query=None,
|
||||
page_current=1,
|
||||
page_size=10,
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
|
||||
assert call_count["n"] == first_count, (
|
||||
"query_marches was called again — pagination triggered cache miss"
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_table_data_cleanup_trigger_for_non_tableau(monkeypatch, sample_lff):
|
||||
"""Non-tableau pages still get a fresh uuid trigger, not no_update."""
|
||||
from dash import no_update
|
||||
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "query_marches", lambda: sample_lff.collect()
|
||||
)
|
||||
|
||||
result = table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
filter_query="{objet} icontains travaux",
|
||||
page_current=0,
|
||||
page_size=20,
|
||||
sort_by=[],
|
||||
source_table="acheteur",
|
||||
)
|
||||
|
||||
cleanup = result[8]
|
||||
assert cleanup is not no_update
|
||||
assert isinstance(cleanup, str)
|
||||
assert len(cleanup) >= 32 # uuid4 hex string
|
||||
|
||||
|
||||
def test_prepare_table_data_with_external_data_does_not_use_cache(
|
||||
monkeypatch, sample_lff
|
||||
):
|
||||
"""When a caller passes data (acheteur/titulaire/observatoire path),
|
||||
bypass the memoized helper entirely."""
|
||||
from src.utils import table
|
||||
|
||||
sentinel = {"called": False}
|
||||
|
||||
def should_not_be_called(*a, **kw):
|
||||
sentinel["called"] = True
|
||||
raise AssertionError("Memoized helper must not be called when data is provided")
|
||||
|
||||
monkeypatch.setattr(
|
||||
table, "_load_filter_sort_postprocess", should_not_be_called
|
||||
)
|
||||
|
||||
table.prepare_table_data(
|
||||
data=sample_lff, # external LazyFrame
|
||||
data_timestamp=0,
|
||||
filter_query=None,
|
||||
page_current=0,
|
||||
page_size=20,
|
||||
sort_by=[],
|
||||
source_table="acheteur",
|
||||
)
|
||||
|
||||
assert sentinel["called"] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v -k prepare_table_data`
|
||||
Expected: At least the cache-hit (`paginates_without_recomputing`) and `track_search`-routing tests FAIL because the current `prepare_table_data` re-runs the full pipeline on every call and routes tracking through `filter_table_data` (which Task 2 already neutralized — so tracking would be lost without the new explicit call).
|
||||
|
||||
- [ ] **Step 3: Refactor prepare_table_data**
|
||||
|
||||
Edit `src/utils/table.py`. Replace the entire `prepare_table_data` function body with:
|
||||
|
||||
```python
|
||||
def prepare_table_data(
|
||||
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
|
||||
):
|
||||
"""
|
||||
Préparation des données pour les datatables.
|
||||
|
||||
Pour la page Tableau (data is None), le calcul lourd (chargement complet,
|
||||
filtre, tri, post-traitement) est mémorisé via _load_filter_sort_postprocess.
|
||||
Les changements de page deviennent ainsi des cache hits.
|
||||
|
||||
Pour les autres pages (data fourni), le chemin original est conservé : la
|
||||
LazyFrame externe n'est pas hashable et le coût de filtre/tri y est déjà
|
||||
minime puisque les données sont pré-restreintes.
|
||||
"""
|
||||
logger.debug(" + + + + + + + + + + + + + + + + + + ")
|
||||
|
||||
# Side effect non-cacheable : le tracking doit firer sur chaque action
|
||||
# utilisateur, y compris sur cache hit.
|
||||
if filter_query:
|
||||
track_search(filter_query, source_table)
|
||||
|
||||
# Trigger uuid pour les pages autres que tableau (clientside cleanup)
|
||||
trigger_cleanup = (
|
||||
no_update if source_table == "tableau" else str(uuid.uuid4())
|
||||
)
|
||||
|
||||
if data is None:
|
||||
# Tableau path : utilise le cache
|
||||
sort_by_key = normalize_sort_by(sort_by)
|
||||
dff: pl.DataFrame = _load_filter_sort_postprocess(
|
||||
filter_query=filter_query, sort_by_key=sort_by_key
|
||||
)
|
||||
else:
|
||||
# acheteur / titulaire / observatoire path : code original, non caché
|
||||
if isinstance(data, list):
|
||||
lff: pl.LazyFrame = pl.LazyFrame(
|
||||
data, strict=False, infer_schema_length=5000
|
||||
)
|
||||
elif isinstance(data, pl.LazyFrame):
|
||||
lff = data
|
||||
else:
|
||||
lff = query_marches().lazy()
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query, source_table)
|
||||
|
||||
if sort_by and len(sort_by) > 0:
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
dff = lff.collect()
|
||||
dff = dff.cast(pl.String)
|
||||
dff = dff.fill_null("")
|
||||
dff = add_links(dff)
|
||||
if "sourceFile" in dff.columns:
|
||||
dff = add_resource_link(dff)
|
||||
if dff.height > 0:
|
||||
dff = format_values(dff)
|
||||
|
||||
height = dff.height
|
||||
|
||||
if height > 0:
|
||||
nb_rows = (
|
||||
f"{format_number(height)} lignes "
|
||||
f"({format_number(dff.select('uid').unique().height)} marchés)"
|
||||
)
|
||||
else:
|
||||
nb_rows = "0 lignes (0 marchés)"
|
||||
|
||||
# Pagination — toujours hors cache pour rester sur des cache hits
|
||||
start_row = page_current * page_size
|
||||
dff = dff.slice(start_row, page_size)
|
||||
|
||||
table_columns, tooltip = setup_table_columns(dff)
|
||||
|
||||
dicts = dff.to_dicts()
|
||||
|
||||
download_disabled, download_text, download_title = get_button_properties(height)
|
||||
|
||||
return (
|
||||
dicts,
|
||||
table_columns,
|
||||
tooltip,
|
||||
data_timestamp + 1,
|
||||
nb_rows,
|
||||
download_disabled,
|
||||
download_text,
|
||||
download_title,
|
||||
trigger_cleanup,
|
||||
)
|
||||
```
|
||||
|
||||
Notes on what changed vs the original at `src/utils/table.py:372-458`:
|
||||
|
||||
- `track_search` now called explicitly at the top, on every invocation (not via `filter_table_data`).
|
||||
- `data is None` branch delegates the heavy work to the memoized helper.
|
||||
- `data is not None` branch is functionally identical to the original (pagination still happens after collect+post-process).
|
||||
- The post-processing (`cast`, `fill_null`, `add_links`, `add_resource_link`, `format_values`) is now done in BOTH branches before `nb_rows` calculation. In the cached branch this was already done inside `_load_filter_sort_postprocess`; in the uncached branch we keep doing it inline. This means `nb_rows` and `dff.select('uid').unique().height` operate on the post-processed frame in both branches, matching the original semantics.
|
||||
|
||||
- [ ] **Step 4: Run all unit tests**
|
||||
|
||||
Run: `uv run pytest tests/test_table.py -v`
|
||||
Expected: All PASS, including `test_prepare_table_data_paginates_without_recomputing`.
|
||||
|
||||
- [ ] **Step 5: Run the full repo test suite to catch regressions**
|
||||
|
||||
Run: `uv run pytest -v`
|
||||
Expected: All PASS. Selenium tests (`tests/test_main.py`) require Chrome/Chromium; if the executor lacks a browser, those tests will error/skip — note the failures and rerun in an environment with Chrome before declaring done.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py tests/test_table.py
|
||||
git commit -m "perf(tableau): memoize filter+sort+postprocess pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual smoke test in the browser
|
||||
|
||||
**Files:** none modified.
|
||||
|
||||
Type checks and unit tests cannot validate that page navigation actually feels faster. This task is explicitly a hands-on verification.
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
Run: `uv run run.py`
|
||||
Wait for `Dash is running on http://...`.
|
||||
|
||||
- [ ] **Step 2: Open the Tableau page and warm the cache**
|
||||
|
||||
1. Open `http://localhost:8050/tableau` (or whatever port the dev server prints).
|
||||
2. With no filter applied, wait for the first page to load fully. This is the cold-cache load (slow expected).
|
||||
3. Open the browser devtools Network panel.
|
||||
|
||||
- [ ] **Step 3: Verify pagination is fast**
|
||||
|
||||
1. Click "page 2" / "page 3" / "page 4" in the table footer in quick succession.
|
||||
2. Each navigation should return data in well under 1 second (in the original code each took several seconds).
|
||||
3. In the dev server logs, look for the line `Cache miss — recomputing for filter=...` from `_load_filter_sort_postprocess`. It should appear ONCE for the initial load and NOT appear again as you change pages.
|
||||
|
||||
- [ ] **Step 4: Verify a new filter triggers exactly one cache miss**
|
||||
|
||||
1. In the table, type a filter into one of the columns (e.g. `paris` in `acheteur_commune_nom`) and press Enter.
|
||||
2. The dev log should show ONE new `Cache miss — recomputing` line.
|
||||
3. Change page within the filtered view — no new cache miss line should appear.
|
||||
|
||||
- [ ] **Step 5: Verify filter cleanup trigger still fires**
|
||||
|
||||
1. Open `http://localhost:8050/acheteur?id=<some_acheteur_id>` (use any valid id from the dataset).
|
||||
2. Apply a filter on the embedded table.
|
||||
3. The clientside callback for filter cleanup (`src/assets/dash_clientside.js` `clean_filters`) should still rewrite the filter operators (e.g. `contains` → `icontains`). If it doesn't fire, the `trigger_cleanup` uuid is broken — investigate.
|
||||
|
||||
- [ ] **Step 6: Verify download still works**
|
||||
|
||||
1. On the Tableau page, click "Télécharger au format Excel" (the button must be enabled — apply a filter that brings the row count under 65,000).
|
||||
2. The downloaded XLSX must open and contain the filtered rows.
|
||||
|
||||
- [ ] **Step 7: Stop the dev server**
|
||||
|
||||
Ctrl-C.
|
||||
|
||||
- [ ] **Step 8: If all checks pass, this completes the implementation**
|
||||
|
||||
No commit — this task is verification only. Report results to the user.
|
||||
@@ -0,0 +1,951 @@
|
||||
# Observatoire — filtrage natif DuckDB — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer le filtrage Polars sur LazyFrame dans `prepare_dashboard_data` par un requêtage natif DuckDB, pour ne matérialiser que le sous-ensemble utile au lieu de l'intégralité de la table `decp` (~1,5 M lignes).
|
||||
|
||||
**Architecture:** Nouveau helper pur `dashboard_filters_to_sql(**filter_params) -> (where_sql, params)` dans `src/utils/table_sql.py` (modèle de `filter_query_to_sql`). `prepare_dashboard_data` devient une fonction fine qui appelle `query_marches(where_sql, params)` et retourne une `pl.DataFrame`. Les 3 appelants dans `src/pages/observatoire.py` sont adaptés à la nouvelle signature.
|
||||
|
||||
**Tech Stack:** Python 3.12, Polars, DuckDB, Dash, pytest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**À créer :**
|
||||
|
||||
- `tests/test_dashboard_filters_to_sql.py` — tests unitaires du nouveau helper SQL (cas vide + cas par filtre).
|
||||
- `tests/test_prepare_dashboard_data.py` — test d'intégration léger (appel DuckDB réel sur `tests/test.parquet`).
|
||||
|
||||
**À modifier :**
|
||||
|
||||
- `src/utils/table_sql.py` — ajouter `dashboard_filters_to_sql` + import `datetime`/`timedelta`.
|
||||
- `src/utils/data.py` — réécrire `prepare_dashboard_data` (signature et implémentation), ajouter `query_marches` aux imports `from src.db`.
|
||||
- `src/pages/observatoire.py` — adapter 3 sites d'appel (lignes ~668, ~791, ~882) ; retirer `query_marches` de l'import `from src.db` (plus utilisé).
|
||||
- `tests/test_main.py` — supprimer `test_010_observatoire_montant_filter` (migré en test unitaire du helper).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Tests unitaires — cas par défaut + filtre année
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.utils.table_sql import dashboard_filters_to_sql
|
||||
|
||||
|
||||
def test_no_filters_uses_default_365_day_window():
|
||||
where_sql, params = dashboard_filters_to_sql()
|
||||
assert where_sql == '"dateNotification" > ?'
|
||||
assert len(params) == 1
|
||||
assert isinstance(params[0], datetime)
|
||||
expected = datetime.now() - timedelta(days=365)
|
||||
assert abs((params[0] - expected).total_seconds()) < 2
|
||||
|
||||
|
||||
def test_year_filter_overrides_default_window():
|
||||
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
|
||||
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||
assert params == [2025]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: FAIL with `ImportError: cannot import name 'dashboard_filters_to_sql'`.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
Add to the top of `src/utils/table_sql.py` (below existing imports):
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
```
|
||||
|
||||
Append this function at the end of `src/utils/table_sql.py`:
|
||||
|
||||
```python
|
||||
def dashboard_filters_to_sql(
|
||||
dashboard_year=None,
|
||||
dashboard_acheteur_id=None,
|
||||
dashboard_acheteur_categorie=None,
|
||||
dashboard_acheteur_departement_code=None,
|
||||
dashboard_titulaire_id=None,
|
||||
dashboard_titulaire_categorie=None,
|
||||
dashboard_titulaire_departement_code=None,
|
||||
dashboard_marche_type=None,
|
||||
dashboard_marche_objet=None,
|
||||
dashboard_marche_code_cpv=None,
|
||||
dashboard_marche_considerations_sociales=None,
|
||||
dashboard_marche_considerations_environnementales=None,
|
||||
dashboard_marche_techniques=None,
|
||||
dashboard_marche_innovant=None,
|
||||
dashboard_marche_sous_traitance_declaree=None,
|
||||
dashboard_montant_min=None,
|
||||
dashboard_montant_max=None,
|
||||
) -> tuple[str, list]:
|
||||
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
|
||||
clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if dashboard_year:
|
||||
clauses.append('YEAR("dateNotification") = ?')
|
||||
params.append(int(dashboard_year))
|
||||
else:
|
||||
clauses.append('"dateNotification" > ?')
|
||||
params.append(datetime.now() - timedelta(days=365))
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): squelette de dashboard_filters_to_sql (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Filtres d'égalité simples (catégorie, type, innovant, sous-traitance)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests**
|
||||
|
||||
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
def test_marche_type_equality():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_type="Marché",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
|
||||
assert params == [2025, "Marché"]
|
||||
|
||||
|
||||
def test_innovant_value_all_is_skipped():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_innovant="all",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ?'
|
||||
assert params == [2025]
|
||||
|
||||
|
||||
def test_innovant_value_oui_adds_clause():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_innovant="oui",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
|
||||
assert params == [2025, "oui"]
|
||||
|
||||
|
||||
def test_sous_traitance_value_non_adds_clause():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_sous_traitance_declaree="non",
|
||||
)
|
||||
assert (
|
||||
where_sql
|
||||
== 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
|
||||
)
|
||||
assert params == [2025, "non"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: 4 new tests FAIL (missing clauses).
|
||||
|
||||
- [ ] **Step 3: Extend the helper**
|
||||
|
||||
Insert the following block in `dashboard_filters_to_sql`, **after** the `if dashboard_year / else` block and **before** `return " AND ".join(clauses), params`:
|
||||
|
||||
```python
|
||||
if dashboard_marche_type:
|
||||
clauses.append('"type" = ?')
|
||||
params.append(dashboard_marche_type)
|
||||
|
||||
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
|
||||
clauses.append('"marcheInnovant" = ?')
|
||||
params.append(dashboard_marche_innovant)
|
||||
|
||||
if (
|
||||
dashboard_marche_sous_traitance_declaree
|
||||
and dashboard_marche_sous_traitance_declaree != "all"
|
||||
):
|
||||
clauses.append('"sousTraitanceDeclaree" = ?')
|
||||
params.append(dashboard_marche_sous_traitance_declaree)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (6 tests total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Filtres LIKE/ILIKE (ids, objet, cpv)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests**
|
||||
|
||||
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
def test_acheteur_id_uses_like_wildcards():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_id="12345678900010",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||
assert params == [2025, "%12345678900010%"]
|
||||
|
||||
|
||||
def test_titulaire_id_uses_like_wildcards():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_titulaire_id="999",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||
assert params == [2025, "%999%"]
|
||||
|
||||
|
||||
def test_marche_objet_uses_case_insensitive_ilike():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_objet="travaux",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?'
|
||||
assert params == [2025, "%travaux%"]
|
||||
|
||||
|
||||
def test_code_cpv_uses_prefix_like():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_code_cpv="4521",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
|
||||
assert params == [2025, "4521%"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: 4 new tests FAIL.
|
||||
|
||||
- [ ] **Step 3: Extend the helper**
|
||||
|
||||
Insert the following block, **just after** the year/default block and **before** the `if dashboard_marche_type` block:
|
||||
|
||||
```python
|
||||
if dashboard_acheteur_id:
|
||||
clauses.append('"acheteur_id" LIKE ?')
|
||||
params.append(f"%{dashboard_acheteur_id}%")
|
||||
|
||||
if dashboard_titulaire_id:
|
||||
clauses.append('"titulaire_id" LIKE ?')
|
||||
params.append(f"%{dashboard_titulaire_id}%")
|
||||
```
|
||||
|
||||
Insert in the "marché" block, **after** `dashboard_marche_type` and **before** `dashboard_marche_innovant`:
|
||||
|
||||
```python
|
||||
if dashboard_marche_objet:
|
||||
clauses.append('"objet" ILIKE ?')
|
||||
params.append(f"%{dashboard_marche_objet}%")
|
||||
|
||||
if dashboard_marche_code_cpv:
|
||||
clauses.append('"codeCPV" LIKE ?')
|
||||
params.append(f"{dashboard_marche_code_cpv}%")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (10 tests total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Filtre IN (départements) + skip conditionnel par ID
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests**
|
||||
|
||||
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
def test_acheteur_departement_multiple_uses_in_clause():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_departement_code=["75", "92", "93"],
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? '
|
||||
'AND "acheteur_departement_code" IN (?, ?, ?)'
|
||||
)
|
||||
assert params == [2025, "75", "92", "93"]
|
||||
|
||||
|
||||
def test_acheteur_categorie_adds_clause():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_categorie="Commune",
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
|
||||
assert params == [2025, "Commune"]
|
||||
|
||||
|
||||
def test_titulaire_categorie_and_departement():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_titulaire_categorie="PME",
|
||||
dashboard_titulaire_departement_code=["35"],
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? '
|
||||
'AND "titulaire_categorie" = ? '
|
||||
'AND "titulaire_departement_code" IN (?)'
|
||||
)
|
||||
assert params == [2025, "PME", "35"]
|
||||
|
||||
|
||||
def test_acheteur_id_present_skips_categorie_and_departement():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_id="123",
|
||||
dashboard_acheteur_categorie="Commune",
|
||||
dashboard_acheteur_departement_code=["75"],
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
|
||||
assert params == [2025, "%123%"]
|
||||
|
||||
|
||||
def test_titulaire_id_present_skips_categorie_and_departement():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_titulaire_id="999",
|
||||
dashboard_titulaire_categorie="PME",
|
||||
dashboard_titulaire_departement_code=["35"],
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
|
||||
assert params == [2025, "%999%"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: 5 new tests FAIL.
|
||||
|
||||
- [ ] **Step 3: Refactor the helper with conditional skip**
|
||||
|
||||
Replace the two simple `if dashboard_acheteur_id` / `if dashboard_titulaire_id` blocks added in Task 3 with the nested form:
|
||||
|
||||
```python
|
||||
if dashboard_acheteur_id:
|
||||
clauses.append('"acheteur_id" LIKE ?')
|
||||
params.append(f"%{dashboard_acheteur_id}%")
|
||||
else:
|
||||
if dashboard_acheteur_categorie:
|
||||
clauses.append('"acheteur_categorie" = ?')
|
||||
params.append(dashboard_acheteur_categorie)
|
||||
if dashboard_acheteur_departement_code:
|
||||
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
|
||||
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
|
||||
params.extend(dashboard_acheteur_departement_code)
|
||||
|
||||
if dashboard_titulaire_id:
|
||||
clauses.append('"titulaire_id" LIKE ?')
|
||||
params.append(f"%{dashboard_titulaire_id}%")
|
||||
else:
|
||||
if dashboard_titulaire_categorie:
|
||||
clauses.append('"titulaire_categorie" = ?')
|
||||
params.append(dashboard_titulaire_categorie)
|
||||
if dashboard_titulaire_departement_code:
|
||||
placeholders = ", ".join(
|
||||
["?"] * len(dashboard_titulaire_departement_code)
|
||||
)
|
||||
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
|
||||
params.extend(dashboard_titulaire_departement_code)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (15 tests total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): IN départements et skip conditionnel par ID (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Filtre liste (techniques, considérations sociales/environnementales)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests**
|
||||
|
||||
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
def test_marche_techniques_uses_list_has_any():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? '
|
||||
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
assert params == [2025, ["Enchère", "Accord-cadre"]]
|
||||
|
||||
|
||||
def test_considerations_sociales_uses_list_has_any():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_considerations_sociales=["Clause sociale"],
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? '
|
||||
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
assert params == [2025, ["Clause sociale"]]
|
||||
|
||||
|
||||
def test_considerations_environnementales_uses_list_has_any():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_marche_considerations_environnementales=["Clause env."],
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? '
|
||||
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
assert params == [2025, ["Clause env."]]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: 3 new tests FAIL.
|
||||
|
||||
- [ ] **Step 3: Extend the helper**
|
||||
|
||||
Insert the following block in `dashboard_filters_to_sql`, **after** the `dashboard_marche_sous_traitance_declaree` block and **before** `return " AND ".join(clauses), params`:
|
||||
|
||||
```python
|
||||
if dashboard_marche_techniques:
|
||||
clauses.append(
|
||||
"list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
params.append(list(dashboard_marche_techniques))
|
||||
|
||||
if dashboard_marche_considerations_sociales:
|
||||
clauses.append(
|
||||
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
params.append(list(dashboard_marche_considerations_sociales))
|
||||
|
||||
if dashboard_marche_considerations_environnementales:
|
||||
clauses.append(
|
||||
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
|
||||
)
|
||||
params.append(list(dashboard_marche_considerations_environnementales))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (18 tests total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): filtres liste via list_has_any (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Filtres montant min/max (incluant 0)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_dashboard_filters_to_sql.py`
|
||||
- Modify: `src/utils/table_sql.py`
|
||||
|
||||
- [ ] **Step 1: Add failing tests**
|
||||
|
||||
Append to `tests/test_dashboard_filters_to_sql.py`:
|
||||
|
||||
```python
|
||||
def test_montant_min_only():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_montant_min=1000,
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||
assert params == [2025, 1000]
|
||||
|
||||
|
||||
def test_montant_max_only():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_montant_max=500,
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
|
||||
assert params == [2025, 500]
|
||||
|
||||
|
||||
def test_montant_zero_is_a_valid_lower_bound():
|
||||
# 0 est falsy mais reste un filtre valide (distinct de None)
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_montant_min=0,
|
||||
)
|
||||
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
|
||||
assert params == [2025, 0]
|
||||
|
||||
|
||||
def test_montant_min_and_max_combined():
|
||||
where_sql, params = dashboard_filters_to_sql(
|
||||
dashboard_year="2025",
|
||||
dashboard_montant_min=100,
|
||||
dashboard_montant_max=1000,
|
||||
)
|
||||
assert where_sql == (
|
||||
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
|
||||
)
|
||||
assert params == [2025, 100, 1000]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: 4 new tests FAIL.
|
||||
|
||||
- [ ] **Step 3: Extend the helper**
|
||||
|
||||
Insert at the very end of `dashboard_filters_to_sql`, **just before** `return " AND ".join(clauses), params`:
|
||||
|
||||
```python
|
||||
if dashboard_montant_min is not None:
|
||||
clauses.append('"montant" >= ?')
|
||||
params.append(dashboard_montant_min)
|
||||
|
||||
if dashboard_montant_max is not None:
|
||||
clauses.append('"montant" <= ?')
|
||||
params.append(dashboard_montant_max)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
Expected: PASS (22 tests total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
|
||||
rtk git commit -m "feat(observatoire): filtres montant min/max (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Réécriture de `prepare_dashboard_data`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/data.py`
|
||||
- Modify: `tests/test_main.py` (supprimer `test_010_observatoire_montant_filter`)
|
||||
|
||||
- [ ] **Step 1: Remove the obsolete Polars-based test**
|
||||
|
||||
Delete the function `test_010_observatoire_montant_filter` from `tests/test_main.py` (lines ~218-256). La couverture du filtre montant est déjà assurée par les tests unitaires `test_montant_*` de la Task 6.
|
||||
|
||||
- [ ] **Step 2: Rewrite `prepare_dashboard_data`**
|
||||
|
||||
Replace the entire `prepare_dashboard_data` function in `src/utils/data.py` (lines ~86-194) with:
|
||||
|
||||
```python
|
||||
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
|
||||
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
|
||||
|
||||
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
|
||||
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
|
||||
appellent `.lazy()` sur le résultat.
|
||||
"""
|
||||
from src.utils.table_sql import dashboard_filters_to_sql
|
||||
|
||||
where_sql, params = dashboard_filters_to_sql(**filter_params)
|
||||
return query_marches(where_sql=where_sql, params=params)
|
||||
```
|
||||
|
||||
Update the import at the top of `src/utils/data.py`:
|
||||
|
||||
```python
|
||||
from src.db import get_cursor, query_marches, schema
|
||||
```
|
||||
|
||||
Remove the now-unused import in `src/utils/data.py`:
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
```
|
||||
|
||||
(Si `datetime` n'est plus référencé dans `data.py` hors de `prepare_dashboard_data`, sinon garder.)
|
||||
|
||||
**Vérification rapide à effectuer avant de supprimer `datetime`/`timedelta`** :
|
||||
|
||||
```bash
|
||||
rtk grep -n "datetime\|timedelta" src/utils/data.py
|
||||
```
|
||||
|
||||
Si d'autres occurrences existent, conserver les imports.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py tests/test_main.py -v -k "not selenium and not dash_duo"`
|
||||
|
||||
Ou, si filter n'est pas pratique :
|
||||
|
||||
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
|
||||
|
||||
Expected: PASS (22 tests).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files src/utils/data.py tests/test_main.py
|
||||
rtk git add src/utils/data.py tests/test_main.py
|
||||
rtk git commit -m "refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Adaptation des 3 appelants dans `observatoire.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py`
|
||||
|
||||
- [ ] **Step 1: Update `_compute_dashboard_children`**
|
||||
|
||||
Remplacer dans `src/pages/observatoire.py` (autour des lignes 660-670) :
|
||||
|
||||
```python
|
||||
@cache.memoize()
|
||||
def _compute_dashboard_children(filter_params_normalized: tuple):
|
||||
logger.debug("Cache miss — computing dashboard")
|
||||
filter_params = {
|
||||
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
||||
}
|
||||
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
lff = prepare_dashboard_data(lff=lff, **filter_params)
|
||||
|
||||
dff = lff.collect(engine="streaming")
|
||||
```
|
||||
|
||||
Par :
|
||||
|
||||
```python
|
||||
@cache.memoize()
|
||||
def _compute_dashboard_children(filter_params_normalized: tuple):
|
||||
logger.debug("Cache miss — computing dashboard")
|
||||
filter_params = {
|
||||
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
|
||||
}
|
||||
|
||||
dff = prepare_dashboard_data(**filter_params)
|
||||
lff = dff.lazy()
|
||||
```
|
||||
|
||||
Le reste de la fonction (à partir de `df_per_uid = ...`) est inchangé.
|
||||
|
||||
- [ ] **Step 2: Update `download_observatoire`**
|
||||
|
||||
Remplacer dans `src/pages/observatoire.py` (autour des lignes 789-800) :
|
||||
|
||||
```python
|
||||
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||
|
||||
if hidden_columns:
|
||||
lff = lff.drop(hidden_columns)
|
||||
|
||||
def to_bytes(buffer):
|
||||
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
||||
|
||||
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
||||
```
|
||||
|
||||
Par :
|
||||
|
||||
```python
|
||||
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||
|
||||
if hidden_columns:
|
||||
dff = dff.drop(hidden_columns)
|
||||
|
||||
def to_bytes(buffer):
|
||||
dff.write_excel(buffer, worksheet="DECP")
|
||||
|
||||
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `populate_preview_table`**
|
||||
|
||||
Remplacer dans `src/pages/observatoire.py` (autour des lignes 879-892) :
|
||||
|
||||
```python
|
||||
if not is_open:
|
||||
return (no_update,) * 9
|
||||
|
||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||
|
||||
return prepare_table_data(
|
||||
lff,
|
||||
data_timestamp,
|
||||
filter_query,
|
||||
page_current,
|
||||
page_size,
|
||||
sort_by,
|
||||
"observatoire-preview",
|
||||
)
|
||||
```
|
||||
|
||||
Par :
|
||||
|
||||
```python
|
||||
if not is_open:
|
||||
return (no_update,) * 9
|
||||
|
||||
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||
|
||||
return prepare_table_data(
|
||||
dff.lazy(),
|
||||
data_timestamp,
|
||||
filter_query,
|
||||
page_current,
|
||||
page_size,
|
||||
sort_by,
|
||||
"observatoire-preview",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove unused `query_marches` import**
|
||||
|
||||
Dans `src/pages/observatoire.py`, ligne ~19 :
|
||||
|
||||
```python
|
||||
from src.db import query_marches, schema
|
||||
```
|
||||
|
||||
Devient :
|
||||
|
||||
```python
|
||||
from src.db import schema
|
||||
```
|
||||
|
||||
Vérifier avant de committer :
|
||||
|
||||
```bash
|
||||
rtk grep -n "query_marches" src/pages/observatoire.py
|
||||
```
|
||||
|
||||
Expected: aucun résultat (ou uniquement des commentaires).
|
||||
|
||||
- [ ] **Step 5: Smoke test**
|
||||
|
||||
Démarrer l'app et naviguer sur `/observatoire`, vérifier à la main que :
|
||||
|
||||
- Les cartes s'affichent.
|
||||
- Un filtre année se propage.
|
||||
- Un filtre acheteur par SIRET partiel fonctionne.
|
||||
- Un filtre département (multi-valeur) fonctionne.
|
||||
- Un filtre montant_min fonctionne.
|
||||
- Le bouton « Télécharger au format Excel » génère un fichier non vide.
|
||||
- Le bouton « Voir les données » ouvre l'offcanvas et peuple la table.
|
||||
|
||||
Run: `python run.py`
|
||||
|
||||
Expected: app démarre sans erreur ; les filtres se comportent comme avant.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files src/pages/observatoire.py
|
||||
rtk git add src/pages/observatoire.py
|
||||
rtk git commit -m "refactor(observatoire): appelants utilisent la nouvelle signature (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Test d'intégration — `prepare_dashboard_data` sur `tests/test.parquet`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_prepare_dashboard_data.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Le but : vérifier que la fonction s'exécute réellement contre DuckDB, retourne une `pl.DataFrame`, et applique bien les filtres simples. `conftest.py` construit `tests/test.parquet` avec un jeu de données d'une ligne : acheteur_id `123`, acheteur_departement_code `75`, dateNotification `2025-01-01`, montant `10`.
|
||||
|
||||
Create `tests/test_prepare_dashboard_data.py`:
|
||||
|
||||
```python
|
||||
import polars as pl
|
||||
|
||||
|
||||
def test_returns_dataframe_with_year_filter():
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
dff = prepare_dashboard_data(dashboard_year="2025")
|
||||
assert isinstance(dff, pl.DataFrame)
|
||||
assert dff.height == 1
|
||||
|
||||
|
||||
def test_year_mismatch_returns_empty():
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
dff = prepare_dashboard_data(dashboard_year="2024")
|
||||
assert isinstance(dff, pl.DataFrame)
|
||||
assert dff.height == 0
|
||||
|
||||
|
||||
def test_acheteur_id_partial_match():
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
dff = prepare_dashboard_data(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_id="12",
|
||||
)
|
||||
assert dff.height == 1
|
||||
|
||||
|
||||
def test_departement_in_clause():
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
dff = prepare_dashboard_data(
|
||||
dashboard_year="2025",
|
||||
dashboard_acheteur_departement_code=["75", "92"],
|
||||
)
|
||||
assert dff.height == 1
|
||||
|
||||
|
||||
def test_montant_min_above_value_excludes_row():
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
dff = prepare_dashboard_data(
|
||||
dashboard_year="2025",
|
||||
dashboard_montant_min=1000,
|
||||
)
|
||||
assert dff.height == 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test**
|
||||
|
||||
Run: `rtk pytest tests/test_prepare_dashboard_data.py -v`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk pre-commit run --files tests/test_prepare_dashboard_data.py
|
||||
rtk git add tests/test_prepare_dashboard_data.py
|
||||
rtk git commit -m "test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Vérification finale
|
||||
|
||||
**Files:** (aucune modification)
|
||||
|
||||
- [ ] **Step 1: Run the full test suite**
|
||||
|
||||
Run: `rtk pytest -v`
|
||||
Expected: tous les tests unitaires passent. Les tests Selenium peuvent échouer si Chrome n'est pas disponible — ce n'est pas bloquant s'ils étaient déjà rouges avant.
|
||||
|
||||
- [ ] **Step 2: Check for leftover references**
|
||||
|
||||
Run: `rtk grep -rn "prepare_dashboard_data(lff" src/ tests/`
|
||||
Expected: aucun résultat (plus d'appels avec l'ancienne signature).
|
||||
|
||||
Run: `rtk grep -rn "query_marches().lazy()" src/`
|
||||
Expected: aucun résultat (ou uniquement dans `src/utils/table.py:prepare_table_data` pour le fallback).
|
||||
|
||||
- [ ] **Step 3: Confirm `datetime`/`timedelta` in data.py if needed**
|
||||
|
||||
Run: `rtk grep -n "datetime\|timedelta" src/utils/data.py`
|
||||
|
||||
Si aucune occurrence hors imports, vérifier que les imports inutiles ont bien été retirés dans Task 7.
|
||||
|
||||
- [ ] **Step 4: Manual timing sanity check (optionnel)**
|
||||
|
||||
Si possible, comparer informellement le temps de `_compute_dashboard_children` sur un filtre sélectif (ex. un département) avant/après. Pas de benchmark formel attendu.
|
||||
|
||||
- [ ] **Step 5: Push (manuel, à l'initiative de l'utilisateur)**
|
||||
|
||||
Conformément aux consignes projet, ne jamais `git push`. Laisser l'utilisateur pousser la branche `feature/72_observatoire_duckdb_filters` et ouvrir la PR.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Plan: Ajouter des cartes de localisation aux pages acheteur et titulaire
|
||||
|
||||
## Date: 2026-04-28
|
||||
|
||||
## Statut: Approuvé
|
||||
|
||||
## Objectif: Ajouter des cartes interactives montrant la localisation des organisations sur les pages acheteur et titulaire
|
||||
|
||||
## Contexte
|
||||
|
||||
- Les pages acheteur et titulaire ont déjà des placeholders pour les cartes (`acheteur_map` et `titulaire_map`)
|
||||
- La fonction `point_on_map()` existe déjà dans `src/figures.py` mais utilise un centrage fixe sur la France
|
||||
- Les données de localisation proviennent de l'API Annuaire des Entreprises
|
||||
- Les codes départementaux sont disponibles et plus fiables que les coordonnées pour la détection de région
|
||||
|
||||
## Exigences
|
||||
|
||||
### 1. Carte interactive
|
||||
|
||||
- **Localisation**: Colonne de droite dans la section d'informations sur l'organisation
|
||||
- **Taille**: 400px de largeur × 300px de hauteur (fixe)
|
||||
- **Contenu**: Carte centrée sur la France ou le département d'outre-mer approprié avec un point rouge à l'emplacement de l'organisation
|
||||
- **Niveau de zoom**: Approprié pour montrer l'Hexagone ou le département d'outre-mer spécifique
|
||||
- **Style**: Fond de carte clair avec point rouge visible
|
||||
- **Interactivité**: Carte zoomable et déplaçable (pas de configuration statique)
|
||||
|
||||
### 2. Sources de données
|
||||
|
||||
- Utiliser les colonnes `acheteur_latitude` et `acheteur_longitude` pour les pages acheteur
|
||||
- Utiliser les colonnes `titulaire_latitude` et `titulaire_longitude` pour les pages titulaire
|
||||
- Utiliser les codes départementaux (`acheteur_departement_code`, `titulaire_departement_code`) pour la détection de région
|
||||
- Solution de repli: Si les coordonnées ou codes départementaux sont manquants ou invalides, afficher une div vide
|
||||
|
||||
### 3. Détection de région
|
||||
|
||||
- **Départements métropolitains**: Codes à 2 caractères (ex: "75" pour Paris) → Carte Hexagone
|
||||
- **Départements d'outre-mer**:
|
||||
- "971" → Guadeloupe
|
||||
- "972" → Martinique
|
||||
- "973" → Guyane
|
||||
- "974" → La Réunion
|
||||
- "976" → Mayotte
|
||||
- **Code département manquant**: Retourner une div vide (pas de détection basée sur les coordonnées)
|
||||
|
||||
### 4. Gestion des erreurs
|
||||
|
||||
- Coordonnées invalides → div vide
|
||||
- Code département manquant → div vide
|
||||
- Échec de l'API Annuaire → div vide (comportement existant)
|
||||
- Format de code département invalide → div vide
|
||||
|
||||
## Implémentation
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
#### 1. `src/figures.py` - Améliorer la fonction `point_on_map()`
|
||||
|
||||
**Ligne 178-209**: Remplacer la fonction existante par une version améliorée avec:
|
||||
|
||||
- Détection de région basée sur les codes départementaux
|
||||
- Configuration de carte interactive (zoomable)
|
||||
- Point plus grand (size=15)
|
||||
- Commentaires en français
|
||||
|
||||
#### 2. `src/pages/acheteur.py` - Mettre à jour le callback
|
||||
|
||||
**Ligne 249-297**: Modifier `update_acheteur_infos()` pour:
|
||||
|
||||
- Extraire le code département du code postal
|
||||
- Passer le code département à `point_on_map()`
|
||||
- Ajouter des commentaires en français
|
||||
|
||||
#### 3. `src/pages/titulaire.py` - Mettre à jour le callback
|
||||
|
||||
**Ligne 259-297**: Modifier `update_titulaire_infos()` pour:
|
||||
|
||||
- Extraire le code département du code postal
|
||||
- Passer le code département à `point_on_map()`
|
||||
- Ajouter des commentaires en français
|
||||
|
||||
## Plan de Test
|
||||
|
||||
### Cas de test prioritaires
|
||||
|
||||
1. **Organisation métropolitaine**: Code département "75" (Paris) → Carte Hexagone
|
||||
2. **Organisation à La Réunion**: Code département "974" → Carte centrée sur La Réunion
|
||||
3. **Code département manquant**: Retourne une div vide
|
||||
4. **Coordonnées invalides**: Retourne une div vide
|
||||
5. **Interactivité**: Vérifier zoom et déplacement
|
||||
|
||||
### Critères d'acceptation
|
||||
|
||||
- [ ] Cartes fonctionnelles avec codes départementaux valides
|
||||
- [ ] Div vide pour codes manquants/invalides
|
||||
- [ ] Cartes correctement centrées et zoomées
|
||||
- [ ] Interactivité (zoom et déplacement)
|
||||
- [ ] Point de localisation visible (size=15)
|
||||
|
||||
## Approbation
|
||||
|
||||
Plan approuvé avec spécifications:
|
||||
|
||||
- Réutiliser et améliorer `point_on_map`
|
||||
- Retourner div vide sans code département
|
||||
- Point légèrement plus grand
|
||||
- Cartes zoomables
|
||||
- Utiliser codes départementaux pour détection de région
|
||||
- Commentaires en français
|
||||
@@ -0,0 +1,699 @@
|
||||
# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? » — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Créer une page statique `/etapes` qui affiche un graphique HTML/CSS montrant quelles données (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP) sont publiées à chaque étape de la passation d'un marché public et à partir de quel seuil réglementaire.
|
||||
|
||||
**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). La page rend **deux représentations des mêmes données** basculées par media query : sur desktop/tablette, un graphique en grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage ; sur mobile portrait (< 768 px), une liste verticale par étape. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar.
|
||||
|
||||
**Tech Stack:** Python 3, Dash 3.4 (pages API), CSS (grille + positionnement absolu), Flask (route sitemap existante).
|
||||
|
||||
---
|
||||
|
||||
## Contexte pour l'engineer (à lire avant de commencer)
|
||||
|
||||
- decp.info est une app Dash multi-pages. Chaque page est un module dans `src/pages/` qui appelle `register_page(...)` au niveau du module et expose une variable `layout`. Dash découvre ces pages automatiquement grâce à `use_pages=True` (voir `src/app.py:30`).
|
||||
- **Imports** : toujours importer les modules de l'app avec le préfixe `src.` (ex. `from src.utils.seo import META_CONTENT`).
|
||||
- La navbar (`src/app.py:170-182`) est construite à partir d'une **liste blanche de noms** : `["Recherche", "À propos", "Tableau", "Observatoire"]`. Une page dont le `name` n'est pas dans cette liste **n'apparaît pas** dans la navbar. On ne touche donc PAS à la navbar.
|
||||
- Le sitemap (`src/app.py:70-86`) est une **liste d'URLs codée en dur**. Il faut y ajouter `/etapes`.
|
||||
- Le CSS personnalisé est dans `src/assets/css/style.css` (Dash charge automatiquement tout ce qui est dans `src/assets/`). On y ajoute les règles du graphique.
|
||||
- **Pré-requis commit** : ce dépôt utilise `pre-commit` (prettier, ruff). Les hooks ne tournent que si le virtualenv est activé. Avant chaque `git commit`, faire `source .venv/bin/activate` dans la même commande shell. Prettier peut reformater les fichiers Markdown/CSS : si un commit échoue parce que des fichiers ont été modifiés par un hook, refaire `git add` puis `git commit`.
|
||||
- **Référence visuelle** : la maquette validée est `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`. Le code HTML/CSS ci-dessous en est la transposition.
|
||||
- Ce projet n'a **pas** de test automatisé pour cette page (contenu 100 % statique). La vérification est manuelle via `python run.py`. Les tâches ci-dessous remplacent donc le cycle TDD par des vérifications de rendu explicites.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient `build_chart()` (graphique grille desktop), `build_mobile()` (liste verticale mobile, alimentée par la structure `STAGES_MOBILE`) et `build_legend()`, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`.
|
||||
- **Modify** `src/app.py` — ajouter `"/etapes"` à la liste `pages` de la fonction `sitemap()`.
|
||||
- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` : graphique en grille, liste mobile `.etapes-m-*`, et media query de bascule à 768 px.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Squelette de la page `/etapes`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/pages/etapes.py`
|
||||
|
||||
- [ ] **Step 1: Créer le fichier avec l'enregistrement de page et un layout minimal**
|
||||
|
||||
Créer `src/pages/etapes.py` avec exactement ce contenu (le graphique sera ajouté en Task 2) :
|
||||
|
||||
```python
|
||||
from dash import dcc, html, register_page
|
||||
|
||||
from src.utils.seo import META_CONTENT
|
||||
|
||||
NAME = "Quelles données pour quelles étapes et quels seuils ?"
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/etapes",
|
||||
title=f"{NAME} | decp.info",
|
||||
name="Étapes et données",
|
||||
description=(
|
||||
"À chaque étape d'un marché public (programmation, publicité, "
|
||||
"attribution), quelles données sont publiées et à partir de quel "
|
||||
"seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch."
|
||||
),
|
||||
image_url=META_CONTENT["image_url"],
|
||||
)
|
||||
|
||||
layout = html.Div(
|
||||
className="container",
|
||||
children=[
|
||||
html.H2(NAME),
|
||||
dcc.Markdown(
|
||||
"Un marché public passe par plusieurs étapes. À chacune, des "
|
||||
"données peuvent être publiées — selon le montant du marché et "
|
||||
"des obligations réglementaires. Ce graphique situe les "
|
||||
"principales publications de données par **étape** (de haut en "
|
||||
"bas) et par **seuil** (de gauche à droite, en euros hors taxes)."
|
||||
),
|
||||
# Le graphique sera inséré ici en Task 2
|
||||
dcc.Markdown(
|
||||
"**À noter :** l'axe horizontal n'est pas linéaire — les seuils "
|
||||
"sont espacés régulièrement pour rester lisibles. Les étapes "
|
||||
"*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée "
|
||||
"en open data.",
|
||||
className="etapes-note",
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer l'app et vérifier que la page se charge**
|
||||
|
||||
Run :
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && python run.py
|
||||
```
|
||||
|
||||
Puis ouvrir `http://127.0.0.1:8050/etapes` dans un navigateur.
|
||||
Expected : la page affiche le titre « Quelles données pour quelles étapes et quels seuils ? », le paragraphe d'intro et la note, avec le bandeau de navigation en haut. Aucune erreur dans la console du serveur. Arrêter le serveur (Ctrl-C).
|
||||
|
||||
- [ ] **Step 3: Vérifier l'absence dans la navbar**
|
||||
|
||||
Sur n'importe quelle page, vérifier visuellement que « Étapes et données » **n'apparaît pas** dans la barre de navigation (la liste blanche `src/app.py:181` ne la contient pas).
|
||||
Expected : la navbar montre uniquement Recherche / Tableau / Observatoire / À propos.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"
|
||||
```
|
||||
|
||||
(Si le commit échoue car un hook a reformaté le fichier : refaire `git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Le graphique HTML/CSS
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/etapes.py`
|
||||
|
||||
Le graphique est une grille de 6 colonnes : 1 colonne de libellés d'étape (150 px) + 5 colonnes de seuils égales. L'en-tête X et chaque ligne d'étape occupent les colonnes 2 → 6 (`grid-column: 2 / -1`). À l'intérieur d'une ligne, les barres sont positionnées en `position:absolute` avec `left`/`right` en pourcentage, où chaque segment de seuil = 20 % de la largeur :
|
||||
|
||||
- Segment 1 (0 € → 40 k€) : 0 % – 20 %
|
||||
- Segment 2 (40 k€ → 90 k€) : 20 % – 40 %
|
||||
- Segment 3 (90 k€ → 140/216 k€) : 40 % – 60 %
|
||||
- Segment 4 (140/216 k€ → 5,404 M€) : 60 % – 80 %
|
||||
- Segment 5 (≥ 5,404 M€) : 80 % – 100 %
|
||||
|
||||
Une barre qui « commence à 40 k€ et va jusqu'à l'infini » s'écrit donc `left:20%; right:2%` (les `2%` de marge évitent de coller au bord). Une barre qui remplit la case 90 k€ → seuil formalisé s'écrit `left:40%; right:40%`.
|
||||
|
||||
- [ ] **Step 1: Ajouter la fonction `build_chart()` au-dessus de `layout`**
|
||||
|
||||
Dans `src/pages/etapes.py`, insérer cette fonction entre le bloc `register_page(...)` et la définition de `layout` :
|
||||
|
||||
```python
|
||||
def _lane(*bars):
|
||||
"""Une ligne d'étape : fond segmenté en 5 + barres positionnées."""
|
||||
return html.Div(
|
||||
className="etapes-lane",
|
||||
children=[
|
||||
html.Div(
|
||||
className="etapes-segs",
|
||||
children=[html.Div() for _ in range(5)],
|
||||
),
|
||||
*bars,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _bar(label, color, style):
|
||||
base = {"backgroundColor": color}
|
||||
base.update(style)
|
||||
return html.Div(label, className="etapes-bar", style=base)
|
||||
|
||||
|
||||
def build_chart():
|
||||
return html.Div(
|
||||
className="etapes-chart-scroll",
|
||||
children=html.Div(
|
||||
className="etapes-chart",
|
||||
children=[
|
||||
# En-tête : coin vide + 5 marqueurs de seuils
|
||||
html.Div(className="etapes-corner"),
|
||||
html.Div(
|
||||
className="etapes-xhead",
|
||||
children=[
|
||||
html.Div("0 €", className="etapes-xcell"),
|
||||
html.Div(
|
||||
[html.Strong("40 000 €"), "seuil DECP"],
|
||||
className="etapes-xcell",
|
||||
),
|
||||
html.Div(
|
||||
[html.Strong("90 000 €"), "publicité"],
|
||||
className="etapes-xcell",
|
||||
),
|
||||
html.Div(
|
||||
[html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"],
|
||||
className="etapes-xcell",
|
||||
),
|
||||
html.Div(
|
||||
[html.Strong("5,404 M€"), "travaux (UE)"],
|
||||
className="etapes-xcell",
|
||||
),
|
||||
],
|
||||
),
|
||||
# Programmation
|
||||
html.Div("Programmation", className="etapes-stage"),
|
||||
_lane(
|
||||
_bar(
|
||||
"Approch — sourcing / préinformation (non réglementaire)",
|
||||
"#7c5cff",
|
||||
{"left": "2%", "right": "2%"},
|
||||
),
|
||||
),
|
||||
# Publicité (appel d'offres)
|
||||
html.Div(
|
||||
["Publicité ", html.Small("(appel d'offres)")],
|
||||
className="etapes-stage",
|
||||
),
|
||||
_lane(
|
||||
_bar(
|
||||
"Journaux d'annonces légales",
|
||||
"#f79009",
|
||||
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
|
||||
),
|
||||
_bar(
|
||||
"BOAMP",
|
||||
"#1570ef",
|
||||
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
|
||||
),
|
||||
_bar(
|
||||
"JOUE — avis de marché",
|
||||
"#0e9384",
|
||||
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
|
||||
),
|
||||
),
|
||||
# Attribution
|
||||
html.Div("Attribution", className="etapes-stage"),
|
||||
_lane(
|
||||
_bar(
|
||||
"DECP — données essentielles",
|
||||
"#12b76a",
|
||||
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
|
||||
),
|
||||
_bar(
|
||||
"JOUE — avis d'attribution",
|
||||
"#0e9384",
|
||||
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
|
||||
),
|
||||
),
|
||||
# Contrat (vide)
|
||||
html.Div("Contrat", className="etapes-stage"),
|
||||
html.Div(
|
||||
"— aucune donnée publiée aujourd'hui —",
|
||||
className="etapes-lane etapes-empty",
|
||||
),
|
||||
# Paiement (vide)
|
||||
html.Div("Paiement", className="etapes-stage"),
|
||||
html.Div(
|
||||
"— aucune donnée publiée aujourd'hui —",
|
||||
className="etapes-lane etapes-empty",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_legend():
|
||||
items = [
|
||||
("Approch", "#7c5cff"),
|
||||
("Journaux d'annonces légales", "#f79009"),
|
||||
("BOAMP", "#1570ef"),
|
||||
("JOUE", "#0e9384"),
|
||||
("DECP", "#12b76a"),
|
||||
]
|
||||
return html.Div(
|
||||
className="etapes-legend",
|
||||
children=[
|
||||
html.Span(
|
||||
[
|
||||
html.I(style={"backgroundColor": color}),
|
||||
label,
|
||||
]
|
||||
)
|
||||
for label, color in items
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Insérer le graphique et la légende dans `layout`**
|
||||
|
||||
Dans `layout`, remplacer la ligne de commentaire `# Le graphique sera inséré ici en Task 2` par :
|
||||
|
||||
```python
|
||||
build_chart(),
|
||||
build_legend(),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer l'app et vérifier le rendu**
|
||||
|
||||
Run :
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/etapes`.
|
||||
Expected (comparer à la maquette `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`) :
|
||||
|
||||
- En-tête X : `0 € · 40 000 € (seuil DECP) · 90 000 € (publicité) · 140 k€/216 k€ (seuils formalisés UE) · 5,404 M€ (travaux UE)`.
|
||||
- Lignes de haut en bas : Programmation (barre Approch pleine largeur), Publicité (Journaux d'annonces légales + BOAMP + JOUE), Attribution (DECP + JOUE), Contrat (vide), Paiement (vide).
|
||||
- La barre « Journaux d'annonces légales » occupe la case 90 k€ → seuil formalisé ; DECP démarre à 40 k€ ; JOUE et BOAMP démarrent aux bons segments.
|
||||
- La légende sous le graphique liste les 5 publications avec leurs couleurs.
|
||||
|
||||
À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 4. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer. Arrêter le serveur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): graphique données par étape et par seuil"
|
||||
```
|
||||
|
||||
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Vue mobile (liste verticale par étape)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/etapes.py`
|
||||
|
||||
Sur écran portrait étroit, le graphique en grille n'est pas lisible (vue d'ensemble perdue). On ajoute une **liste verticale par étape** qui décrit les mêmes données en texte. Le basculement entre les deux rendus se fera en CSS (Task 4). Pour éviter la duplication, les publications de chaque étape sont décrites dans une structure de données Python consommée par le rendu mobile.
|
||||
|
||||
- [ ] **Step 1: Ajouter la structure de données et `build_mobile()`**
|
||||
|
||||
Dans `src/pages/etapes.py`, ajouter ce bloc juste avant la fonction `build_legend()` :
|
||||
|
||||
```python
|
||||
# Données par étape, partagées par la vue mobile.
|
||||
# Chaque item : (libellé, couleur, plage de seuils en texte).
|
||||
STAGES_MOBILE = [
|
||||
(
|
||||
"Programmation",
|
||||
[
|
||||
("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Publicité (appel d'offres)",
|
||||
[
|
||||
("Journaux d'annonces légales", "#f79009", "de 90 000 € au seuil formalisé"),
|
||||
("BOAMP", "#1570ef", "à partir de 90 000 €"),
|
||||
(
|
||||
"JOUE — avis de marché",
|
||||
"#0e9384",
|
||||
"à partir des seuils formalisés (140 k€ / 216 k€)",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Attribution",
|
||||
[
|
||||
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
|
||||
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
|
||||
],
|
||||
),
|
||||
("Contrat", []),
|
||||
("Paiement", []),
|
||||
]
|
||||
|
||||
|
||||
def build_mobile():
|
||||
blocks = []
|
||||
for stage, items in STAGES_MOBILE:
|
||||
if items:
|
||||
children = [
|
||||
html.Div(
|
||||
[
|
||||
html.I(style={"backgroundColor": color}),
|
||||
html.Span(label, className="etapes-m-label"),
|
||||
html.Span(seuil, className="etapes-m-seuil"),
|
||||
],
|
||||
className="etapes-m-item",
|
||||
)
|
||||
for label, color, seuil in items
|
||||
]
|
||||
else:
|
||||
children = [
|
||||
html.Div(
|
||||
"aucune donnée publiée aujourd'hui",
|
||||
className="etapes-m-item etapes-m-empty",
|
||||
)
|
||||
]
|
||||
blocks.append(
|
||||
html.Div(
|
||||
[html.H4(stage, className="etapes-m-stage"), *children],
|
||||
className="etapes-m-block",
|
||||
)
|
||||
)
|
||||
return html.Div(blocks, className="etapes-mobile")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Insérer `build_mobile()` dans `layout`**
|
||||
|
||||
Dans `layout`, la ligne `build_chart(),` (insérée en Task 2) est suivie de `build_mobile(),`, soit :
|
||||
|
||||
```python
|
||||
build_chart(),
|
||||
build_mobile(),
|
||||
build_legend(),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer l'app et vérifier (rendu brut, avant CSS de bascule)**
|
||||
|
||||
Run :
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/etapes`. À ce stade les deux rendus s'affichent l'un sous l'autre (la bascule CSS arrive en Task 4) : sous le graphique, la liste affiche Programmation (Approch…), Publicité (3 publications), Attribution (2 publications), puis Contrat et Paiement avec « aucune donnée publiée aujourd'hui ». C'est attendu. Arrêter le serveur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): vue mobile liste par étape"
|
||||
```
|
||||
|
||||
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : CSS du graphique + bascule mobile
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/assets/css/style.css`
|
||||
|
||||
- [ ] **Step 1: Ajouter le bloc CSS à la fin de `src/assets/css/style.css`**
|
||||
|
||||
Ajouter à la fin du fichier :
|
||||
|
||||
```css
|
||||
/* ===== Page /etapes : graphique données par étape et par seuil ===== */
|
||||
|
||||
.etapes-chart-scroll {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.etapes-chart {
|
||||
min-width: 720px;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
display: grid;
|
||||
grid-template-columns: 150px repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.etapes-corner {
|
||||
border-bottom: 2px solid #344054;
|
||||
}
|
||||
|
||||
.etapes-xhead {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
border-bottom: 2px solid #344054;
|
||||
}
|
||||
|
||||
.etapes-xcell {
|
||||
text-align: center;
|
||||
padding: 6px 2px;
|
||||
font-size: 11px;
|
||||
color: #475467;
|
||||
border-left: 1px dashed #d0d5dd;
|
||||
}
|
||||
|
||||
.etapes-xcell strong {
|
||||
display: block;
|
||||
color: #101828;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.etapes-stage {
|
||||
padding: 14px 10px;
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
border-bottom: 1px solid #eaecf0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.etapes-stage small {
|
||||
font-weight: 400;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.etapes-lane {
|
||||
grid-column: 2 / -1;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #eaecf0;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.etapes-segs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.etapes-segs > div {
|
||||
border-left: 1px dashed #eaecf0;
|
||||
}
|
||||
|
||||
.etapes-bar {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.etapes-empty {
|
||||
color: #98a2b3;
|
||||
font-style: italic;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.etapes-legend {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.etapes-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.etapes-legend i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.etapes-note {
|
||||
margin-top: 8px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* --- Vue mobile (liste par étape) : masquée par défaut --- */
|
||||
|
||||
.etapes-mobile {
|
||||
display: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.etapes-m-block {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.etapes-m-stage {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #eaecf0;
|
||||
font-size: 15px;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.etapes-m-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f2f4f7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.etapes-m-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.etapes-m-item i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.etapes-m-label {
|
||||
font-weight: 600;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.etapes-m-seuil {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.etapes-m-empty {
|
||||
color: #98a2b3;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.etapes-chart-scroll,
|
||||
.etapes-legend {
|
||||
display: none;
|
||||
}
|
||||
.etapes-mobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer l'app et vérifier le rendu final**
|
||||
|
||||
Run :
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/etapes` en grand écran (≥ 768 px).
|
||||
Expected : le graphique est identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. La **liste mobile est masquée** (le graphique seul est visible).
|
||||
|
||||
- [ ] **Step 3: Vérifier la bascule responsive**
|
||||
|
||||
Dans le navigateur, ouvrir les devtools et passer en mode mobile portrait (largeur < 768 px, ex. iPhone SE 375 px). Tester aussi une largeur intermédiaire (~800 px).
|
||||
Expected :
|
||||
|
||||
- À largeur intermédiaire (~800 px, ≥ 768) : le **graphique** s'affiche, défilable horizontalement (`overflow-x:auto` + `min-width:720px`), barres non écrasées ; liste mobile masquée.
|
||||
- En portrait (< 768 px) : le graphique **et la légende disparaissent**, remplacés par la **liste verticale par étape** — chaque étape est un bloc avec son titre, et chaque publication a sa pastille de couleur, son nom et sa plage de seuils en texte. Aucun défilement horizontal nécessaire. Contrat/Paiement affichent « aucune donnée publiée aujourd'hui » en italique.
|
||||
|
||||
Arrêter le serveur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && git add src/assets/css/style.css && git commit -m "feat(etapes): styles du graphique étapes/seuils"
|
||||
```
|
||||
|
||||
(Si échec dû à un hook prettier : refaire `git add` puis `git commit`.)
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Référencement de la page dans le sitemap
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app.py` (fonction `sitemap()`, ~ligne 73)
|
||||
|
||||
- [ ] **Step 1: Ajouter `/etapes` à la liste des URLs du sitemap**
|
||||
|
||||
Dans `src/app.py`, dans la fonction `sitemap()`, modifier la liste `pages` :
|
||||
|
||||
```python
|
||||
pages = [
|
||||
"/",
|
||||
"/observatoire",
|
||||
"/tableau",
|
||||
"/a-propos",
|
||||
"/etapes",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier le sitemap**
|
||||
|
||||
Run :
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/sitemap.xml`.
|
||||
Expected : le XML contient désormais une entrée `<loc>https://decp.info/etapes</loc>`. Arrêter le serveur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate && git add src/app.py && git commit -m "feat(etapes): référencement de /etapes dans le sitemap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vérification finale (checklist de la spec)
|
||||
|
||||
- [ ] `/etapes` affiche le graphique fidèle à la maquette v3, avec le bandeau de navigation global en haut.
|
||||
- [ ] La page est **absente** de la navbar.
|
||||
- [ ] `/sitemap.xml` **contient** `/etapes`.
|
||||
- [ ] Sur fenêtre intermédiaire (≥ 768 px), le graphique défile horizontalement sans s'écraser.
|
||||
- [ ] Sur écran portrait étroit (< 768 px), le graphique est masqué et remplacé par la liste verticale par étape, lisible sans défilement horizontal.
|
||||
- [ ] Titre H2 de la page = « Quelles données pour quelles étapes et quels seuils ? ».
|
||||
- [ ] `name` de la page = « Étapes et données ».
|
||||
@@ -0,0 +1,665 @@
|
||||
# Bootstrap résilient des données et du schéma — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendre le démarrage de decp.info résilient aux ressources externes KO (parquet, schéma, stats), pour que l'API partagée ne tombe plus à cause d'un déploiement défaillant.
|
||||
|
||||
**Architecture:** Trois invariants. (1) Le DuckDB est réutilisé si la reconstruction échoue. (2) Le schéma suit la chaîne `URL → cache → RuntimeError`, le dernier schéma distant fonctionnel étant persisté localement. (3) Les chargements de pages au boot ne lèvent jamais sur une ressource externe KO. Une tâche préalable répare le baseline de tests laissé rouge par le merge de `main`.
|
||||
|
||||
**Tech Stack:** Python, Polars, DuckDB, httpx, flask-caching, pytest (+ monkeypatch).
|
||||
|
||||
**Spec de référence:** `docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
| Fichier | Responsabilité | Action |
|
||||
| ------------------------------------- | ---------------------------------------- | ----------------------------- |
|
||||
| `tests/test_db.py` | Tests bootstrap DuckDB | Modifier (réparer + ajouter) |
|
||||
| `tests/conftest.py` | Setup déterministe des tests | Modifier (seed schéma) |
|
||||
| `tests/schema.fixture.json` | Schéma complet figé pour tests (offline) | Créer (commité) |
|
||||
| `tests/test_schema.py` | Tests résolution schéma | Créer |
|
||||
| `tests/test_page_loads.py` | Tests chargements best-effort (C/D) | Créer |
|
||||
| `src/utils/data.py` | Résolution schéma | Modifier |
|
||||
| `src/db.py` | Bootstrap DuckDB | Modifier (`_ensure_database`) |
|
||||
| `src/utils/__init__.py` | Helper date MAJ best-effort | Modifier (ajout fonction) |
|
||||
| `src/pages/tableau.py` | Date MAJ au niveau module | Modifier |
|
||||
| `src/figures.py` | `get_sources_tables` | Modifier |
|
||||
| `.template.env`, `.env`, `.gitignore` | Config | Modifier |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Réparer le baseline de tests `test_db.py`
|
||||
|
||||
Le merge de `main` a changé `build_database(db_path)` (1 arg, parquet lu via env) et memoïsé `get_last_modified` (besoin du contexte d'app). Trois corrections pour repartir au vert. **Aucune logique applicative ne change ici.**
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tests/test_db.py`
|
||||
|
||||
- [ ] **Step 1 : Corriger la fixture `built_db` (signature `build_database`)**
|
||||
|
||||
Dans `tests/test_db.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.db import build_database
|
||||
|
||||
build_database(db_path, parquet_path)
|
||||
return db_path
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.db import build_database
|
||||
|
||||
build_database(db_path)
|
||||
return db_path
|
||||
```
|
||||
|
||||
(L'env `DATA_FILE_PARQUET_PATH` est déjà posé juste au-dessus dans la fixture.)
|
||||
|
||||
- [ ] **Step 2 : Corriger `test_concurrent_build_serialized`**
|
||||
|
||||
Ajouter `monkeypatch` à la signature et poser l'env du parquet ; corriger l'appel `build_database`.
|
||||
|
||||
Remplacer la ligne de signature :
|
||||
|
||||
```python
|
||||
def test_concurrent_build_serialized(tmp_path):
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
def test_concurrent_build_serialized(tmp_path, monkeypatch):
|
||||
```
|
||||
|
||||
Juste après `df.write_parquet(parquet_path)`, ajouter :
|
||||
|
||||
```python
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
|
||||
```
|
||||
|
||||
Et dans `worker()`, remplacer :
|
||||
|
||||
```python
|
||||
if db.should_rebuild(db_path, parquet_path):
|
||||
db.build_database(db_path, parquet_path)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
if db.should_rebuild(db_path, parquet_path):
|
||||
db.build_database(db_path)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Corriger les 2 tests prod de `should_rebuild` (memoïsation)**
|
||||
|
||||
`should_rebuild` non-dev appelle `get_last_modified`, memoïsé et inutilisable hors contexte d'app en test. On le monkeypatche pour tester la logique de comparaison de dates.
|
||||
|
||||
Dans `test_should_rebuild_prod_when_parquet_newer`, juste avant l'`assert`, ajouter :
|
||||
|
||||
```python
|
||||
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||
```
|
||||
|
||||
Dans `test_should_not_rebuild_prod_when_parquet_older`, juste avant l'`assert`, ajouter la même ligne :
|
||||
|
||||
```python
|
||||
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests, vérifier le vert**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_db.py -q`
|
||||
Expected: tous PASS (plus aucun `TypeError`/`AttributeError`).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_db.py
|
||||
git commit -m "test: réparer le baseline test_db cassé par le merge (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Schéma résilient — chaîne `URL → cache → RuntimeError`
|
||||
|
||||
Réécrire `get_data_schema` avec helpers robustes et persistance du dernier schéma distant fonctionnel. Supprimer `DATA_SCHEMA_LOCAL` au profit de `DATA_SCHEMA_CACHE`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/schema.fixture.json`, `tests/test_schema.py`
|
||||
- Modify: `tests/conftest.py`, `src/utils/data.py`, `.template.env`, `.env`, `.gitignore`
|
||||
|
||||
- [ ] **Step 1 : Créer le fixture schéma complet (offline, déterministe)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cp ../decp-processing/dist/schema.json tests/schema.fixture.json
|
||||
test -s tests/schema.fixture.json && python -c "import json;assert 'fields' in json.load(open('tests/schema.fixture.json'))" && echo OK
|
||||
```
|
||||
|
||||
Expected: `OK` (le fixture contient bien une clé `fields`).
|
||||
|
||||
- [ ] **Step 2 : Rendre la résolution du schéma déterministe en test (conftest)**
|
||||
|
||||
Dans `tests/conftest.py`, ajouter `import json` en tête (avec les autres imports) puis, au niveau module **avant** toute logique existante (juste après la ligne `_DB_PATH = Path(...)`), ajouter :
|
||||
|
||||
```python
|
||||
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
|
||||
# fixture commité et on désactive la récupération distante.
|
||||
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
|
||||
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
|
||||
os.environ.pop("DATA_SCHEMA_PATH", None)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Écrire les tests schéma (échouent d'abord)**
|
||||
|
||||
Créer `tests/test_schema.py` :
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.utils import data as data_mod
|
||||
|
||||
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
|
||||
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload, ok=True, bad_json=False):
|
||||
self._payload = payload
|
||||
self._ok = ok
|
||||
self._bad_json = bad_json
|
||||
|
||||
def raise_for_status(self):
|
||||
if not self._ok:
|
||||
raise httpx.HTTPError("boom")
|
||||
return self
|
||||
|
||||
def json(self):
|
||||
if self._bad_json:
|
||||
raise json.JSONDecodeError("bad", "", 0)
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "schema.cache.json"
|
||||
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||
result = data_mod.get_data_schema()
|
||||
assert "uid" in result
|
||||
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
|
||||
|
||||
|
||||
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "schema.cache.json"
|
||||
cache.write_text(json.dumps(VALID))
|
||||
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
|
||||
assert "uid" in data_mod.get_data_schema()
|
||||
|
||||
|
||||
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "schema.cache.json"
|
||||
cache.write_text(json.dumps(VALID))
|
||||
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
|
||||
assert "uid" in data_mod.get_data_schema()
|
||||
|
||||
|
||||
def test_no_url_uses_cache(tmp_path, monkeypatch):
|
||||
cache = tmp_path / "schema.cache.json"
|
||||
cache.write_text(json.dumps(VALID))
|
||||
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||
assert "uid" in data_mod.get_data_schema()
|
||||
|
||||
|
||||
def test_no_source_raises(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
|
||||
with pytest.raises(RuntimeError):
|
||||
data_mod.get_data_schema()
|
||||
|
||||
|
||||
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
|
||||
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
|
||||
cache = tmp_path / "nodir" / "schema.cache.json"
|
||||
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
|
||||
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
|
||||
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
|
||||
assert "uid" in data_mod.get_data_schema()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
|
||||
Expected: FAIL (les helpers/comportements n'existent pas encore ; `get_data_schema` actuel plante différemment).
|
||||
|
||||
- [ ] **Step 5 : Réécrire `get_data_schema` + helpers**
|
||||
|
||||
Dans `src/utils/data.py`, remplacer entièrement la fonction `get_data_schema` (actuellement lignes ~68-95) par :
|
||||
|
||||
```python
|
||||
def _validate_schema(raw) -> dict | None:
|
||||
if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]:
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_remote_schema(url: str | None) -> dict | None:
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
raw = get(url, follow_redirects=True).raise_for_status().json()
|
||||
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Schéma distant indisponible ({url}) : {e}")
|
||||
return None
|
||||
return _validate_schema(raw)
|
||||
|
||||
|
||||
def _load_schema_file(path: str) -> dict | None:
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Schéma local illisible ({path}) : {e}")
|
||||
return None
|
||||
return _validate_schema(raw)
|
||||
|
||||
|
||||
def _persist_schema_cache(raw: dict, path: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(raw, f)
|
||||
os.replace(tmp, path)
|
||||
except OSError as e:
|
||||
logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}")
|
||||
|
||||
|
||||
def get_data_schema() -> dict:
|
||||
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
|
||||
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
|
||||
if raw is not None:
|
||||
_persist_schema_cache(raw, cache_path)
|
||||
else:
|
||||
raw = _load_schema_file(cache_path)
|
||||
if raw is None:
|
||||
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
|
||||
return OrderedDict((c["name"], c) for c in raw["fields"])
|
||||
```
|
||||
|
||||
Vérifier que les imports en tête de `src/utils/data.py` couvrent : `json`, `os`, `OrderedDict`, `httpx`, `get` (déjà présents : `from httpx import HTTPError, get`). `HTTPError` peut devenir inutilisé — voir Step 7.
|
||||
|
||||
- [ ] **Step 6 : Lancer les tests, vérifier le vert**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
|
||||
Expected: 6 PASS.
|
||||
|
||||
- [ ] **Step 7 : Nettoyer imports + config + gitignore**
|
||||
|
||||
Dans `src/utils/data.py`, si `HTTPError` n'est plus utilisé, remplacer `from httpx import HTTPError, get` par `from httpx import get` (garder `import httpx`). Vérifier avec :
|
||||
|
||||
Run: `rtk proxy python -m ruff check src/utils/data.py`
|
||||
Expected: pas d'erreur F401.
|
||||
|
||||
Dans `.gitignore`, ajouter sous la ligne `**/decp.duckdb` :
|
||||
|
||||
```
|
||||
**/schema.cache.json
|
||||
```
|
||||
|
||||
Dans `.template.env`, supprimer la ligne `DATA_SCHEMA_PATH_LOCAL=...` et ajouter :
|
||||
|
||||
```
|
||||
DATA_SCHEMA_CACHE=./schema.cache.json
|
||||
```
|
||||
|
||||
Dans `.env` (local, non versionné), supprimer la ligne `DATA_SCHEMA_LOCAL=...` et ajouter `DATA_SCHEMA_CACHE=./schema.cache.json`.
|
||||
|
||||
- [ ] **Step 8 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/schema.fixture.json tests/test_schema.py tests/conftest.py src/utils/data.py .template.env .gitignore
|
||||
git commit -m "feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Bootstrap DuckDB résilient
|
||||
|
||||
Ajouter le garde-fou `try/except` dans `_ensure_database` : réutiliser le DuckDB existant si la reconstruction échoue ; ne lever qu'en cold start.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/db.py:117-128` (`_ensure_database`)
|
||||
- Test: `tests/test_db.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
|
||||
|
||||
Ajouter à la fin de `tests/test_db.py` :
|
||||
|
||||
```python
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch):
|
||||
import src.db as db
|
||||
|
||||
dbf = tmp_path / "decp.duckdb"
|
||||
dbf.write_bytes(b"existing")
|
||||
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||
monkeypatch.setattr(db, "should_rebuild", _raise)
|
||||
result = db._ensure_database() # ne doit pas lever
|
||||
assert result == dbf
|
||||
assert dbf.read_bytes() == b"existing"
|
||||
|
||||
|
||||
def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch):
|
||||
import src.db as db
|
||||
|
||||
dbf = tmp_path / "decp.duckdb"
|
||||
dbf.write_bytes(b"existing")
|
||||
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||
monkeypatch.setattr(db, "build_database", _raise)
|
||||
db._ensure_database() # ne doit pas lever
|
||||
assert dbf.read_bytes() == b"existing"
|
||||
|
||||
|
||||
def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch):
|
||||
import src.db as db
|
||||
|
||||
dbf = tmp_path / "decp.duckdb" # n'existe pas
|
||||
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
|
||||
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||
monkeypatch.setattr(db, "build_database", _raise)
|
||||
with pytest.raises(RuntimeError):
|
||||
db._ensure_database()
|
||||
|
||||
|
||||
def test_ensure_database_builds_when_needed(tmp_path, monkeypatch):
|
||||
import src.db as db
|
||||
|
||||
dbf = tmp_path / "decp.duckdb"
|
||||
dbf.write_bytes(b"old")
|
||||
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x")
|
||||
called = {}
|
||||
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
|
||||
monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p))
|
||||
db._ensure_database()
|
||||
assert called.get("built") == dbf
|
||||
```
|
||||
|
||||
Ajouter `import pytest` en tête de `tests/test_db.py` s'il n'y est pas déjà (il y est).
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_db.py -q -k ensure_database`
|
||||
Expected: FAIL (le `try/except` n'existe pas ; les exceptions remontent).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le garde-fou**
|
||||
|
||||
Dans `src/db.py`, remplacer `_ensure_database` (lignes ~117-128) par :
|
||||
|
||||
```python
|
||||
def _ensure_database() -> Path:
|
||||
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
|
||||
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
|
||||
lock_path = db_path.with_suffix(".duckdb.lock")
|
||||
db_exists = db_path.exists()
|
||||
|
||||
with open(lock_path, "w") as lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
if should_rebuild(db_path, parquet_path):
|
||||
build_database(db_path)
|
||||
else:
|
||||
logger.debug("Base de données déjà disponible et à jour.")
|
||||
except Exception as e:
|
||||
if db_exists:
|
||||
logger.error(
|
||||
f"Bootstrap données KO ({e}). "
|
||||
f"Réutilisation du DuckDB existant : {db_path}"
|
||||
)
|
||||
else:
|
||||
logger.critical("Aucune base DuckDB et reconstruction impossible.")
|
||||
raise
|
||||
return db_path
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer, vérifier le vert**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_db.py -q`
|
||||
Expected: tous PASS (anciens + 4 nouveaux).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/db.py tests/test_db.py
|
||||
git commit -m "feat: réutiliser le DuckDB existant si le bootstrap échoue (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Chargements de pages best-effort (C + D)
|
||||
|
||||
`tableau.py` (date MAJ via `get_last_modified`) et `a-propos.py` (stats via `get_sources_tables`) chargent au boot et peuvent tuer le démarrage. On les rend best-effort.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_page_loads.py`
|
||||
- Modify: `src/utils/__init__.py`, `src/pages/tableau.py`, `src/figures.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
|
||||
|
||||
Créer `tests/test_page_loads.py` :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
|
||||
def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch):
|
||||
import src.utils as u
|
||||
from src.utils import get_data_update_timestamp
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("net down")
|
||||
|
||||
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||
fb = tmp_path / "decp.duckdb"
|
||||
fb.write_bytes(b"x")
|
||||
assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb))
|
||||
|
||||
|
||||
def test_update_timestamp_none_when_all_fail(monkeypatch):
|
||||
import src.utils as u
|
||||
from src.utils import get_data_update_timestamp
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("net down")
|
||||
|
||||
monkeypatch.setattr(u, "get_last_modified", boom)
|
||||
assert get_data_update_timestamp("http://x", None) is None
|
||||
|
||||
|
||||
def test_update_timestamp_nominal(monkeypatch):
|
||||
import src.utils as u
|
||||
from src.utils import get_data_update_timestamp
|
||||
|
||||
monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0)
|
||||
assert get_data_update_timestamp("http://x", None) == 123.0
|
||||
|
||||
|
||||
def test_sources_tables_none_path():
|
||||
from src.figures import get_sources_tables
|
||||
|
||||
div = get_sources_tables(None)
|
||||
assert "indisponible" in str(div.children).lower()
|
||||
|
||||
|
||||
def test_sources_tables_missing_file():
|
||||
from src.figures import get_sources_tables
|
||||
|
||||
div = get_sources_tables("/does/not/exist.csv")
|
||||
assert "indisponible" in str(div.children).lower()
|
||||
|
||||
|
||||
def test_sources_tables_valid_csv(tmp_path):
|
||||
from dash import dash_table
|
||||
|
||||
from src.figures import get_sources_tables
|
||||
|
||||
csv = tmp_path / "s.csv"
|
||||
csv.write_text(
|
||||
"nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n"
|
||||
"Source A,Org A,5,2,XA,http://a,1\n"
|
||||
)
|
||||
div = get_sources_tables(str(csv))
|
||||
assert isinstance(div.children, dash_table.DataTable)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
|
||||
Expected: FAIL (`get_data_update_timestamp` n'existe pas ; `get_sources_tables(None)` plante).
|
||||
|
||||
- [ ] **Step 3 : Ajouter `get_data_update_timestamp` dans `src/utils/__init__.py`**
|
||||
|
||||
À la fin de `src/utils/__init__.py`, ajouter :
|
||||
|
||||
```python
|
||||
def get_data_update_timestamp(
|
||||
parquet_path: str, fallback_path: str | None = None
|
||||
) -> float | None:
|
||||
"""Date de MAJ des données, best-effort, sans jamais lever (usage au boot)."""
|
||||
try:
|
||||
return get_last_modified(parquet_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Date de mise à jour des données indisponible ({e})")
|
||||
if fallback_path:
|
||||
try:
|
||||
return os.path.getmtime(fallback_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
```
|
||||
|
||||
(`os` et `logger` sont déjà disponibles dans ce module.)
|
||||
|
||||
- [ ] **Step 4 : Utiliser le helper dans `tableau.py`**
|
||||
|
||||
Dans `src/pages/tableau.py`, remplacer l'import ligne 24 :
|
||||
|
||||
```python
|
||||
from src.utils import get_last_modified, logger
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.utils import get_data_update_timestamp, logger
|
||||
```
|
||||
|
||||
Et remplacer les lignes 36-38 :
|
||||
|
||||
```python
|
||||
update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", ""))
|
||||
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
update_date_timestamp = get_data_update_timestamp(
|
||||
os.getenv("DATA_FILE_PARQUET_PATH", ""),
|
||||
os.getenv("DUCKDB_PATH", "./decp.duckdb"),
|
||||
)
|
||||
if update_date_timestamp is not None:
|
||||
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||
else:
|
||||
update_date = "date inconnue"
|
||||
update_date_iso = ""
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Élargir `get_sources_tables` dans `src/figures.py`**
|
||||
|
||||
Dans `src/figures.py` (`get_sources_tables`, ~lignes 122-125), remplacer :
|
||||
|
||||
```python
|
||||
try:
|
||||
dff = pl.read_csv(source_path)
|
||||
except (URLError, HTTPError):
|
||||
return html.Div("Erreur de connexion")
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
try:
|
||||
if not source_path:
|
||||
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
|
||||
dff = pl.read_csv(source_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sources de données indisponibles ({e})")
|
||||
return html.Div("Sources de données momentanément indisponibles.")
|
||||
```
|
||||
|
||||
Si `URLError`/`HTTPError` (import ligne 3 `from urllib.error import HTTPError, URLError`) ne sont plus utilisés ailleurs dans le fichier, supprimer cet import.
|
||||
|
||||
Run: `rtk proxy python -m ruff check src/figures.py`
|
||||
Expected: pas d'erreur F401.
|
||||
|
||||
- [ ] **Step 6 : Lancer, vérifier le vert**
|
||||
|
||||
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
|
||||
Expected: 6 PASS.
|
||||
|
||||
- [ ] **Step 7 : Smoke test — l'import des modules modifiés ne casse pas**
|
||||
|
||||
Run: `rtk proxy python -c "import src.figures, src.pages.tableau; print('import OK')"`
|
||||
Expected: `import OK`.
|
||||
(NB : `a-propos.py` a un tiret, non importable par nom — son correctif `get_sources_tables` est couvert par les tests unitaires du Step 6 et la page sera validée par la suite Selenium au Step 8.)
|
||||
|
||||
- [ ] **Step 8 : Suite complète**
|
||||
|
||||
Run: `rtk proxy python -m pytest -q`
|
||||
Expected: vert (hors tests Selenium nécessitant Chrome, à lancer si l'environnement le permet).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_page_loads.py src/utils/__init__.py src/pages/tableau.py src/figures.py
|
||||
git commit -m "feat: chargements de pages best-effort au boot (tableau, sources) (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes d'exécution
|
||||
|
||||
- **Dev hors-ligne 1er run :** « cache seul » supprime le fallback in-repo. Au tout premier démarrage sur une machine sans `schema.cache.json` ni réseau, le boot lèvera `RuntimeError`. En conditions normales (URL OK une fois, ou cache déjà présent) c'est transparent. Les tests sont rendus déterministes via `tests/schema.fixture.json` (Task 2).
|
||||
- **CHANGELOG :** penser à ajouter une entrée (résilience bootstrap données/schéma) avant de finaliser la PR #78, si le projet le tient à jour.
|
||||
- **`get_last_modified` reste memoïsé** : non modifié ici ; le cache FileSystem est vidé à chaque boot (`rmtree` dans `app.py`), donc pas de last-modified périmé entre déploiements.
|
||||
@@ -0,0 +1,725 @@
|
||||
# Parité API decp.info / tabular-api — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Atteindre la parité de l'API `/api/v1/data` de decp.info avec `tabular-api` (data.gouv.fr) sur les opérateurs manquants, et documenter chaque mot-clé dans le Swagger UI.
|
||||
|
||||
**Architecture:** Le parsing des filtres reste dans `src/api/filters.py` (`build_where`). On y ajoute l'opérateur `differs` et une nouvelle fonction `parse_aggregators` qui détecte les drapeaux d'agrégation et produit des fragments SQL. `src/db.py` gagne `aggregate_marches`. `src/api/routes.py` oriente vers le chemin agrégation ou le chemin normal, renomme le param réservé `count`→`count_results`, et documente les opérateurs.
|
||||
|
||||
**Tech Stack:** Flask + flask-smorest, DuckDB, Polars, pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.api.filters`).
|
||||
- Valeurs de filtre liées par paramètres `?` (jamais interpolées). Noms de colonnes validés contre `schema` avant interpolation.
|
||||
- Spec de référence : `docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md`.
|
||||
- Périmètre : `count_results`, `differs`, agrégation (`groupby`/`count`/`sum`/`avg`/`min`/`max`), doc. **Hors périmètre : `or`.**
|
||||
- Lancer les tests via `rtk pytest` ; l'environnement de test définit `DEVELOPMENT=true` et `DATA_FILE_PARQUET_PATH=tests/test.parquet`.
|
||||
- `tests/test.parquet` contient notamment les colonnes : `uid`, `montant` (Int64), `acheteur_departement_code`, `objet`, `dateNotification` (Date).
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Renommer le param réservé `count` → `count_results`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/api/filters.py` (constante `RESERVED_PARAMS`)
|
||||
- Modify: `src/api/routes.py` (lecture du param + doc swagger)
|
||||
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien.
|
||||
- Produces: le param réservé s'appelle désormais `count_results` ; `count` n'est plus réservé (libéré pour l'agrégation en Task 3).
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour les tests existants**
|
||||
|
||||
Dans `tests/api/test_filters.py`, remplacer `("count", "false")` par `("count_results", "false")` dans `test_reserved_params_are_ignored` :
|
||||
|
||||
```python
|
||||
def test_reserved_params_are_ignored():
|
||||
where, params, order = build_where(
|
||||
[
|
||||
("page", "2"),
|
||||
("page_size", "100"),
|
||||
("columns", "uid"),
|
||||
("count_results", "false"),
|
||||
("uid__exact", "z"),
|
||||
],
|
||||
SCHEMA,
|
||||
)
|
||||
assert where == '"uid" = ?'
|
||||
assert params == ["z"]
|
||||
```
|
||||
|
||||
Dans `tests/api/test_endpoints_data.py`, renommer le test et l'URL :
|
||||
|
||||
```python
|
||||
def test_data_count_results_false_omits_total(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert "total" not in body["meta"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_count_results_false_omits_total tests/api/test_filters.py::test_reserved_params_are_ignored -v`
|
||||
Expected: FAIL (`count_results` encore traité comme filtre inconnu → 400 / `FilterError`).
|
||||
|
||||
- [ ] **Step 3 : Modifier `RESERVED_PARAMS`**
|
||||
|
||||
Dans `src/api/filters.py` :
|
||||
|
||||
```python
|
||||
RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Modifier la lecture dans la route**
|
||||
|
||||
Dans `src/api/routes.py`, fonction `data()`, remplacer :
|
||||
|
||||
```python
|
||||
count = request.args.get("count", "true").lower() != "false"
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
count_results = request.args.get("count_results", "true").lower() != "false"
|
||||
```
|
||||
|
||||
et plus bas remplacer `if count else None` par `if count_results else None`.
|
||||
|
||||
- [ ] **Step 5 : Mettre à jour la doc swagger du param**
|
||||
|
||||
Dans `src/api/routes.py`, dans `@bp.doc(parameters=[...])`, remplacer le bloc du paramètre `count` par :
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "count_results",
|
||||
"in": "query",
|
||||
"schema": {"type": "string", "enum": ["true", "false"], "default": "true"},
|
||||
"description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.",
|
||||
},
|
||||
```
|
||||
|
||||
Et dans le docstring de `data()`, remplacer la mention `count (true|false ...)` par `count_results (true|false ; mettre false pour économiser le COUNT(*))`.
|
||||
|
||||
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_endpoints_data.py tests/api/test_filters.py -v`
|
||||
Expected: PASS (tous).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/filters.py src/api/routes.py tests/api/test_filters.py tests/api/test_endpoints_data.py
|
||||
git commit -m "feat(api): renomme le param réservé count en count_results (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Opérateur de filtre `differs`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/api/filters.py` (`OPERATORS`, `build_where`)
|
||||
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `build_where(args, schema) -> (where_sql, params, order_sql)` (existant).
|
||||
- Produces: `col__differs=val` → fragment SQL `"col" IS DISTINCT FROM ?`.
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
|
||||
|
||||
Dans `tests/api/test_filters.py` :
|
||||
|
||||
```python
|
||||
def test_differs_filter():
|
||||
where, params, _ = build_where([("uid__differs", "abc")], SCHEMA)
|
||||
assert where == '"uid" IS DISTINCT FROM ?'
|
||||
assert params == ["abc"]
|
||||
|
||||
|
||||
def test_differs_filter_on_int():
|
||||
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
|
||||
assert where == '"annee" IS DISTINCT FROM ?'
|
||||
assert params == [2020]
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_filters.py::test_differs_filter tests/api/test_filters.py::test_differs_filter_on_int -v`
|
||||
Expected: FAIL (`FilterError: Opérateur inconnu : __differs`).
|
||||
|
||||
- [ ] **Step 3 : Ajouter `differs` à `OPERATORS`**
|
||||
|
||||
Dans `src/api/filters.py`, ajouter `"differs",` dans le set `OPERATORS` (après `"notcontains",`).
|
||||
|
||||
- [ ] **Step 4 : Ajouter la branche dans `build_where`**
|
||||
|
||||
Dans `src/api/filters.py`, dans `build_where`, après le bloc `elif op == "notcontains":` (lignes ~132-134), ajouter :
|
||||
|
||||
```python
|
||||
elif op == "differs":
|
||||
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
|
||||
params.append(v)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer les tests unitaires, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_filters.py -k differs -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6 : Ajouter un test d'endpoint**
|
||||
|
||||
Dans `tests/api/test_endpoints_data.py` :
|
||||
|
||||
```python
|
||||
def test_data_differs_excludes_value(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
|
||||
uid = base["data"][0]["uid"]
|
||||
resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert all(row["uid"] != uid for row in body["data"])
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Lancer, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_differs_excludes_value -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/filters.py tests/api/test_filters.py tests/api/test_endpoints_data.py
|
||||
git commit -m "feat(api): ajoute l'opérateur de filtre differs (IS DISTINCT FROM) (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Parsing des agrégateurs (`parse_aggregators`)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/api/filters.py` (constantes `AGGREGATORS`/`AGG_SQL`, dataclass `AggregationSpec`, fonction `parse_aggregators`, skip dans `build_where`)
|
||||
- Test: `tests/api/test_filters.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `_split_key`, `FilterError` (existants).
|
||||
- Produces:
|
||||
|
||||
- `AGGREGATORS: set[str]` = `{"groupby","count","sum","avg","min","max"}`.
|
||||
- `@dataclass class AggregationSpec: select_sql: str; group_by_sql: str | None`.
|
||||
- `parse_aggregators(args: list[tuple[str,str]], schema: pl.Schema) -> AggregationSpec | None` — `None` si aucun agrégateur.
|
||||
- `build_where` ignore désormais les clés dont l'opérateur ∈ `AGGREGATORS`.
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
|
||||
|
||||
Dans `tests/api/test_filters.py`, ajouter l'import et les tests :
|
||||
|
||||
```python
|
||||
from src.api.filters import AggregationSpec, parse_aggregators
|
||||
|
||||
|
||||
def test_parse_aggregators_none_when_absent():
|
||||
assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None
|
||||
|
||||
|
||||
def test_parse_aggregators_groupby_and_count():
|
||||
spec = parse_aggregators(
|
||||
[("annee__groupby", ""), ("uid__count", "")], SCHEMA
|
||||
)
|
||||
assert isinstance(spec, AggregationSpec)
|
||||
assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"'
|
||||
assert spec.group_by_sql == '"annee"'
|
||||
|
||||
|
||||
def test_parse_aggregators_multiple_aggregates():
|
||||
spec = parse_aggregators(
|
||||
[
|
||||
("annee__groupby", ""),
|
||||
("montant__sum", ""),
|
||||
("montant__avg", ""),
|
||||
("montant__min", ""),
|
||||
("montant__max", ""),
|
||||
],
|
||||
SCHEMA,
|
||||
)
|
||||
assert spec.select_sql == (
|
||||
'"annee", SUM("montant") AS "montant__sum", '
|
||||
'AVG("montant") AS "montant__avg", '
|
||||
'MIN("montant") AS "montant__min", '
|
||||
'MAX("montant") AS "montant__max"'
|
||||
)
|
||||
assert spec.group_by_sql == '"annee"'
|
||||
|
||||
|
||||
def test_parse_aggregators_global_without_groupby():
|
||||
spec = parse_aggregators([("uid__count", "")], SCHEMA)
|
||||
assert spec.select_sql == 'COUNT("uid") AS "uid__count"'
|
||||
assert spec.group_by_sql is None
|
||||
|
||||
|
||||
def test_parse_aggregators_unknown_column_raises():
|
||||
with pytest.raises(FilterError):
|
||||
parse_aggregators([("nope__count", "")], SCHEMA)
|
||||
|
||||
|
||||
def test_build_where_ignores_aggregator_flags():
|
||||
where, params, _ = build_where(
|
||||
[("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")],
|
||||
SCHEMA,
|
||||
)
|
||||
assert where == '"montant" >= ?'
|
||||
assert params == [100.0]
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_filters.py -k aggregator -v`
|
||||
Expected: FAIL (`ImportError: cannot import name 'parse_aggregators'`).
|
||||
|
||||
- [ ] **Step 3 : Ajouter constantes + dataclass + fonction**
|
||||
|
||||
Dans `src/api/filters.py`, ajouter en haut l'import `from dataclasses import dataclass` (après les imports existants), puis après `RESERVED_PARAMS` :
|
||||
|
||||
```python
|
||||
AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"}
|
||||
AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregationSpec:
|
||||
select_sql: str
|
||||
group_by_sql: str | None
|
||||
|
||||
|
||||
def parse_aggregators(
|
||||
args: list[tuple[str, str]], schema: pl.Schema
|
||||
) -> AggregationSpec | None:
|
||||
"""Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...).
|
||||
|
||||
Retourne None si aucun agrégateur. Sinon, construit les fragments SQL
|
||||
`select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma).
|
||||
"""
|
||||
group_cols: list[str] = []
|
||||
aggregates: list[tuple[str, str]] = [] # (operator, column)
|
||||
has_agg = False
|
||||
|
||||
for key, _ in args:
|
||||
parsed = _split_key(key)
|
||||
if not parsed:
|
||||
continue
|
||||
col, op = parsed
|
||||
if op not in AGGREGATORS:
|
||||
continue
|
||||
has_agg = True
|
||||
if col not in schema:
|
||||
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
|
||||
if op == "groupby":
|
||||
group_cols.append(col)
|
||||
else:
|
||||
aggregates.append((op, col))
|
||||
|
||||
if not has_agg:
|
||||
return None
|
||||
|
||||
select_parts = [f'"{c}"' for c in group_cols]
|
||||
for op, col in aggregates:
|
||||
select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"')
|
||||
|
||||
group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None
|
||||
return AggregationSpec(select_sql=", ".join(select_parts), group_by_sql=group_by_sql)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Faire ignorer les drapeaux d'agrégation par `build_where`**
|
||||
|
||||
Dans `src/api/filters.py`, dans `build_where`, juste après `col, op = parsed` (et avant `if op not in OPERATORS:`), ajouter :
|
||||
|
||||
```python
|
||||
if op in AGGREGATORS:
|
||||
continue
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_filters.py -v`
|
||||
Expected: PASS (tous, anciens et nouveaux).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/filters.py tests/api/test_filters.py
|
||||
git commit -m "feat(api): parsing des opérateurs d'agrégation (groupby/count/sum/avg/min/max) (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : `aggregate_marches` dans la couche DB
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/db.py` (nouvelle fonction `aggregate_marches`)
|
||||
- Test: `tests/api/test_db_aggregate.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_cursor()`, `logger` (existants dans `src/db.py`).
|
||||
- Produces: `aggregate_marches(select_sql: str, where_sql: str = "TRUE", params: tuple | list = (), group_by: str | None = None, limit: int | None = None, offset: int | None = None) -> pl.DataFrame`.
|
||||
|
||||
- [ ] **Step 1 : Écrire le test (échec attendu)**
|
||||
|
||||
Créer `tests/api/test_db_aggregate.py` :
|
||||
|
||||
```python
|
||||
import polars as pl
|
||||
|
||||
from src.db import aggregate_marches
|
||||
|
||||
|
||||
def test_aggregate_groupby_count_returns_named_columns():
|
||||
df = aggregate_marches(
|
||||
select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"',
|
||||
group_by='"acheteur_departement_code"',
|
||||
)
|
||||
assert isinstance(df, pl.DataFrame)
|
||||
assert df.columns == ["acheteur_departement_code", "uid__count"]
|
||||
assert df["uid__count"].sum() > 0
|
||||
|
||||
|
||||
def test_aggregate_global_without_groupby_returns_one_row():
|
||||
df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"')
|
||||
assert df.height == 1
|
||||
assert df["uid__count"][0] > 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
|
||||
Expected: FAIL (`ImportError: cannot import name 'aggregate_marches'`).
|
||||
|
||||
- [ ] **Step 3 : Implémenter `aggregate_marches`**
|
||||
|
||||
Dans `src/db.py`, après `count_marches` (vers la ligne 186), ajouter :
|
||||
|
||||
```python
|
||||
def aggregate_marches(
|
||||
select_sql: str,
|
||||
where_sql: str = "TRUE",
|
||||
params: tuple | list = (),
|
||||
group_by: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""SELECT agrégé paramétré contre la table decp.
|
||||
|
||||
`select_sql` et `group_by` sont des fragments SQL construits depuis des
|
||||
noms de colonnes validés (jamais de valeur utilisateur libre). Les
|
||||
valeurs de filtre passent par le binding `?` via `params`.
|
||||
"""
|
||||
sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}"
|
||||
if group_by:
|
||||
sql += f" GROUP BY {group_by}"
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
if offset is not None:
|
||||
sql += f" OFFSET {int(offset)}"
|
||||
|
||||
logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params))
|
||||
|
||||
return get_cursor().execute(sql, list(params)).pl()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/db.py tests/api/test_db_aggregate.py
|
||||
git commit -m "feat(db): aggregate_marches pour les requêtes GROUP BY (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : Orchestration du mode agrégation dans la route
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/api/routes.py` (fonction `data()`)
|
||||
- Test: `tests/api/test_endpoints_data.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `parse_aggregators` (Task 3), `aggregate_marches` (Task 4), `build_where` (existant), `AggregationSpec`.
|
||||
- Produces: l'endpoint `/api/v1/data` renvoie des lignes agrégées quand un opérateur d'agrégation est présent ; `meta` sans `total` ; `columns` + agrégation → 400.
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests d'endpoint (échec attendu)**
|
||||
|
||||
Dans `tests/api/test_endpoints_data.py` :
|
||||
|
||||
```python
|
||||
def test_data_aggregation_groupby_count(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
resp = client.get(
|
||||
"/api/v1/data?acheteur_departement_code__groupby&uid__count",
|
||||
headers=valid_token_header,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["data"], "agrégation vide ?"
|
||||
for row in body["data"]:
|
||||
assert set(row.keys()) == {"acheteur_departement_code", "uid__count"}
|
||||
assert "total" not in body["meta"]
|
||||
|
||||
|
||||
def test_data_aggregation_global_count(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
resp = client.get("/api/v1/data?uid__count", headers=valid_token_header)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert len(body["data"]) == 1
|
||||
assert "uid__count" in body["data"][0]
|
||||
|
||||
|
||||
def test_data_aggregation_with_filter(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
resp = client.get(
|
||||
"/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0",
|
||||
headers=valid_token_header,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header):
|
||||
client, _ = api_client
|
||||
resp = client.get(
|
||||
"/api/v1/data?uid__count&columns=uid",
|
||||
headers=valid_token_header,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_endpoints_data.py -k aggregation -v`
|
||||
Expected: FAIL (les drapeaux d'agrégation sont ignorés → réponse non agrégée, clés inattendues / pas de 400).
|
||||
|
||||
- [ ] **Step 3 : Mettre à jour les imports de la route**
|
||||
|
||||
Dans `src/api/routes.py`, remplacer la ligne d'import des filtres :
|
||||
|
||||
```python
|
||||
from src.api.filters import FilterError, build_where
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.api.filters import FilterError, build_where, parse_aggregators
|
||||
from src.db import aggregate_marches
|
||||
```
|
||||
|
||||
(et conserver l'import existant `from src.db import count_marches, query_marches`).
|
||||
|
||||
- [ ] **Step 4 : Brancher le chemin agrégation dans `data()`**
|
||||
|
||||
Dans `src/api/routes.py`, fonction `data()`, remplacer le bloc qui va de `try:` (parsing `build_where`) jusqu'au `return {...}` final par :
|
||||
|
||||
```python
|
||||
args = list(request.args.items(multi=True))
|
||||
try:
|
||||
agg = parse_aggregators(args, duckdb_schema)
|
||||
where_sql, params, order_sql = build_where(args, duckdb_schema)
|
||||
except FilterError as e:
|
||||
abort(400, message=str(e), errors={"field": e.field})
|
||||
|
||||
if agg is not None:
|
||||
if columns:
|
||||
abort(
|
||||
400,
|
||||
message="`columns` ne peut pas être combiné avec une agrégation",
|
||||
)
|
||||
df = aggregate_marches(
|
||||
select_sql=agg.select_sql,
|
||||
where_sql=where_sql,
|
||||
params=params,
|
||||
group_by=agg.group_by_sql,
|
||||
limit=page_size,
|
||||
offset=(page - 1) * page_size,
|
||||
)
|
||||
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||
return {
|
||||
"data": df_ready.to_dicts(),
|
||||
"meta": {"page": page, "page_size": page_size},
|
||||
"links": _build_links(page, page_size, None),
|
||||
}
|
||||
|
||||
df = query_marches(
|
||||
where_sql=where_sql,
|
||||
params=params,
|
||||
columns=columns,
|
||||
order_by=order_sql,
|
||||
limit=page_size,
|
||||
offset=(page - 1) * page_size,
|
||||
)
|
||||
|
||||
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
|
||||
df_ready = df.with_columns(cs.temporal().cast(pl.String))
|
||||
|
||||
total = count_marches(where_sql, params) if count_results else None
|
||||
meta = {"page": page, "page_size": page_size}
|
||||
if total is not None:
|
||||
meta["total"] = total
|
||||
|
||||
return {
|
||||
"data": df_ready.to_dicts(),
|
||||
"meta": meta,
|
||||
"links": _build_links(page, page_size, total),
|
||||
}
|
||||
```
|
||||
|
||||
(Note : `count_results` provient de la Task 1 ; `columns`, `page`, `page_size` sont déjà calculés plus haut dans la fonction.)
|
||||
|
||||
- [ ] **Step 5 : Lancer les tests d'endpoint, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
|
||||
Expected: PASS (tous).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/routes.py tests/api/test_endpoints_data.py
|
||||
git commit -m "feat(api): mode agrégation sur /data (groupby + agrégats) (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 : Documentation Swagger des mots-clés
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/api/routes.py` (bloc `@bp.doc` du param dynamique + docstring de `data()`)
|
||||
- Test: `tests/api/test_openapi_doc.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: l'OpenAPI généré, servi sur `/api/v1/openapi.json`.
|
||||
- Produces: la description du paramètre `<colonne>__<opérateur>` liste tous les opérateurs (filtres + agrégation) avec une définition d'une ligne chacun, et décrit le mode agrégation.
|
||||
|
||||
- [ ] **Step 1 : Écrire le test (échec attendu)**
|
||||
|
||||
Créer `tests/api/test_openapi_doc.py` :
|
||||
|
||||
```python
|
||||
def test_openapi_documents_new_keywords(api_client):
|
||||
client, _ = api_client
|
||||
resp = client.get("/api/v1/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
raw = resp.get_data(as_text=True)
|
||||
for keyword in ["count_results", "differs", "groupby", "__sum", "__avg", "__min", "__max"]:
|
||||
assert keyword in raw, f"{keyword} absent de la doc OpenAPI"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
|
||||
Expected: FAIL (`differs`, `groupby`, etc. absents de la description).
|
||||
|
||||
- [ ] **Step 3 : Étoffer la description du paramètre dynamique**
|
||||
|
||||
Dans `src/api/routes.py`, remplacer la `description` du paramètre `<colonne>__<opérateur>` par :
|
||||
|
||||
```python
|
||||
"description": (
|
||||
"Filtre ou agrégation dynamique : `<colonne>__<opérateur>` "
|
||||
"(voir les colonnes via `/schema`).\n\n"
|
||||
"**Filtres** (`<colonne>__<op>=<valeur>`) :\n"
|
||||
"- `exact` : égal à la valeur\n"
|
||||
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
|
||||
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n"
|
||||
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
|
||||
"- `less` / `greater` : ≤ / ≥\n"
|
||||
"- `strictly_less` / `strictly_greater` : < / >\n"
|
||||
"- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n"
|
||||
"- `sort` : tri, valeur `asc` ou `desc`\n\n"
|
||||
"**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n"
|
||||
"- `groupby` : regroupe sur la colonne\n"
|
||||
"- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; "
|
||||
"la colonne de sortie est nommée `colonne__count`, `colonne__sum`, "
|
||||
"`colonne__avg`, `colonne__min`, `colonne__max`\n\n"
|
||||
"En mode agrégation, la réponse contient des lignes groupées, "
|
||||
"`columns` est interdit et `meta` ne contient pas `total`.\n\n"
|
||||
"Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, "
|
||||
"`acheteur_departement_code__groupby&montant__sum`."
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Mettre à jour le docstring de `data()`**
|
||||
|
||||
Dans `src/api/routes.py`, remplacer le docstring de `data()` par :
|
||||
|
||||
```python
|
||||
"""Récupère des marchés publics filtrés, triés ou agrégés.
|
||||
|
||||
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
|
||||
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin,
|
||||
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort.
|
||||
|
||||
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
|
||||
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
|
||||
`<colonne>__<opérateur>`. `columns` est interdit avec une agrégation et
|
||||
`meta` ne contient alors pas `total`.
|
||||
|
||||
Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000),
|
||||
columns (csv), count_results (true|false ; mettre false pour économiser
|
||||
le COUNT(*)).
|
||||
|
||||
Exemple d'agrégation :
|
||||
`?acheteur_departement_code__groupby&uid__count&montant__sum`
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Lancer, vérifier le succès**
|
||||
|
||||
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6 : Vérifier la non-régression complète de l'API**
|
||||
|
||||
Run: `rtk pytest tests/api/ -v`
|
||||
Expected: PASS (tous).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/routes.py tests/api/test_openapi_doc.py
|
||||
git commit -m "docs(api): documente les opérateurs (filtres + agrégation) dans Swagger (#78)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes de vérification de référence (manuel, hors tests automatisés)
|
||||
|
||||
Après implémentation, vérifier que quelques requêtes d'agrégation renvoient
|
||||
des valeurs cohérentes avec data.gouv.fr sur la même ressource DECP
|
||||
(`22847056-61df-452d-837d-8b8ceadbfc52`), aux différences de fraîcheur près :
|
||||
|
||||
```
|
||||
GET /api/v1/data?acheteur_departement_code__groupby&uid__count&montant__sum
|
||||
```
|
||||
|
||||
à comparer à :
|
||||
|
||||
```
|
||||
https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/?acheteur_departement_code__groupby&uid__count&montant__sum
|
||||
```
|
||||
@@ -0,0 +1,426 @@
|
||||
# Tuile « Considérations sociales et environnementales » — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ajouter dans `/observatoire`, juste après la tuile « Type d'achat », une tuile montrant via deux barres de progression la part des marchés filtrés comportant au moins une considération sociale (rouge) et au moins une considération environnementale (vert).
|
||||
|
||||
**Architecture:** Une fonction pure de calcul (`compute_considerations_stats`) dans `src/figures.py`, testée directement, alimente une fonction de rendu (`get_considerations_card_content`) qui produit un `html.Div` de deux `dbc.Progress`. La tuile est ajoutée dans `_compute_dashboard_children` via le `make_card` existant.
|
||||
|
||||
**Tech Stack:** Polars (LazyFrame), Dash / Dash Bootstrap Components (`dbc.Progress`), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `from src.figures import ...`), jamais `figures` ou `utils`.
|
||||
- Dédoublonnage **par `uid`** : un marché compté une fois (première valeur par `uid`).
|
||||
- « Au moins une considération » = la valeur de colonne **contient** (insensible casse) `Clause`, `Critère` ou `Marché réservé`. Regex exacte : `(?i)Clause|Critère|Marché réservé`.
|
||||
- Dénominateur = **tous** les marchés filtrés (uid distincts), y compris `Sans objet` et non renseignés.
|
||||
- `pct = round(100 * numérateur / dénominateur)` ; si dénominateur = 0 → `pct = 0`.
|
||||
- Couleurs issues de `px.colors.qualitative.Safe` : sociales = `rgb(204, 102, 119)` (index 1, rouge) ; environnementales = `rgb(17, 119, 51)` (index 3, vert).
|
||||
- Robustesse : si une colonne `considerations*` est absente du schéma, son `(count, pct)` vaut `(0, 0)` sans exception.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fonction de calcul `compute_considerations_stats`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py` (ajouter la fonction après `get_dashboard_summary_table`, ~ ligne 729)
|
||||
- Test: `tests/test_figures.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien (fonction pure prenant un `pl.LazyFrame`).
|
||||
- Produces: `compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]` renvoyant `{"sociales": (count, pct), "environnementales": (count, pct)}` où `count` = nombre de marchés (uid distincts) avec au moins une considération et `pct` = pourcentage entier sur le total des uid distincts.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Créer `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
import polars as pl
|
||||
|
||||
|
||||
def _make_lff(rows):
|
||||
return pl.LazyFrame(rows)
|
||||
|
||||
|
||||
def test_compute_considerations_stats_basic():
|
||||
from src.figures import compute_considerations_stats
|
||||
|
||||
lff = _make_lff(
|
||||
[
|
||||
# uid u1 : social oui (Clause), env non (Sans objet)
|
||||
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||
# uid u2 : social non (Sans objet), env oui (Critère)
|
||||
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
|
||||
# uid u3 : social oui (Marché réservé compte), env null
|
||||
{"uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None},
|
||||
# uid u4 : aucune considération
|
||||
{"uid": "u4", "considerationsSociales": "Pas de considération sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||
]
|
||||
)
|
||||
|
||||
stats = compute_considerations_stats(lff)
|
||||
|
||||
# 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%.
|
||||
assert stats["sociales"] == (2, 50)
|
||||
assert stats["environnementales"] == (1, 25)
|
||||
|
||||
|
||||
def test_compute_considerations_stats_dedup_per_uid():
|
||||
from src.figures import compute_considerations_stats
|
||||
|
||||
lff = _make_lff(
|
||||
[
|
||||
# uid u1 présent 2 fois (2 titulaires) -> compté une seule fois
|
||||
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Sans objet"},
|
||||
]
|
||||
)
|
||||
|
||||
stats = compute_considerations_stats(lff)
|
||||
|
||||
# 2 marchés distincts. Social : u1 -> 1/2 = 50%.
|
||||
assert stats["sociales"] == (1, 50)
|
||||
assert stats["environnementales"] == (0, 0)
|
||||
|
||||
|
||||
def test_compute_considerations_stats_missing_column():
|
||||
from src.figures import compute_considerations_stats
|
||||
|
||||
lff = _make_lff(
|
||||
[
|
||||
{"uid": "u1", "considerationsSociales": "Clause sociale"},
|
||||
{"uid": "u2", "considerationsSociales": "Sans objet"},
|
||||
]
|
||||
)
|
||||
|
||||
stats = compute_considerations_stats(lff)
|
||||
|
||||
# Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%.
|
||||
assert stats["sociales"] == (1, 50)
|
||||
assert stats["environnementales"] == (0, 0)
|
||||
|
||||
|
||||
def test_compute_considerations_stats_empty():
|
||||
from src.figures import compute_considerations_stats
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
{
|
||||
"uid": pl.Series([], dtype=pl.String),
|
||||
"considerationsSociales": pl.Series([], dtype=pl.String),
|
||||
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
|
||||
}
|
||||
)
|
||||
|
||||
stats = compute_considerations_stats(lff)
|
||||
|
||||
assert stats["sociales"] == (0, 0)
|
||||
assert stats["environnementales"] == (0, 0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'compute_considerations_stats'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/figures.py`, ajouter après `get_dashboard_summary_table` (avant `make_card`) :
|
||||
|
||||
```python
|
||||
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
|
||||
CONSIDERATIONS_COLUMNS = {
|
||||
"sociales": "considerationsSociales",
|
||||
"environnementales": "considerationsEnvironnementales",
|
||||
}
|
||||
|
||||
|
||||
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]:
|
||||
"""Part des marchés (uid distincts) ayant au moins une considération.
|
||||
|
||||
Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}.
|
||||
Dénominateur = tous les uid distincts. Colonne absente -> (0, 0).
|
||||
"""
|
||||
names = lff.collect_schema().names()
|
||||
present = {
|
||||
key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names
|
||||
}
|
||||
|
||||
stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS}
|
||||
|
||||
if not present:
|
||||
return stats
|
||||
|
||||
agg = (
|
||||
lff.select(["uid"] + list(present.values()))
|
||||
.group_by("uid")
|
||||
.agg([pl.col(col).first() for col in present.values()])
|
||||
.collect(engine="streaming")
|
||||
)
|
||||
|
||||
total = agg.height
|
||||
if total == 0:
|
||||
return stats
|
||||
|
||||
for key, col in present.items():
|
||||
count = agg.filter(
|
||||
pl.col(col).str.contains(CONSIDERATIONS_REGEX)
|
||||
).height
|
||||
pct = round(100 * count / total)
|
||||
stats[key] = (count, pct)
|
||||
|
||||
return stats
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: 4 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "feat(observatoire): calcul part marchés avec considération sociale/env
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rendu de la tuile `get_considerations_card_content`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py` (ajouter après `compute_considerations_stats`)
|
||||
- Test: `tests/test_figures.py` (ajouter au fichier de la Task 1)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `compute_considerations_stats(lff) -> dict[str, tuple[int, int]]` (Task 1) ; `format_number` (déjà importé en tête de `src/figures.py` : `from src.utils.table import add_links, format_number, setup_table_columns`).
|
||||
- Produces: `get_considerations_card_content(lff: pl.LazyFrame) -> html.Div` : un `html.Div` contenant deux blocs (sociales, environnementales), chacun avec un libellé, un `dbc.Progress` coloré rempli au pourcentage, et le nombre de marchés.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter à `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
def test_get_considerations_card_content_returns_two_progress_bars():
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import html
|
||||
|
||||
from src.figures import get_considerations_card_content
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
[
|
||||
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
|
||||
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
|
||||
]
|
||||
)
|
||||
|
||||
div = get_considerations_card_content(lff)
|
||||
|
||||
assert isinstance(div, html.Div)
|
||||
|
||||
# Récupère récursivement tous les dbc.Progress
|
||||
def find_progress(component, found):
|
||||
children = getattr(component, "children", None)
|
||||
if isinstance(component, dbc.Progress):
|
||||
found.append(component)
|
||||
if isinstance(children, (list, tuple)):
|
||||
for c in children:
|
||||
find_progress(c, found)
|
||||
elif children is not None:
|
||||
find_progress(children, found)
|
||||
return found
|
||||
|
||||
bars = find_progress(div, [])
|
||||
assert len(bars) == 2
|
||||
|
||||
# Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%.
|
||||
social_bar, env_bar = bars[0], bars[1]
|
||||
assert social_bar.value == 50
|
||||
assert social_bar.label == "50 %"
|
||||
assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)"
|
||||
assert env_bar.value == 50
|
||||
assert env_bar.label == "50 %"
|
||||
assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py::test_get_considerations_card_content_returns_two_progress_bars -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'get_considerations_card_content'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/figures.py`, ajouter après `compute_considerations_stats` :
|
||||
|
||||
```python
|
||||
CONSIDERATIONS_DISPLAY = [
|
||||
# (clé, libellé, couleur Safe)
|
||||
("sociales", "Sociales", "rgb(204, 102, 119)"),
|
||||
("environnementales", "Environnementales", "rgb(17, 119, 51)"),
|
||||
]
|
||||
|
||||
|
||||
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
|
||||
"""Deux barres de progression : part des marchés avec considération."""
|
||||
stats = compute_considerations_stats(lff)
|
||||
|
||||
blocks = []
|
||||
for key, label, color in CONSIDERATIONS_DISPLAY:
|
||||
count, pct = stats[key]
|
||||
blocks.append(
|
||||
html.Div(
|
||||
className="mb-3",
|
||||
children=[
|
||||
html.Div(
|
||||
className="d-flex justify-content-between",
|
||||
children=[
|
||||
html.Span(label),
|
||||
html.Span(
|
||||
f"{format_number(count)} marchés",
|
||||
className="text-muted",
|
||||
),
|
||||
],
|
||||
),
|
||||
dbc.Progress(
|
||||
value=pct,
|
||||
label=f"{pct} %",
|
||||
style={"backgroundColor": color},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(children=blocks)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: tous les tests PASS (5 au total).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "feat(observatoire): tuile considérations en barres de progression
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Intégration dans l'Observatoire
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py` (import ~ lignes 20-31 ; appel dans `_compute_dashboard_children` après le bloc « Type d'achat », ~ ligne 723)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_considerations_card_content(lff) -> html.Div` (Task 2) ; `make_card` (déjà importé).
|
||||
- Produces: une nouvelle `dbc.Col` (card) insérée dans la liste `cards` entre « Type d'achat » et « Distance acheteur–titulaire ».
|
||||
|
||||
- [ ] **Step 1: Ajouter l'import**
|
||||
|
||||
Dans `src/pages/observatoire.py`, dans le bloc `from src.figures import (...)` (lignes 20-31), ajouter `get_considerations_card_content` en respectant l'ordre alphabétique existant (après `get_barchart_sources`) :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_barchart_sources,
|
||||
get_considerations_card_content,
|
||||
get_dashboard_summary_table,
|
||||
get_distance_histogram,
|
||||
get_duplicate_matrix,
|
||||
get_geographic_maps,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
make_donut,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Insérer la tuile après « Type d'achat »**
|
||||
|
||||
Dans `_compute_dashboard_children`, juste après le `cards.append(...)` du donut « Type d'achat » (qui se termine ligne ~723) et avant `distance_histogram = ...`, insérer :
|
||||
|
||||
```python
|
||||
considerations_content = get_considerations_card_content(lff)
|
||||
cards.append(
|
||||
make_card(
|
||||
title="Considérations sociales et environnementales",
|
||||
subtitle="part des marchés concernés",
|
||||
fig=considerations_content,
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
Le bloc résultant doit ressembler à :
|
||||
|
||||
```python
|
||||
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
|
||||
cards.append(
|
||||
make_card(
|
||||
title="Type d'achat",
|
||||
subtitle="en nombre de marchés attribués",
|
||||
fig=donut_marche_type,
|
||||
)
|
||||
)
|
||||
|
||||
considerations_content = get_considerations_card_content(lff)
|
||||
cards.append(
|
||||
make_card(
|
||||
title="Considérations sociales et environnementales",
|
||||
subtitle="part des marchés concernés",
|
||||
fig=considerations_content,
|
||||
)
|
||||
)
|
||||
|
||||
distance_histogram = get_distance_histogram(lff)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Vérifier que la page se charge (test d'import/rendu)**
|
||||
|
||||
Run: `uv run pytest tests/test_page_loads.py -v`
|
||||
Expected: PASS (aucune régression sur le chargement des pages). Si `tests/test_page_loads.py` ne couvre pas `/observatoire`, lancer en complément :
|
||||
|
||||
Run: `uv run python -c "import src.pages.observatoire"`
|
||||
Expected: aucune erreur d'import.
|
||||
|
||||
- [ ] **Step 4: Lancer l'ensemble de la suite figures + observatoire**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: tous PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py
|
||||
git commit -m "feat(observatoire): afficher la tuile considérations après Type d'achat
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
|
||||
- Définition « au moins une considération » (regex incl. `Marché réservé`) → Task 1, `CONSIDERATIONS_REGEX`, tests `basic`.
|
||||
- Dédoublonnage par `uid` → Task 1, test `dedup_per_uid`.
|
||||
- Dénominateur = tous les marchés filtrés → Task 1 (`total = agg.height`), tests.
|
||||
- Colonne absente → 0 % → Task 1, test `missing_column` ; cas vide → test `empty`.
|
||||
- Deux barres `dbc.Progress`, couleurs Safe rouge/vert, labels `XX %` + `N marchés` → Task 2.
|
||||
- Insertion après « Type d'achat », dimensions par défaut `make_card` → Task 3.
|
||||
- Hors périmètre (pas de tooltip, pas de filtre) → respecté, rien d'ajouté.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni.
|
||||
|
||||
**Type consistency :** `compute_considerations_stats` renvoie `dict[str, tuple[int, int]]` clés `sociales`/`environnementales`, consommé tel quel par `get_considerations_card_content` (Task 2) ; `get_considerations_card_content` renvoie `html.Div`, passé à `make_card(fig=...)` (Task 3). Cohérent.
|
||||
@@ -0,0 +1,321 @@
|
||||
# Excel Export Styling Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Améliorer les exports Excel de toutes les vues tableau : en-têtes stylisés (fond rouge `#b33821`, texte blanc, gras), largeurs de colonnes issues du `DataTable` commun, largeur minimale de 3.5 cm, et retour à la ligne automatique dans toutes les cellules.
|
||||
|
||||
**Architecture:** Une fonction utilitaire `write_styled_excel(df, buffer, worksheet)` centralisée dans `src/utils/table.py` crée le workbook xlsxwriter avec `default_format_properties: {text_wrap: True}`, puis délègue à `polars.DataFrame.write_excel` avec le `header_format` et les `column_widths` calculés. Les 6 callbacks de téléchargement appellent cette fonction à la place de `.write_excel()` direct.
|
||||
|
||||
**Tech Stack:** Python, Polars, xlsxwriter (déjà en dep), openpyxl (disponible, utilisé en test uniquement)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports de modules internes toujours via `src.` (ex. `from src.utils.table import …`)
|
||||
- `xlsxwriter` est en dep de prod — pas besoin de l'ajouter
|
||||
- `openpyxl` est disponible (dep transitive) — pas besoin de l'ajouter à `pyproject.toml`
|
||||
- Run tests : `uv run pytest` (pas `pytest` direct)
|
||||
- Couleur primaire de l'app : `#b33821`
|
||||
- Largeur minimale : 132 pixels (≈ 3.5 cm à 96 DPI)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Fichier | Action | Rôle |
|
||||
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/utils/table.py` | Modifier | Ajouter `import xlsxwriter`, constantes `_EXCEL_*`, fonction `write_styled_excel` |
|
||||
| `tests/test_excel.py` | Créer | Tests unitaires de `write_styled_excel` |
|
||||
| `src/pages/tableau.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_data` |
|
||||
| `src/pages/acheteur.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_acheteur_data` et `download_filtered_acheteur_data` |
|
||||
| `src/pages/titulaire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_titulaire_data` et `download_filtered_titulaire_data` |
|
||||
| `src/pages/observatoire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_observatoire` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : `write_styled_excel` dans `src/utils/table.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py:1` (ajouter import), `src/utils/table.py:559` (fin de fichier)
|
||||
- Create: `tests/test_excel.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` — exporté publiquement depuis `src.utils.table`
|
||||
|
||||
- [ ] **Step 1 : Créer `tests/test_excel.py` avec les tests en échec**
|
||||
|
||||
```python
|
||||
import io
|
||||
|
||||
import openpyxl
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from src.utils.table import write_styled_excel
|
||||
|
||||
|
||||
def test_write_styled_excel_header_bold_and_color():
|
||||
df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
cell = ws.cell(row=1, column=1)
|
||||
assert cell.font.bold is True
|
||||
assert cell.fill.fgColor.rgb == "FFB33821"
|
||||
assert cell.font.color.rgb == "FFFFFFFF"
|
||||
|
||||
|
||||
def test_write_styled_excel_data_cell_text_wrap():
|
||||
df = pl.DataFrame({"objet": ["marché A"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
cell = ws.cell(row=2, column=1)
|
||||
assert cell.alignment.wrap_text is True
|
||||
|
||||
|
||||
def test_write_styled_excel_known_column_wider_than_minimum():
|
||||
# "objet" a une largeur explicite (350px) > minimum (132px)
|
||||
# "autre" n'a pas de largeur explicite → reçoit le minimum
|
||||
df = pl.DataFrame({"autre": ["x"], "objet": ["y"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf)
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
ws = wb.active
|
||||
width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre"
|
||||
width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet"
|
||||
assert width_autre > 0
|
||||
assert width_objet > width_autre
|
||||
|
||||
|
||||
def test_write_styled_excel_custom_worksheet_name():
|
||||
df = pl.DataFrame({"col": ["val"]})
|
||||
buf = io.BytesIO()
|
||||
write_styled_excel(df, buf, worksheet="2025")
|
||||
buf.seek(0)
|
||||
wb = openpyxl.load_workbook(buf)
|
||||
assert "2025" in wb.sheetnames
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py -v
|
||||
```
|
||||
|
||||
Attendu : `ImportError` ou `AttributeError: module 'src.utils.table' has no attribute 'write_styled_excel'`
|
||||
|
||||
- [ ] **Step 3 : Ajouter `import xlsxwriter` dans `src/utils/table.py`**
|
||||
|
||||
Ligne 1 du fichier, après les imports existants. Le bloc imports actuel (lignes 1–14) devient :
|
||||
|
||||
```python
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import polars as pl
|
||||
import xlsxwriter
|
||||
from dash import no_update
|
||||
from polars import selectors as cs
|
||||
from unidecode import unidecode
|
||||
|
||||
from src.db import count_marches, count_unique_marches, query_marches, schema
|
||||
from src.utils import logger
|
||||
from src.utils.cache import cache
|
||||
from src.utils.data import DATA_SCHEMA
|
||||
from src.utils.frontend import get_button_properties
|
||||
from src.utils.tracking import track_search
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter les constantes et `write_styled_excel` à la fin de `src/utils/table.py`**
|
||||
|
||||
Après la ligne `COLUMNS = schema.names()` (actuellement dernière ligne, 559) :
|
||||
|
||||
```python
|
||||
|
||||
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
|
||||
_EXCEL_HEADER_FORMAT = {
|
||||
"bold": True,
|
||||
"bg_color": "#b33821",
|
||||
"font_color": "white",
|
||||
}
|
||||
_EXCEL_COLUMN_WIDTHS = {
|
||||
"objet": 350,
|
||||
"acheteur_nom": 250,
|
||||
"titulaire_nom": 250,
|
||||
"acheteur_id": 160,
|
||||
}
|
||||
|
||||
|
||||
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
|
||||
col_widths = {
|
||||
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
|
||||
for col in df.columns
|
||||
}
|
||||
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
|
||||
ws = wb.add_worksheet(worksheet)
|
||||
df.write_excel(
|
||||
workbook=wb,
|
||||
worksheet=ws,
|
||||
header_format=_EXCEL_HEADER_FORMAT,
|
||||
column_widths=col_widths,
|
||||
)
|
||||
wb.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py -v
|
||||
```
|
||||
|
||||
Attendu : 4 tests PASS
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py tests/test_excel.py
|
||||
git commit -m "feat: add write_styled_excel utility for styled Excel exports #83"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Mettre à jour les 6 callbacks de téléchargement
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/tableau.py:26-33,344-345`
|
||||
- Modify: `src/pages/acheteur.py:30-37,399-402,441-442`
|
||||
- Modify: `src/pages/titulaire.py:29-36,421-423,462-463`
|
||||
- Modify: `src/pages/observatoire.py:43,813-814`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` depuis Task 1
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour `src/pages/tableau.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 26–33) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
get_default_hidden_columns,
|
||||
invert_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_data` (lignes 344–345) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour `src/pages/acheteur.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 30–37) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_default_hidden_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_acheteur_data` (lignes 399–402) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(
|
||||
df_to_download,
|
||||
buffer,
|
||||
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_filtered_acheteur_data` (ligne 441–442) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Mettre à jour `src/pages/titulaire.py`**
|
||||
|
||||
Ajouter `write_styled_excel` à l'import existant (lignes 29–36) :
|
||||
|
||||
```python
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_default_hidden_columns,
|
||||
prepare_table_data,
|
||||
sort_table_data,
|
||||
write_styled_excel,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_titulaire_data` (lignes 421–423) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(
|
||||
df_to_download,
|
||||
buffer,
|
||||
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_filtered_titulaire_data` (lignes 462–463) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(lff.collect(engine="streaming"), buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Mettre à jour `src/pages/observatoire.py`**
|
||||
|
||||
Modifier l'import (ligne 43) :
|
||||
|
||||
```python
|
||||
from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data, write_styled_excel
|
||||
```
|
||||
|
||||
Remplacer le bloc `def to_bytes` dans `download_observatoire` (ligne 813–814) :
|
||||
|
||||
```python
|
||||
def to_bytes(buffer):
|
||||
write_styled_excel(dff, buffer)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier les tests existants et les nouveaux**
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_excel.py tests/test_main.py::test_003_tableau_download -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS (le test existant vérifie que le contenu base64 est non vide et que le nom de fichier commence par `decp_` — comportement inchangé)
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py src/pages/observatoire.py
|
||||
git commit -m "refactor: use write_styled_excel in all download callbacks #83"
|
||||
```
|
||||
@@ -0,0 +1,350 @@
|
||||
# Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page.
|
||||
|
||||
**Architecture:** Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes `position: sticky; top: 0`. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué.
|
||||
|
||||
**Tech Stack:** Dash 3.4 `dash_table.DataTable`, CSS (`src/assets/css/style.css`), JS vanilla auto-chargé depuis `src/assets/` (pattern existant : MutationObserver, cf. `dash_clientside.js`), tests Selenium (`dash[testing]` / `DashComposite`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Importer les modules de l'app avec le préfixe `src.` (ex. `src.figures`), jamais `figures`.
|
||||
- UI en français.
|
||||
- Cibler la classe partagée `marches_table` (présente sur les 4 pages : `/tableau`, `/acheteur`, `/titulaire`, `/observatoire`) — pas de duplication par page.
|
||||
- Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée).
|
||||
- Les commits référencent `#82`.
|
||||
- Le pre-commit hook lance `prettier` (CSS/JS/MD) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis recommiter.
|
||||
- Terminer chaque message de commit par : `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité | Action |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| `src/assets/css/style.css` | En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier |
|
||||
| `src/assets/table_hscroll.js` | Injecter la barre miroir au-dessus de chaque `.marches_table`, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render | Créer |
|
||||
| `tests/test_tableau_hscroll.py` | Test Selenium : barre miroir présente + en-tête sticky sur `/tableau` | Créer |
|
||||
| `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` | Consigner le verdict du spike (Task 1) | Modifier |
|
||||
|
||||
**Décision DOM clé :** la barre miroir est **injectée par JS** (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash `.dash-spreadsheet-container` (ou son parent direct), confirmé au spike.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Spike — vérifier le DOM réel et la technique sticky
|
||||
|
||||
**But :** lever l'inconnue principale (structure DOM de DataTable + compatibilité `position: sticky` des en-têtes avec un conteneur `overflow-x`). Produit un verdict écrit qui pilote les tâches suivantes.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` (section « Verdict du spike »)
|
||||
|
||||
- [ ] **Step 1 : Lancer l'app**
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python run.py
|
||||
```
|
||||
|
||||
Ouvrir `http://127.0.0.1:8050/tableau` dans le navigateur.
|
||||
|
||||
- [ ] **Step 2 : Inspecter le DOM du tableau**
|
||||
|
||||
Dans les DevTools, sur le tableau, relever et noter :
|
||||
|
||||
- l'élément qui porte la **largeur totale** du tableau (table `.cell-table`) et sa largeur (`scrollWidth`) vs. celle de son parent (`clientWidth`) → confirme le débordement ;
|
||||
- l'élément qui (le cas échéant) porte déjà un `overflow`/`overflow-x` (regarder `.dash-spreadsheet-container`, `.dash-spreadsheet-inner`) via l'onglet _Computed_ ;
|
||||
- le sélecteur exact de la ligne d'en-tête (attendu : `th.dash-header` dans un `tr`).
|
||||
|
||||
- [ ] **Step 3 : Tester l'hypothèse sticky en live**
|
||||
|
||||
Dans la console DevTools, appliquer à chaud :
|
||||
|
||||
```js
|
||||
document
|
||||
.querySelectorAll(
|
||||
".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner"
|
||||
)
|
||||
.forEach((e) => (e.style.overflow = "visible"));
|
||||
document.querySelectorAll(".marches_table th.dash-header").forEach((e) => {
|
||||
e.style.position = "sticky";
|
||||
e.style.top = "0";
|
||||
e.style.zIndex = "10";
|
||||
e.style.background = "#fff";
|
||||
});
|
||||
```
|
||||
|
||||
Scroller verticalement la page → **les en-têtes restent-ils collés en haut ?**
|
||||
|
||||
- [ ] **Step 4 : Consigner le verdict**
|
||||
|
||||
Ajouter une section « Verdict du spike » à la spec, répondant à :
|
||||
|
||||
- sélecteur exact de l'en-tête et du conteneur scrollable ;
|
||||
- l'astuce `overflow: visible` sur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ;
|
||||
- si **non** : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md
|
||||
git commit -m "docs: verdict spike DOM tableaux #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : En-têtes collants (CSS)
|
||||
|
||||
**But :** les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/assets/css/style.css` (ajouter après le bloc `.marches_table.stuck`, vers la ligne 360)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: sélecteurs confirmés au spike (Task 1) : `.marches_table th.dash-header`, conteneurs `.dash-spreadsheet-container` / `.dash-spreadsheet-inner`.
|
||||
- Produces: classe `marches_table` dont les conteneurs Dash internes sont en `overflow: visible` et dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir.
|
||||
|
||||
- [ ] **Step 1 : Écrire le CSS des en-têtes sticky**
|
||||
|
||||
Dans `src/assets/css/style.css`, ajouter :
|
||||
|
||||
```css
|
||||
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
|
||||
|
||||
/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */
|
||||
.marches_table .dash-spreadsheet-container,
|
||||
.marches_table .dash-spreadsheet-inner {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* En-têtes de colonnes collants en haut de la fenêtre */
|
||||
.marches_table th.dash-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #fff;
|
||||
}
|
||||
```
|
||||
|
||||
> Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit.
|
||||
|
||||
- [ ] **Step 2 : Vérifier dans le navigateur**
|
||||
|
||||
App lancée (`python run.py`), sur `/tableau` : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi `/acheteur` et `/observatoire`.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/assets/css/style.css
|
||||
git commit -m "feat(tableaux): en-têtes de colonnes collants #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Barre de défilement miroir (JS + CSS)
|
||||
|
||||
**But :** une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/assets/table_hscroll.js`
|
||||
- Modify: `src/assets/css/style.css` (styles de `.dt-hscroll`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: les `.marches_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`.
|
||||
- Produces: pour chaque `.marches_table`, un nœud `<div class="dt-hscroll">` inséré en première position, contenant `<div class="dt-hscroll-inner">` dont la largeur = largeur totale du tableau.
|
||||
|
||||
- [ ] **Step 1 : Écrire le CSS de la barre miroir**
|
||||
|
||||
Dans `src/assets/css/style.css`, à la suite du bloc de la Task 2 :
|
||||
|
||||
```css
|
||||
/* Barre de défilement horizontale miroir, collée en haut */
|
||||
.marches_table .dt-hscroll {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 11; /* au-dessus des en-têtes sticky */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.marches_table .dt-hscroll.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le JS de synchronisation**
|
||||
|
||||
Créer `src/assets/table_hscroll.js` :
|
||||
|
||||
```js
|
||||
// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Renvoie le conteneur réellement scrollable horizontalement du tableau.
|
||||
function getScrollEl(wrapper) {
|
||||
return wrapper.querySelector(".dash-spreadsheet-container") || wrapper;
|
||||
}
|
||||
|
||||
function setup(wrapper) {
|
||||
if (wrapper.dataset.hscrollReady === "1") return;
|
||||
const scrollEl = getScrollEl(wrapper);
|
||||
if (!scrollEl) return;
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "dt-hscroll is-hidden";
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "dt-hscroll-inner";
|
||||
bar.appendChild(inner);
|
||||
wrapper.insertBefore(bar, wrapper.firstChild);
|
||||
|
||||
let syncing = false;
|
||||
const onBar = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
scrollEl.scrollLeft = bar.scrollLeft;
|
||||
syncing = false;
|
||||
};
|
||||
const onTable = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
bar.scrollLeft = scrollEl.scrollLeft;
|
||||
syncing = false;
|
||||
};
|
||||
bar.addEventListener("scroll", onBar);
|
||||
scrollEl.addEventListener("scroll", onTable);
|
||||
|
||||
const refresh = () => {
|
||||
const total = scrollEl.scrollWidth;
|
||||
const visible = scrollEl.clientWidth;
|
||||
inner.style.width = total + "px";
|
||||
bar.classList.toggle("is-hidden", total <= visible + 1);
|
||||
bar.scrollLeft = scrollEl.scrollLeft;
|
||||
};
|
||||
|
||||
// Recalcule quand le tableau change (pagination, tri, filtre, données).
|
||||
const obs = new MutationObserver(() => refresh());
|
||||
obs.observe(scrollEl, { childList: true, subtree: true, attributes: true });
|
||||
window.addEventListener("resize", refresh);
|
||||
|
||||
wrapper.dataset.hscrollReady = "1";
|
||||
refresh();
|
||||
}
|
||||
|
||||
function scan() {
|
||||
document.querySelectorAll(".marches_table").forEach(setup);
|
||||
}
|
||||
|
||||
// Les tableaux apparaissent après le rendu Dash : observer le body.
|
||||
const rootObs = new MutationObserver(() => scan());
|
||||
rootObs.observe(document.body, { childList: true, subtree: true });
|
||||
scan();
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Vérifier dans le navigateur**
|
||||
|
||||
App lancée, sur `/tableau` : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que `/acheteur`, `/titulaire`, `/observatoire` (tableaux multiples) fonctionnent chacun indépendamment.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/assets/table_hscroll.js src/assets/css/style.css
|
||||
git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Test Selenium de non-régression
|
||||
|
||||
**But :** garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de `/tableau`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/test_tableau_hscroll.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: éléments DOM produits par Task 2 (`th.dash-header` sticky) et Task 3 (`.marches_table .dt-hscroll`).
|
||||
|
||||
- [ ] **Step 1 : Écrire le test**
|
||||
|
||||
Créer `tests/test_tableau_hscroll.py` (s'aligner sur le style des tests existants dans `tests/`, notamment l'usage de `dash_duo`/`DashComposite` et l'import de l'app) :
|
||||
|
||||
```python
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
|
||||
def test_tableau_hscroll_bar_present(dash_duo, start_app):
|
||||
"""La barre miroir est injectée et l'en-tête est collant sur /tableau."""
|
||||
dash_duo.wait_for_element(".marches_table", timeout=20)
|
||||
# Barre miroir injectée par table_hscroll.js
|
||||
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
|
||||
|
||||
header = dash_duo.find_element(".marches_table th.dash-header")
|
||||
position = header.value_of_css_property("position")
|
||||
assert position == "sticky"
|
||||
```
|
||||
|
||||
> Adapter les fixtures (`dash_duo`, `start_app`, navigation initiale vers `/tableau`) à ce qui existe déjà dans `tests/` : reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex. `tests/test_main.py`) plutôt que d'en inventer un.
|
||||
|
||||
- [ ] **Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS** (sanity)
|
||||
|
||||
```bash
|
||||
rtk pytest tests/test_tableau_hscroll.py -v
|
||||
```
|
||||
|
||||
Expected: PASS avec le JS/CSS en place.
|
||||
|
||||
- [ ] **Step 3 : Lancer la suite Selenium impactée**
|
||||
|
||||
```bash
|
||||
rtk pytest tests/test_main.py -v
|
||||
```
|
||||
|
||||
Expected: pas de régression (mêmes résultats qu'avant la branche).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_tableau_hscroll.py
|
||||
git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Couverture spec :**
|
||||
|
||||
- En-têtes sticky → Task 2. ✓
|
||||
- Barre miroir synchronisée en haut → Task 3. ✓
|
||||
- Masquage si pas de débordement → Task 3 (`is-hidden`). ✓
|
||||
- Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓
|
||||
- Plusieurs tableaux par page → Task 3 (`querySelectorAll` + `dataset.hscrollReady`). ✓
|
||||
- Contrainte overflow CSS → Task 1 (spike) + Task 2 (`overflow: visible`). ✓
|
||||
- Portée 4 pages via `marches_table` → ciblage CSS/JS global. ✓
|
||||
- Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓
|
||||
|
||||
**Placeholders :** les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers `tests/test_main.py` comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs.
|
||||
|
||||
**Cohérence des noms :** classes `dt-hscroll` / `dt-hscroll-inner` / `is-hidden` et flag `dataset.hscrollReady` utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). `marches_table` et `th.dash-header` cohérents entre Task 2 et Task 4.
|
||||
@@ -0,0 +1,452 @@
|
||||
# Migration des emails transactionnels vers Brevo — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer le transport SMTP/Flask-Mail des emails transactionnels (vérification d'adresse, reset de mot de passe) par l'API Brevo et ses templates hébergés, sans changer l'interface publique du mailer.
|
||||
|
||||
**Architecture:** `src/auth/mailer.py` est réécrit pour construire un client `Brevo` (SDK v5) et envoyer via `client.transactional_emails.send_transac_email(template_id, params, sender, to)`. Les deux fonctions publiques (`send_verification_email`, `send_reset_email`) gardent leur signature, donc `src/auth/routes.py` n'est pas touché. Seul l'appel d'init dans `setup.py` change (`init_mailer()` sans `app`).
|
||||
|
||||
**Tech Stack:** Python, Flask, SDK `brevo-python` v5 (`5.0.0rc1`), pytest, monkeypatch.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex: `from src.utils import logger`).
|
||||
- SDK épinglé strictement : `brevo-python==5.0.0rc1` (pré-release ; un specifier qui pointe exactement une rc autorise pip à l'installer sans `--pre` global).
|
||||
- API v5 vérifiée par introspection :
|
||||
- `from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem`
|
||||
- `from brevo.core.api_error import ApiError`
|
||||
- `Brevo(api_key: str, headers: dict | None = None)`
|
||||
- `client.transactional_emails.send_transac_email(template_id=int, params=dict, sender=SendTransacEmailRequestSender, to=[SendTransacEmailRequestToItem], headers=dict|None)`
|
||||
- `SendTransacEmailRequestSender(email=..., name=...)`, `SendTransacEmailRequestToItem(email=..., name=...)`
|
||||
- Tests unitaires hermétiques : aucun appel réseau (le client est monkeypatché).
|
||||
- Commits fréquents, un par tâche.
|
||||
- Lancer les tests avec `uv run pytest` (pas `source .venv/bin/activate`).
|
||||
- Le hook pre-commit (prettier/ruff) peut reformater des fichiers : si un commit échoue pour cause de reformatage, `git add` les fichiers modifiés et recommitter.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Fichier | Responsabilité | Action |
|
||||
| --------------------------------------- | ------------------------------------------ | ----------------------------------------- |
|
||||
| `pyproject.toml` | Dépendances | Modifier (swap flask-mail → brevo-python) |
|
||||
| `src/auth/mailer.py` | Envoi des emails transactionnels via Brevo | Réécrire |
|
||||
| `src/auth/setup.py` | Initialisation du blueprint auth | Modifier (1 ligne) |
|
||||
| `tests/auth/test_mailer.py` | Tests unitaires du mailer (mock) | Réécrire |
|
||||
| `tests/auth/test_mailer_integration.py` | Test d'intégration sandbox optionnel | Créer |
|
||||
| `.template.env` | Documentation des variables d'env | Modifier |
|
||||
| `src/auth/templates/emails/*` | Anciens templates Jinja | Supprimer |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Basculer la dépendance vers le SDK Brevo
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pyproject.toml:25`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: (rien)
|
||||
- Produces: le paquet `brevo` importable dans l'environnement.
|
||||
|
||||
- [ ] **Step 1: Remplacer la dépendance dans `pyproject.toml`**
|
||||
|
||||
Remplacer la ligne 25 :
|
||||
|
||||
```toml
|
||||
"flask-mail",
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```toml
|
||||
"brevo-python==5.0.0rc1",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Réinstaller les dépendances du projet**
|
||||
|
||||
Run: `uv pip install -e . --group=dev`
|
||||
Expected: installation réussie, `brevo-python 5.0.0rc1` installé, `flask-mail` retiré.
|
||||
|
||||
- [ ] **Step 3: Vérifier que le SDK s'importe avec les symboles attendus**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run python -c "from brevo import Brevo, SendTransacEmailRequestSender, SendTransacEmailRequestToItem; from brevo.core.api_error import ApiError; print('brevo v5 OK')"
|
||||
```
|
||||
|
||||
Expected: affiche `brevo v5 OK` sans erreur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml
|
||||
git commit -m "build(brevo): remplacer flask-mail par brevo-python v5 (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Réécrire le mailer pour utiliser l'API Brevo (TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tests/auth/test_mailer.py` (réécriture complète)
|
||||
- Modify: `src/auth/mailer.py` (réécriture complète)
|
||||
- Modify: `src/auth/setup.py:39`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `brevo.Brevo`, `brevo.SendTransacEmailRequestSender`, `brevo.SendTransacEmailRequestToItem`, `brevo.core.api_error.ApiError`, `src.utils.logger`.
|
||||
- Produces (interface publique, inchangée pour `routes.py`) :
|
||||
- `init_mailer() -> None`
|
||||
- `send_verification_email(email: str, token: str) -> None`
|
||||
- `send_reset_email(email: str, token: str) -> None`
|
||||
- Variables d'env lues : `BREVO_API_KEY`, `BREVO_SANDBOX`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM`, `MAIL_FROM_NAME`, `APP_BASE_URL`.
|
||||
|
||||
- [ ] **Step 1: Réécrire le test du mailer (mock du client Brevo)**
|
||||
|
||||
Remplacer **tout** le contenu de `tests/auth/test_mailer.py` par :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.auth import mailer
|
||||
|
||||
|
||||
class _FakeTransac:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def send_transac_email(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.transactional_emails = _FakeTransac()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(monkeypatch):
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(mailer, "_client", client)
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_RESET_ID", "22")
|
||||
monkeypatch.setenv("MAIL_FROM", "noreply@decp.info")
|
||||
monkeypatch.setenv("MAIL_FROM_NAME", "decp.info")
|
||||
return client
|
||||
|
||||
|
||||
def test_send_verification_email(fake_client):
|
||||
mailer.send_verification_email("a@b.c", "TOKEN123")
|
||||
calls = fake_client.transactional_emails.calls
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["template_id"] == 11
|
||||
assert call["to"][0].email == "a@b.c"
|
||||
assert call["sender"].email == "noreply@decp.info"
|
||||
assert "/auth/verify-email?token=TOKEN123" in call["params"]["link"]
|
||||
|
||||
|
||||
def test_send_reset_email(fake_client):
|
||||
mailer.send_reset_email("a@b.c", "RESET456")
|
||||
calls = fake_client.transactional_emails.calls
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["template_id"] == 22
|
||||
assert "reinitialiser-mot-de-passe?token=RESET456" in call["params"]["link"]
|
||||
|
||||
|
||||
def test_init_mailer_builds_client(monkeypatch):
|
||||
monkeypatch.setenv("BREVO_API_KEY", "test-key")
|
||||
monkeypatch.delenv("BREVO_SANDBOX", raising=False)
|
||||
monkeypatch.setattr(mailer, "_client", None)
|
||||
mailer.init_mailer()
|
||||
assert mailer._client is not None
|
||||
|
||||
|
||||
def test_send_without_init_raises(monkeypatch):
|
||||
monkeypatch.setattr(mailer, "_client", None)
|
||||
monkeypatch.setenv("BREVO_TEMPLATE_VERIFY_ID", "11")
|
||||
with pytest.raises(AssertionError):
|
||||
mailer.send_verification_email("a@b.c", "TOKEN123")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour les voir échouer**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer.py -v`
|
||||
Expected: FAIL — l'ancien `mailer.py` importe `flask_mail` (désinstallé en Task 1) et n'expose pas `_client` ; erreurs d'import / d'attribut.
|
||||
|
||||
- [ ] **Step 3: Réécrire `src/auth/mailer.py`**
|
||||
|
||||
Remplacer **tout** le contenu de `src/auth/mailer.py` par :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from brevo import (
|
||||
Brevo,
|
||||
SendTransacEmailRequestSender,
|
||||
SendTransacEmailRequestToItem,
|
||||
)
|
||||
from brevo.core.api_error import ApiError
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
_client: Brevo | None = None
|
||||
|
||||
|
||||
def init_mailer() -> None:
|
||||
"""Construit le client Brevo à partir des variables d'environnement."""
|
||||
global _client
|
||||
api_key = os.getenv("BREVO_API_KEY", "")
|
||||
sandbox = os.getenv("BREVO_SANDBOX", "").lower() == "true"
|
||||
headers = {"X-Sib-Sandbox": "drop"} if sandbox else None
|
||||
_client = Brevo(api_key=api_key, headers=headers)
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")
|
||||
|
||||
|
||||
def _sender() -> SendTransacEmailRequestSender:
|
||||
return SendTransacEmailRequestSender(
|
||||
email=os.getenv("MAIL_FROM", "noreply@decp.info"),
|
||||
name=os.getenv("MAIL_FROM_NAME", "decp.info"),
|
||||
)
|
||||
|
||||
|
||||
def _template_id(env_var: str) -> int:
|
||||
raw = os.getenv(env_var, "")
|
||||
if not raw:
|
||||
raise RuntimeError(f"{env_var} non défini (template Brevo)")
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _send_template(template_id: int, recipient: str, params: dict) -> None:
|
||||
assert _client is not None, "Mailer non initialisé (init_mailer() non appelé)"
|
||||
try:
|
||||
_client.transactional_emails.send_transac_email(
|
||||
template_id=template_id,
|
||||
params=params,
|
||||
sender=_sender(),
|
||||
to=[SendTransacEmailRequestToItem(email=recipient)],
|
||||
)
|
||||
except ApiError:
|
||||
logger.exception(
|
||||
"Échec d'envoi Brevo (template %s) à %s", template_id, recipient
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def send_verification_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/auth/verify-email?token={token}"
|
||||
_send_template(
|
||||
_template_id("BREVO_TEMPLATE_VERIFY_ID"), email, {"link": link}
|
||||
)
|
||||
|
||||
|
||||
def send_reset_email(email: str, token: str) -> None:
|
||||
link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
|
||||
_send_template(
|
||||
_template_id("BREVO_TEMPLATE_RESET_ID"), email, {"link": link}
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mettre à jour l'appel d'init dans `setup.py`**
|
||||
|
||||
Dans `src/auth/setup.py`, ligne 39, remplacer :
|
||||
|
||||
```python
|
||||
mailer.init_mailer(app)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
mailer.init_mailer()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer les tests du mailer pour les voir passer**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 6: Lancer toute la suite auth pour vérifier l'absence de régression**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS (aucune dépendance résiduelle à flask-mail ; `routes.py` inchangé fonctionne).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/mailer.py src/auth/setup.py tests/auth/test_mailer.py
|
||||
git commit -m "feat(brevo): envoyer les emails transactionnels via l'API Brevo v5 (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Nettoyer la configuration et les templates Jinja
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.template.env`
|
||||
- Delete: `src/auth/templates/emails/verify_email.html`, `verify_email.txt`, `reset_password.html`, `reset_password.txt` (tout le dossier `src/auth/templates/`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: (rien — `mailer.py` n'utilise plus de templates locaux après Task 2)
|
||||
- Produces: (rien)
|
||||
|
||||
- [ ] **Step 1: Vérifier que les variables legacy ne sont pas utilisées ailleurs**
|
||||
|
||||
Run: `grep -rn "SENDER_SERVER_DOMAIN\|LOGIN_EMAIL\|FROM_EMAIL\|TO_EMAIL" . --include=*.py`
|
||||
Expected: aucun résultat. (Si des résultats apparaissent hors `.template.env`, NE PAS supprimer ces variables et le signaler.)
|
||||
|
||||
- [ ] **Step 2: Mettre à jour `.template.env`**
|
||||
|
||||
Dans la section SMTP de `.template.env`, supprimer les lignes :
|
||||
|
||||
```
|
||||
SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP
|
||||
LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP
|
||||
FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From)
|
||||
TO_EMAIL="to@example.com" # adresse de destination des emails (To)
|
||||
# SMTP pour envoi d'emails (vérification email, reset mot de passe)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=True
|
||||
MAIL_SUPPRESS_SEND= # laisser vide pour hériter de DEVELOPMENT ; mettre false pour forcer l'envoi en mode dev
|
||||
```
|
||||
|
||||
et les remplacer par :
|
||||
|
||||
```
|
||||
# Brevo — envoi des emails transactionnels (vérification email, reset mot de passe)
|
||||
BREVO_API_KEY= # clé API transactionnelle Brevo
|
||||
BREVO_TEMPLATE_VERIFY_ID= # ID numérique du template "vérification d'adresse"
|
||||
BREVO_TEMPLATE_RESET_ID= # ID numérique du template "réinitialisation mot de passe"
|
||||
BREVO_SANDBOX= # mettre "true" pour valider sans délivrer (dev / intégration)
|
||||
MAIL_FROM=noreply@decp.info # expéditeur (doit être un expéditeur vérifié dans Brevo)
|
||||
MAIL_FROM_NAME=decp.info # nom d'expéditeur
|
||||
```
|
||||
|
||||
Conserver la variable `APP_BASE_URL` si elle est déjà présente ailleurs dans le fichier ; sinon l'ajouter :
|
||||
|
||||
```
|
||||
APP_BASE_URL=http://localhost:8050 # base des liens dans les emails
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Supprimer les templates Jinja d'email devenus inutiles**
|
||||
|
||||
Run: `git rm -r src/auth/templates`
|
||||
Expected: les 4 fichiers `emails/*.{html,txt}` sont supprimés.
|
||||
|
||||
- [ ] **Step 4: Vérifier qu'aucun code ne référence encore ces templates**
|
||||
|
||||
Run: `grep -rn "verify_email\|reset_password\|render_template\|jinja_loader\|MAIL_SUPPRESS_SEND\|flask_mail" src/ tests/ --include=*.py`
|
||||
Expected: aucun résultat.
|
||||
|
||||
- [ ] **Step 5: Relancer la suite auth complète**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .template.env src/auth/templates
|
||||
git commit -m "chore(brevo): retirer la config SMTP et les templates Jinja d'email (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Test d'intégration sandbox optionnel
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/auth/test_mailer_integration.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: interface publique `mailer.init_mailer()`, `mailer.send_verification_email(email, token)`.
|
||||
- Produces: un test `@pytest.mark.integration` skippé sauf si `BREVO_API_KEY` est défini.
|
||||
|
||||
- [ ] **Step 1: Créer le test d'intégration**
|
||||
|
||||
Créer `tests/auth/test_mailer_integration.py` :
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src.auth import mailer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("BREVO_API_KEY"),
|
||||
reason="BREVO_API_KEY absent — test d'intégration Brevo ignoré",
|
||||
)
|
||||
def test_send_verification_email_sandbox(monkeypatch):
|
||||
# Force le mode sandbox : Brevo valide la requête sans délivrer.
|
||||
monkeypatch.setenv("BREVO_SANDBOX", "true")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
mailer.init_mailer()
|
||||
# Ne doit pas lever : l'API Brevo accepte la requête en sandbox.
|
||||
mailer.send_verification_email(
|
||||
os.getenv("MAIL_FROM", "noreply@decp.info"), "INTEGRATION_TOKEN"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Enregistrer le marker `integration` (si absent)**
|
||||
|
||||
Vérifier la présence du marker dans `pyproject.toml` :
|
||||
|
||||
Run: `grep -n "integration" pyproject.toml`
|
||||
|
||||
Si aucune section `[tool.pytest.ini_options]` avec `markers` ne déclare `integration`, l'ajouter. Exemple à insérer/compléter dans `pyproject.toml` :
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"integration: tests touchant des services externes (Brevo) ; skippés par défaut en CI",
|
||||
]
|
||||
```
|
||||
|
||||
(Si la section existe déjà, n'ajouter que la ligne `integration: ...` dans la liste `markers` existante, sans dupliquer la section.)
|
||||
|
||||
- [ ] **Step 3: Vérifier que le test est bien collecté puis skippé sans clé**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_mailer_integration.py -v`
|
||||
Expected: 1 test SKIPPED (motif « BREVO_API_KEY absent »), aucun appel réseau.
|
||||
|
||||
- [ ] **Step 4: Vérifier que la suite par défaut reste verte**
|
||||
|
||||
Run: `uv run pytest tests/auth/ -v`
|
||||
Expected: PASS, avec le test d'intégration SKIPPED.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/auth/test_mailer_integration.py pyproject.toml
|
||||
git commit -m "test(brevo): test d'intégration sandbox optionnel (#87)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes de vérification finale (manuel, hors CI)
|
||||
|
||||
Avant de merger, vérification manuelle réelle :
|
||||
|
||||
1. Renseigner `.env` avec `BREVO_API_KEY`, `BREVO_TEMPLATE_VERIFY_ID`, `BREVO_TEMPLATE_RESET_ID`, `MAIL_FROM` (expéditeur vérifié Brevo) et `BREVO_SANDBOX=true`.
|
||||
2. Lancer `python run.py`, déclencher une inscription, vérifier dans les logs Brevo (tableau de bord) que la requête est reçue (sandbox = acceptée, non délivrée).
|
||||
3. Passer `BREVO_SANDBOX=` (vide), refaire une inscription avec une vraie adresse, confirmer la réception et que `{{ params.link }}` est correctement substitué dans le template.
|
||||
4. Confirmer la valeur du header sandbox (`X-Sib-Sandbox: drop`) si le comportement diffère de l'attendu.
|
||||
@@ -0,0 +1,852 @@
|
||||
# Connexion avec LinkedIn (OIDC) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre la création de compte / connexion via LinkedIn (OIDC) en parallèle de l'auth email+mot de passe existante.
|
||||
|
||||
**Architecture:** Authlib (intégration Flask) gère le flux OIDC LinkedIn. Deux routes (`/auth/linkedin`, `/auth/linkedin/callback`) s'ajoutent au blueprint `auth` existant. Une fonction `resolve_oauth_user` relie l'identité LinkedIn à un compte (existant par email, ou nouveau). Le schéma SQLite gagne une table `oauth_identities` et rend `password_hash` nullable.
|
||||
|
||||
**Tech Stack:** Flask, Flask-Login, Authlib, SQLite, Dash (pages), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `from src.auth import db`).
|
||||
- Tests : `uv run pytest` (pas de `source .venv/bin/activate`).
|
||||
- Provider unique : `linkedin`. Pas d'abstraction multi-provider.
|
||||
- On ne stocke que l'email (pas de nom/photo).
|
||||
- Liaison automatique par email (l'email LinkedIn est vérifié).
|
||||
- Libellé du bouton : exactement « Connexion avec LinkedIn ».
|
||||
- Couleur du bouton : fond `rgb(10, 102, 194)`, texte blanc.
|
||||
- Discovery OIDC LinkedIn : `https://www.linkedin.com/oauth/.well-known/openid-configuration`.
|
||||
- Scopes : `openid profile email`.
|
||||
- URL de callback construite depuis `APP_BASE_URL` : `{APP_BASE_URL}/auth/linkedin/callback`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Schéma DB — `password_hash` nullable + table `oauth_identities` + helpers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/db.py`
|
||||
- Test: `tests/auth/test_oauth_db.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: helpers existants `get_conn`, `init_schema`, `get_user_by_email`, `get_user_by_id`, `_now`.
|
||||
- Produces:
|
||||
|
||||
- `create_oauth_user(email: str) -> int` — crée un user `password_hash = NULL`, `email_verified = 1`, renvoie l'id.
|
||||
- `get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None`.
|
||||
- `link_oauth_identity(provider: str, subject: str, user_id: int) -> None`.
|
||||
- Schéma : `users.password_hash` nullable ; table `oauth_identities(provider, subject, user_id, created_at)` PK `(provider, subject)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_db.py`:
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.auth import db
|
||||
|
||||
|
||||
def test_oauth_identities_table_created(users_db_path):
|
||||
db.init_schema()
|
||||
tables = {
|
||||
r[0]
|
||||
for r in db.get_conn().execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
assert "oauth_identities" in tables
|
||||
|
||||
|
||||
def test_password_hash_is_nullable_on_fresh_schema(users_db_path):
|
||||
db.init_schema()
|
||||
cols = {r["name"]: r for r in db.get_conn().execute("PRAGMA table_info(users)")}
|
||||
assert cols["password_hash"]["notnull"] == 0
|
||||
|
||||
|
||||
def test_create_oauth_user(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
row = db.get_user_by_id(uid)
|
||||
assert row["email"] == "oauth@example.com"
|
||||
assert row["password_hash"] is None
|
||||
assert row["email_verified"] == 1
|
||||
|
||||
|
||||
def test_link_and_get_oauth_identity(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
assert db.get_oauth_identity("linkedin", "sub-123") is None
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
row = db.get_oauth_identity("linkedin", "sub-123")
|
||||
assert row["user_id"] == uid
|
||||
|
||||
|
||||
def test_oauth_identity_pk_prevents_duplicate(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_oauth_user("oauth@example.com")
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.link_oauth_identity("linkedin", "sub-123", uid)
|
||||
|
||||
|
||||
def test_migration_makes_legacy_password_hash_nullable(users_db_path):
|
||||
# Simule un schéma hérité où password_hash est NOT NULL.
|
||||
conn = db.get_conn()
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE email_verification_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
now = db._now()
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, email, password_hash, email_verified, created_at, updated_at)"
|
||||
" VALUES (1, 'legacy@example.com', 'hash', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at)"
|
||||
" VALUES ('tok', 1, ?, ?)",
|
||||
(now, now),
|
||||
)
|
||||
|
||||
db.init_schema() # déclenche _migrate
|
||||
|
||||
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(users)")}
|
||||
assert cols["password_hash"]["notnull"] == 0
|
||||
# Données préservées, pas de cascade-delete déclenchée pendant la reconstruction.
|
||||
assert db.get_user_by_id(1)["email"] == "legacy@example.com"
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM email_verification_tokens"
|
||||
).fetchone()[0] == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_db.py -v`
|
||||
Expected: FAIL (`oauth_identities` absent, `create_oauth_user` non défini, `password_hash` encore `notnull=1`).
|
||||
|
||||
- [ ] **Step 3: Update schema and add helpers in `src/auth/db.py`**
|
||||
|
||||
In `USERS_SCHEMA`, change the `password_hash` line to nullable and append the new table. The `users` CREATE becomes:
|
||||
|
||||
```python
|
||||
USERS_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, subject),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
```
|
||||
|
||||
Replace `_migrate` with a version that also drops the `NOT NULL` on legacy DBs, and add the rebuild helper:
|
||||
|
||||
```python
|
||||
def _migrate(conn: sqlite3.Connection) -> None:
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if "pending_email" not in cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN pending_email TEXT")
|
||||
cols = {row["name"]: row for row in conn.execute("PRAGMA table_info(users)")}
|
||||
if cols.get("password_hash") and cols["password_hash"]["notnull"] == 1:
|
||||
_rebuild_users_password_nullable(conn)
|
||||
|
||||
|
||||
def _rebuild_users_password_nullable(conn: sqlite3.Connection) -> None:
|
||||
# SQLite ne peut pas retirer un NOT NULL via ALTER : on reconstruit la table.
|
||||
# foreign_keys OFF pour éviter le cascade-delete pendant le DROP.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO users_new (id, email, password_hash, email_verified, "
|
||||
"pending_email, created_at, updated_at) "
|
||||
"SELECT id, email, password_hash, email_verified, pending_email, "
|
||||
"created_at, updated_at FROM users"
|
||||
)
|
||||
conn.execute("DROP TABLE users")
|
||||
conn.execute("ALTER TABLE users_new RENAME TO users")
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
```
|
||||
|
||||
Append the new helpers at the end of `src/auth/db.py`:
|
||||
|
||||
```python
|
||||
def create_oauth_user(email: str) -> int:
|
||||
conn = get_conn()
|
||||
now = _now()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
|
||||
"VALUES (?, NULL, 1, ?, ?)",
|
||||
(email.lower(), now, now),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_oauth_identity(provider: str, subject: str) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM oauth_identities WHERE provider = ? AND subject = ?",
|
||||
(provider, subject),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def link_oauth_identity(provider: str, subject: str, user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"INSERT INTO oauth_identities (provider, subject, user_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(provider, subject, user_id, _now()),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_db.py tests/auth/test_db.py -v`
|
||||
Expected: PASS (new tests + existing db tests still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/db.py tests/auth/test_oauth_db.py
|
||||
git commit -m "feat(auth): oauth_identities table + password_hash nullable
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Résolution de compte `resolve_oauth_user`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py`
|
||||
- Test: `tests/auth/test_oauth_resolve.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes (from Task 1): `db.get_oauth_identity`, `db.link_oauth_identity`, `db.create_oauth_user`, plus existants `db.get_user_by_email`, `db.get_user_by_id`, `db.set_email_verified`.
|
||||
- Produces:
|
||||
- `resolve_oauth_user(provider: str, subject: str, email: str, email_verified: bool) -> User` — renvoie l'utilisateur (existant lié, lié par email, ou nouvellement créé).
|
||||
|
||||
Placée dans `routes.py` (et non `db.py`) car elle construit un `User`, ce qui éviterait un import circulaire `db` → `models`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_resolve.py`:
|
||||
|
||||
```python
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from src.auth import db
|
||||
from src.auth.models import User
|
||||
from src.auth.routes import resolve_oauth_user
|
||||
|
||||
|
||||
def test_creates_new_user_when_unknown(users_db_path):
|
||||
db.init_schema()
|
||||
user = resolve_oauth_user("linkedin", "sub-1", "new@example.com", True)
|
||||
assert isinstance(user, User)
|
||||
row = db.get_user_by_id(int(user.get_id()))
|
||||
assert row["email"] == "new@example.com"
|
||||
assert row["password_hash"] is None
|
||||
assert row["email_verified"] == 1
|
||||
assert db.get_oauth_identity("linkedin", "sub-1")["user_id"] == row["id"]
|
||||
|
||||
|
||||
def test_links_to_existing_email_account(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_user("alice@example.com", generate_password_hash("password12"))
|
||||
db.set_email_verified(uid)
|
||||
|
||||
user = resolve_oauth_user("linkedin", "sub-2", "alice@example.com", True)
|
||||
assert int(user.get_id()) == uid
|
||||
assert db.get_oauth_identity("linkedin", "sub-2")["user_id"] == uid
|
||||
# Le compte garde son mot de passe.
|
||||
assert db.get_user_by_id(uid)["password_hash"] is not None
|
||||
|
||||
|
||||
def test_links_and_verifies_unverified_existing_account(users_db_path):
|
||||
db.init_schema()
|
||||
uid = db.create_user("bob@example.com", generate_password_hash("password12"))
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 0
|
||||
|
||||
resolve_oauth_user("linkedin", "sub-3", "bob@example.com", True)
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 1
|
||||
|
||||
|
||||
def test_returns_same_user_for_known_identity(users_db_path):
|
||||
db.init_schema()
|
||||
first = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
|
||||
second = resolve_oauth_user("linkedin", "sub-4", "carol@example.com", True)
|
||||
assert first.get_id() == second.get_id()
|
||||
# Pas de doublon d'identité.
|
||||
count = db.get_conn().execute(
|
||||
"SELECT COUNT(*) FROM oauth_identities WHERE provider='linkedin' AND subject='sub-4'"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
|
||||
Expected: FAIL with `ImportError: cannot import name 'resolve_oauth_user'`.
|
||||
|
||||
- [ ] **Step 3: Implement `resolve_oauth_user` in `src/auth/routes.py`**
|
||||
|
||||
Add after the imports / `_DUMMY_HASH` definition:
|
||||
|
||||
```python
|
||||
def resolve_oauth_user(
|
||||
provider: str, subject: str, email: str, email_verified: bool
|
||||
) -> User:
|
||||
identity = db.get_oauth_identity(provider, subject)
|
||||
if identity is not None:
|
||||
return User(db.get_user_by_id(identity["user_id"]))
|
||||
|
||||
row = db.get_user_by_email(email)
|
||||
if row is not None:
|
||||
db.link_oauth_identity(provider, subject, row["id"])
|
||||
if email_verified and not row["email_verified"]:
|
||||
db.set_email_verified(row["id"])
|
||||
return User(db.get_user_by_id(row["id"]))
|
||||
|
||||
user_id = db.create_oauth_user(email)
|
||||
db.link_oauth_identity(provider, subject, user_id)
|
||||
return User(db.get_user_by_id(user_id))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_resolve.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_oauth_resolve.py
|
||||
git commit -m "feat(auth): resolve_oauth_user (liaison/création de compte OIDC)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Initialisation Authlib + dépendance + variables d'env
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/auth/oauth.py`
|
||||
- Modify: `src/auth/setup.py`
|
||||
- Modify: `pyproject.toml`
|
||||
- Modify: `.template.env`
|
||||
- Modify: `tests/auth/conftest.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
- `src.auth.oauth.oauth` — instance `authlib.integrations.flask_client.OAuth` (registre).
|
||||
- `src.auth.oauth.init_oauth(app: Flask) -> None` — init + register du provider `linkedin`.
|
||||
- `init_auth` appelle `init_oauth(app)`.
|
||||
- Consumes: env `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
|
||||
|
||||
- [ ] **Step 1: Add the dependency**
|
||||
|
||||
In `pyproject.toml`, add `"authlib"` to the `dependencies` list (alphabetical-ish, near `email-validator`):
|
||||
|
||||
```toml
|
||||
"email-validator",
|
||||
"authlib",
|
||||
```
|
||||
|
||||
Then install it:
|
||||
|
||||
Run: `uv pip install authlib`
|
||||
Expected: installs Authlib successfully.
|
||||
|
||||
- [ ] **Step 2: Create `src/auth/oauth.py`**
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from flask import Flask
|
||||
|
||||
LINKEDIN_DISCOVERY_URL = (
|
||||
"https://www.linkedin.com/oauth/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def init_oauth(app: Flask) -> None:
|
||||
oauth.init_app(app)
|
||||
oauth.register(
|
||||
name="linkedin",
|
||||
client_id=os.getenv("LINKEDIN_CLIENT_ID"),
|
||||
client_secret=os.getenv("LINKEDIN_CLIENT_SECRET"),
|
||||
server_metadata_url=LINKEDIN_DISCOVERY_URL,
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire it into `init_auth`**
|
||||
|
||||
In `src/auth/setup.py`, after `app.register_blueprint(auth_bp)` and before/after the CSRF init, add the import and call. Add near the other `from src.auth...` imports at top:
|
||||
|
||||
```python
|
||||
from src.auth.oauth import init_oauth
|
||||
```
|
||||
|
||||
And inside `init_auth`, after `app.register_blueprint(auth_bp)`:
|
||||
|
||||
```python
|
||||
init_oauth(app)
|
||||
```
|
||||
|
||||
Also warn if LinkedIn isn't configured — append to the bottom of `init_auth`, next to the existing BREVO warning:
|
||||
|
||||
```python
|
||||
if not os.getenv("LINKEDIN_CLIENT_ID"):
|
||||
logger.warning(
|
||||
"LINKEDIN_CLIENT_ID non défini : la connexion LinkedIn échouera. "
|
||||
"Définissez LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET dans .env."
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add env template entries**
|
||||
|
||||
In `.template.env`, under the `# Comptes utilisateurs` section, add:
|
||||
|
||||
```bash
|
||||
# Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal,
|
||||
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
|
||||
# {APP_BASE_URL}/auth/linkedin/callback
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Set test env so `init_auth` can register the provider**
|
||||
|
||||
In `tests/auth/conftest.py`, in the `app` fixture, set dummy LinkedIn credentials before `init_auth(app)`:
|
||||
|
||||
```python
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("LINKEDIN_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("LINKEDIN_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
app = Flask(__name__)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the app still boots under tests**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_app_integration.py -v`
|
||||
Expected: PASS (registering the provider is lazy — no network call at init).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/oauth.py src/auth/setup.py pyproject.toml .template.env tests/auth/conftest.py
|
||||
git commit -m "feat(auth): init Authlib + provider LinkedIn (config env)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Routes `/auth/linkedin` et `/auth/linkedin/callback`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py`
|
||||
- Test: `tests/auth/test_oauth_routes.py` (create)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `resolve_oauth_user` (Task 2), `oauth` (Task 3), `safe_next`, `login_user`, `session`.
|
||||
- Produces: routes `GET /auth/linkedin`, `GET /auth/linkedin/callback`. Redirections d'erreur vers `/connexion?error=oauth_cancelled|oauth_failed`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/auth/test_oauth_routes.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.auth import db
|
||||
from src.auth import oauth as oauth_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_userinfo(monkeypatch):
|
||||
"""Patche authorize_access_token pour éviter tout appel réseau."""
|
||||
|
||||
state = {"userinfo": None, "raise": False}
|
||||
|
||||
def _authorize_access_token():
|
||||
if state["raise"]:
|
||||
raise RuntimeError("token exchange failed")
|
||||
return {"userinfo": state["userinfo"]}
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth_module.oauth.linkedin,
|
||||
"authorize_access_token",
|
||||
_authorize_access_token,
|
||||
raising=False,
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def test_login_route_redirects_to_linkedin(client, monkeypatch):
|
||||
from flask import redirect
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth_module.oauth.linkedin,
|
||||
"authorize_redirect",
|
||||
lambda redirect_uri, **kw: redirect("https://www.linkedin.com/oauth/authorize"),
|
||||
raising=False,
|
||||
)
|
||||
resp = client.get("/auth/linkedin")
|
||||
assert resp.status_code == 302
|
||||
assert "linkedin.com" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_creates_user_and_logs_in(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["userinfo"] = {
|
||||
"sub": "sub-xyz",
|
||||
"email": "newbie@example.com",
|
||||
"email_verified": True,
|
||||
}
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/compte/admin")
|
||||
assert db.get_user_by_email("newbie@example.com") is not None
|
||||
|
||||
|
||||
def test_callback_without_email_fails(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["userinfo"] = {"sub": "sub-noemail", "email_verified": True}
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_failed" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_token_error_redirects(client, fake_userinfo, users_db_path):
|
||||
db.init_schema()
|
||||
fake_userinfo["raise"] = True
|
||||
resp = client.get("/auth/linkedin/callback")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_failed" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_callback_user_cancelled_redirects(client, users_db_path):
|
||||
db.init_schema()
|
||||
resp = client.get("/auth/linkedin/callback?error=user_cancelled_login")
|
||||
assert resp.status_code == 302
|
||||
assert "error=oauth_cancelled" in resp.headers["Location"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
|
||||
Expected: FAIL (routes 404 / not defined).
|
||||
|
||||
- [ ] **Step 3: Implement the routes in `src/auth/routes.py`**
|
||||
|
||||
Add `session` to the flask import line:
|
||||
|
||||
```python
|
||||
from flask import Blueprint, redirect, request, session
|
||||
```
|
||||
|
||||
Add the import of the oauth registry near the top imports:
|
||||
|
||||
```python
|
||||
from src.auth.oauth import oauth
|
||||
```
|
||||
|
||||
Append the two routes at the end of the file:
|
||||
|
||||
```python
|
||||
@auth_bp.route("/linkedin", methods=["GET"])
|
||||
def linkedin_login():
|
||||
session["oauth_next"] = safe_next(
|
||||
request.args.get("next"), fallback="/compte/admin"
|
||||
)
|
||||
redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback"
|
||||
return oauth.linkedin.authorize_redirect(redirect_uri)
|
||||
|
||||
|
||||
@auth_bp.route("/linkedin/callback", methods=["GET"])
|
||||
def linkedin_callback():
|
||||
next_url = safe_next(session.pop("oauth_next", None), fallback="/compte/admin")
|
||||
if request.args.get("error"):
|
||||
# L'utilisateur a refusé / annulé l'autorisation côté LinkedIn.
|
||||
return _redirect_with_error("/connexion", "oauth_cancelled")
|
||||
try:
|
||||
token = oauth.linkedin.authorize_access_token()
|
||||
except Exception:
|
||||
logger.exception("Échec de l'échange de token LinkedIn")
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
userinfo = token.get("userinfo") or {}
|
||||
subject = userinfo.get("sub")
|
||||
email = (userinfo.get("email") or "").strip().lower()
|
||||
if not subject or not email:
|
||||
logger.error("Réponse LinkedIn sans sub/email : %s", userinfo)
|
||||
return _redirect_with_error("/connexion", "oauth_failed")
|
||||
|
||||
user = resolve_oauth_user(
|
||||
"linkedin", subject, email, bool(userinfo.get("email_verified"))
|
||||
)
|
||||
login_user(user, remember=True)
|
||||
return redirect(next_url)
|
||||
```
|
||||
|
||||
Add the `os` import at the top of the file if absent:
|
||||
|
||||
```python
|
||||
import os
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Run the full auth suite**
|
||||
|
||||
Run: `uv run pytest tests/auth -v`
|
||||
Expected: PASS (all auth tests green).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_oauth_routes.py
|
||||
git commit -m "feat(auth): routes /auth/linkedin et callback OIDC
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Bouton « Connexion avec LinkedIn » sur `/connexion` et `/inscription`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py`
|
||||
- Modify: `src/pages/inscription.py`
|
||||
- Test: `tests/auth/test_oauth_routes.py` (extend — render assertions)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: route `/auth/linkedin` (Task 4).
|
||||
- Produces: lien `<a href="/auth/linkedin">` stylé, libellé « Connexion avec LinkedIn », + messages d'erreur oauth.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/auth/test_oauth_routes.py`:
|
||||
|
||||
```python
|
||||
def test_connexion_layout_has_linkedin_button():
|
||||
from src.pages.connexion import layout
|
||||
|
||||
html_str = str(layout())
|
||||
assert "Connexion avec LinkedIn" in html_str
|
||||
assert "/auth/linkedin" in html_str
|
||||
|
||||
|
||||
def test_connexion_layout_shows_oauth_error():
|
||||
from src.pages.connexion import layout
|
||||
|
||||
assert "LinkedIn" in str(layout(error="oauth_failed"))
|
||||
|
||||
|
||||
def test_inscription_layout_has_linkedin_button():
|
||||
from src.pages.inscription import layout
|
||||
|
||||
html_str = str(layout())
|
||||
assert "Connexion avec LinkedIn" in html_str
|
||||
assert "/auth/linkedin" in html_str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
|
||||
Expected: FAIL (button/text absent).
|
||||
|
||||
- [ ] **Step 3: Add a shared button helper and use it**
|
||||
|
||||
In `src/pages/connexion.py`, add the error messages and the button. Extend `ERROR_MESSAGES`:
|
||||
|
||||
```python
|
||||
ERROR_MESSAGES = {
|
||||
"invalid_credentials": "Identifiants invalides.",
|
||||
"email_not_verified": "Vérifiez d'abord votre adresse email (consultez votre boîte de réception).",
|
||||
"oauth_cancelled": "Connexion LinkedIn annulée.",
|
||||
"oauth_failed": "Échec de la connexion via LinkedIn. Réessayez.",
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper (above `layout`) in `connexion.py`:
|
||||
|
||||
```python
|
||||
def linkedin_button():
|
||||
return html.A(
|
||||
"Connexion avec LinkedIn",
|
||||
href="/auth/linkedin",
|
||||
className="btn w-100 mb-2",
|
||||
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
|
||||
)
|
||||
```
|
||||
|
||||
In `connexion.py` `layout`, insert a separator + button between the `html.Form(...)` and the `html.Hr()`:
|
||||
|
||||
```python
|
||||
html.Form(
|
||||
...
|
||||
),
|
||||
html.Div("ou", className="text-center text-muted my-2"),
|
||||
linkedin_button(),
|
||||
html.Hr(),
|
||||
```
|
||||
|
||||
In `src/pages/inscription.py`, import the helper and place it likewise. At the top, after the existing imports:
|
||||
|
||||
```python
|
||||
from src.pages.connexion import linkedin_button
|
||||
```
|
||||
|
||||
In `inscription.py` `layout`, insert between the `html.Form(...)` and `html.Hr()`:
|
||||
|
||||
```python
|
||||
html.Form(
|
||||
...
|
||||
),
|
||||
html.Div("ou", className="text-center text-muted my-2"),
|
||||
linkedin_button(),
|
||||
html.Hr(),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_oauth_routes.py -k layout -v`
|
||||
Expected: PASS (3 layout tests).
|
||||
|
||||
- [ ] **Step 5: Manual smoke check (no network)**
|
||||
|
||||
Run: `uv run python -c "from src.pages.connexion import layout; from src.pages.inscription import layout as l2; assert 'Connexion avec LinkedIn' in str(layout()) and 'Connexion avec LinkedIn' in str(l2()); print('ok')"`
|
||||
Expected: prints `ok`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py src/pages/inscription.py tests/auth/test_oauth_routes.py
|
||||
git commit -m "feat(auth): bouton Connexion avec LinkedIn sur connexion/inscription
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Run the whole test suite touched by this work**
|
||||
|
||||
Run: `uv run pytest tests/auth -v`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 2: Confirm the docs/spec prerequisites are accurate**
|
||||
|
||||
Re-read `docs/superpowers/specs/2026-06-24-linkedin-oauth-design.md` § Prérequis. Ensure the redirect URIs and env var names match what was implemented (`/auth/linkedin/callback`, `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||
|
||||
- [ ] **Step 3: No commit needed if nothing changed.** Otherwise commit doc fixes.
|
||||
|
||||
---
|
||||
|
||||
## Manual setup required before LinkedIn login works in production
|
||||
|
||||
(Not code — for the project admin.)
|
||||
|
||||
1. LinkedIn Developer Portal → create app → add product « Sign In with LinkedIn using OpenID Connect ».
|
||||
2. Authorized redirect URLs:
|
||||
- `http://localhost:8050/auth/linkedin/callback`
|
||||
- `https://test.decp.info/auth/linkedin/callback`
|
||||
- `https://decp.info/auth/linkedin/callback`
|
||||
3. Copy Client ID / Secret into each environment's `.env` (`LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`).
|
||||
4. Ensure `APP_BASE_URL` matches the deployment origin in each `.env`.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Accès gratuit pour tous (`TOUS_ABONNES`) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Un drapeau d'environnement `TOUS_ABONNES` qui ouvre gratuitement les fonctionnalités réservées aux abonnés à tout utilisateur connecté, affiche un bandeau d'info sur `/compte/abonnement` et y grise les boutons « S'abonner ».
|
||||
|
||||
**Architecture:** L'accès est déjà centralisé dans `src/pages/_compte_shell.py::current_user_has_subscription()` (pilote menu + `account_guard`). On y branche le drapeau. La page `/compte/abonnement` lit le même drapeau pour afficher un bandeau et désactiver les boutons. Le contrôle bas-niveau `db.has_active_subscription()` reste inchangé (vrai abonnement payant), donc la redirection post-login et l'anti-double-abonnement gardent leur comportement.
|
||||
|
||||
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.utils`, `src.pages._compte_shell`).
|
||||
- Convention drapeau booléen : `os.getenv("NOM", "False").lower() == "true"` (cf. `DEVELOPMENT` dans `src/utils/__init__.py`).
|
||||
- Drapeau lu via `from src.utils import TOUS_ABONNES` **dans le corps** des fonctions (lecture paresseuse → testable par `monkeypatch.setattr`).
|
||||
- Quand `TOUS_ABONNES` est absent / `false`, comportement actuel strictement inchangé.
|
||||
- Texte exact du bandeau (verbatim) : « Les fonctionnalités normalement accessibles contre un abonnement de 20 € HT par mois sont accessibles à tous et toutes en attendant la validation de mon dossier pour recevoir des paiements. »
|
||||
- Tests lancés avec `uv run pytest`.
|
||||
- Messages de commit en français, terminés par la ligne `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Déblocage de l'accès via `TOUS_ABONNES`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/__init__.py` (ajouter la constante près de `DEVELOPMENT`, ligne ~33)
|
||||
- Modify: `src/pages/_compte_shell.py:41-46` (`current_user_has_subscription`)
|
||||
- Modify: `.template.env` (documenter la variable)
|
||||
- Test: `tests/test_compte_shell.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien (première tâche).
|
||||
- Produces:
|
||||
|
||||
- `src.utils.TOUS_ABONNES: bool` — constante module, défaut `False`.
|
||||
- `src.pages._compte_shell.current_user_has_subscription() -> bool` — renvoie `True` si l'utilisateur est authentifié **et** `TOUS_ABONNES`, sinon le comportement DB existant ; toujours `False` si non authentifié.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à la fin de `tests/test_compte_shell.py` :
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _fake_user(authenticated: bool):
|
||||
user = type("U", (), {})()
|
||||
user.is_authenticated = authenticated
|
||||
user.id = 1
|
||||
return user
|
||||
|
||||
|
||||
def test_has_subscription_true_for_authenticated_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(True)):
|
||||
assert shell.current_user_has_subscription() is True
|
||||
|
||||
|
||||
def test_has_subscription_false_for_anonymous_even_with_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(False)):
|
||||
assert shell.current_user_has_subscription() is False
|
||||
|
||||
|
||||
def test_has_subscription_uses_db_when_flag_off(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
with patch("src.pages._compte_shell.current_user", _fake_user(True)), patch(
|
||||
"src.subscriptions.db.has_active_subscription", return_value=False
|
||||
) as mocked:
|
||||
assert shell.current_user_has_subscription() is False
|
||||
mocked.assert_called_once_with(1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_shell.py -v`
|
||||
Expected: les 3 nouveaux tests ÉCHOUENT (`test_has_subscription_true_...` échoue car `current_user_has_subscription` ne consulte pas encore `TOUS_ABONNES` ; `monkeypatch.setattr("src.utils.TOUS_ABONNES", True)` réussit seulement si l'attribut existe — sinon `AttributeError`, ce qui confirme aussi qu'il faut créer la constante).
|
||||
|
||||
- [ ] **Step 3: Ajouter la constante dans `src/utils/__init__.py`**
|
||||
|
||||
Juste après la ligne `DEVELOPMENT = os.getenv("DEVELOPMENT", "False").lower() == "true"` (ligne ~33) :
|
||||
|
||||
```python
|
||||
# Accès gratuit temporaire à toutes les fonctionnalités d'abonné, le temps que
|
||||
# la plateforme de paiement Frisbii valide la réception de paiements.
|
||||
TOUS_ABONNES = os.getenv("TOUS_ABONNES", "False").lower() == "true"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Brancher le drapeau dans `current_user_has_subscription`**
|
||||
|
||||
Remplacer la fonction (`src/pages/_compte_shell.py:41-46`) par :
|
||||
|
||||
```python
|
||||
def current_user_has_subscription() -> bool:
|
||||
from src.subscriptions import db
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
if TOUS_ABONNES:
|
||||
return True
|
||||
return db.has_active_subscription(current_user.id)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Documenter la variable dans `.template.env`**
|
||||
|
||||
Ajouter une ligne (par ex. à la suite du bloc abonnement/Frisbii) :
|
||||
|
||||
```
|
||||
TOUS_ABONNES=false # true = ouvre gratuitement les fonctionnalités d'abonné à tout compte
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_shell.py -v`
|
||||
Expected: les 8 tests PASSENT (5 existants + 3 nouveaux).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/__init__.py src/pages/_compte_shell.py .template.env tests/test_compte_shell.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Ajoute le drapeau TOUS_ABONNES pour l'accès gratuit
|
||||
|
||||
Ouvre les fonctionnalités d'abonné à tout utilisateur connecté tant que
|
||||
Frisbii n'a pas validé la réception de paiements.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bandeau d'info et boutons « S'abonner » désactivés sur `/compte/abonnement`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte_abonnement.py` (`_plan_card` ~25-57, ajout `_tous_abonnes_banner`, `layout` ~198-221)
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.utils.TOUS_ABONNES` (Task 1).
|
||||
- Produces:
|
||||
|
||||
- `compte_abonnement._tous_abonnes_banner()` — renvoie un `dbc.Alert` (color `info`) contenant le texte verbatim si `TOUS_ABONNES`, sinon `None`.
|
||||
- `_plan_card` rend un bouton désactivé (`className="btn btn-secondary disabled"`, `disabled=True`) quand `TOUS_ABONNES`, sinon le bouton primaire actuel.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_subscribe_buttons_disabled_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
|
||||
assert "btn-secondary disabled" in text
|
||||
assert "btn-primary" not in text
|
||||
|
||||
|
||||
def test_subscribe_buttons_active_when_flag_off(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._plan_cards(trial_for=lambda key: 2))
|
||||
assert "btn-primary" in text
|
||||
|
||||
|
||||
def test_banner_present_when_tous_abonnes(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", True)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._tous_abonnes_banner())
|
||||
assert "accessibles à tous et toutes" in text
|
||||
|
||||
|
||||
def test_banner_absent_when_flag_off(monkeypatch):
|
||||
monkeypatch.setattr("src.utils.TOUS_ABONNES", False)
|
||||
from src.pages import compte_abonnement
|
||||
|
||||
assert compte_abonnement._tous_abonnes_banner() is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: les 4 nouveaux tests ÉCHOUENT (`_tous_abonnes_banner` n'existe pas → `AttributeError` ; le bouton reste `btn-primary`).
|
||||
|
||||
- [ ] **Step 3: Désactiver le bouton dans `_plan_card`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py::_plan_card`, remplacer le bloc `html.Button("S'abonner", ...)` (lignes ~48-50) par une lecture du drapeau. Ajouter en tête de fonction puis le bouton conditionnel :
|
||||
|
||||
```python
|
||||
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if trial_used:
|
||||
```
|
||||
|
||||
et remplacer le `html.Button(...)` par :
|
||||
|
||||
```python
|
||||
html.Button(
|
||||
"S'abonner",
|
||||
type="submit",
|
||||
className=(
|
||||
"btn btn-secondary disabled"
|
||||
if TOUS_ABONNES
|
||||
else "btn btn-primary"
|
||||
),
|
||||
disabled=TOUS_ABONNES,
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter le helper `_tous_abonnes_banner`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py`, ajouter (par ex. juste avant `def layout`) :
|
||||
|
||||
```python
|
||||
def _tous_abonnes_banner():
|
||||
from src.utils import TOUS_ABONNES
|
||||
|
||||
if not TOUS_ABONNES:
|
||||
return None
|
||||
return dbc.Alert(
|
||||
"Les fonctionnalités normalement accessibles contre un abonnement de "
|
||||
"20 € HT par mois sont accessibles à tous et toutes en attendant la "
|
||||
"validation de mon dossier pour recevoir des paiements.",
|
||||
color="info",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Insérer le bandeau dans `layout`**
|
||||
|
||||
Dans `src/pages/compte_abonnement.py::layout`, remplacer la construction de `body` (ligne ~214) par une version qui place le bandeau en tête quand présent :
|
||||
|
||||
```python
|
||||
body = [html.H2("Abonnement", className="mb-4")]
|
||||
banner = _tous_abonnes_banner()
|
||||
if banner is not None:
|
||||
body.append(banner)
|
||||
body.extend(_feedback(query))
|
||||
if has_access and row is not None:
|
||||
body.append(_active_view(row))
|
||||
else:
|
||||
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: tous les tests PASSENT (4 existants + 4 nouveaux).
|
||||
|
||||
- [ ] **Step 7: Lancer la suite des tests d'abonnement et du shell**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ tests/test_compte_shell.py -v`
|
||||
Expected: PASS (aucune régression).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte_abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Bandeau TOUS_ABONNES et boutons S'abonner désactivés
|
||||
|
||||
Affiche un bandeau d'info sur /compte/abonnement et grise les boutons
|
||||
S'abonner quand l'accès gratuit pour tous est activé.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
|
||||
- Drapeau d'env `TOUS_ABONNES` → Task 1, Steps 3 & 5.
|
||||
- Accès aux fonctionnalités d'abonné pour tout connecté → Task 1, Step 4 (`current_user_has_subscription`, qui pilote menu + `account_guard`).
|
||||
- Bandeau sur `/compte/abonnement` → Task 2, Steps 4-5.
|
||||
- Boutons « S'abonner » désactivés et gris → Task 2, Step 3.
|
||||
- `db.has_active_subscription` inchangé / redirection post-login inchangée → aucun changement (couvert par l'architecture ; non gating ailleurs vérifié dans la spec).
|
||||
- Comportement inchangé si drapeau off → tests `_flag_off` dans les deux tâches.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code et toutes les commandes sont explicites.
|
||||
|
||||
**Type consistency :** `current_user_has_subscription() -> bool`, `_tous_abonnes_banner() -> dbc.Alert | None`, `TOUS_ABONNES: bool` utilisés de façon cohérente entre tâches et tests.
|
||||
@@ -0,0 +1,990 @@
|
||||
# Sauvegarde des vues du Tableau — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre aux abonné·es d'enregistrer des vues nommées (filtres + tris + colonnes) sur `/tableau`, de les appliquer en un clic, et de les gérer sur `/compte/vues`.
|
||||
|
||||
**Architecture:** Une vue = un nom + la query string `filtres`/`tris`/`colonnes` que `/tableau` produit et restaure déjà. Stockage serveur dans `users.sqlite` (nouveau module `src/saved_views/`). La logique métier (construction de query, validation, builders d'UI) vit dans des fonctions pures testables ; les callbacks Dash ne font que les câbler. Réutilisation du `restore_view_from_url` existant pour appliquer une vue (navigation vers `/tableau?<query>`).
|
||||
|
||||
**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, SQLite (`users.sqlite` via `src.auth.db.get_conn()`), Flask-Login (`current_user`), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.saved_views.db`), jamais `saved_views.db`.
|
||||
- Accès DB via `src.auth.db.get_conn()` (connexion thread-local sur `users.sqlite`).
|
||||
- Contrôle d'abonnement via `src.pages._compte_shell.current_user_has_subscription()` (respecte `TOUS_ABONNES`). Ne jamais appeler `db.has_active_subscription()` directement pour le gating UI.
|
||||
- Toute opération DB inclut `user_id` dans le `WHERE` (isolation entre comptes).
|
||||
- Périmètre strict : `/tableau` uniquement. La colonne `table_name` vaut toujours `'tableau'`.
|
||||
- Tests lancés avec `uv run pytest`.
|
||||
- Format/lint : `prettier` (markdown) et `ruff` tournent en pre-commit ; committer du code déjà formaté.
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
| Fichier | Responsabilité |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/saved_views/__init__.py` (créer) | Marqueur de package. |
|
||||
| `src/saved_views/db.py` (créer) | Schéma `saved_views` + CRUD (`init_schema`, `list_views`, `upsert`, `rename`, `delete`, `get`). |
|
||||
| `src/saved_views/ui.py` (créer) | Fonctions **pures** d'UI/validation : `bar_style`, `clean_view_name`, `prepare_view_to_save`, `saved_views_items`, `views_table`. Aucune dépendance à un app Dash (pas de `register_page`/`callback`). |
|
||||
| `src/utils/table.py` (modifier) | Ajouter `build_view_query()` ; refactorer `sync_url_and_reset_button` (dans `tableau.py`) pour l'utiliser. |
|
||||
| `src/app.py` (modifier) | Appeler `saved_views.db.init_schema()` au démarrage. |
|
||||
| `src/pages/tableau.py` (modifier) | Barre « vues » dans `table-menu` + 3 callbacks (visibilité, sauvegarde, remplissage du menu) qui câblent les helpers. |
|
||||
| `src/pages/_compte_shell.py` (modifier) | Ajouter la section `vues` à `SECTIONS`. |
|
||||
| `src/pages/compte_vues.py` (créer) | Page `/compte/vues` (liste/renommer/supprimer), gabarit `compte_admin.py`. |
|
||||
| `tests/saved_views/__init__.py` (créer) | Package de tests. |
|
||||
| `tests/saved_views/conftest.py` (créer) | Fixture `users_db_path` (copie de `tests/subscriptions/conftest.py`). |
|
||||
| `tests/saved_views/test_db.py` (créer) | Tests unitaires du CRUD. |
|
||||
| `tests/saved_views/test_ui.py` (créer) | Tests des fonctions pures d'UI/validation. |
|
||||
| `tests/saved_views/test_build_view_query.py` (créer) | Tests de `build_view_query`. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Module DB `src/saved_views/db.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/saved_views/__init__.py`
|
||||
- Create: `src/saved_views/db.py`
|
||||
- Create: `tests/saved_views/__init__.py`
|
||||
- Create: `tests/saved_views/conftest.py`
|
||||
- Create: `tests/saved_views/test_db.py`
|
||||
- Modify: `src/app.py` (après `init_subscriptions(app.server)`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.auth.db.get_conn()`, `src.auth.db.init_schema()`, `src.auth.db.create_user(email, password_hash) -> int`, `src.auth.db.delete_user(user_id)`.
|
||||
- Produces:
|
||||
|
||||
- `SCHEMA: str`
|
||||
- `init_schema() -> None`
|
||||
- `list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]`
|
||||
- `upsert(user_id: int, table_name: str, name: str, query: str) -> None`
|
||||
- `rename(view_id: int, user_id: int, new_name: str) -> None`
|
||||
- `delete(view_id: int, user_id: int) -> None`
|
||||
- `get(view_id: int, user_id: int) -> sqlite3.Row | None`
|
||||
|
||||
- [ ] **Step 1: Créer le package et le fichier de tests vide**
|
||||
|
||||
Créer `src/saved_views/__init__.py` (vide) et `tests/saved_views/__init__.py` (vide).
|
||||
|
||||
Créer `tests/saved_views/conftest.py` (copie de la fixture de `tests/subscriptions/conftest.py`) :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def users_db_path(monkeypatch, tmp_path):
|
||||
from src.auth.db import reset_conn_for_tests
|
||||
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||
reset_conn_for_tests()
|
||||
yield db_path
|
||||
reset_conn_for_tests()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_db.py` :
|
||||
|
||||
```python
|
||||
from src.auth import db as auth_db
|
||||
from src.saved_views import db
|
||||
|
||||
|
||||
def _make_user(email="u@ex.fr"):
|
||||
auth_db.init_schema()
|
||||
return auth_db.create_user(email, "hash")
|
||||
|
||||
|
||||
def test_init_schema_creates_table(users_db_path):
|
||||
db.init_schema()
|
||||
conn = auth_db.get_conn()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
assert "saved_views" in tables
|
||||
|
||||
|
||||
def test_upsert_creates_and_lists(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
|
||||
views = db.list_views(uid, "tableau")
|
||||
assert len(views) == 1
|
||||
assert views[0]["name"] == "Ma vue"
|
||||
assert views[0]["query"] == "filtres=foo"
|
||||
|
||||
|
||||
def test_upsert_same_name_overwrites(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=foo")
|
||||
db.upsert(uid, "tableau", "Ma vue", "filtres=bar")
|
||||
views = db.list_views(uid, "tableau")
|
||||
assert len(views) == 1
|
||||
assert views[0]["query"] == "filtres=bar"
|
||||
|
||||
|
||||
def test_list_views_is_isolated_per_user(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
assert db.list_views(uid2, "tableau") == []
|
||||
|
||||
|
||||
def test_rename_only_affects_owner(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
view_id = db.list_views(uid1, "tableau")[0]["id"]
|
||||
db.rename(view_id, uid2, "Pirate") # mauvais propriétaire → no-op
|
||||
assert db.get(view_id, uid1)["name"] == "Vue A"
|
||||
db.rename(view_id, uid1, "Vue B")
|
||||
assert db.get(view_id, uid1)["name"] == "Vue B"
|
||||
|
||||
|
||||
def test_delete_only_affects_owner(users_db_path):
|
||||
db.init_schema()
|
||||
uid1 = _make_user("a@ex.fr")
|
||||
uid2 = _make_user("b@ex.fr")
|
||||
db.upsert(uid1, "tableau", "Vue A", "filtres=a")
|
||||
view_id = db.list_views(uid1, "tableau")[0]["id"]
|
||||
db.delete(view_id, uid2) # mauvais propriétaire → no-op
|
||||
assert db.get(view_id, uid1) is not None
|
||||
db.delete(view_id, uid1)
|
||||
assert db.get(view_id, uid1) is None
|
||||
|
||||
|
||||
def test_views_deleted_on_user_cascade(users_db_path):
|
||||
db.init_schema()
|
||||
uid = _make_user()
|
||||
db.upsert(uid, "tableau", "Vue A", "filtres=a")
|
||||
auth_db.delete_user(uid)
|
||||
assert db.list_views(uid, "tableau") == []
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_db.py -v`
|
||||
Expected: FAIL (ModuleNotFoundError: `src.saved_views.db`).
|
||||
|
||||
- [ ] **Step 4: Écrire `src/saved_views/db.py`**
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.auth.db import get_conn
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS saved_views (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
table_name TEXT NOT NULL DEFAULT 'tableau',
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, table_name, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_views_user
|
||||
ON saved_views(user_id, table_name);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def init_schema() -> None:
|
||||
get_conn().executescript(SCHEMA)
|
||||
|
||||
|
||||
def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? "
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
(user_id, table_name),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
|
||||
|
||||
def get(view_id: int, user_id: int) -> sqlite3.Row | None:
|
||||
return (
|
||||
get_conn()
|
||||
.execute(
|
||||
"SELECT * FROM saved_views WHERE id = ? AND user_id = ?",
|
||||
(view_id, user_id),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def upsert(user_id: int, table_name: str, name: str, query: str) -> None:
|
||||
now = _now()
|
||||
get_conn().execute(
|
||||
"INSERT INTO saved_views "
|
||||
"(user_id, table_name, name, query, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(user_id, table_name, name) DO UPDATE SET "
|
||||
"query = excluded.query, updated_at = excluded.updated_at",
|
||||
(user_id, table_name, name, query, now, now),
|
||||
)
|
||||
|
||||
|
||||
def rename(view_id: int, user_id: int, new_name: str) -> None:
|
||||
get_conn().execute(
|
||||
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?",
|
||||
(new_name, _now(), view_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def delete(view_id: int, user_id: int) -> None:
|
||||
get_conn().execute(
|
||||
"DELETE FROM saved_views WHERE id = ? AND user_id = ?",
|
||||
(view_id, user_id),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_db.py -v`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 6: Câbler `init_schema()` au démarrage dans `src/app.py`**
|
||||
|
||||
Juste après la ligne `init_subscriptions(app.server)` (≈ ligne 106), ajouter :
|
||||
|
||||
```python
|
||||
from src.saved_views import db as saved_views_db # noqa: E402
|
||||
|
||||
saved_views_db.init_schema()
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import de l'app**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur (sortie vide ou logs normaux).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/saved_views/__init__.py src/saved_views/db.py src/app.py \
|
||||
tests/saved_views/__init__.py tests/saved_views/conftest.py \
|
||||
tests/saved_views/test_db.py
|
||||
git commit -m "feat: table saved_views et CRUD #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Helper `build_view_query` dans `src/utils/table.py`
|
||||
|
||||
Extrait la construction de query string aujourd'hui inline dans `sync_url_and_reset_button` (`tableau.py`), pour la réutiliser à la sauvegarde.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils/table.py` (ajouter la fonction + imports si absents)
|
||||
- Modify: `src/pages/tableau.py` (`sync_url_and_reset_button` utilise le helper)
|
||||
- Create: `tests/saved_views/test_build_view_query.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.utils.table.invert_columns(columns) -> list[str]`.
|
||||
- Produces: `build_view_query(filter_query: str | None, sort_by: list | None, hidden_columns: list | None) -> str` — renvoie une query string (`urlencode`) avec les clés `filtres`, `tris` (JSON), `colonnes` (CSV des colonnes **visibles**). Renvoie `""` si aucun paramètre.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_build_view_query.py` :
|
||||
|
||||
```python
|
||||
import urllib.parse
|
||||
|
||||
from src.utils.table import build_view_query
|
||||
|
||||
|
||||
def test_empty_inputs_give_empty_string():
|
||||
assert build_view_query(None, None, None) == ""
|
||||
assert build_view_query("", [], []) == ""
|
||||
|
||||
|
||||
def test_filter_only():
|
||||
q = build_view_query("{objet} icontains route", None, None)
|
||||
params = urllib.parse.parse_qs(q)
|
||||
assert params["filtres"] == ["{objet} icontains route"]
|
||||
assert "tris" not in params
|
||||
assert "colonnes" not in params
|
||||
|
||||
|
||||
def test_sort_is_json_encoded():
|
||||
sort_by = [{"column_id": "montant", "direction": "desc"}]
|
||||
q = build_view_query(None, sort_by, None)
|
||||
params = urllib.parse.parse_qs(q)
|
||||
import json
|
||||
|
||||
assert json.loads(params["tris"][0]) == sort_by
|
||||
|
||||
|
||||
def test_hidden_columns_become_visible_csv():
|
||||
# build_view_query reçoit les colonnes MASQUÉES et stocke les VISIBLES
|
||||
q = build_view_query(None, None, ["objet"])
|
||||
params = urllib.parse.parse_qs(q)
|
||||
visible = params["colonnes"][0].split(",")
|
||||
assert "objet" not in visible
|
||||
assert len(visible) > 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
|
||||
Expected: FAIL (ImportError: `build_view_query`).
|
||||
|
||||
- [ ] **Step 3: Ajouter `build_view_query` à `src/utils/table.py`**
|
||||
|
||||
Vérifier que le haut du fichier contient `import json` et `import urllib.parse` ; les ajouter sinon. Puis ajouter, à la fin du fichier (après `invert_columns`) :
|
||||
|
||||
```python
|
||||
def build_view_query(filter_query, sort_by, hidden_columns) -> str:
|
||||
"""
|
||||
Construit la query string d'une vue Tableau (filtres + tris + colonnes),
|
||||
identique à celle produite par le bouton « Partager la vue ».
|
||||
|
||||
hidden_columns : colonnes masquées ; on stocke les colonnes visibles.
|
||||
"""
|
||||
params = {}
|
||||
if filter_query:
|
||||
params["filtres"] = filter_query
|
||||
if sort_by:
|
||||
params["tris"] = json.dumps(sort_by)
|
||||
if hidden_columns:
|
||||
params["colonnes"] = ",".join(invert_columns(hidden_columns))
|
||||
return urllib.parse.urlencode(params)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_build_view_query.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Refactorer `sync_url_and_reset_button` dans `src/pages/tableau.py`**
|
||||
|
||||
Dans `src/pages/tableau.py`, importer le helper en haut (ajouter `build_view_query` à l'import existant depuis `src.utils.table`).
|
||||
|
||||
Remplacer le corps de construction de l'URL (lignes ≈ 427-440, du `params = {}` jusqu'au `full_url = ...`) par :
|
||||
|
||||
```python
|
||||
query_string = build_view_query(filter_query, sort_by, hidden_columns)
|
||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||
```
|
||||
|
||||
Supprimer l'import devenu inutile s'il n'est plus utilisé ailleurs (vérifier `json`/`urllib` restent utilisés par d'autres callbacks — ils le sont, ne pas les retirer).
|
||||
|
||||
- [ ] **Step 6: Vérifier la non-régression du partage**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur.
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/table.py src/pages/tableau.py tests/saved_views/test_build_view_query.py
|
||||
git commit -m "refactor: extrait build_view_query et le réutilise dans le partage #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fonctions pures d'UI/validation `src/saved_views/ui.py`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/saved_views/ui.py`
|
||||
- Create: `tests/saved_views/test_ui.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `dash_bootstrap_components as dbc`, `dash.html`.
|
||||
- Produces:
|
||||
|
||||
- `bar_style(has_subscription: bool) -> dict` — `{}` si abonné, `{"display": "none"}` sinon.
|
||||
- `clean_view_name(name: str | None) -> str` — `name.strip()`, `""` si vide/None.
|
||||
- `prepare_view_to_save(has_subscription: bool, name: str | None) -> tuple[str | None, str | None]` — renvoie `(clean_name, None)` si OK ; `(None, message)` si refus (non-abonné ou nom vide).
|
||||
- `saved_views_items(views) -> list` — liste de `dbc.DropdownMenuItem` liens `href="/tableau?<query>"` (un par vue).
|
||||
- `views_table(views) -> html.Div` — bloc de gestion pour `/compte/vues` (un `html.Div` par vue avec id pattern-matching pour Ouvrir/Renommer/Supprimer), ou message d'état vide.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Créer `tests/saved_views/test_ui.py` :
|
||||
|
||||
```python
|
||||
from src.saved_views import ui
|
||||
|
||||
|
||||
class _Row(dict):
|
||||
"""Imite un sqlite3.Row : accès par clé."""
|
||||
|
||||
|
||||
def _view(view_id, name, query):
|
||||
return _Row(id=view_id, name=name, query=query)
|
||||
|
||||
|
||||
def test_bar_style_hidden_for_non_subscriber():
|
||||
assert ui.bar_style(False) == {"display": "none"}
|
||||
assert ui.bar_style(True) == {}
|
||||
|
||||
|
||||
def test_clean_view_name_strips_and_empties():
|
||||
assert ui.clean_view_name(" Ma vue ") == "Ma vue"
|
||||
assert ui.clean_view_name(" ") == ""
|
||||
assert ui.clean_view_name(None) == ""
|
||||
|
||||
|
||||
def test_prepare_refuses_non_subscriber():
|
||||
name, err = ui.prepare_view_to_save(False, "Ma vue")
|
||||
assert name is None
|
||||
assert err
|
||||
|
||||
|
||||
def test_prepare_refuses_empty_name():
|
||||
name, err = ui.prepare_view_to_save(True, " ")
|
||||
assert name is None
|
||||
assert err
|
||||
|
||||
|
||||
def test_prepare_accepts_valid():
|
||||
name, err = ui.prepare_view_to_save(True, " Ma vue ")
|
||||
assert name == "Ma vue"
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_saved_views_items_build_links():
|
||||
items = ui.saved_views_items(
|
||||
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
|
||||
)
|
||||
assert len(items) == 2
|
||||
assert items[0].href == "/tableau?filtres=a"
|
||||
assert items[0].children == "Vue A"
|
||||
|
||||
|
||||
def test_views_table_empty_state():
|
||||
out = ui.views_table([])
|
||||
# un Div non vide (message d'état) sans item de suppression
|
||||
assert out is not None
|
||||
|
||||
|
||||
def test_views_table_lists_views():
|
||||
out = ui.views_table([_view(1, "Vue A", "filtres=a")])
|
||||
text = str(out)
|
||||
assert "Vue A" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_ui.py -v`
|
||||
Expected: FAIL (ModuleNotFoundError: `src.saved_views.ui`).
|
||||
|
||||
- [ ] **Step 3: Écrire `src/saved_views/ui.py`**
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import html
|
||||
|
||||
|
||||
def bar_style(has_subscription: bool) -> dict:
|
||||
return {} if has_subscription else {"display": "none"}
|
||||
|
||||
|
||||
def clean_view_name(name: str | None) -> str:
|
||||
return (name or "").strip()
|
||||
|
||||
|
||||
def prepare_view_to_save(
|
||||
has_subscription: bool, name: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not has_subscription:
|
||||
return None, "Réservé aux abonné·es."
|
||||
clean = clean_view_name(name)
|
||||
if not clean:
|
||||
return None, "Veuillez saisir un nom pour la vue."
|
||||
return clean, None
|
||||
|
||||
|
||||
def saved_views_items(views) -> list:
|
||||
return [
|
||||
dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}")
|
||||
for view in views
|
||||
]
|
||||
|
||||
|
||||
def _view_row(view) -> html.Div:
|
||||
view_id = view["id"]
|
||||
return html.Div(
|
||||
className="saved-view-row d-flex align-items-center gap-2 mb-2",
|
||||
children=[
|
||||
html.Span(view["name"], className="flex-grow-1"),
|
||||
dbc.Button(
|
||||
"Ouvrir",
|
||||
href=f"/tableau?{view['query']}",
|
||||
color="link",
|
||||
size="sm",
|
||||
),
|
||||
dbc.Button(
|
||||
"Renommer",
|
||||
id={"type": "vue-rename-open", "index": view_id},
|
||||
color="secondary",
|
||||
outline=True,
|
||||
size="sm",
|
||||
),
|
||||
dbc.Button(
|
||||
"Supprimer",
|
||||
id={"type": "vue-delete", "index": view_id},
|
||||
color="danger",
|
||||
outline=True,
|
||||
size="sm",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def views_table(views) -> html.Div:
|
||||
if not views:
|
||||
return html.Div(
|
||||
html.P(
|
||||
"Vous n'avez pas encore de vue enregistrée. "
|
||||
"Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »."
|
||||
)
|
||||
)
|
||||
return html.Div([_view_row(v) for v in views])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_ui.py -v`
|
||||
Expected: PASS (8 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/saved_views/ui.py tests/saved_views/test_ui.py
|
||||
git commit -m "feat: helpers UI/validation des vues sauvegardées #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Intégration sur `/tableau`
|
||||
|
||||
Câble les helpers : barre « vues » (masquée par défaut), callback de visibilité (gating), callback de sauvegarde (avec modale), callback de remplissage du menu déroulant.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/tableau.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.saved_views.db` (`upsert`, `list_views`), `src.saved_views.ui` (`bar_style`, `prepare_view_to_save`, `saved_views_items`), `src.utils.table.build_view_query`, `src.pages._compte_shell.current_user_has_subscription`, `flask_login.current_user`.
|
||||
- Produces: composants d'id `saved-views-bar`, `btn-save-view`, `save-view-modal`, `save-view-name`, `btn-save-view-confirm`, `save-view-feedback`, `saved-views-menu`, `saved-views-refresh` (Store).
|
||||
|
||||
- [ ] **Step 1: Ajouter les imports en haut de `src/pages/tableau.py`**
|
||||
|
||||
```python
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import current_user_has_subscription
|
||||
from src.saved_views import db as saved_views_db
|
||||
from src.saved_views import ui as saved_views_ui
|
||||
```
|
||||
|
||||
(`build_view_query` a déjà été ajouté à l'import `src.utils.table` en Task 2.)
|
||||
|
||||
- [ ] **Step 2: Ajouter la barre « vues » dans la `table-menu`**
|
||||
|
||||
Dans le `children` de la `html.Div(className="table-menu", ...)` (≈ lignes 154-265), juste après le bouton « Choisir les colonnes » (id `tableau_columns_open`), insérer :
|
||||
|
||||
```python
|
||||
html.Div(
|
||||
id="saved-views-bar",
|
||||
style={"display": "none"},
|
||||
className="d-inline-flex align-items-center gap-2",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Sauvegarder la vue",
|
||||
id="btn-save-view",
|
||||
title="Enregistrer les filtres, tris et colonnes actuels sous un nom",
|
||||
),
|
||||
dbc.DropdownMenu(
|
||||
id="saved-views-menu",
|
||||
label="Mes vues",
|
||||
children=[],
|
||||
className="d-inline-block",
|
||||
),
|
||||
],
|
||||
),
|
||||
dcc.Store(id="saved-views-refresh"),
|
||||
dbc.Modal(
|
||||
id="save-view-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")),
|
||||
dbc.ModalBody(
|
||||
[
|
||||
dbc.Label("Nom de la vue"),
|
||||
dcc.Input(
|
||||
id="save-view-name",
|
||||
type="text",
|
||||
className="form-control",
|
||||
),
|
||||
html.Div(id="save-view-feedback", className="mt-2"),
|
||||
]
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button(
|
||||
"Enregistrer",
|
||||
id="btn-save-view-confirm",
|
||||
color="primary",
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ajouter le callback de visibilité (gating)**
|
||||
|
||||
À la fin de `src/pages/tableau.py`, ajouter :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("saved-views-bar", "style"),
|
||||
Input("tableau_url", "pathname"),
|
||||
)
|
||||
def toggle_saved_views_bar(_pathname):
|
||||
return saved_views_ui.bar_style(current_user_has_subscription())
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter le callback d'ouverture de la modale**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("save-view-modal", "is_open"),
|
||||
Input("btn-save-view", "n_clicks"),
|
||||
Input("btn-save-view-confirm", "n_clicks"),
|
||||
State("save-view-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_save_view_modal(_open, _confirm, is_open):
|
||||
return not is_open
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Ajouter le callback de sauvegarde (avec contrôle serveur)**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("save-view-feedback", "children"),
|
||||
Output("saved-views-refresh", "data"),
|
||||
Input("btn-save-view-confirm", "n_clicks"),
|
||||
State("save-view-name", "value"),
|
||||
State("tableau_datatable", "filter_query"),
|
||||
State("tableau_datatable", "sort_by"),
|
||||
State("tableau_datatable", "hidden_columns"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def save_view(_n, name, filter_query, sort_by, hidden_columns):
|
||||
has_sub = current_user_has_subscription()
|
||||
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
|
||||
if error:
|
||||
return html.Span(error, style={"color": "red"}), no_update
|
||||
query = build_view_query(filter_query, sort_by, hidden_columns)
|
||||
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
|
||||
return (
|
||||
html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}),
|
||||
clean_name,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ajouter le callback de remplissage du menu déroulant**
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("saved-views-menu", "children"),
|
||||
Input("tableau_url", "pathname"),
|
||||
Input("saved-views-refresh", "data"),
|
||||
)
|
||||
def populate_saved_views_menu(_pathname, _refresh):
|
||||
if not current_user_has_subscription():
|
||||
return []
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.saved_views_items(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import et la non-régression**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur (pas d'erreur de callback dupliqué/composant manquant).
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Vérification manuelle (smoke test)**
|
||||
|
||||
Démarrer `uv run python run.py`, se connecter avec un compte abonné (ou `TOUS_ABONNES=true` dans `.env`), aller sur `/tableau` :
|
||||
|
||||
- la barre « Sauvegarder la vue » + « Mes vues » est visible ;
|
||||
- appliquer un filtre, cliquer « Sauvegarder la vue », saisir un nom, Enregistrer → confirmation verte ;
|
||||
- ouvrir « Mes vues » → la vue apparaît ; cliquer dessus applique le filtre.
|
||||
- Se déconnecter → la barre disparaît.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/tableau.py
|
||||
git commit -m "feat: UI sauvegarde et application des vues sur /tableau #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Page de gestion `/compte/vues`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/_compte_shell.py` (ajout section `vues`)
|
||||
- Create: `src/pages/compte_vues.py`
|
||||
- Create: `tests/saved_views/test_compte_vues.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `src.pages._compte_shell` (`account_guard`, `account_shell`, `SECTIONS`), `src.saved_views.db` (`list_views`, `rename`, `delete`), `src.saved_views.ui.views_table`, `flask_login.current_user`.
|
||||
- Produces: page enregistrée sur `/compte/vues` ; section `vues` dans `SECTIONS`.
|
||||
|
||||
- [ ] **Step 1: Écrire le test de la section (échoue)**
|
||||
|
||||
Créer `tests/saved_views/test_compte_vues.py` :
|
||||
|
||||
```python
|
||||
from src.pages import _compte_shell as shell
|
||||
|
||||
|
||||
def test_vues_section_is_gated_subscription():
|
||||
section = next(s for s in shell.SECTIONS if s["key"] == "vues")
|
||||
assert section["href"] == "/compte/vues"
|
||||
assert section["require_subscription"] is True
|
||||
|
||||
|
||||
def test_vues_hidden_without_subscription():
|
||||
keys = {s["key"] for s in shell.visible_sections(has_subscription=False)}
|
||||
assert "vues" not in keys
|
||||
|
||||
|
||||
def test_vues_visible_with_subscription():
|
||||
keys = {s["key"] for s in shell.visible_sections(has_subscription=True)}
|
||||
assert "vues" in keys
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
|
||||
Expected: FAIL (`StopIteration` : section `vues` absente).
|
||||
|
||||
- [ ] **Step 3: Ajouter la section dans `src/pages/_compte_shell.py`**
|
||||
|
||||
Dans la liste `SECTIONS`, après l'entrée `filtres`, ajouter :
|
||||
|
||||
```python
|
||||
{
|
||||
"key": "vues",
|
||||
"label": "Mes vues",
|
||||
"href": "/compte/vues",
|
||||
"require_subscription": True,
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer le test, vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/saved_views/test_compte_vues.py -v`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Créer la page `src/pages/compte_vues.py`**
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import (
|
||||
ALL,
|
||||
Input,
|
||||
Output,
|
||||
State,
|
||||
callback,
|
||||
ctx,
|
||||
html,
|
||||
no_update,
|
||||
register_page,
|
||||
)
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._compte_shell import account_guard, account_shell
|
||||
from src.saved_views import db as saved_views_db
|
||||
from src.saved_views import ui as saved_views_ui
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/compte/vues",
|
||||
title="Mes vues | decp.info",
|
||||
name="Mes vues",
|
||||
description="Gérez vos vues enregistrées du tableau des marchés.",
|
||||
)
|
||||
|
||||
|
||||
def _content():
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return html.Div(
|
||||
[
|
||||
html.H2("Mes vues"),
|
||||
html.P(
|
||||
"Les vues que vous enregistrez depuis le Tableau apparaissent ici. "
|
||||
"Cliquez sur « Ouvrir » pour appliquer une vue."
|
||||
),
|
||||
html.Div(saved_views_ui.views_table(views), id="vues-list"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def layout(**_):
|
||||
guard = account_guard("/compte/vues", require_subscription=True)
|
||||
if guard is not None:
|
||||
return guard
|
||||
return account_shell("vues", _content())
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children"),
|
||||
Input({"type": "vue-delete", "index": ALL}, "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def delete_view(n_clicks):
|
||||
if not ctx.triggered_id or not any(n_clicks):
|
||||
return no_update
|
||||
saved_views_db.delete(ctx.triggered_id["index"], current_user.id)
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ajouter le renommage (modale partagée)**
|
||||
|
||||
Ajouter en bas de `src/pages/compte_vues.py` une modale de renommage et ses callbacks. Compléter `_content()` pour inclure la modale :
|
||||
|
||||
Dans `_content()`, ajouter à la liste des enfants (après `vues-list`) :
|
||||
|
||||
```python
|
||||
dbc.Modal(
|
||||
id="vue-rename-modal",
|
||||
is_open=False,
|
||||
children=[
|
||||
dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")),
|
||||
dbc.ModalBody(
|
||||
dbc.Input(id="vue-rename-input", type="text"),
|
||||
),
|
||||
dbc.ModalFooter(
|
||||
dbc.Button("Renommer", id="vue-rename-confirm", color="primary")
|
||||
),
|
||||
],
|
||||
),
|
||||
dbc.Input(id="vue-rename-id", type="hidden"),
|
||||
```
|
||||
|
||||
Puis les callbacks :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("vue-rename-modal", "is_open"),
|
||||
Output("vue-rename-id", "value"),
|
||||
Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-modal", "is_open"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def toggle_rename_modal(_open, _confirm, is_open):
|
||||
if isinstance(ctx.triggered_id, dict) and any(_open):
|
||||
return True, str(ctx.triggered_id["index"])
|
||||
return False, no_update
|
||||
|
||||
|
||||
@callback(
|
||||
Output("vues-list", "children", allow_duplicate=True),
|
||||
Input("vue-rename-confirm", "n_clicks"),
|
||||
State("vue-rename-id", "value"),
|
||||
State("vue-rename-input", "value"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def rename_view(_n, view_id, new_name):
|
||||
clean = saved_views_ui.clean_view_name(new_name)
|
||||
if not view_id or not clean:
|
||||
return no_update
|
||||
saved_views_db.rename(int(view_id), current_user.id, clean)
|
||||
views = saved_views_db.list_views(current_user.id, "tableau")
|
||||
return saved_views_ui.views_table(views)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vérifier l'import et les tests**
|
||||
|
||||
Run: `uv run python -c "import src.app"`
|
||||
Expected: aucune erreur.
|
||||
Run: `uv run pytest tests/saved_views -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Vérification manuelle (smoke test)**
|
||||
|
||||
Avec un compte abonné, aller sur `/compte/vues` : la liste des vues s'affiche ; « Supprimer » retire une vue ; « Renommer » ouvre la modale et met à jour le nom. Vérifier qu'un·e non-abonné·e est redirigé·e vers `/compte/abonnement`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/_compte_shell.py src/pages/compte_vues.py tests/saved_views/test_compte_vues.py
|
||||
git commit -m "feat: page /compte/vues (liste, renommer, supprimer) #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Lancer toute la suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (les tests Selenium peuvent nécessiter Chrome ; au minimum `tests/saved_views`, `tests/test_compte_shell.py`, `tests/subscriptions` doivent passer).
|
||||
|
||||
- [ ] **Step 2: Vérifier le formatage**
|
||||
|
||||
Run: `uv run ruff format --check src/saved_views src/pages/compte_vues.py`
|
||||
Expected: déjà formaté (sinon lancer `uv run ruff format` et committer).
|
||||
|
||||
- [ ] **Step 3: Commit final si nécessaire**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: formatage vues sauvegardées #95"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Couverture de la spec :**
|
||||
|
||||
- Stockage `saved_views` dans `users.sqlite` → Task 1. ✓
|
||||
- Vue = nom + query (`filtres`/`tris`/`colonnes`), réutilise le mécanisme existant → Task 2 (`build_view_query`) + Task 4. ✓
|
||||
- Bouton « Sauvegarder la vue » + modale de nommage → Task 4. ✓
|
||||
- Menu déroulant des vues, application par navigation → Task 4 (`saved_views_items`, liens `/tableau?<query>` + `restore_view_from_url` existant). ✓
|
||||
- Masquage pour non-abonné·es + contrôle serveur → Task 4 (`toggle_saved_views_bar`, `save_view` re-vérifie l'abonnement). ✓
|
||||
- Gestion `/compte/vues` (liste, renommer, supprimer), section gatée → Task 5. ✓
|
||||
- Isolation par `user_id`, cascade → Task 1 (tests). ✓
|
||||
- Hors périmètre titulaire/acheteur, colonne `table_name` réservée → respecté (`table_name="tableau"` partout). ✓
|
||||
- Tests DB + gating → Tasks 1, 3, 5. ✓
|
||||
|
||||
**Scan placeholders :** aucun TBD/TODO ; tout le code est fourni.
|
||||
|
||||
**Cohérence des types/noms :** `build_view_query(filter_query, sort_by, hidden_columns)` cohérent Task 2↔4 ; `upsert(user_id, table_name, name, query)`, `list_views(user_id, table_name)`, `rename(view_id, user_id, new_name)`, `delete(view_id, user_id)` cohérents Task 1↔4↔5 ; `prepare_view_to_save(has_subscription, name) -> (clean_name, error)` cohérent Task 3↔4 ; ids des composants pattern-matching (`vue-delete`, `vue-rename-open`) cohérents `ui.py`↔`compte_vues.py`.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Charte graphique des boutons — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer les couleurs de boutons dérivées du thème Simplex (danger mauve, secondary gris illisible) par une charte à trois rôles — `primary` terracotta, `secondary` gris ardoise, `danger` rouge — où la couleur encode la fonction et le remplissage l'emphase.
|
||||
|
||||
**Architecture:** Override CSS ciblant exclusivement les classes `.btn-*` dans `src/assets/css/style.css` (jamais les variables `--bs-*` racine, pour ne pas affecter alertes/badges). Le code applicatif continue d'utiliser `color="primary|secondary|danger"` + `outline=True|False` natifs de dash-bootstrap-components. Puis audit des boutons existants pour conformer leur usage à la charte.
|
||||
|
||||
**Tech Stack:** Dash 3.4, dash-bootstrap-components, Bootstrap 5 (thème Simplex), CSS, pytest + Selenium (DashComposite).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Override **uniquement** les sélecteurs `.btn-*` ; ne **jamais** modifier les variables racine `--bs-primary`, `--bs-danger`, `--bs-secondary` (alertes/badges doivent rester inchangés). Source : spec « Hors périmètre ».
|
||||
- Imports applicatifs toujours préfixés `src.` (ex. `src.pages.recherche`).
|
||||
- Palette exacte : terracotta `#b33821`, danger rouge `#c0392b`, secondary texte `#344054` / bord `#5a6570`, disabled bord `#ccc` / texte `#666`.
|
||||
- `border-radius: 3px` et typographie `Inter` poids 400 conservés sur les boutons.
|
||||
- Focus clavier visible (`:focus-visible`) sur tous les boutons restylés (accessibilité).
|
||||
- Respecter `prefers-reduced-motion` pour toute transition de survol.
|
||||
- Tests : `rtk pytest` (Selenium, nécessite Chrome/Chromium). `DEVELOPMENT=true` est positionné automatiquement.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Override CSS des trois rôles de boutons
|
||||
|
||||
Redéfinit l'apparence des classes `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger` dans la feuille de style applicative, en conservant l'API dbc native. Ajoute un test Selenium de non-régression vérifiant que la feuille est bien appliquée au bouton primaire de la page d'accueil (publique).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/assets/css/style.css` (bloc « Base Button Styles » autour des lignes 42-79)
|
||||
- Test: `tests/test_boutons.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien (première tâche).
|
||||
- Produces: classes CSS restylées `.btn-primary`, `.btn-outline-primary`, `.btn-secondary`, `.btn-outline-secondary`, `.btn-danger`, `.btn-outline-danger`. Aucun symbole Python.
|
||||
|
||||
- [ ] **Step 1: Écrire le test de non-régression (échoue d'abord car couleur cible non garantie)**
|
||||
|
||||
Créer `tests/test_boutons.py`. Le bouton « Rechercher » de la page d'accueil (`/`, public, `className="btn btn-primary"` dans `src/pages/recherche.py:56`) doit être rendu terracotta plein (le dégradé existant a `rgb(179, 56, 33)` en couleur médiane → on vérifie une composante rouge dominante et un fond non transparent).
|
||||
|
||||
```python
|
||||
from dash.testing.composite import DashComposite
|
||||
|
||||
|
||||
def _rgb_tuple(css_color: str) -> tuple[int, int, int]:
|
||||
"""Parse 'rgb(r, g, b)' ou 'rgba(r, g, b, a)' en (r, g, b)."""
|
||||
inner = css_color[css_color.index("(") + 1 : css_color.index(")")]
|
||||
parts = [p.strip() for p in inner.split(",")]
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
|
||||
|
||||
def test_btn_primary_is_terracotta(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_element("a.btn.btn-primary, button.btn.btn-primary", timeout=10)
|
||||
btn = dash_duo.find_element("a.btn.btn-primary, button.btn.btn-primary")
|
||||
|
||||
# Le dégradé terracotta est posé via background-image ; la couleur de
|
||||
# repli background-color ne doit pas être le bleu Bootstrap par défaut.
|
||||
bg_image = btn.value_of_css_property("background-image")
|
||||
color = btn.value_of_css_property("color")
|
||||
|
||||
assert "gradient" in bg_image # dégradé terracotta appliqué
|
||||
r, g, b = _rgb_tuple(color)
|
||||
assert (r, g, b) == (255, 255, 255) # texte blanc
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test pour vérifier l'état initial**
|
||||
|
||||
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
|
||||
Expected: PASS si le `.btn-primary` actuel s'applique déjà (dégradé + texte blanc déjà présents lignes 51-65). Ce test sert de **garde de non-régression** : il doit rester vert après le refactor CSS. S'il échoue ici, c'est que la feuille n'est pas chargée → investiguer avant de continuer.
|
||||
|
||||
- [ ] **Step 3: Réécrire le bloc boutons dans `style.css`**
|
||||
|
||||
Remplacer le bloc commenté/partiel « Base Button Styles » (lignes ~42-79 : le commentaire mort, `button.btn.btn-primary, button.show-hide { … }`, son `:hover`, et `button[disabled]`) par la charte complète ci-dessous. Conserver le dégradé terracotta existant pour `.btn-primary` et le `button.show-hide` (qui réutilise ce style).
|
||||
|
||||
```css
|
||||
/* ==========================================================================
|
||||
Boutons — charte à 3 rôles (couleur = fonction, remplissage = emphase)
|
||||
Voir docs/superpowers/specs/2026-06-30-charte-boutons-design.md
|
||||
Override scopé aux classes .btn-* uniquement (n'affecte pas les alertes).
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--btn-terracotta: #b33821;
|
||||
--btn-terracotta-text: #fff;
|
||||
--btn-secondary-text: #344054;
|
||||
--btn-secondary-border: #5a6570;
|
||||
--btn-secondary-hover-bg: #f1f3f5;
|
||||
--btn-danger: #c0392b;
|
||||
--btn-danger-text: #fff;
|
||||
}
|
||||
|
||||
/* Base commune : rayon + typo */
|
||||
.btn {
|
||||
border-radius: 3px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* --- PRIMARY plein : action principale validante (1 par contexte) --- */
|
||||
button.btn.btn-primary,
|
||||
a.btn.btn-primary,
|
||||
button.show-hide {
|
||||
display: block;
|
||||
outline: 0;
|
||||
color: var(--btn-terracotta-text);
|
||||
border: 0;
|
||||
background-image: linear-gradient(
|
||||
rgb(209, 96, 73),
|
||||
rgb(179, 56, 33) 26%,
|
||||
rgb(159, 36, 22)
|
||||
);
|
||||
}
|
||||
|
||||
button.btn.btn-primary:hover,
|
||||
a.btn.btn-primary:hover,
|
||||
button.show-hide:hover {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-image: linear-gradient(
|
||||
rgb(239, 126, 103),
|
||||
rgb(209, 86, 63) 26%,
|
||||
rgb(189, 66, 52)
|
||||
);
|
||||
}
|
||||
|
||||
/* --- PRIMARY outline : action affirmative de moindre emphase --- */
|
||||
.btn.btn-outline-primary {
|
||||
color: var(--btn-terracotta);
|
||||
border: 1px solid var(--btn-terracotta);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-primary:hover,
|
||||
.btn.btn-outline-primary:focus-visible {
|
||||
color: var(--btn-terracotta-text);
|
||||
background-color: var(--btn-terracotta);
|
||||
border-color: var(--btn-terracotta);
|
||||
}
|
||||
|
||||
/* --- SECONDARY : action neutre / alternative (gris ardoise) --- */
|
||||
.btn.btn-secondary,
|
||||
.btn.btn-outline-secondary {
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-secondary:hover,
|
||||
.btn.btn-secondary:focus-visible,
|
||||
.btn.btn-outline-secondary:hover,
|
||||
.btn.btn-outline-secondary:focus-visible {
|
||||
color: var(--btn-secondary-text);
|
||||
background-color: var(--btn-secondary-hover-bg);
|
||||
border-color: var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
/* --- DANGER outline : action destructive dans le flux courant --- */
|
||||
.btn.btn-outline-danger {
|
||||
color: var(--btn-danger);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-outline-danger:hover,
|
||||
.btn.btn-outline-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: var(--btn-danger);
|
||||
border-color: var(--btn-danger);
|
||||
}
|
||||
|
||||
/* --- DANGER plein : confirmation finale destructive (modale) --- */
|
||||
.btn.btn-danger {
|
||||
color: var(--btn-danger-text);
|
||||
border: 1px solid var(--btn-danger);
|
||||
background-color: var(--btn-danger);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.btn.btn-danger:hover,
|
||||
.btn.btn-danger:focus-visible {
|
||||
color: var(--btn-danger-text);
|
||||
background-color: #a93226;
|
||||
border-color: #a93226;
|
||||
}
|
||||
|
||||
/* --- État désactivé commun (conserve l'existant) --- */
|
||||
.btn[disabled],
|
||||
button[disabled] {
|
||||
border-color: #ccc;
|
||||
color: #666;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.btn {
|
||||
transition: background-color 0.15s ease, color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Relancer le test de non-régression**
|
||||
|
||||
Run: `rtk pytest tests/test_boutons.py::test_btn_primary_is_terracotta -v`
|
||||
Expected: PASS (le `.btn-primary` conserve son dégradé terracotta et son texte blanc).
|
||||
|
||||
- [ ] **Step 5: Vérifier l'absence de régression sur la suite complète**
|
||||
|
||||
Run: `rtk pytest`
|
||||
Expected: PASS (aucune régression introduite par le CSS).
|
||||
|
||||
- [ ] **Step 6: Vérification visuelle des trois rôles**
|
||||
|
||||
Lancer l'app (`python run.py`), se connecter, ouvrir `/compte/vues`. Vérifier :
|
||||
|
||||
- « Renommer » : gris ardoise lisible (texte `#344054`, bord `#5a6570`), plus de gris clair illisible ;
|
||||
- « Supprimer » : rouge `#c0392b` en outline, plus aucun mauve ; au survol, plein rouge texte blanc ;
|
||||
- focus clavier (Tab) : anneau/fond visible sur chaque bouton.
|
||||
Prendre une capture pour comparaison avec la capture initiale.
|
||||
|
||||
- [ ] **Step 7: Commit** (différé — voir note d'intégration en fin de plan : spec + plan + code committés ensemble)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Audit et conformation des boutons existants
|
||||
|
||||
Passe en revue les ~46 occurrences `color=…` et 8 `outline=True` pour s'assurer qu'elles respectent la charte : une seule action `primary` pleine par contexte, destructif outline par défaut sauf confirmation finale en modale (plein rouge), neutre en `secondary` outline.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Inspect: `src/saved_views/ui.py`, `src/pages/compte_vues.py`, `src/pages/compte_abonnement.py`, `src/pages/compte_abonnement_mes_infos.py`, `src/pages/mot_de_passe_oublie.py`, `src/pages/recherche.py`, `src/pages/tableau.py`, `src/app.py`
|
||||
- Modify: uniquement les fichiers dont un bouton enfreint la charte (à déterminer pendant l'audit)
|
||||
- Test: `tests/test_boutons.py` (réutilise la garde de non-régression de Task 1)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: classes CSS restylées de Task 1.
|
||||
- Produces: aucun symbole ; usages de boutons conformes à la charte.
|
||||
|
||||
- [ ] **Step 1: Recenser les boutons et leur rôle**
|
||||
|
||||
Run: `grep -rn 'dbc.Button\|className="btn' src/ --include=*.py`
|
||||
Pour chaque bouton, noter (page, libellé, `color`, `outline`) et confronter à la charte :
|
||||
|
||||
| Cas | Attendu |
|
||||
| ------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| Action principale d'un écran/formulaire (Rechercher, Enregistrer, Envoyer, S'abonner) | `color="primary"` plein |
|
||||
| Déclencheur destructif dans une liste (Supprimer, Se désabonner) | `color="danger", outline=True` |
|
||||
| Confirmation destructive finale en modale | `color="danger"` plein |
|
||||
| Action neutre (Renommer, Annuler, navigation) | `color="secondary", outline=True` |
|
||||
|
||||
Vérifier en particulier : aucun écran ne doit présenter **deux** boutons `primary` pleins en concurrence.
|
||||
|
||||
- [ ] **Step 2: Corriger les boutons non conformes**
|
||||
|
||||
Éditer uniquement les boutons divergents. Exemple de correction type (n'appliquer que si un cas réel est trouvé) :
|
||||
|
||||
```python
|
||||
# Avant — déclencheur de suppression en plein rouge dans une liste
|
||||
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger")
|
||||
# Après — outline par défaut hors confirmation finale
|
||||
dbc.Button("Supprimer", id={"type": "vue-delete", "index": vid}, color="danger", outline=True)
|
||||
```
|
||||
|
||||
Si l'audit ne révèle aucune divergence (les usages de `src/saved_views/ui.py` sont déjà conformes : Renommer = `secondary`+outline, Supprimer = `danger`+outline ; la confirmation modale `vue-rename-confirm` = `primary`), documenter « aucun changement nécessaire » dans le message de commit et passer au Step 3.
|
||||
|
||||
- [ ] **Step 3: Vérifier la non-régression**
|
||||
|
||||
Run: `rtk pytest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Vérification visuelle multi-pages**
|
||||
|
||||
Lancer l'app, parcourir `/compte/vues`, `/compte/abonnement`, `/` et `/tableau`. Confirmer que chaque bouton respecte son rôle et qu'il n'y a pas deux `primary` pleins concurrents par écran.
|
||||
|
||||
- [ ] **Step 5: Commit** (différé — voir note d'intégration ci-dessous)
|
||||
|
||||
---
|
||||
|
||||
## Note d'intégration
|
||||
|
||||
Sur demande explicite de l'utilisateur, le **spec + le plan + le code** sont
|
||||
committés **ensemble** (pas de commit intermédiaire). Après validation des deux
|
||||
tâches, faire un unique commit :
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-30-charte-boutons-design.md \
|
||||
docs/superpowers/plans/2026-06-30-charte-boutons.md \
|
||||
src/assets/css/style.css tests/test_boutons.py
|
||||
# + tout fichier page modifié pendant l'audit (Task 2)
|
||||
git commit -m "feat: charte graphique des boutons (primary/secondary/danger) #<issue>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
Branche courante : `dev` (auto-déploie sur test.decp.info). Ne pas pousser sans
|
||||
demande explicite.
|
||||
@@ -0,0 +1,754 @@
|
||||
# Panneau admin — éditeur générique de tables Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer les pages admin dédiées (`/admin/user/<id>`, `/admin/journal` + formulaire de changement de statut) par une unique page `/admin` : un sélecteur de table SQLite + une `dash_table.DataTable` filtrable/triable/paginée en natif dont les cellules sont éditables directement.
|
||||
|
||||
**Architecture:** Un registre statique de tables autorisées (`src/admin/tables.py`) porte toute la logique de validation/écriture, testable sans Dash. La page (`src/pages/admin/liste.py`) ne fait que du câblage : un callback unique, déclenché soit par le changement de table (recharge les données), soit par une édition de cellule (diff `data`/`data_previous`, valide, écrit, logue).
|
||||
|
||||
**Tech Stack:** Dash 3.4 (`dash_table.DataTable`, `editable`, `dropdown`), dash-bootstrap-components, sqlite3 brut (pas d'ORM).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-admin-table-editor-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Tables éditables : `users` (jamais `password_hash` — colonne totalement exclue de `SELECT`/affichage), `subscriptions`, `subscriber_state`. `admin_actions` est consultable dans le même sélecteur mais en lecture seule (aucune colonne éditable).
|
||||
- Colonnes jamais éditables, quelle que soit la table : la clé primaire, `created_at`, `updated_at`. Colonnes explicitement exclues de l'édition même si elles ne sont ni PK ni timestamp : `frisbii_customer_handle`, `frisbii_subscription_handle`, `user_id` (FK).
|
||||
- Nom de table et de colonne **toujours** validés contre le registre `TABLES` avant toute requête SQL — jamais de nom interpolé directement depuis une valeur venant du client sans passer par ce registre.
|
||||
- Chaque colonne éditable a un type attendu (`int`, `float`, `str`) ; une valeur qui ne convertit pas proprement est rejetée avant écriture (rien n'est écrit, une alerte s'affiche).
|
||||
- `dash_table.DataTable` : `filter_action="native"`, `sort_action="native"`, `page_action="native"`, `page_size=20` partout dans cette page.
|
||||
- `is_admin()` (déjà en place, `src/admin/guard.py`) garde la page : non-admin → composant 404 (`not_admin()`), jamais de redirection.
|
||||
- Chaque édition de cellule réussie est loguée via `log_action()` (déjà en place, `src/admin/db.py`) avec `action=f"edit_{table}"`, `target_user_id` dérivé par table, `details=f"{column}: {old!r} → {new!r}"`.
|
||||
- Run tests with `uv run pytest` (venv activation via le Bash tool ne met pas PATH à jour de façon fiable ici).
|
||||
- `sqlite3` : connexions autocommit (`isolation_level=None`) — aucun `conn.commit()` dans les nouvelles fonctions DB, comme partout ailleurs dans le projet.
|
||||
- `tests/users.test.sqlite` est committé dans git et partagé pour toute la session de tests Selenium (`USERS_DB_PATH` fixé globalement dans `pyproject.toml`) — toute ligne créée par un test Selenium doit être supprimée en `finally`, avec vérification `git status --short tests/users.test.sqlite` vide après un run complet.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Registre des tables (`src/admin/tables.py`)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/admin/tables.py`
|
||||
- Test: `tests/admin/test_tables.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `TableConfig` (dataclass), `TABLES: dict[str, TableConfig]`
|
||||
- Produces: `get_rows(table: str) -> list[dict]`
|
||||
- Produces: `set_cell(table: str, pk_value, column: str, value) -> None` (lève `ValueError` si table/colonne/valeur invalide)
|
||||
- Produces: `find_changed_cell(data: list[dict], data_previous: list[dict] | None) -> tuple[int, str, object, object] | None` — `(row_index, column, old_value, new_value)` de la première cellule modifiée, ou `None`.
|
||||
- Consumes: `get_conn()` de `src.auth.db` (déjà existant), `SUBSCRIPTION_STATUSES` de `src.subscriptions.db` (déjà existant, valeurs `("active", "trial", "cancelled", "expired", "pending")`), `PLANS` de `src.subscriptions.plans` (déjà existant, dict avec les clés `"simple"`, `"soutien"`).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/admin/test_tables.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
from src.admin import tables
|
||||
|
||||
|
||||
def test_get_rows_users_excludes_password_hash(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
auth_db.create_user("a@ex.fr", "secret-hash")
|
||||
|
||||
rows = tables.get_rows("users")
|
||||
|
||||
assert rows[0]["email"] == "a@ex.fr"
|
||||
assert "password_hash" not in rows[0]
|
||||
|
||||
|
||||
def test_set_cell_rejects_unknown_table(users_db_path):
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("not_a_table", 1, "email", "x@ex.fr")
|
||||
|
||||
|
||||
def test_set_cell_rejects_non_editable_column(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("users", uid, "id", "999")
|
||||
|
||||
|
||||
def test_set_cell_rejects_invalid_dropdown_value(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("subscriptions", sub_id, "status", "not_a_status")
|
||||
|
||||
|
||||
def test_set_cell_rejects_bad_type(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tables.set_cell("subscriptions", sub_id, "prix_ht", "not-a-number")
|
||||
|
||||
|
||||
def test_set_cell_writes_valid_value(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
|
||||
tables.set_cell("users", uid, "siret", "12345678900011")
|
||||
|
||||
rows = tables.get_rows("users")
|
||||
assert rows[0]["siret"] == "12345678900011"
|
||||
|
||||
|
||||
def test_set_cell_coerces_numeric_type(users_db_path):
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
auth_db.init_schema()
|
||||
sub_db.init_schema()
|
||||
uid = auth_db.create_user("a@ex.fr", "hash")
|
||||
_handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple")
|
||||
|
||||
tables.set_cell("subscriptions", sub_id, "prix_ht", "30")
|
||||
|
||||
rows = tables.get_rows("subscriptions")
|
||||
assert rows[0]["prix_ht"] == 30.0
|
||||
|
||||
|
||||
def test_find_changed_cell_detects_single_diff():
|
||||
data = [{"id": 1, "email": "new@ex.fr"}]
|
||||
data_previous = [{"id": 1, "email": "old@ex.fr"}]
|
||||
|
||||
result = tables.find_changed_cell(data, data_previous)
|
||||
|
||||
assert result == (0, "email", "old@ex.fr", "new@ex.fr")
|
||||
|
||||
|
||||
def test_find_changed_cell_returns_none_when_identical():
|
||||
data = [{"id": 1, "email": "a@ex.fr"}]
|
||||
data_previous = [{"id": 1, "email": "a@ex.fr"}]
|
||||
|
||||
assert tables.find_changed_cell(data, data_previous) is None
|
||||
|
||||
|
||||
def test_find_changed_cell_returns_none_when_previous_is_none():
|
||||
assert tables.find_changed_cell([{"id": 1}], None) is None
|
||||
|
||||
|
||||
def test_target_user_id_per_table():
|
||||
assert tables.TABLES["users"].target_user_id({"id": 7}) == 7
|
||||
assert tables.TABLES["subscriptions"].target_user_id({"user_id": 9}) == 9
|
||||
assert tables.TABLES["subscriber_state"].target_user_id({"user_id": 3}) == 3
|
||||
assert tables.TABLES["admin_actions"].target_user_id({"id": 1}) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_tables.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'src.admin.tables'`
|
||||
|
||||
- [ ] **Step 3: Implement `src/admin/tables.py`**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from src.auth.db import get_conn
|
||||
from src.subscriptions.db import SUBSCRIPTION_STATUSES
|
||||
from src.subscriptions.plans import PLANS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableConfig:
|
||||
columns: list[str]
|
||||
editable_columns: frozenset[str]
|
||||
pk: str
|
||||
column_types: dict[str, type]
|
||||
dropdowns: dict[str, list[str]]
|
||||
target_user_id: Callable[[dict], int | None]
|
||||
|
||||
|
||||
TABLES: dict[str, TableConfig] = {
|
||||
"users": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"email",
|
||||
"email_verified",
|
||||
"siret",
|
||||
"pending_email",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"email", "email_verified", "siret", "pending_email"}
|
||||
),
|
||||
pk="id",
|
||||
column_types={
|
||||
"email": str,
|
||||
"email_verified": int,
|
||||
"siret": str,
|
||||
"pending_email": str,
|
||||
},
|
||||
dropdowns={"email_verified": ["0", "1"]},
|
||||
target_user_id=lambda row: row["id"],
|
||||
),
|
||||
"subscriptions": TableConfig(
|
||||
columns=[
|
||||
"id",
|
||||
"user_id",
|
||||
"frisbii_customer_handle",
|
||||
"frisbii_subscription_handle",
|
||||
"plan",
|
||||
"prix_ht",
|
||||
"status",
|
||||
"current_period_end",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"plan", "prix_ht", "status", "current_period_end"}
|
||||
),
|
||||
pk="id",
|
||||
column_types={
|
||||
"plan": str,
|
||||
"prix_ht": float,
|
||||
"status": str,
|
||||
"current_period_end": str,
|
||||
},
|
||||
dropdowns={
|
||||
"status": list(SUBSCRIPTION_STATUSES),
|
||||
"plan": list(PLANS.keys()),
|
||||
},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
),
|
||||
"subscriber_state": TableConfig(
|
||||
columns=[
|
||||
"user_id",
|
||||
"trial_used",
|
||||
"votes_balance",
|
||||
"votes_last_credited_at",
|
||||
"updated_at",
|
||||
],
|
||||
editable_columns=frozenset(
|
||||
{"trial_used", "votes_balance", "votes_last_credited_at"}
|
||||
),
|
||||
pk="user_id",
|
||||
column_types={
|
||||
"trial_used": int,
|
||||
"votes_balance": int,
|
||||
"votes_last_credited_at": str,
|
||||
},
|
||||
dropdowns={"trial_used": ["0", "1"]},
|
||||
target_user_id=lambda row: row["user_id"],
|
||||
),
|
||||
"admin_actions": TableConfig(
|
||||
columns=["id", "admin_email", "action", "target_user_id", "details", "created_at"],
|
||||
editable_columns=frozenset(),
|
||||
pk="id",
|
||||
column_types={},
|
||||
dropdowns={},
|
||||
target_user_id=lambda row: None,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_rows(table: str) -> list[dict]:
|
||||
cfg = TABLES[table]
|
||||
cols_sql = ", ".join(cfg.columns)
|
||||
rows = get_conn().execute(f"SELECT {cols_sql} FROM {table}").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _coerce_value(table: str, column: str, value):
|
||||
cfg = TABLES[table]
|
||||
if column not in cfg.editable_columns:
|
||||
raise ValueError(f"Colonne non éditable : {column}")
|
||||
if column in cfg.dropdowns and str(value) not in cfg.dropdowns[column]:
|
||||
raise ValueError(f"Valeur non autorisée pour {column} : {value!r}")
|
||||
expected_type = cfg.column_types[column]
|
||||
try:
|
||||
if expected_type is int:
|
||||
return int(value)
|
||||
if expected_type is float:
|
||||
return float(value)
|
||||
return str(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Valeur invalide pour {column} : {value!r}") from exc
|
||||
|
||||
|
||||
def set_cell(table: str, pk_value, column: str, value) -> None:
|
||||
if table not in TABLES:
|
||||
raise ValueError(f"Table inconnue : {table}")
|
||||
cfg = TABLES[table]
|
||||
coerced = _coerce_value(table, column, value)
|
||||
get_conn().execute(
|
||||
f"UPDATE {table} SET {column} = ? WHERE {cfg.pk} = ?", (coerced, pk_value)
|
||||
)
|
||||
|
||||
|
||||
def find_changed_cell(
|
||||
data: list[dict], data_previous: list[dict] | None
|
||||
) -> tuple[int, str, object, object] | None:
|
||||
if data_previous is None or len(data) != len(data_previous):
|
||||
return None
|
||||
for i, (new_row, old_row) in enumerate(zip(data, data_previous)):
|
||||
for col, new_val in new_row.items():
|
||||
old_val = old_row.get(col)
|
||||
if new_val != old_val:
|
||||
return i, col, old_val, new_val
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_tables.py -v`
|
||||
Expected: 10 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/admin/tables.py tests/admin/test_tables.py
|
||||
git commit -m "feat(admin): add whitelisted table registry and cell validation/write logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Page éditeur générique + suppression du code obsolète
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/admin/liste.py` (réécrit entièrement)
|
||||
- Modify: `src/pages/admin/_shell.py` (retire `admin_nav`)
|
||||
- Modify: `src/auth/setup.py:47-53` (retire l'enregistrement du blueprint `admin_bp`)
|
||||
- Modify: `tests/admin/conftest.py` (retire les fixtures Flask-app devenues inutiles, garde `users_db_path`)
|
||||
- Delete: `src/pages/admin/detail.py`
|
||||
- Delete: `src/pages/admin/journal.py`
|
||||
- Delete: `src/admin/routes.py`
|
||||
- Delete: `tests/admin/test_routes.py`
|
||||
- Delete: `tests/admin/test_pages.py` (recréé dans la Task 3 avec le nouveau flux — le contenu actuel teste des routes qui n'existent plus)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) ; `is_admin()` (`src/admin/guard.py`, inchangé) ; `log_action()` (`src/admin/db.py`, inchangé) ; `not_admin()` (`src/pages/admin/_shell.py`).
|
||||
|
||||
- [ ] **Step 1: Simplifier `src/pages/admin/_shell.py`**
|
||||
|
||||
Remplacer tout le contenu par :
|
||||
|
||||
```python
|
||||
from dash import html
|
||||
|
||||
|
||||
def not_admin():
|
||||
return html.Div(
|
||||
html.H2("404", id="admin-404-heading"), className="py-5 text-center"
|
||||
)
|
||||
```
|
||||
|
||||
(`admin_nav` disparaît : il n'y a plus qu'une seule page, plus de navigation entre sous-pages.)
|
||||
|
||||
- [ ] **Step 2: Réécrire `src/pages/admin/liste.py`**
|
||||
|
||||
Remplacer tout le contenu par :
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, ctx, dash_table, html, no_update, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.admin.db import log_action
|
||||
from src.admin.guard import is_admin
|
||||
from src.admin.tables import TABLES, find_changed_cell, get_rows, set_cell
|
||||
from src.pages.admin._shell import not_admin
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/admin",
|
||||
title="Panneau admin | colibre",
|
||||
name="Admin",
|
||||
description="Panneau d'administration interne.",
|
||||
)
|
||||
|
||||
DEFAULT_TABLE = "users"
|
||||
|
||||
|
||||
def _columns_for(table: str):
|
||||
cfg = TABLES[table]
|
||||
return [
|
||||
{
|
||||
"name": col,
|
||||
"id": col,
|
||||
"editable": col in cfg.editable_columns,
|
||||
**({"presentation": "dropdown"} if col in cfg.dropdowns else {}),
|
||||
}
|
||||
for col in cfg.columns
|
||||
]
|
||||
|
||||
|
||||
def _dropdown_for(table: str):
|
||||
cfg = TABLES[table]
|
||||
return {
|
||||
col: {"options": [{"label": v, "value": v} for v in values]}
|
||||
for col, values in cfg.dropdowns.items()
|
||||
}
|
||||
|
||||
|
||||
def layout(**_):
|
||||
if not is_admin():
|
||||
return not_admin()
|
||||
return dbc.Container(
|
||||
[
|
||||
html.H2("Panneau admin"),
|
||||
html.Div(id="admin-alerts"),
|
||||
dbc.Select(
|
||||
id="admin-table-select",
|
||||
options=[{"label": name, "value": name} for name in TABLES],
|
||||
value=DEFAULT_TABLE,
|
||||
className="mb-3",
|
||||
style={"maxWidth": "300px"},
|
||||
),
|
||||
dash_table.DataTable(
|
||||
id="admin-table",
|
||||
columns=_columns_for(DEFAULT_TABLE),
|
||||
data=get_rows(DEFAULT_TABLE),
|
||||
dropdown=_dropdown_for(DEFAULT_TABLE),
|
||||
editable=True,
|
||||
filter_action="native",
|
||||
sort_action="native",
|
||||
page_action="native",
|
||||
page_size=20,
|
||||
),
|
||||
],
|
||||
fluid=True,
|
||||
className="py-4",
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("admin-table", "data"),
|
||||
Output("admin-table", "columns"),
|
||||
Output("admin-table", "dropdown"),
|
||||
Output("admin-alerts", "children"),
|
||||
Input("admin-table-select", "value"),
|
||||
Input("admin-table", "data"),
|
||||
State("admin-table", "data_previous"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _update_table(selected_table, data, data_previous):
|
||||
if ctx.triggered_id == "admin-table-select":
|
||||
return (
|
||||
get_rows(selected_table),
|
||||
_columns_for(selected_table),
|
||||
_dropdown_for(selected_table),
|
||||
None,
|
||||
)
|
||||
|
||||
change = find_changed_cell(data, data_previous)
|
||||
if change is None:
|
||||
return no_update, no_update, no_update, None
|
||||
|
||||
row_index, column, old_value, new_value = change
|
||||
pk_value = data[row_index][TABLES[selected_table].pk]
|
||||
try:
|
||||
set_cell(selected_table, pk_value, column, new_value)
|
||||
except ValueError as exc:
|
||||
return (
|
||||
no_update,
|
||||
no_update,
|
||||
no_update,
|
||||
dbc.Alert(str(exc), color="danger", dismissable=True),
|
||||
)
|
||||
|
||||
target_user_id = TABLES[selected_table].target_user_id(data[row_index])
|
||||
log_action(
|
||||
current_user.email,
|
||||
f"edit_{selected_table}",
|
||||
target_user_id,
|
||||
f"{column}: {old_value!r} → {new_value!r}",
|
||||
)
|
||||
return (
|
||||
no_update,
|
||||
no_update,
|
||||
no_update,
|
||||
dbc.Alert("Modification enregistrée.", color="success", dismissable=True),
|
||||
)
|
||||
```
|
||||
|
||||
Note d'implémentation : quand une écriture échoue (type invalide, colonne non éditable, valeur hors liste), le callback renvoie `no_update` pour `data` plutôt que de réécrire activement l'ancienne valeur — cela évite tout risque de boucle de déclenchement (le callback a `data` à la fois en `Input` et `Output`). La cellule affichée côté navigateur garde alors la valeur tapée (invalide) jusqu'à ce que l'admin resélectionne la table (ce qui recharge tout depuis la base) ; l'alerte rouge signale explicitement que rien n'a été écrit.
|
||||
|
||||
- [ ] **Step 3: Supprimer le code obsolète**
|
||||
|
||||
```bash
|
||||
git rm src/pages/admin/detail.py src/pages/admin/journal.py src/admin/routes.py tests/admin/test_routes.py tests/admin/test_pages.py
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Retirer l'enregistrement du blueprint dans `src/auth/setup.py`**
|
||||
|
||||
Supprimer ces lignes (actuellement `src/auth/setup.py:51-53`, juste après l'enregistrement de `auth_bp`) :
|
||||
|
||||
```python
|
||||
from src.admin.routes import admin_bp
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Nettoyer `tests/admin/conftest.py`**
|
||||
|
||||
Remplacer tout le contenu par (seule la fixture encore utilisée — par `tests/admin/test_tables.py` et `tests/admin/test_guard.py` — est conservée) :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def users_db_path(monkeypatch, tmp_path):
|
||||
from src.auth.db import reset_conn_for_tests
|
||||
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
||||
reset_conn_for_tests()
|
||||
yield db_path
|
||||
reset_conn_for_tests()
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Vérifier que le reste de la suite passe toujours**
|
||||
|
||||
Run: `uv run pytest tests/admin/ -v`
|
||||
Expected: tous les tests de `test_tables.py` et `test_guard.py` passent (pas de `test_pages.py`/`test_routes.py` à ce stade, supprimés à l'étape 3 — recréés Task 3)
|
||||
|
||||
- [ ] **Step 7: Vérification manuelle du rendu (sans navigateur)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run python -c "
|
||||
import os
|
||||
os.environ.setdefault('USERS_DB_PATH', 'tests/users.test.sqlite')
|
||||
os.environ.setdefault('SECRET_KEY', 'x')
|
||||
os.environ['ADMIN_EMAIL'] = 'admin@ex.fr'
|
||||
import src.app
|
||||
from unittest.mock import patch
|
||||
with src.app.app.server.test_request_context():
|
||||
import src.pages.admin.liste as liste
|
||||
admin = type('U', (), {'is_authenticated': True, 'email': 'admin@ex.fr'})()
|
||||
with patch('src.admin.guard.current_user', admin):
|
||||
print(type(liste.layout()))
|
||||
non_admin = type('U', (), {'is_authenticated': True, 'email': 'autre@ex.fr'})()
|
||||
with patch('src.admin.guard.current_user', non_admin):
|
||||
print(type(liste.layout()))
|
||||
"
|
||||
git status --short tests/users.test.sqlite
|
||||
```
|
||||
|
||||
Expected : deux lignes `<class '...Container.Container'>` puis `<class '...Div'>`, aucune trace d'erreur ; `git status --short tests/users.test.sqlite` ne renvoie rien (fichier inchangé).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(admin): replace dedicated pages with a generic table editor at /admin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Couverture Selenium bout-en-bout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/admin/test_pages.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `src.app.app`, `src.auth.db`, `src.subscriptions.db` (inchangés).
|
||||
|
||||
**Note sur le mécanisme d'édition testé :** la colonne `subscriptions.status` est une cellule "dropdown" (`presentation: "dropdown"`), dont l'interaction Selenium est plus fragile à automatiser de façon fiable qu'une cellule texte standard (widget de sélection non natif, rendu par dash_table). Ce test exerce donc l'édition sur `subscriptions.prix_ht` (cellule texte standard, éditable en cliquant/tapant/tabulant) pour prouver que tout le pipeline fonctionne (clic → édition → callback → écriture DB → audit). La logique de validation spécifique aux colonnes "dropdown" (`status`, `plan`, `email_verified`, `trial_used`) est déjà couverte sans navigateur par `test_set_cell_rejects_invalid_dropdown_value` (Task 1).
|
||||
|
||||
**Note sur les sélecteurs CSS de cellule :** `dash_table.DataTable` rend chaque cellule avec les attributs `data-dash-row` et `data-dash-column` (documentés, stables). Si le rendu réel diverge de ce qui est écrit ci-dessous (versions de Dash), inspecter le DOM réellement produit et ajuster les sélecteurs — l'important est : cliquer précisément dans la cellule ciblée, remplacer sa valeur, puis tabuler/cliquer ailleurs pour déclencher la mise à jour de la prop `data`.
|
||||
|
||||
- [ ] **Step 1: Écrire le fichier de test**
|
||||
|
||||
Create `tests/admin/test_pages.py`:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
|
||||
from dash.testing.composite import DashComposite
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.support.ui import Select
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
PASSWORD = "s3cretpass!"
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}@ex.fr"
|
||||
|
||||
|
||||
def _make_verified_user(email: str) -> int:
|
||||
auth_db.init_schema()
|
||||
uid = auth_db.create_user(email, generate_password_hash(PASSWORD))
|
||||
auth_db.set_email_verified(uid)
|
||||
return uid
|
||||
|
||||
|
||||
def _cleanup_user(user_id: int) -> None:
|
||||
conn = auth_db.get_conn()
|
||||
conn.execute("DELETE FROM admin_actions WHERE target_user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriptions WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM subscriber_state WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
# tests/users.test.sqlite est committé dans git et partagé pour toute la
|
||||
# session Selenium (USERS_DB_PATH fixé globalement dans pyproject.toml).
|
||||
# Les DELETE seuls laissent le fichier byte-diffé : sqlite_sequence
|
||||
# (compteur AUTOINCREMENT) n'est jamais remis à zéro par un DELETE.
|
||||
conn.execute(
|
||||
"UPDATE sqlite_sequence SET seq = 0 "
|
||||
"WHERE name IN ('users', 'subscriptions', 'admin_actions')"
|
||||
)
|
||||
|
||||
|
||||
def _login(dash_duo: DashComposite, email: str):
|
||||
dash_duo.driver.get(dash_duo.server_url + "/connexion")
|
||||
dash_duo.wait_for_element("input[name=email]", timeout=8).send_keys(email)
|
||||
dash_duo.driver.find_element("css selector", "input[name=password]").send_keys(
|
||||
PASSWORD
|
||||
)
|
||||
dash_duo.driver.find_element("css selector", "button[type=submit]").click()
|
||||
|
||||
|
||||
def test_admin_anonymous_gets_404(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
|
||||
|
||||
def test_admin_non_admin_gets_404(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin-only@ex.fr")
|
||||
email = _unique_email("regular")
|
||||
uid = _make_verified_user(email)
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, email)
|
||||
# Cet utilisateur n'a pas d'abonnement : une connexion réussie
|
||||
# redirige vers /compte/abonnement (voir _post_login_url dans
|
||||
# src/auth/routes.py). Ça confirme que le login a bien réussi avant
|
||||
# de vérifier /admin (sinon ce test serait indiscernable de
|
||||
# test_admin_anonymous_gets_404 en cas de régression du login).
|
||||
dash_duo.wait_for_text_to_equal("h2", "Abonnement", timeout=8)
|
||||
assert "/connexion" not in dash_duo.driver.current_url
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("#admin-404-heading", "404", timeout=8)
|
||||
finally:
|
||||
_cleanup_user(uid)
|
||||
|
||||
|
||||
def test_admin_full_flow(dash_duo: DashComposite, monkeypatch):
|
||||
from src.app import app
|
||||
|
||||
admin_email = _unique_email("admin")
|
||||
monkeypatch.setenv("ADMIN_EMAIL", admin_email)
|
||||
admin_uid = _make_verified_user(admin_email)
|
||||
|
||||
target_email = _unique_email("target")
|
||||
target_uid = _make_verified_user(target_email)
|
||||
sub_db.init_schema()
|
||||
_handle, sub_id = sub_db.create_pending(target_uid, "cust-e2e", "simple", 20.0)
|
||||
sub_db.set_status(sub_id, "active")
|
||||
|
||||
try:
|
||||
dash_duo.start_server(app)
|
||||
_login(dash_duo, admin_email)
|
||||
|
||||
dash_duo.driver.get(dash_duo.server_url + "/admin")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert target_email in dash_duo.driver.page_source # table users, par défaut
|
||||
|
||||
select = Select(dash_duo.wait_for_element("#admin-table-select", timeout=8))
|
||||
select.select_by_value("subscriptions")
|
||||
dash_duo.wait_for_element(
|
||||
"td[data-dash-column='prix_ht'][data-dash-row='0']", timeout=8
|
||||
)
|
||||
|
||||
cell = dash_duo.driver.find_element(
|
||||
"css selector", "td[data-dash-column='prix_ht'][data-dash-row='0']"
|
||||
)
|
||||
cell.click()
|
||||
active_input = dash_duo.driver.switch_to.active_element
|
||||
active_input.send_keys(Keys.CONTROL, "a")
|
||||
active_input.send_keys("30")
|
||||
active_input.send_keys(Keys.TAB)
|
||||
|
||||
dash_duo.wait_for_text_to_equal(
|
||||
"#admin-alerts .alert-success", "Modification enregistrée.", timeout=8
|
||||
)
|
||||
|
||||
row = sub_db.get_current(target_uid)
|
||||
assert row["prix_ht"] == 30.0
|
||||
|
||||
select = Select(
|
||||
dash_duo.driver.find_element("css selector", "#admin-table-select")
|
||||
)
|
||||
select.select_by_value("admin_actions")
|
||||
dash_duo.wait_for_text_to_equal("h2", "Panneau admin", timeout=8)
|
||||
assert "edit_subscriptions" in dash_duo.driver.page_source
|
||||
assert "prix_ht" in dash_duo.driver.page_source
|
||||
finally:
|
||||
_cleanup_user(target_uid)
|
||||
_cleanup_user(admin_uid)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests**
|
||||
|
||||
Run: `uv run pytest tests/admin/test_pages.py -v`
|
||||
Expected: 3 passed. Si le clic/édition de cellule ne déclenche pas la mise à jour attendue, inspecter le DOM réel (`dash_duo.driver.page_source` ou les outils de dev du navigateur en mode non-headless) et ajuster les sélecteurs de `test_admin_full_flow` en conséquence — la structure ci-dessus est le point de départ, pas une garantie absolue selon la version exacte de `dash_table`.
|
||||
|
||||
- [ ] **Step 3: Vérifier `tests/users.test.sqlite` inchangé**
|
||||
|
||||
Run: `git status --short tests/users.test.sqlite`
|
||||
Expected: aucune sortie.
|
||||
|
||||
- [ ] **Step 4: Lancer la suite admin complète + suite globale**
|
||||
|
||||
Run: `uv run pytest tests/admin/ -v`
|
||||
Expected: tous les tests passent (test_tables.py, test_guard.py, test_pages.py).
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: aucune régression sur le reste de la suite.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/admin/test_pages.py
|
||||
git commit -m "test(admin): add end-to-end Selenium coverage for the generic table editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage :** registre de tables + validation/coercition + audit → Task 1 ; page unique, callback de sélection/édition, suppression des pages/route obsolètes, nettoyage `setup.py`/`conftest.py` → Task 2 ; couverture Selenium (accès anonyme/non-admin, flux d'édition complet, audit consultable) → Task 3. `password_hash` jamais sélectionnée (Task 1, `get_rows`/`columns` du registre `users`). Colonnes jamais éditables (PK, timestamps, handles Frisbii, `user_id`) → absentes de `editable_columns` dans le registre (Task 1), revalidées côté serveur dans le callback (Task 2). Pas de tâche pour l'ajout/suppression de lignes ni pour d'autres tables — explicitement hors périmètre du spec.
|
||||
- **Cohérence des types :** `TableConfig`, `TABLES`, `get_rows`, `set_cell`, `find_changed_cell` (Task 1) sont importés tels quels dans `liste.py` (Task 2) sans renommage. `TableConfig.target_user_id` est un `Callable[[dict], int | None]` dans les deux tâches.
|
||||
- **Écart noté par rapport au libellé du spec** ("la cellule revient à son ancienne valeur" en cas d'échec) : le callback renvoie `no_update` plutôt que de réécrire activement l'ancienne valeur, pour éviter tout risque de boucle de déclenchement sur une prop qui est à la fois `Input` et `Output` du même callback. L'intégrité des données est préservée de façon identique (rien n'est écrit en base en cas d'échec) ; seul le retour visuel immédiat diffère (l'alerte rouge est explicite, la cellule garde la saisie invalide jusqu'au rechargement de la table). Documenté dans Task 2, Step 2.
|
||||
@@ -0,0 +1,935 @@
|
||||
# Cartes /acheteur et /titulaire : afficher la contrepartie Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Sur `/acheteurs/<id>` et `/titulaires/<id>`, remplacer la carte Plotly à point unique (basée sur l'annuaire) par une carte dash-leaflet clusterisée qui affiche l'organisme consulté **et** sa contrepartie (titulaires pour un acheteur, acheteurs pour un titulaire).
|
||||
|
||||
**Architecture:** Extraction d'une fonction partagée `build_org_markers` (déjà dupliquée en boucle dans `get_geographic_maps` d'`/observatoire`) puis nouvelle fonction `get_org_location_map` qui assemble deux couches `dl.GeoJSON` clusterisées (une par type d'organisme) avec cadrage automatique (`bounds`). Un nouveau callback par page, déclenché par l'URL et le filtre Année, appelle cette fonction ; l'ancien callback annuaire perd la responsabilité de la carte.
|
||||
|
||||
**Tech Stack:** Python, Polars, DuckDB, Dash, `dash-leaflet` (`dl`), `dash-leaflet.express` (`dlx`), `dash-extensions` (`Namespace`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Couleurs des marqueurs : acheteur `#E69F00` (orange), titulaire `#56B4E9` (bleu ciel) — identiques à `/observatoire`.
|
||||
- La carte suit le filtre **Année** de la page (`acheteur_year`/`titulaire_year`), comme les tableaux Top titulaires/acheteurs.
|
||||
- Cadrage automatique (`fitBounds`) sur l'organisme + toutes ses contreparties, avec padding léger (`boundsOptions={"padding": [30, 30], "maxZoom": 12}`).
|
||||
- Repli sur une vue France fixe (`center=[46.6, 2.2]`, `zoom=5`) si aucune coordonnée exploitable n'est disponible (dataset sans colonnes longitude/latitude, ou organisme sans marché géolocalisé) — jamais d'exception.
|
||||
- Pas de découpage par région/DOM-TOM ni de bascule chloroplèthe sur `/acheteur`/`/titulaire` (réservé à `/observatoire`).
|
||||
- Les contrôles de zoom +/- natifs de `dl.Map` (Leaflet) doivent rester actifs — ne jamais passer `zoomControl=False`.
|
||||
- Imports internes toujours via `src.` (ex. `from src.figures import ...`), jamais `figures.py` en import relatif nu.
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv via l'outil Bash n'est pas fiable dans cet environnement).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify `src/figures.py`** : ajoute `ORG_COLORS`, `build_org_markers`, `get_org_location_map` ; met à jour `get_geographic_maps` et `make_clusters_map` pour réutiliser `build_org_markers`/`ORG_COLORS` ; supprime `point_on_map` (devient mort après migration des deux pages).
|
||||
- **Modify `src/pages/acheteur.py`** : nouveau callback `update_acheteur_map` ; `update_acheteur_infos` perd la construction de la carte.
|
||||
- **Modify `src/pages/titulaire.py`** : nouveau callback `update_titulaire_map` ; `update_titulaire_infos` perd la construction de la carte.
|
||||
- **Test `tests/test_figures.py`** : tests unitaires de `build_org_markers` et `get_org_location_map`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extraire `build_org_markers` et l'utiliser dans `get_geographic_maps`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py:410-551` (fonction `get_geographic_maps`), `src/figures.py:582-633` (fonction `make_clusters_map`)
|
||||
- Test: `tests/test_figures.py` (ajout en fin de fichier)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `ORG_COLORS: dict[str, str]` (clés `"acheteur"`/`"titulaire"`) ; `build_org_markers(lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]) -> list[dict]` où chaque `dict` a les clés `"lat"`, `"lon"`, `"tooltip"`, `"marker_color"`. Renvoie `[]` (sans exception) si les colonnes `{org_type}_longitude`/`{org_type}_latitude` sont absentes du `LazyFrame`.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (qui vont échouer, `build_org_markers` n'existe pas encore)**
|
||||
|
||||
Ajouter à la fin de `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
def test_build_org_markers_groups_and_counts():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u2",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u3",
|
||||
"acheteur_longitude": -1.68,
|
||||
"acheteur_latitude": 48.11,
|
||||
"acheteur_nom": "ACHETEUR B",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
markers = build_org_markers(lff, "acheteur")
|
||||
|
||||
assert len(markers) == 2
|
||||
marker_a = next(m for m in markers if m["tooltip"].startswith("ACHETEUR A"))
|
||||
assert marker_a["tooltip"] == "ACHETEUR A (2 marchés)"
|
||||
assert marker_a["lat"] == 48.85
|
||||
assert marker_a["lon"] == 2.35
|
||||
assert marker_a["marker_color"] == "#E69F00"
|
||||
|
||||
|
||||
def test_build_org_markers_filters_null_coordinates():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": None,
|
||||
"acheteur_latitude": None,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
},
|
||||
{
|
||||
"uid": "u2",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR B",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
markers = build_org_markers(lff, "acheteur")
|
||||
|
||||
assert len(markers) == 1
|
||||
assert markers[0]["tooltip"] == "ACHETEUR B (1 marchés)"
|
||||
|
||||
|
||||
def test_build_org_markers_missing_columns_returns_empty():
|
||||
from src.figures import build_org_markers
|
||||
|
||||
lff = pl.LazyFrame([{"uid": "u1", "acheteur_nom": "ACHETEUR A"}])
|
||||
|
||||
assert build_org_markers(lff, "acheteur") == []
|
||||
assert build_org_markers(lff, "titulaire") == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -k build_org_markers -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'build_org_markers'`
|
||||
|
||||
- [ ] **Step 3: Ajouter `ORG_COLORS` et `build_org_markers`, refactoriser `get_geographic_maps` et `make_clusters_map`**
|
||||
|
||||
Dans `src/figures.py`, insérer juste avant `def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:` (ligne 410) :
|
||||
|
||||
```python
|
||||
ORG_COLORS = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
|
||||
def build_org_markers(
|
||||
lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]
|
||||
) -> list[dict]:
|
||||
"""Regroupe les marchés par point géographique pour un type d'organisme.
|
||||
|
||||
Renvoie [] (sans exception) si les colonnes longitude/latitude de ce
|
||||
type sont absentes du LazyFrame (ex: tests/test.parquet).
|
||||
"""
|
||||
lon_col = f"{org_type}_longitude"
|
||||
lat_col = f"{org_type}_latitude"
|
||||
nom_col = f"{org_type}_nom"
|
||||
|
||||
available = set(lff.collect_schema().names())
|
||||
if lon_col not in available or lat_col not in available:
|
||||
return []
|
||||
|
||||
lff_org = (
|
||||
lff.select("uid", lon_col, lat_col, nom_col)
|
||||
.group_by(lon_col, lat_col, nom_col)
|
||||
.len("nb_marches")
|
||||
.filter(pl.col(lat_col).is_not_null() & pl.col(lon_col).is_not_null())
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"lat": row[lat_col],
|
||||
"lon": row[lon_col],
|
||||
"tooltip": f"{row[nom_col]} ({row['nb_marches']} marchés)",
|
||||
"marker_color": ORG_COLORS[org_type],
|
||||
}
|
||||
for row in lff_org.collect().to_dicts()
|
||||
]
|
||||
```
|
||||
|
||||
Dans le corps de `get_geographic_maps` (fonction interne `make_map_data`), remplacer le bloc actuel :
|
||||
|
||||
```python
|
||||
else:
|
||||
_map_type: str = "clusters"
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
lff_org = (
|
||||
lff.select(
|
||||
"uid",
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.group_by(
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.len("nb_marches")
|
||||
.filter(
|
||||
pl.col(f"{org_type}_latitude").is_not_null()
|
||||
& pl.col(f"{org_type}_longitude").is_not_null()
|
||||
)
|
||||
)
|
||||
|
||||
markers = []
|
||||
|
||||
# Couleurs accessibles (Okabe-Ito)
|
||||
colors = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
for row in lff_org.collect().to_dicts():
|
||||
markers.append(
|
||||
{
|
||||
"lat": row[f"{org_type}_latitude"],
|
||||
"lon": row[f"{org_type}_longitude"],
|
||||
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
|
||||
"marker_color": colors[org_type],
|
||||
}
|
||||
)
|
||||
dfs.append(markers)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
else:
|
||||
_map_type: str = "clusters"
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
dfs.append(build_org_markers(lff, org_type))
|
||||
```
|
||||
|
||||
Dans `make_clusters_map`, remplacer :
|
||||
|
||||
```python
|
||||
# Couleurs
|
||||
color_acheteur = region_acheteurs[0]["marker_color"]
|
||||
color_titulaire = region_titulaires[0]["marker_color"]
|
||||
```
|
||||
|
||||
par (évite un `IndexError` maintenant que `build_org_markers` peut renvoyer une liste vide) :
|
||||
|
||||
```python
|
||||
# Couleurs
|
||||
color_acheteur = ORG_COLORS["acheteur"]
|
||||
color_titulaire = ORG_COLORS["titulaire"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: PASS (tous les tests, y compris les 3 nouveaux)
|
||||
|
||||
- [ ] **Step 5: Lancer la suite complète pour vérifier l'absence de régression**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent (identique à avant la modification)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
refactor(figures): extraire build_org_markers pour réutilisation
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Ajouter `get_org_location_map`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/figures.py` (nouvelle fonction, à la suite de `make_clusters_map`, ligne 633)
|
||||
- Test: `tests/test_figures.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ORG_COLORS`, `build_org_markers` (Task 1)
|
||||
- Produces: `get_org_location_map(dff: pl.DataFrame, home_type: Literal["acheteur", "titulaire"], map_id: str) -> dl.Map`
|
||||
|
||||
- [ ] **Step 1: Écrire les tests (qui vont échouer, la fonction n'existe pas encore)**
|
||||
|
||||
Ajouter à la fin de `tests/test_figures.py` :
|
||||
|
||||
```python
|
||||
def test_get_org_location_map_bounds_cover_home_and_counterpart():
|
||||
import dash_leaflet as dl
|
||||
|
||||
from src.figures import get_org_location_map
|
||||
|
||||
dff = pl.DataFrame(
|
||||
[
|
||||
{
|
||||
"uid": "u1",
|
||||
"acheteur_longitude": 2.35,
|
||||
"acheteur_latitude": 48.85,
|
||||
"acheteur_nom": "ACHETEUR A",
|
||||
"titulaire_longitude": -1.68,
|
||||
"titulaire_latitude": 48.11,
|
||||
"titulaire_nom": "TITULAIRE A",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
|
||||
|
||||
assert isinstance(leaflet_map, dl.Map)
|
||||
assert leaflet_map.bounds == [[48.11, -1.68], [48.85, 2.35]]
|
||||
|
||||
geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
|
||||
assert {layer.id for layer in geojson_layers} == {
|
||||
"test-map-acheteur",
|
||||
"test-map-titulaire",
|
||||
}
|
||||
|
||||
|
||||
def test_get_org_location_map_defaults_to_france_view_without_coordinates():
|
||||
from src.figures import get_org_location_map
|
||||
|
||||
dff = pl.DataFrame(
|
||||
[{"uid": "u1", "acheteur_nom": "ACHETEUR A", "titulaire_nom": "TITULAIRE A"}]
|
||||
)
|
||||
|
||||
leaflet_map = get_org_location_map(dff, "acheteur", "test-map")
|
||||
|
||||
assert leaflet_map.center == [46.6, 2.2]
|
||||
assert leaflet_map.zoom == 5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -k get_org_location_map -v`
|
||||
Expected: FAIL avec `ImportError: cannot import name 'get_org_location_map'`
|
||||
|
||||
- [ ] **Step 3: Implémenter `get_org_location_map`**
|
||||
|
||||
Dans `src/figures.py`, ajouter à la suite de `make_clusters_map` (après la ligne `return leaflet_map` qui la termine) :
|
||||
|
||||
```python
|
||||
def get_org_location_map(
|
||||
dff: pl.DataFrame,
|
||||
home_type: Literal["acheteur", "titulaire"],
|
||||
map_id: str,
|
||||
) -> dl.Map:
|
||||
"""Carte cluster (dash-leaflet) d'un organisme et de sa contrepartie.
|
||||
|
||||
Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
|
||||
consultée) ainsi que ceux de son type complémentaire, clusterisés et
|
||||
colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur
|
||||
l'ensemble des points ; repli sur une vue France fixe si aucun point
|
||||
n'est disponible.
|
||||
"""
|
||||
counterpart_type: Literal["acheteur", "titulaire"] = (
|
||||
"titulaire" if home_type == "acheteur" else "acheteur"
|
||||
)
|
||||
|
||||
lff = dff.lazy()
|
||||
markers_by_type = {
|
||||
"acheteur": build_org_markers(lff, "acheteur"),
|
||||
"titulaire": build_org_markers(lff, "titulaire"),
|
||||
}
|
||||
|
||||
ns = Namespace("dash_clientside", "leaflet")
|
||||
point_to_layer = ns("pointToLayer")
|
||||
cluster_to_layer = ns("clusterToLayer")
|
||||
|
||||
layers: list = [dl.TileLayer()]
|
||||
all_points: list[tuple[float, float]] = []
|
||||
# Ordre fixe (titulaire puis acheteur) pour que l'organisme consulté
|
||||
# soit toujours peint au-dessus de sa contrepartie, comme sur /observatoire.
|
||||
for org_type in ("titulaire", "acheteur"):
|
||||
markers = markers_by_type[org_type]
|
||||
if not markers:
|
||||
continue
|
||||
all_points.extend((m["lat"], m["lon"]) for m in markers)
|
||||
layers.append(
|
||||
dl.GeoJSON(
|
||||
data=dlx.dicts_to_geojson(markers),
|
||||
cluster=True,
|
||||
zoomToBoundsOnClick=True,
|
||||
pointToLayer=point_to_layer,
|
||||
clusterToLayer=cluster_to_layer,
|
||||
id=f"{map_id}-{org_type}",
|
||||
options={"fillColor": ORG_COLORS[org_type]},
|
||||
)
|
||||
)
|
||||
|
||||
map_kwargs: dict = {}
|
||||
if all_points:
|
||||
lats = [lat for lat, _ in all_points]
|
||||
lons = [lon for _, lon in all_points]
|
||||
map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]]
|
||||
map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
|
||||
else:
|
||||
map_kwargs["center"] = [46.6, 2.2]
|
||||
map_kwargs["zoom"] = 5
|
||||
|
||||
return dl.Map(
|
||||
layers,
|
||||
style={"width": "100%", "height": "300px"},
|
||||
id=map_id,
|
||||
**map_kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
Note : `home_type` et `counterpart_type` ne sont pas utilisés pour changer le contenu affiché (les deux types sont toujours construits et affichés) — `home_type` sert uniquement à documenter l'intention de l'appelant et pourrait être utilisé plus tard pour un style différencié. Garder le paramètre tel quel (il est requis par l'appelant pour construire les colonnes de la requête, cf. Task 3/4).
|
||||
|
||||
- [ ] **Step 4: Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `uv run pytest tests/test_figures.py -v`
|
||||
Expected: PASS (tous les tests, y compris les 2 nouveaux)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/figures.py tests/test_figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(figures): ajouter get_org_location_map (carte cluster organisme + contrepartie)
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Brancher la carte sur `/acheteurs/<id>`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/acheteur.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_org_location_map` (Task 2), `_acheteur_scope(pathname, ach_year) -> tuple[str, list]` (déjà existant, `src/pages/acheteur.py:50`), `schema.names()`, `query_marches` (déjà importés)
|
||||
|
||||
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
|
||||
|
||||
Dans `src/pages/acheteur.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_org_location_map,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Retirer la construction de la carte de `update_acheteur_infos`**
|
||||
|
||||
Remplacer le callback existant (celui avec les `Output` `acheteur_siret`, `acheteur_nom`, `acheteur_commune`, `acheteur_map`, `acheteur_departement`, `acheteur_region`, `acheteur_lien_annuaire`) :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="acheteur_siret", component_property="children"),
|
||||
Output(component_id="acheteur_nom", component_property="children"),
|
||||
Output(component_id="acheteur_commune", component_property="children"),
|
||||
Output(component_id="acheteur_map", component_property="children"),
|
||||
Output(component_id="acheteur_departement", component_property="children"),
|
||||
Output(component_id="acheteur_region", component_property="children"),
|
||||
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
)
|
||||
def update_acheteur_infos(url):
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
# if len(acheteur_siret) != 14:
|
||||
# acheteur_siret = (
|
||||
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||
# )
|
||||
data = get_annuaire_data(acheteur_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
acheteur_map = point_on_map(
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
acheteur_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = ""
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
acheteur_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
acheteur_map,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="acheteur_siret", component_property="children"),
|
||||
Output(component_id="acheteur_nom", component_property="children"),
|
||||
Output(component_id="acheteur_commune", component_property="children"),
|
||||
Output(component_id="acheteur_departement", component_property="children"),
|
||||
Output(component_id="acheteur_region", component_property="children"),
|
||||
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
)
|
||||
def update_acheteur_infos(url):
|
||||
acheteur_siret = url.split("/")[-1]
|
||||
data = get_annuaire_data(acheteur_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = ""
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
acheteur_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output(component_id="acheteur_map", component_property="children"),
|
||||
Input(component_id="acheteur_url", component_property="pathname"),
|
||||
Input(component_id="acheteur_year", component_property="value"),
|
||||
)
|
||||
def update_acheteur_map(pathname, ach_year):
|
||||
where_sql, params = _acheteur_scope(pathname, ach_year)
|
||||
geo_columns = [
|
||||
col
|
||||
for col in [
|
||||
"uid",
|
||||
"acheteur_longitude",
|
||||
"acheteur_latitude",
|
||||
"acheteur_nom",
|
||||
"titulaire_longitude",
|
||||
"titulaire_latitude",
|
||||
"titulaire_nom",
|
||||
]
|
||||
if col in schema.names()
|
||||
]
|
||||
dff = query_marches(where_sql, params, columns=geo_columns)
|
||||
return get_org_location_map(dff, "acheteur", "acheteur_map_leaflet")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Vérifier qu'il n'y a plus de référence à `point_on_map` dans le fichier**
|
||||
|
||||
Run: `grep -n "point_on_map" src/pages/acheteur.py`
|
||||
Expected: aucune sortie
|
||||
|
||||
- [ ] **Step 4: Lancer la suite de tests complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent
|
||||
|
||||
- [ ] **Step 5: Vérification manuelle dans le navigateur**
|
||||
|
||||
Run: `python run.py` (dans un terminal séparé, avec le `.venv` activé et un `.env` pointant vers un jeu de données de production contenant des colonnes longitude/latitude)
|
||||
|
||||
Naviguer vers `/acheteurs/<un-siret-existant>` :
|
||||
|
||||
- La carte doit afficher des marqueurs orange (acheteur) et bleus (titulaires), clusterisés.
|
||||
- Les boutons de zoom +/- doivent être visibles sur la carte.
|
||||
- Changer le filtre Année doit mettre à jour la carte.
|
||||
|
||||
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/acheteur.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(acheteur): afficher les titulaires sur la carte de la fiche acheteur
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Brancher la carte sur `/titulaires/<id>` et supprimer `point_on_map`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/titulaire.py`
|
||||
- Modify: `src/figures.py:184-254` (suppression de `point_on_map`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_org_location_map` (Task 2), `_titulaire_scope(pathname, titulaire_year) -> tuple[str, list]` (déjà existant, `src/pages/titulaire.py:49`)
|
||||
|
||||
- [ ] **Step 1: Mettre à jour l'import de `src.figures`**
|
||||
|
||||
Dans `src/pages/titulaire.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_org_location_map,
|
||||
get_top_org_table,
|
||||
make_column_picker,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Retirer la construction de la carte de `update_titulaire_infos`**
|
||||
|
||||
Remplacer le callback existant (celui avec les `Output` `titulaire_siret`, `titulaire_nom`, `titulaire_commune`, `titulaire_map`, `titulaire_departement`, `titulaire_region`, `titulaire_lien_annuaire`, `titulaire_activite_libelle`) :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="titulaire_siret", component_property="children"),
|
||||
Output(component_id="titulaire_nom", component_property="children"),
|
||||
Output(component_id="titulaire_commune", component_property="children"),
|
||||
Output(component_id="titulaire_map", component_property="children"),
|
||||
Output(component_id="titulaire_departement", component_property="children"),
|
||||
Output(component_id="titulaire_region", component_property="children"),
|
||||
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||
Output(component_id="titulaire_activite_libelle", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
)
|
||||
def update_titulaire_infos(url):
|
||||
titulaire_siret = url.split("/")[-1]
|
||||
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
|
||||
activite_libelle_row = DF_TITULAIRES.filter(
|
||||
pl.col("titulaire_id") == titulaire_siret
|
||||
).select("titulaire_activite_libelle")
|
||||
activite_libelle = (
|
||||
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
|
||||
)
|
||||
else:
|
||||
activite_libelle = ""
|
||||
data = get_annuaire_data(titulaire_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
titulaire_map = point_on_map(
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
titulaire_map = html.Div()
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = html.Span(
|
||||
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||
)
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
titulaire_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
titulaire_map,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
activite_libelle,
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output(component_id="titulaire_siret", component_property="children"),
|
||||
Output(component_id="titulaire_nom", component_property="children"),
|
||||
Output(component_id="titulaire_commune", component_property="children"),
|
||||
Output(component_id="titulaire_departement", component_property="children"),
|
||||
Output(component_id="titulaire_region", component_property="children"),
|
||||
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||
Output(component_id="titulaire_activite_libelle", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
)
|
||||
def update_titulaire_infos(url):
|
||||
titulaire_siret = url.split("/")[-1]
|
||||
if "titulaire_activite_libelle" in DF_TITULAIRES.columns:
|
||||
activite_libelle_row = DF_TITULAIRES.filter(
|
||||
pl.col("titulaire_id") == titulaire_siret
|
||||
).select("titulaire_activite_libelle")
|
||||
activite_libelle = (
|
||||
activite_libelle_row.item(0, 0) if activite_libelle_row.height > 0 else ""
|
||||
)
|
||||
else:
|
||||
activite_libelle = ""
|
||||
data = get_annuaire_data(titulaire_siret)
|
||||
data_etablissement = data.get("matching_etablissements") if data else None
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
)
|
||||
departement = f"{nom_departement} ({code_departement})"
|
||||
lien_annuaire = (
|
||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||
)
|
||||
raison_sociale = data["nom_raison_sociale"]
|
||||
libelle_commune = data_etablissement["libelle_commune"]
|
||||
|
||||
else:
|
||||
code_departement, nom_departement, nom_region = "", "", ""
|
||||
departement = ""
|
||||
lien_annuaire = ""
|
||||
raison_sociale = html.Span(
|
||||
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||
)
|
||||
libelle_commune = ""
|
||||
|
||||
return (
|
||||
titulaire_siret,
|
||||
raison_sociale,
|
||||
libelle_commune,
|
||||
departement,
|
||||
nom_region,
|
||||
lien_annuaire,
|
||||
activite_libelle,
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output(component_id="titulaire_map", component_property="children"),
|
||||
Input(component_id="titulaire_url", component_property="pathname"),
|
||||
Input(component_id="titulaire_year", component_property="value"),
|
||||
)
|
||||
def update_titulaire_map(pathname, titulaire_year):
|
||||
where_sql, params = _titulaire_scope(pathname, titulaire_year)
|
||||
geo_columns = [
|
||||
col
|
||||
for col in [
|
||||
"uid",
|
||||
"acheteur_longitude",
|
||||
"acheteur_latitude",
|
||||
"acheteur_nom",
|
||||
"titulaire_longitude",
|
||||
"titulaire_latitude",
|
||||
"titulaire_nom",
|
||||
]
|
||||
if col in schema.names()
|
||||
]
|
||||
dff = query_marches(where_sql, params, columns=geo_columns)
|
||||
return get_org_location_map(dff, "titulaire", "titulaire_map_leaflet")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Supprimer `point_on_map` (devenue inutilisée) de `src/figures.py`**
|
||||
|
||||
Vérifier d'abord qu'il n'y a plus aucun appelant :
|
||||
|
||||
Run: `grep -rn "point_on_map" src/`
|
||||
Expected: aucune sortie
|
||||
|
||||
Puis supprimer la fonction complète dans `src/figures.py` (actuellement lignes 184-254, entre `def point_on_map(lat, lon, departement_code=None):` et le `return html.Div(...)` qui la clôt, juste avant `class DataTable(dash_table.DataTable):`) :
|
||||
|
||||
```python
|
||||
def point_on_map(lat, lon, departement_code=None):
|
||||
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
|
||||
|
||||
Args:
|
||||
lat: Coordonnée de latitude
|
||||
lon: Coordonnée de longitude
|
||||
departement_code: Code du département (ex: '75', '971', etc.)
|
||||
|
||||
Returns:
|
||||
html.Div contenant la carte, ou div vide si invalide
|
||||
"""
|
||||
# Validation des coordonnées
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return html.Div() # Div vide pour les coordonnées invalides
|
||||
|
||||
# Vérification que les coordonnées sont valides
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return html.Div()
|
||||
|
||||
# Si aucun code département n'est fourni, retourner une div vide
|
||||
if not departement_code:
|
||||
return html.Div()
|
||||
|
||||
# Détermination de la région en utilisant le code département
|
||||
# Logique identique à get_geographic_maps
|
||||
if departement_code in ["971", "972", "973", "974", "976"]:
|
||||
region_key = departement_code # Département d'outre-mer
|
||||
elif len(departement_code) == 2: # Département métropolitain
|
||||
region_key = "Hexagone"
|
||||
else:
|
||||
return html.Div() # Format de code département invalide
|
||||
|
||||
# Paramètres de carte par région (réutilisés de get_geographic_maps)
|
||||
regions = {
|
||||
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
|
||||
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
|
||||
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
|
||||
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
|
||||
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
|
||||
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
|
||||
}
|
||||
|
||||
settings = regions.get(region_key, regions["Hexagone"])
|
||||
|
||||
# Création de la carte
|
||||
fig = px.scatter_map(
|
||||
lat=[lat],
|
||||
lon=[lon],
|
||||
height=300,
|
||||
# width=400,
|
||||
color=[1],
|
||||
zoom=settings["zoom"],
|
||||
)
|
||||
|
||||
fig.update_traces(marker=dict(size=10))
|
||||
|
||||
# Configuration de la carte (interactive - zoomable)
|
||||
fig.update_layout(
|
||||
map_style="light", # Fond de carte clair
|
||||
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
||||
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
|
||||
mapbox_zoom=settings["zoom"],
|
||||
coloraxis_showscale=False,
|
||||
)
|
||||
|
||||
return html.Div(
|
||||
dcc.Graph(figure=fig, config={"displayModeBar": False}),
|
||||
)
|
||||
```
|
||||
|
||||
Supprimer l'intégralité de ce bloc (la ligne vide qui le sépare de `class DataTable` peut rester telle quelle).
|
||||
|
||||
- [ ] **Step 4: Lancer la suite de tests complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: tous les tests passent
|
||||
|
||||
- [ ] **Step 5: Vérification manuelle dans le navigateur**
|
||||
|
||||
Run: `python run.py` (avec un jeu de données de production contenant des colonnes longitude/latitude)
|
||||
|
||||
Naviguer vers `/titulaires/<un-siret-existant>` :
|
||||
|
||||
- La carte doit afficher un marqueur bleu (titulaire) et des marqueurs orange (acheteurs), clusterisés.
|
||||
- Les boutons de zoom +/- doivent être visibles.
|
||||
- Changer le filtre Année doit mettre à jour la carte.
|
||||
|
||||
Arrêter le serveur (`Ctrl+C`) une fois la vérification faite.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/titulaire.py src/figures.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(titulaire): afficher les acheteurs sur la carte de la fiche titulaire
|
||||
|
||||
Supprime point_on_map, devenue inutilisée après migration des deux pages
|
||||
vers get_org_location_map.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
@@ -0,0 +1,849 @@
|
||||
# Refonte du tunnel d'abonnement — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendre l'offre d'abonnement visible aux visiteurs non connectés et raccourcir le tunnel d'inscription/abonnement.
|
||||
|
||||
**Architecture:** Les composants « cards » d'abonnement quittent la page authentifiée `/compte/abonnement` pour la page publique `/a-propos/abonnement`, complétés d'un unique bouton « Je m'abonne » dont la cible dépend de l'état de connexion. Le choix du plan (simple/soutien) migre dans le formulaire `mes-infos`. La validation d'email connecte automatiquement l'utilisateur et le dépose dans `mes-infos`.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask + Flask-Login, SQLite (users.sqlite), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Interface et libellés **en français**.
|
||||
- Imports du code applicatif toujours préfixés `src.` (ex. `src.pages.compte.abonnement`).
|
||||
- Lancer les tests avec `uv run pytest` (l'activation de venv via Bash n'est pas fiable ici).
|
||||
- **Soumission de formulaire native** : seul un `dcc.Input`/`dbc.Input` portant un
|
||||
attribut `name` est soumis dans le POST d'un `html.Form`. Une interaction Dash
|
||||
contrôlée (clic sur carte, `RadioItems`…) n'est **pas** soumise nativement — la
|
||||
relier à un `dcc.Input(type="hidden", name=...)` via callback. `html.Input` n'existe
|
||||
pas en Dash 3.4.
|
||||
- Boutons : classes Bootstrap existantes (`btn btn-primary`, `btn btn-outline-danger`…), cohérentes avec le reste du site.
|
||||
- Chaque commit doit passer `uv run pytest` (au minimum les tests touchés).
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : `linkedin_button` paramétrable + inscription vers le tunnel
|
||||
|
||||
Le bouton LinkedIn est réutilisé par `/connexion` et `/inscription`. On lui ajoute un
|
||||
paramètre `next_url` optionnel pour que l'inscription via LinkedIn finisse dans
|
||||
`mes-infos` (au lieu du fallback `/compte/admin`).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py` (fonction `linkedin_button`, lignes 31-37)
|
||||
- Modify: `src/pages/inscription.py` (appel `linkedin_button()`, ligne 73)
|
||||
- Test: `tests/test_linkedin_button.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `linkedin_button(next_url: str | None = None) -> html.A`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/test_linkedin_button.py
|
||||
def test_linkedin_button_default_has_no_next():
|
||||
from src.pages.connexion import linkedin_button
|
||||
|
||||
html_str = str(linkedin_button())
|
||||
assert "href='/auth/linkedin'" in html_str
|
||||
assert "next=" not in html_str
|
||||
|
||||
|
||||
def test_linkedin_button_with_next_appends_query():
|
||||
from src.pages.connexion import linkedin_button
|
||||
|
||||
html_str = str(linkedin_button("/compte/abonnement/mes-infos"))
|
||||
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in html_str
|
||||
|
||||
|
||||
def test_inscription_linkedin_targets_mes_infos():
|
||||
from src.pages import inscription
|
||||
|
||||
assert "/auth/linkedin?next=/compte/abonnement/mes-infos" in str(
|
||||
inscription.layout()
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_linkedin_button.py -v`
|
||||
Expected: FAIL (`linkedin_button()` prend 0 argument / le `next` n'est pas présent)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/connexion.py`, remplacer la fonction `linkedin_button` :
|
||||
|
||||
```python
|
||||
def linkedin_button(next_url: str | None = None):
|
||||
href = "/auth/linkedin"
|
||||
if next_url:
|
||||
href += f"?next={next_url}"
|
||||
return html.A(
|
||||
"Connexion avec LinkedIn",
|
||||
href=href,
|
||||
className="btn w-100 mb-2",
|
||||
style={"backgroundColor": "rgb(10, 102, 194)", "color": "white"},
|
||||
)
|
||||
```
|
||||
|
||||
Dans `src/pages/inscription.py`, ligne 73, remplacer `linkedin_button(),` par :
|
||||
|
||||
```python
|
||||
linkedin_button("/compte/abonnement/mes-infos"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_linkedin_button.py -v`
|
||||
Expected: PASS (3 tests)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py src/pages/inscription.py tests/test_linkedin_button.py
|
||||
git commit -m "feat(abonnement): linkedin_button paramétrable par next, inscription vers mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Auto-login à la validation d'email
|
||||
|
||||
`verify_email` doit connecter automatiquement l'utilisateur et le rediriger vers
|
||||
`mes-infos` au lieu de `/connexion?verified=1`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/auth/routes.py` (fonction `verify_email`, lignes 92-101)
|
||||
- Test: `tests/auth/test_verify_email.py` (modifier le test du token valide)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `db.get_user_by_id`, `db.set_email_verified`, `tokens.consume_verification_token`, `User`, `login_user` (déjà importés dans le module).
|
||||
|
||||
- [ ] **Step 1 : Adapter le test pour qu'il échoue**
|
||||
|
||||
Dans `tests/auth/test_verify_email.py`, remplacer `test_verify_email_valid_token_marks_user_verified` par :
|
||||
|
||||
```python
|
||||
def test_verify_email_valid_token_logs_in_and_redirects_to_mes_infos(
|
||||
client, users_db_path
|
||||
):
|
||||
db.init_schema()
|
||||
uid = db.create_user("a@b.c", "hash")
|
||||
token = tokens.create_verification_token(uid)
|
||||
|
||||
resp = client.get(f"/auth/verify-email?token={token}")
|
||||
assert resp.status_code == 302
|
||||
assert "/compte/abonnement/mes-infos" in resp.headers["Location"]
|
||||
assert db.get_user_by_id(uid)["email_verified"] == 1
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("_user_id") == str(uid)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_verify_email.py -v`
|
||||
Expected: FAIL (Location vaut encore `/connexion?verified=1`, session vide)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/auth/routes.py`, dans `verify_email`, remplacer les deux dernières lignes
|
||||
(`db.set_email_verified(user_id)` + `return redirect("/connexion?verified=1")`) par :
|
||||
|
||||
```python
|
||||
db.set_email_verified(user_id)
|
||||
login_user(User(db.get_user_by_id(user_id)), remember=True)
|
||||
return redirect("/compte/abonnement/mes-infos")
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/auth/test_verify_email.py -v`
|
||||
Expected: PASS (4 tests — les 3 autres restent inchangés)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/auth/routes.py tests/auth/test_verify_email.py
|
||||
git commit -m "feat(auth): auto-login à la validation d'email, redirection vers mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Page publique `/a-propos/abonnement` (cards + bouton « Je m'abonne »)
|
||||
|
||||
On installe dans la page publique : les cards informatives (sans bouton par card),
|
||||
l'explainer, et le bouton unique conditionnel. On retire la sous-section
|
||||
« Fonctionnalités incluses » des CGU (doublon avec l'explainer).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/a_propos/abonnement.py`
|
||||
- Test: `tests/test_abonnement_public.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
|
||||
- `_plan_card(meta: dict, trial: int | None) -> dbc.Card`
|
||||
- `_plan_cards(trial_for=plans.trial_days) -> dbc.Row`
|
||||
- `_explainer() -> dbc.Row`
|
||||
- `_subscribe_button(authenticated: bool, has_active_subscription: bool, tous_abonnes: bool) -> html.Div`
|
||||
- `abonnement_features` et `subscription_terms` restent exportés (déjà consommés par `compte/abonnement_mes_infos.py`).
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
```python
|
||||
# tests/test_abonnement_public.py
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plan_env(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
|
||||
|
||||
def test_plan_cards_informative_no_button():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._plan_cards(trial_for=lambda key: 2))
|
||||
assert "Abonnement" in text
|
||||
assert "Abonnement de soutien" in text
|
||||
assert "2 jours d'essai gratuit" in text
|
||||
assert "S'abonner" not in text
|
||||
|
||||
|
||||
def test_subscribe_button_visitor_goes_to_inscription():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(False, False, False))
|
||||
assert "Je m'abonne" in text
|
||||
assert "href='/inscription'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_authenticated_no_sub_goes_to_mes_infos():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(True, False, False))
|
||||
assert "href='/compte/abonnement/mes-infos'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_active_sub_manages():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(True, True, False))
|
||||
assert "Gérer mon abonnement" in text
|
||||
assert "href='/compte/abonnement'" in text
|
||||
|
||||
|
||||
def test_subscribe_button_disabled_when_tous_abonnes():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page._subscribe_button(False, False, True))
|
||||
assert "disabled" in text
|
||||
|
||||
|
||||
def test_cgu_terms_trimmed_of_features_section():
|
||||
from src.pages.a_propos import abonnement as page
|
||||
|
||||
text = str(page.subscription_terms)
|
||||
assert "Fonctionnalités incluses" not in text
|
||||
assert "Résiliation" in text
|
||||
assert "Tarifs" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_abonnement_public.py -v`
|
||||
Expected: FAIL (`_plan_cards`/`_subscribe_button` inexistants ; « Fonctionnalités incluses » encore présent)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/a_propos/abonnement.py` :
|
||||
|
||||
3a. Ajouter les imports en tête :
|
||||
|
||||
```python
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import dcc, html, register_page
|
||||
from flask_login import current_user
|
||||
|
||||
from src.pages._apropos_shell import apropos_shell
|
||||
from src.subscriptions import db as sub_db
|
||||
from src.subscriptions import plans
|
||||
from src.utils import TOUS_ABONNES
|
||||
from src.utils.seo import META_CONTENT
|
||||
```
|
||||
|
||||
3b. Ajouter les composants cards (avant `subscription_terms`) :
|
||||
|
||||
```python
|
||||
def _plan_card(meta: dict, trial: int | None):
|
||||
badge = (
|
||||
html.Div(f"{trial} jours d'essai gratuit", className="mb-3") if trial else None
|
||||
)
|
||||
return dbc.Card(
|
||||
dbc.CardBody(
|
||||
[
|
||||
html.H4(meta["label"], className="mb-1"),
|
||||
html.P(
|
||||
f"{meta['prix_ht']} € HT / mois "
|
||||
f"({str(int(meta['prix_ht']) * 1.2).replace('.0', '')} € TTC)",
|
||||
className="text-muted mb-3",
|
||||
),
|
||||
html.P(meta["description"], className="mb-3"),
|
||||
badge,
|
||||
],
|
||||
className="p-4",
|
||||
),
|
||||
className="h-100",
|
||||
)
|
||||
|
||||
|
||||
def _plan_cards(trial_for=plans.trial_days):
|
||||
cards = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if meta:
|
||||
cards.append(_plan_card(meta, trial_for(key)))
|
||||
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-4")
|
||||
|
||||
|
||||
def _explainer():
|
||||
col_left = dbc.Col(
|
||||
[html.H4("Fonctionnalités réservées aux abonné·es :"), abonnement_features],
|
||||
md=6,
|
||||
style={
|
||||
"borderRight": "1px solid var(--bs-border-color)",
|
||||
"paddingRight": "2rem",
|
||||
},
|
||||
)
|
||||
col_right = dbc.Col(
|
||||
[
|
||||
html.H4("Ce que les abonnements permettent"),
|
||||
html.Ul(
|
||||
[
|
||||
html.Li(
|
||||
"passer plus de temps à développer colibre et moins de temps "
|
||||
"à chercher des missions"
|
||||
),
|
||||
html.Li(
|
||||
"rédaction d'études à partir des données, par exemple sur les "
|
||||
"acheteurs dont les données sont introuvables et les raisons "
|
||||
"de cette non-publication."
|
||||
),
|
||||
html.Li(
|
||||
"fédération des bonnes volontés souhaitant militer pour une "
|
||||
"législation plus ambitieuse sur la transparence de la "
|
||||
"commande publique."
|
||||
),
|
||||
]
|
||||
),
|
||||
],
|
||||
md=6,
|
||||
style={"paddingLeft": "2rem"},
|
||||
)
|
||||
return dbc.Row([col_left, col_right], className="align-items-start pt-2 mb-4")
|
||||
|
||||
|
||||
def _subscribe_button(
|
||||
authenticated: bool, has_active_subscription: bool, tous_abonnes: bool
|
||||
):
|
||||
if tous_abonnes:
|
||||
return html.Div(
|
||||
[
|
||||
dbc.Alert(
|
||||
"Les fonctionnalités normalement accessibles contre un abonnement "
|
||||
"de 20 € HT par mois sont accessibles à tous et toutes en attendant "
|
||||
"la validation de mon dossier pour recevoir des paiements.",
|
||||
color="info",
|
||||
),
|
||||
html.A(
|
||||
"Je m'abonne",
|
||||
href="#",
|
||||
className="btn btn-secondary disabled",
|
||||
),
|
||||
],
|
||||
className="text-center my-4",
|
||||
)
|
||||
if authenticated and has_active_subscription:
|
||||
label, href = "Gérer mon abonnement", "/compte/abonnement"
|
||||
elif authenticated:
|
||||
label, href = "Je m'abonne", "/compte/abonnement/mes-infos"
|
||||
else:
|
||||
label, href = "Je m'abonne", "/inscription"
|
||||
return html.Div(
|
||||
html.A(label, href=href, className="btn btn-primary btn-lg"),
|
||||
className="text-center my-4",
|
||||
)
|
||||
```
|
||||
|
||||
3c. Dans `subscription_terms`, **supprimer** le bloc « Fonctionnalités incluses »
|
||||
(le `html.H4("Fonctionnalités incluses")`, les deux `dcc.Markdown` qui l'entourent
|
||||
et l'insertion de `abonnement_features`). Garder `abonnement_features` **défini** au
|
||||
niveau module (il sert à `_explainer`). Le reste des CGU est inchangé.
|
||||
|
||||
3d. Remplacer `layout` :
|
||||
|
||||
```python
|
||||
def layout(**_):
|
||||
authenticated = current_user.is_authenticated
|
||||
has_active = authenticated and sub_db.has_active_subscription(current_user.id)
|
||||
body = html.Div(
|
||||
[
|
||||
_plan_cards(),
|
||||
_explainer(),
|
||||
_subscribe_button(authenticated, has_active, TOUS_ABONNES),
|
||||
subscription_terms,
|
||||
]
|
||||
)
|
||||
return apropos_shell("abonnement", body)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_abonnement_public.py -v`
|
||||
Expected: PASS (6 tests)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/a_propos/abonnement.py tests/test_abonnement_public.py
|
||||
git commit -m "feat(abonnement): page publique avec cards et bouton Je m'abonne conditionnel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : `/compte/abonnement` non-abonné → bouton « M'abonner » / « Me réabonner »
|
||||
|
||||
La branche non-abonné n'affiche plus de cards mais un bouton dont le libellé dépend de
|
||||
`has_used_trial`. On supprime les composants cards devenus inutilisés ici et on migre
|
||||
les tests obsolètes.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py`
|
||||
- Modify: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `db.has_used_trial(user_id) -> bool` (existant).
|
||||
- Produces: `_reabo_button(has_used_trial: bool) -> html.Div`
|
||||
|
||||
- [ ] **Step 1 : Écrire / migrer les tests**
|
||||
|
||||
Dans `tests/subscriptions/test_compte_abonnement.py`, **supprimer** ces 4 tests
|
||||
(devenus obsolètes : les cards et leurs boutons ont quitté ce module) :
|
||||
`test_plan_cards_present_when_no_subscription`, `test_plan_cards_no_trial_when_trial_used`,
|
||||
`test_subscribe_buttons_disabled_when_tous_abonnes`, `test_subscribe_buttons_active_when_flag_off`.
|
||||
|
||||
**Ajouter** :
|
||||
|
||||
```python
|
||||
def test_reabo_button_never_subscribed():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._reabo_button(has_used_trial=False))
|
||||
assert "M'abonner" in text
|
||||
assert "Me réabonner" not in text
|
||||
assert "href='/a-propos/abonnement'" in text
|
||||
|
||||
|
||||
def test_reabo_button_former_subscriber():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._reabo_button(has_used_trial=True))
|
||||
assert "Me réabonner" in text
|
||||
assert "href='/a-propos/abonnement'" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: FAIL (`_reabo_button` inexistant)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py` :
|
||||
|
||||
3a. Supprimer l'import `from src.pages.a_propos.abonnement import abonnement_features`.
|
||||
|
||||
3b. Supprimer les fonctions `_plan_card`, `_plan_cards`, `_explainer` (devenues
|
||||
inutilisées ici — elles vivent désormais dans `a_propos/abonnement.py`).
|
||||
|
||||
3c. Ajouter :
|
||||
|
||||
```python
|
||||
def _reabo_button(has_used_trial: bool):
|
||||
label = "Me réabonner" if has_used_trial else "M'abonner"
|
||||
return html.Div(
|
||||
[
|
||||
html.P(
|
||||
"Abonnez-vous pour accéder aux fonctionnalités réservées "
|
||||
"aux abonné·es.",
|
||||
className="mb-3",
|
||||
),
|
||||
html.A(
|
||||
label,
|
||||
href="/a-propos/abonnement",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
],
|
||||
className="mb-4",
|
||||
)
|
||||
```
|
||||
|
||||
3d. Dans `layout`, la branche `else` (celle qui faisait
|
||||
`body.extend([_plan_cards(trial_used=trial_used), _explainer()])`) devient :
|
||||
|
||||
```python
|
||||
else:
|
||||
if row is not None and row["status"] == "expired":
|
||||
body.append(
|
||||
dbc.Alert(
|
||||
"Votre abonnement a expiré.", color="warning", className="mb-4"
|
||||
)
|
||||
)
|
||||
body.append(_reabo_button(db.has_used_trial(current_user.id)))
|
||||
```
|
||||
|
||||
La ligne `trial_used = db.has_used_trial(...)` en tête de `layout` n'est plus utilisée
|
||||
que… nulle part → **la supprimer** (lignes ~290-292 : la variable `trial_used`).
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: PASS (les tests `_active_view`, `_tous_abonnes_banner`, `_show_active_view` + les 2 nouveaux)
|
||||
|
||||
- [ ] **Step 5 : Vérifier l'absence de régression d'import**
|
||||
|
||||
Run: `uv run pytest tests/test_compte_pages.py tests/subscriptions/ -v`
|
||||
Expected: PASS (aucun module ne référence plus `compte.abonnement._plan_cards`)
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): /compte/abonnement non-abonné affiche M'abonner/Me réabonner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : CTA de `/connexion` vers l'offre d'abonnement
|
||||
|
||||
Le bouton du bas de `/connexion` pointe vers `/a-propos/abonnement` au lieu de
|
||||
`/inscription`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/connexion.py` (bloc final du `layout`, lignes 91-96)
|
||||
- Test: `tests/test_connexion_page.py` (créer)
|
||||
|
||||
- [ ] **Step 1 : Écrire le test qui échoue**
|
||||
|
||||
```python
|
||||
# tests/test_connexion_page.py
|
||||
def test_connexion_cta_points_to_abonnement():
|
||||
from src.pages import connexion
|
||||
|
||||
text = str(connexion.layout())
|
||||
assert "/a-propos/abonnement" in text
|
||||
assert "Voir les abonnements" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer le test pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/test_connexion_page.py -v`
|
||||
Expected: FAIL (le CTA pointe encore vers `/inscription`)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/pages/connexion.py`, remplacer le dernier bloc du `layout` (le
|
||||
`html.A("Créer un compte avec mon adresse email", href="/inscription", ...)`) par :
|
||||
|
||||
```python
|
||||
html.A(
|
||||
"Pas encore de compte ? Voir les abonnements",
|
||||
href="/a-propos/abonnement",
|
||||
className="btn btn-primary w-100",
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/test_connexion_page.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/connexion.py tests/test_connexion_page.py
|
||||
git commit -m "feat(abonnement): CTA connexion vers /a-propos/abonnement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 : Sélection de la formule par cartes cliquables dans `mes-infos`
|
||||
|
||||
Le formulaire `mes-infos` n'exige plus de paramètre `?plan=`. La formule se choisit en
|
||||
cliquant l'une des deux cartes (réutilisées de la page publique). Aucune sélection par
|
||||
défaut : un texte invite l'utilisateur ; la carte choisie prend un fond teinté. Un
|
||||
callback recopie la valeur dans un champ caché natif soumis dans le POST.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||
- Modify: `src/assets/css/style.css`
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py` (créer)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `_plan_card` (Task 3, depuis `src.pages.a_propos.abonnement`),
|
||||
`plans.plan_meta`, `plans.trial_days`, `sub_db.has_used_trial`.
|
||||
- Produces:
|
||||
|
||||
- `_selectable_cards(trial_for) -> dbc.Row`
|
||||
- `_selection_state(selected: str) -> tuple[str, str, str, str]` (valeur cachée, classe carte simple, classe carte soutien, classe invite) — pur, testable
|
||||
- callback `_select_plan` (déclenché par `n_clicks` des cartes via `ctx.triggered_id`)
|
||||
- `_toggle_submit(retractation, cgu, plan) -> bool` (désactive tant qu'une case ou la formule manque)
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
```python
|
||||
# tests/subscriptions/test_mes_infos_plan.py
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plan_env(monkeypatch):
|
||||
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
|
||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||
|
||||
|
||||
def test_selectable_cards_render_both_plans():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._selectable_cards(trial_for=lambda key: 2))
|
||||
assert "plan-card-simple" in text
|
||||
assert "plan-card-soutien" in text
|
||||
assert "plan-selectable" in text
|
||||
assert "Abonnement de soutien" in text
|
||||
|
||||
|
||||
def test_selection_state_simple():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
value, cls_simple, cls_soutien, cls_invite = m._selection_state("simple")
|
||||
assert value == "simple"
|
||||
assert "selected" in cls_simple
|
||||
assert "selected" not in cls_soutien
|
||||
assert cls_invite == "d-none"
|
||||
|
||||
|
||||
def test_selection_state_soutien():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
value, cls_simple, cls_soutien, _ = m._selection_state("soutien")
|
||||
assert value == "soutien"
|
||||
assert "selected" in cls_soutien
|
||||
assert "selected" not in cls_simple
|
||||
|
||||
|
||||
def test_submit_disabled_without_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
assert m._toggle_submit(["ok"], ["ok"], "") is True
|
||||
assert m._toggle_submit(["ok"], ["ok"], "simple") is False
|
||||
assert m._toggle_submit([], ["ok"], "simple") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: FAIL (`_selectable_cards` / `_selection_state` inexistants)
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
3a. Dans `src/pages/compte/abonnement_mes_infos.py`, compléter les imports :
|
||||
ajouter `ctx` à l'import `from dash import ...`, ajouter `_plan_card` à l'import
|
||||
existant depuis `src.pages.a_propos.abonnement`, et ajouter :
|
||||
|
||||
```python
|
||||
from src.subscriptions import db as sub_db
|
||||
from src.subscriptions import plans
|
||||
```
|
||||
|
||||
3b. Ajouter les helpers + callback de sélection (par ex. juste après `_csrf_input`) :
|
||||
|
||||
```python
|
||||
def _trial_for(user_id):
|
||||
used = sub_db.has_used_trial(user_id)
|
||||
return lambda key: None if used else plans.trial_days(key)
|
||||
|
||||
|
||||
def _selectable_cards(trial_for):
|
||||
cols = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if not meta:
|
||||
continue
|
||||
cols.append(
|
||||
dbc.Col(
|
||||
html.Div(
|
||||
_plan_card(meta, trial_for(key)),
|
||||
id=f"plan-card-{key}",
|
||||
n_clicks=0,
|
||||
className="plan-selectable",
|
||||
),
|
||||
md=6,
|
||||
)
|
||||
)
|
||||
return dbc.Row(cols, className="g-4 mb-2")
|
||||
|
||||
|
||||
def _selection_state(selected):
|
||||
base = "plan-selectable"
|
||||
return (
|
||||
selected,
|
||||
f"{base} selected" if selected == "simple" else base,
|
||||
f"{base} selected" if selected == "soutien" else base,
|
||||
"d-none",
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("inf-plan-hidden", "value"),
|
||||
Output("plan-card-simple", "className"),
|
||||
Output("plan-card-soutien", "className"),
|
||||
Output("inf-plan-invite", "className"),
|
||||
Input("plan-card-simple", "n_clicks"),
|
||||
Input("plan-card-soutien", "n_clicks"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _select_plan(_n_simple, _n_soutien):
|
||||
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
|
||||
return _selection_state(selected)
|
||||
```
|
||||
|
||||
3c. Modifier le callback `_toggle_submit` existant pour exiger aussi une formule :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("inf-submit", "disabled"),
|
||||
Input("inf-cb-retractation", "value"),
|
||||
Input("inf-cb-cgu", "value"),
|
||||
Input("inf-plan-hidden", "value"),
|
||||
)
|
||||
def _toggle_submit(retractation, cgu, plan):
|
||||
return not (retractation and cgu and plan)
|
||||
```
|
||||
|
||||
3d. Dans `layout`, **supprimer** la garde `?plan=` (lignes 53-55) :
|
||||
|
||||
```python
|
||||
plan = query.get("plan", "")
|
||||
if not plan:
|
||||
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
||||
```
|
||||
|
||||
3e. Dans le `html.Form` (`children=`), **remplacer** la ligne
|
||||
`dcc.Input(type="hidden", name="plan", value=plan),` par l'invite, les cartes
|
||||
sélectionnables et le champ caché vide :
|
||||
|
||||
```python
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Div(
|
||||
"Choisissez votre formule :",
|
||||
id="inf-plan-invite",
|
||||
className="fw-bold mb-2",
|
||||
),
|
||||
_selectable_cards(_trial_for(current_user.id)),
|
||||
dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value=""),
|
||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||
checkboxes,
|
||||
html.Button(
|
||||
"Ajout d'une carte de paiement",
|
||||
id="inf-submit",
|
||||
type="submit",
|
||||
className="btn btn-primary",
|
||||
disabled=True,
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
3f. Ajouter à la fin de `src/assets/css/style.css` :
|
||||
|
||||
```css
|
||||
.plan-selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.plan-selectable .card {
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.plan-selectable.selected .card {
|
||||
background-color: var(--bs-primary-bg-subtle);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5 : Vérifier qu'aucun test existant ne dépendait de la garde `?plan=`**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ tests/test_page_loads.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement_mes_infos.py src/assets/css/style.css tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): sélection de formule par cartes cliquables dans mes-infos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7 : Vérification de bout en bout
|
||||
|
||||
**Files:** aucun (validation).
|
||||
|
||||
- [ ] **Step 1 : Suite complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (aucune régression). Si des tests Selenium échouent faute de Chrome,
|
||||
les relever mais valider au moins les tests unitaires et de routes touchés.
|
||||
|
||||
- [ ] **Step 2 : Revue manuelle du parcours** (skill `verify` recommandé)
|
||||
|
||||
Dérouler : `/a-propos/abonnement` (déconnecté) → « Je m'abonne » → `/inscription` →
|
||||
signup → lien email → auto-login → `mes-infos` (radios présents, défaut « simple »)
|
||||
→ soumission → checkout. Puis, connecté sans abonnement : `/compte/abonnement`
|
||||
montre « M'abonner ».
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (auteur)
|
||||
|
||||
**Couverture spec :**
|
||||
|
||||
- §1 page publique cards/explainer/bouton/trim CGU → Task 3 ✓
|
||||
- §2 bouton conditionnel (4 états) → Task 3 (`_subscribe_button`) ✓
|
||||
- §3 `/compte/abonnement` M'abonner/Me réabonner via `has_used_trial` → Task 4 ✓
|
||||
- §4 mes-infos cartes cliquables + gating submit + suppression garde `?plan=` → Task 6 ✓
|
||||
- §5 `verify_email` auto-login → Task 2 ✓
|
||||
- §6 CTA connexion → Task 5 ✓
|
||||
- §7 `linkedin_button(next)` + inscription → Task 1 ✓
|
||||
|
||||
**Placeholders :** aucun ; tout le code est fourni.
|
||||
|
||||
**Cohérence des types :** `_subscribe_button(bool, bool, bool)`, `_reabo_button(bool)`,
|
||||
`_plan_cards(trial_for)` / `_plan_card(meta, trial)` (Task 3, réutilisé par Task 6),
|
||||
`_selectable_cards(trial_for) -> dbc.Row`, `_selection_state(str) -> tuple[str,str,str,str]`,
|
||||
`_toggle_submit(retractation, cgu, plan) -> bool` — noms et signatures cohérents entre
|
||||
tâches et tests.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Changer de méthode de paiement (issue #108) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre à un·e abonné·e en essai ou actif de changer sa carte bancaire depuis `/compte/abonnement`, via la page hébergée Frisbii dédiée (`hosted_page_links.payment_info` de l'objet subscription).
|
||||
|
||||
**Architecture:** Une fonction client Frisbii récupère l'abonnement et construit l'URL de la page hébergée avec `accept_url`/`cancel_url`. Une route Flask (symétrique à `/subscriptions/add-payment` et `/subscriptions/cancel`) sert de point d'entrée POST et redirige (303) vers cette URL. Un bouton sur `/compte/abonnement` déclenche cette route ; les retours (`?carte=succes`/`?carte=annule`) affichent un message dans `_feedback()`.
|
||||
|
||||
**Tech Stack:** Python, Flask, Dash + Dash Bootstrap Components, httpx (déjà en place dans `src/subscriptions/client.py`), pytest + monkeypatch pour les tests.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Le bouton n'apparaît que pour `row["status"] in ("trial", "active")` — pas `pending` (garde son bouton "Ajouter une méthode de paiement" existant), pas `cancelled` (rien à facturer, chemin logique = reprendre un abonnement).
|
||||
- Réutiliser `client.get_subscription` déjà existant (`src/subscriptions/client.py:136`), ne pas dupliquer l'appel HTTP.
|
||||
- Suivre le pattern CSRF + `html.Form` POST déjà utilisé pour "Me désabonner" / "Ajouter une méthode de paiement" dans `src/pages/compte/abonnement.py`.
|
||||
- Aucun nouveau webhook/callback : Frisbii associe la nouvelle méthode de paiement à l'abonnement de son côté.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `client.get_payment_info_url`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/client.py`
|
||||
- Test: `tests/subscriptions/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `get_subscription(subscription_handle: str) -> dict` (déjà défini à `src/subscriptions/client.py:136`), qui retourne le JSON brut de `GET /v1/subscription/{handle}` — notamment `hosted_page_links: {"payment_info": "<url>"}`.
|
||||
- Produces: `get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str`, utilisé par la route du Task 2.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_client.py` :
|
||||
|
||||
```python
|
||||
def test_get_payment_info_url_appends_query_params(fake_httpx):
|
||||
fake_httpx["queue"].append(
|
||||
fake_httpx["Response"](
|
||||
200,
|
||||
{
|
||||
"handle": "abo-1-1",
|
||||
"hosted_page_links": {
|
||||
"payment_info": "https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
url = client.get_payment_info_url(
|
||||
"abo-1-1", "https://app/compte/abonnement?carte=succes",
|
||||
"https://app/compte/abonnement?carte=annule",
|
||||
)
|
||||
assert url.startswith(
|
||||
"https://checkout.reepay.com/#/subscription/pay/en_GB/x/abo-1-1"
|
||||
)
|
||||
assert "accept_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dsucces" in url
|
||||
assert "cancel_url=https%3A%2F%2Fapp%2Fcompte%2Fabonnement%3Fcarte%3Dannule" in url
|
||||
|
||||
|
||||
def test_get_payment_info_url_merges_existing_query_string(fake_httpx):
|
||||
fake_httpx["queue"].append(
|
||||
fake_httpx["Response"](
|
||||
200,
|
||||
{
|
||||
"handle": "abo-1-2",
|
||||
"hosted_page_links": {
|
||||
"payment_info": "https://checkout.reepay.com/pay/abo-1-2?locale=fr"
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
url = client.get_payment_info_url("abo-1-2", "https://app/ok", "https://app/ko")
|
||||
assert "locale=fr" in url
|
||||
assert "accept_url=https%3A%2F%2Fapp%2Fok" in url
|
||||
assert "cancel_url=https%3A%2F%2Fapp%2Fko" in url
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
|
||||
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'get_payment_info_url'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/subscriptions/client.py`, ajouter l'import en haut du fichier (avec les autres imports) :
|
||||
|
||||
```python
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
```
|
||||
|
||||
Puis, juste après `get_subscription` (après la ligne `return _call("GET", f"/v1/subscription/{subscription_handle}")`) :
|
||||
|
||||
```python
|
||||
def get_payment_info_url(sub_handle: str, accept_url: str, cancel_url: str) -> str:
|
||||
sub = get_subscription(sub_handle)
|
||||
url = sub["hosted_page_links"]["payment_info"]
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query))
|
||||
query["accept_url"] = accept_url
|
||||
query["cancel_url"] = cancel_url
|
||||
return urlunsplit(parts._replace(query=urlencode(query)))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -k payment_info_url -v`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: Run the full client test file to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git commit -m "feat(abonnement): client.get_payment_info_url pour le changement de carte Frisbii"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route `POST /subscriptions/change-payment-method`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/routes.py`
|
||||
- Test: `tests/subscriptions/test_routes.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `client.get_payment_info_url(sub_handle, accept_url, cancel_url) -> str` (Task 1) ; `db.get_current(user_id) -> sqlite3.Row | None` (déjà existant, colonne `frisbii_subscription_handle`) ; `client.FrisbiiError` (déjà existant).
|
||||
- Produces: route Flask `POST /subscriptions/change-payment-method`, utilisée par le bouton du Task 3. Redirige (303) vers l'URL Frisbii en cas de succès, 400 si pas d'abonnement, redirect 302 vers `/compte/abonnement?error=frisbii` en cas d'erreur API.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_routes.py` :
|
||||
|
||||
```python
|
||||
def test_change_payment_method_redirects_to_hosted_url(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"get_payment_info_url",
|
||||
lambda h, accept_url, cancel_url: f"https://pay.test/{h}?a={accept_url}&c={cancel_url}",
|
||||
)
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["Location"].startswith(f"https://pay.test/{handle}")
|
||||
assert "carte%3Dsucces" in resp.headers["Location"] or "carte=succes" in resp.headers["Location"]
|
||||
assert "carte%3Dannule" in resp.headers["Location"] or "carte=annule" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_change_payment_method_without_subscription(logged_in_client):
|
||||
client, _ = logged_in_client
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_change_payment_method_api_error_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
def boom(h, accept_url, cancel_url):
|
||||
raise frisbii_client.FrisbiiError(500, "boom")
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "get_payment_info_url", boom)
|
||||
resp = client.post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code == 302
|
||||
assert "error=frisbii" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_change_payment_method_requires_login(sub_app):
|
||||
resp = sub_app.test_client().post("/subscriptions/change-payment-method")
|
||||
assert resp.status_code in (302, 401)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
|
||||
Expected: FAIL — `test_change_payment_method_without_subscription` gets a 404 (route doesn't exist yet) instead of 400, others fail similarly.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/subscriptions/routes.py`, ajouter la route juste après `add_payment_callback()` (avant `cancel()`) :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/change-payment-method", methods=["POST"])
|
||||
@login_required
|
||||
def change_payment_method():
|
||||
base = os.getenv("APP_BASE_URL", "")
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement actif", 400
|
||||
try:
|
||||
url = client.get_payment_info_url(
|
||||
row["frisbii_subscription_handle"],
|
||||
f"{base}/compte/abonnement?carte=succes",
|
||||
f"{base}/compte/abonnement?carte=annule",
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de récupération du lien de paiement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect(url, code=303)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -k change_payment_method -v`
|
||||
Expected: PASS (4 tests)
|
||||
|
||||
- [ ] **Step 5: Run the full routes test file to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git commit -m "feat(abonnement): route /subscriptions/change-payment-method"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Bouton et messages de retour sur `/compte/abonnement`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py`
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: rien de nouveau — utilise `_csrf_input()` déjà défini dans ce fichier (`src/pages/compte/abonnement.py:18`).
|
||||
- Produces: `_active_view(row)` inclut désormais le bouton pour `status in ("trial", "active")` ; `_feedback(query)` gère `query.get("carte")`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter à la fin de `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_active_view_shows_change_payment_method_for_active():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Changer de méthode de paiement" in text
|
||||
assert "/subscriptions/change-payment-method" in text
|
||||
|
||||
|
||||
def test_active_view_shows_change_payment_method_for_trial():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "trial",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Changer de méthode de paiement" in str(
|
||||
compte_abonnement._active_view(row)
|
||||
)
|
||||
|
||||
|
||||
def test_active_view_hides_change_payment_method_for_cancelled():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "cancelled",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Changer de méthode de paiement" not in str(
|
||||
compte_abonnement._active_view(row)
|
||||
)
|
||||
|
||||
|
||||
def test_active_view_hides_change_payment_method_for_pending():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {"plan": "simple", "status": "pending", "current_period_end": None}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Changer de méthode de paiement" not in text
|
||||
assert "Ajouter une méthode de paiement" in text
|
||||
|
||||
|
||||
def test_feedback_carte_succes():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"carte": "succes"}))
|
||||
assert "Méthode de paiement mise à jour." in text
|
||||
|
||||
|
||||
def test_feedback_carte_annule():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"carte": "annule"}))
|
||||
assert "Modification annulée." in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: FAIL — the 6 new tests fail (button/messages absent), the 8 pre-existing tests in this file still PASS.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py`, modifier `_active_view` : remplacer le bloc
|
||||
|
||||
```python
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.Button(
|
||||
"Me désabonner",
|
||||
id="resiliation-trigger",
|
||||
n_clicks=0,
|
||||
className="btn btn-outline-danger mt-3",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(blocks)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
if row["status"] in ("trial", "active"):
|
||||
blocks.append(
|
||||
html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/change-payment-method",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Button(
|
||||
"Changer de méthode de paiement",
|
||||
type="submit",
|
||||
className="btn btn-outline-secondary mt-3 me-2",
|
||||
),
|
||||
],
|
||||
style={"display": "inline-block"},
|
||||
)
|
||||
)
|
||||
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.Button(
|
||||
"Me désabonner",
|
||||
id="resiliation-trigger",
|
||||
n_clicks=0,
|
||||
className="btn btn-outline-danger mt-3",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(blocks)
|
||||
```
|
||||
|
||||
Puis modifier `_feedback` : remplacer
|
||||
|
||||
```python
|
||||
if query.get("error") == "frisbii":
|
||||
out.append(
|
||||
dbc.Alert(
|
||||
"Une erreur est survenue avec le service de paiement.", color="primary"
|
||||
)
|
||||
)
|
||||
return out
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
if query.get("carte") == "succes":
|
||||
out.append(dbc.Alert("Méthode de paiement mise à jour.", color="success"))
|
||||
if query.get("carte") == "annule":
|
||||
out.append(dbc.Alert("Modification annulée.", color="secondary"))
|
||||
if query.get("error") == "frisbii":
|
||||
out.append(
|
||||
dbc.Alert(
|
||||
"Une erreur est survenue avec le service de paiement.", color="primary"
|
||||
)
|
||||
)
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: All 14 tests PASS (8 pre-existing + 6 new)
|
||||
|
||||
- [ ] **Step 5: Run the full subscriptions test suite to check no regression**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): bouton changer de méthode de paiement sur /compte/abonnement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Vérification manuelle en local
|
||||
|
||||
**Files:** aucun (vérification uniquement)
|
||||
|
||||
- [ ] **Step 1: Lancer l'app en local**
|
||||
|
||||
Run: `uv run run.py`
|
||||
|
||||
- [ ] **Step 2: Créer un abonnement actif de test et vérifier l'affichage du bouton**
|
||||
|
||||
Se connecter, créer un abonnement (trial ou active selon état de la base de test), aller sur `/compte/abonnement`, vérifier que le bouton "Changer de méthode de paiement" est visible, et absent une fois l'abonnement résilié (statut `cancelled`).
|
||||
|
||||
- [ ] **Step 3: Vérifier le clic (sans compte Frisbii réel si `FRISBII_API_KEY` n'est pas configurée en local)**
|
||||
|
||||
Si `FRISBII_API_KEY` n'est pas renseignée, le clic déclenchera une `FrisbiiError` (requête échouée) → vérifier la redirection vers `/compte/abonnement?error=frisbii` et l'affichage du message d'erreur existant. Si une clé de test Frisbii est disponible, vérifier la redirection réelle vers la page hébergée et le retour via `?carte=succes`/`?carte=annule`.
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: All PASS
|
||||
|
||||
Pas de commit pour cette tâche (vérification uniquement).
|
||||
@@ -0,0 +1,827 @@
|
||||
# Configurer son abonnement (#109) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre à un·e abonné·e `active`, `trial` ou `pending` de changer de formule (simple ↔ soutien) et de mettre à jour ses infos de facturation depuis `/compte/abonnement`, et afficher le prix (HT + TTC) de la formule courante.
|
||||
|
||||
**Architecture:** On réutilise la page `/compte/abonnement/mes-infos` en la « flexibilisant » via un `mode` dérivé de l'état de l'abonnement (`configure` vs `subscribe`), plutôt que de créer une page dupliquée (impossible de partager les `id` de composants Dash entre deux pages). Un nouveau bouton « Configurer mon abonnement » sur `/compte/abonnement` y renvoie. Le changement de formule passe par une nouvelle route `/subscriptions/update` qui appelle `update_customer` puis, si la formule change, un nouvel endpoint client `change_subscription` (`PUT /v1/subscription/{handle}`), sans redirection vers un checkout.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Dash Bootstrap Components, Flask (blueprint `subscriptions`), httpx (client Frisbii/Reepay), pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `src.subscriptions.client`), jamais `subscriptions.client`.
|
||||
- `suppress_callback_exceptions=True` est déjà activé (`src/app.py:88`) : les composants rendus conditionnellement (cases à cocher absentes en mode `configure`) ne cassent pas les callbacks — un callback dont un `Input` n'existe pas ne se déclenche simplement pas.
|
||||
- TVA France = 20 % : prix TTC = `round(prix_ht * 1.2, 2)`, formaté avec `:g` pour éviter les artefacts flottants (ex. `28.8`, pas `28.799999999999997`).
|
||||
- Effet du changement de formule : `timing="renewal"` (prochaine échéance) pour `active`/`trial` ; `timing="immediate"` pour `pending` (pas d'échéance à laquelle rattacher un renouvellement).
|
||||
- Avant `git add`/`git commit`, exécuter `pre-commit` (ruff formate ; prettier reformate le markdown). Terminer chaque message de commit par `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici). Chaque tâche ne lance QUE son fichier de test ; la suite complète (`uv run pytest`, sans chemin) uniquement à la toute fin.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Endpoint client `change_subscription`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/client.py` (ajouter la fonction en fin de fichier)
|
||||
- Test: `tests/subscriptions/test_client.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `client.change_subscription(sub_handle: str, plan_handle: str, timing: str = "renewal") -> dict` — appelle `PUT /v1/subscription/{sub_handle}` avec le corps `{"timing": timing, "plan": plan_handle}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_client.py` :
|
||||
|
||||
```python
|
||||
def test_change_subscription_sends_timing_and_plan(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-1"}))
|
||||
client.change_subscription("abo-1-1", "plan_soutien", timing="renewal")
|
||||
call = fake_httpx["calls"][0]
|
||||
assert call["method"] == "PUT"
|
||||
assert call["url"] == "https://api.test/v1/subscription/abo-1-1"
|
||||
assert call["json"] == {"timing": "renewal", "plan": "plan_soutien"}
|
||||
|
||||
|
||||
def test_change_subscription_immediate_timing(fake_httpx):
|
||||
fake_httpx["queue"].append(fake_httpx["Response"](200, {"handle": "abo-1-2"}))
|
||||
client.change_subscription("abo-1-2", "plan_simple", timing="immediate")
|
||||
assert fake_httpx["calls"][0]["json"]["timing"] == "immediate"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py::test_change_subscription_sends_timing_and_plan -v`
|
||||
Expected: FAIL with `AttributeError: module 'src.subscriptions.client' has no attribute 'change_subscription'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Ajouter en fin de `src/subscriptions/client.py` :
|
||||
|
||||
```python
|
||||
def change_subscription(
|
||||
sub_handle: str, plan_handle: str, timing: str = "renewal"
|
||||
) -> dict:
|
||||
return _call(
|
||||
"PUT",
|
||||
f"/v1/subscription/{sub_handle}",
|
||||
json={"timing": timing, "plan": plan_handle},
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_client.py -v`
|
||||
Expected: PASS (tous, dont les deux nouveaux)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git add src/subscriptions/client.py tests/subscriptions/test_client.py
|
||||
git commit -m "feat(abonnement): client.change_subscription (PUT /v1/subscription) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Route `/subscriptions/update`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/subscriptions/routes.py` (ajouter la route après `change_payment_method`, avant `cancel`)
|
||||
- Test: `tests/subscriptions/test_routes.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `client.change_subscription(...)` (Task 1), `client.update_customer`, `db.get_current`, `plans.resolve_handle`, `auth_db.set_siret`.
|
||||
- Produces: route `POST /subscriptions/update` — met à jour le customer Frisbii et, si la formule diffère, appelle `change_subscription` ; redirige `303` vers `/compte/abonnement?maj=succes`.
|
||||
|
||||
Comportement : valide d'abord le plan (400 si inconnu), puis vérifie l'abonnement (400 si aucun), met à jour les infos de facturation, change la formule seulement si le handle diffère. `timing="immediate"` si `status == "pending"`, sinon `"renewal"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_routes.py` :
|
||||
|
||||
```python
|
||||
def test_update_changes_plan_and_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
|
||||
def fake_change(sub_handle, plan_handle, timing="renewal"):
|
||||
captured.update(sub_handle=sub_handle, plan_handle=plan_handle, timing=timing)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "change_subscription", fake_change)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert captured["sub_handle"] == handle
|
||||
assert captured["plan_handle"] == "plan_soutien"
|
||||
assert captured["timing"] == "renewal"
|
||||
|
||||
|
||||
def test_update_pending_uses_immediate_timing(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda sub_handle, plan_handle, timing="renewal": captured.update(timing=timing),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 303
|
||||
assert captured["timing"] == "immediate"
|
||||
|
||||
|
||||
def test_update_same_plan_skips_change_subscription(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
called = []
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", lambda h, d: {})
|
||||
monkeypatch.setattr(
|
||||
frisbii_client,
|
||||
"change_subscription",
|
||||
lambda *a, **k: called.append(a),
|
||||
)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 303
|
||||
assert "maj=succes" in resp.headers["Location"]
|
||||
assert called == [], "formule inchangée : change_subscription ne doit pas être appelé"
|
||||
|
||||
|
||||
def test_update_without_subscription_returns_400(logged_in_client):
|
||||
client, _ = logged_in_client
|
||||
resp = client.post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_unknown_plan_returns_400(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
resp = client.post("/subscriptions/update", data={"plan": "bidon"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_update_api_error_redirects(logged_in_client, monkeypatch):
|
||||
client, uid = logged_in_client
|
||||
handle, _ = db.create_pending(uid, "colibre-%d" % uid, "simple")
|
||||
db.update_from_webhook(handle, "active", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
def boom(h, d):
|
||||
raise frisbii_client.FrisbiiError(500, "boom")
|
||||
|
||||
monkeypatch.setattr(frisbii_client, "update_customer", boom)
|
||||
resp = client.post("/subscriptions/update", data={"plan": "soutien"})
|
||||
assert resp.status_code == 302
|
||||
assert "error=frisbii" in resp.headers["Location"]
|
||||
|
||||
|
||||
def test_update_requires_login(sub_app):
|
||||
resp = sub_app.test_client().post("/subscriptions/update", data={"plan": "simple"})
|
||||
assert resp.status_code in (302, 401)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py::test_update_changes_plan_and_redirects -v`
|
||||
Expected: FAIL avec `404` (route inexistante)
|
||||
|
||||
- [ ] **Step 3: Write the route**
|
||||
|
||||
Dans `src/subscriptions/routes.py`, insérer après la fonction `change_payment_method` (avant `cancel`) :
|
||||
|
||||
```python
|
||||
@subscriptions_bp.route("/subscriptions/update", methods=["POST"])
|
||||
@login_required
|
||||
def update():
|
||||
new_plan_key = request.form.get("plan") or ""
|
||||
new_handle = plans.resolve_handle(new_plan_key)
|
||||
if new_handle is None:
|
||||
return "Plan inconnu", 400
|
||||
|
||||
row = db.get_current(current_user.id)
|
||||
if row is None or not row["frisbii_subscription_handle"]:
|
||||
return "Aucun abonnement à mettre à jour", 400
|
||||
|
||||
cust = _customer_handle(current_user.id)
|
||||
siret = (request.form.get("siret") or "").strip()
|
||||
billing: dict = {
|
||||
"email": current_user.email,
|
||||
"first_name": request.form.get("first_name", ""),
|
||||
"last_name": request.form.get("last_name", ""),
|
||||
"address": request.form.get("address", ""),
|
||||
"city": request.form.get("city", ""),
|
||||
"postal_code": request.form.get("postal_code", ""),
|
||||
"country": request.form.get("country", "FR"),
|
||||
}
|
||||
if request.form.get("address2"):
|
||||
billing["address2"] = request.form["address2"]
|
||||
if request.form.get("company"):
|
||||
billing["company"] = request.form["company"]
|
||||
|
||||
try:
|
||||
if siret:
|
||||
auth_db.set_siret(current_user.id, siret)
|
||||
client.update_customer(cust, billing)
|
||||
if new_handle != plans.resolve_handle(row["plan"]):
|
||||
timing = "immediate" if row["status"] == "pending" else "renewal"
|
||||
client.change_subscription(
|
||||
row["frisbii_subscription_handle"], new_handle, timing
|
||||
)
|
||||
except client.FrisbiiError:
|
||||
logger.exception("Échec de mise à jour de l'abonnement Frisbii")
|
||||
return redirect("/compte/abonnement?error=frisbii")
|
||||
return redirect("/compte/abonnement?maj=succes", code=303)
|
||||
```
|
||||
|
||||
(Aucun import à ajouter : `client`, `db`, `plans`, `auth_db`, `logger`, `current_user`, `request`, `redirect` sont déjà importés dans ce fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_routes.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git add src/subscriptions/routes.py tests/subscriptions/test_routes.py
|
||||
git commit -m "feat(abonnement): route /subscriptions/update (maj formule + facturation) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Page `/compte/abonnement` — prix, bouton « Configurer », message de succès
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement.py` (`_active_view`, `_feedback`, + nouveau helper `_price_text`)
|
||||
- Test: `tests/subscriptions/test_compte_abonnement.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `_price_text(meta: dict) -> str | None` ; `_active_view` affiche le prix sous le libellé et un lien « Configurer mon abonnement » (statuts `pending`/`trial`/`active`) ; `_feedback` gère `maj=succes`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_compte_abonnement.py` :
|
||||
|
||||
```python
|
||||
def test_price_text_rounds_ttc():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
assert compte_abonnement._price_text({"prix_ht": 20}) == "20 € HT / mois (24 € TTC)"
|
||||
assert compte_abonnement._price_text({"prix_ht": 50}) == "50 € HT / mois (60 € TTC)"
|
||||
assert compte_abonnement._price_text({"label": "x"}) is None
|
||||
|
||||
|
||||
def test_active_view_shows_price():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "24 € TTC" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_active():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "active",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
text = str(compte_abonnement._active_view(row))
|
||||
assert "Configurer mon abonnement" in text
|
||||
assert "href='/compte/abonnement/mes-infos'" in text
|
||||
|
||||
|
||||
def test_active_view_shows_configure_button_for_pending():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {"plan": "simple", "status": "pending", "current_period_end": None}
|
||||
assert "Configurer mon abonnement" in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_active_view_hides_configure_button_for_cancelled():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
row = {
|
||||
"plan": "simple",
|
||||
"status": "cancelled",
|
||||
"current_period_end": "2099-01-01T00:00:00+00:00",
|
||||
}
|
||||
assert "Configurer mon abonnement" not in str(compte_abonnement._active_view(row))
|
||||
|
||||
|
||||
def test_feedback_maj_succes():
|
||||
from src.pages.compte import abonnement as compte_abonnement
|
||||
|
||||
text = str(compte_abonnement._feedback({"maj": "succes"}))
|
||||
assert "Votre abonnement a été mis à jour." in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py::test_price_text_rounds_ttc -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_price_text'`
|
||||
|
||||
- [ ] **Step 3a: Add `_price_text` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement.py`, ajouter avant `_active_view` :
|
||||
|
||||
```python
|
||||
def _price_text(meta: dict) -> str | None:
|
||||
ht = meta.get("prix_ht")
|
||||
if ht is None:
|
||||
return None
|
||||
return f"{ht} € HT / mois ({round(ht * 1.2, 2):g} € TTC)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Show price in `_active_view`**
|
||||
|
||||
Dans `_active_view`, remplacer :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-3")]
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
|
||||
end = format_date_french(row["current_period_end"])
|
||||
blocks = [html.H3(meta["label"], className="mb-1")]
|
||||
price = _price_text(meta)
|
||||
if price:
|
||||
blocks.append(html.P(price, className="text-muted mb-3"))
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Add « Configurer mon abonnement » button**
|
||||
|
||||
Toujours dans `_active_view`, juste avant le bloc `if row["status"] in ("trial", "active"):` (celui du formulaire « Changer de méthode de paiement »), insérer :
|
||||
|
||||
```python
|
||||
if row["status"] in ("pending", "trial", "active"):
|
||||
blocks.append(
|
||||
html.A(
|
||||
"Configurer mon abonnement",
|
||||
href="/compte/abonnement/mes-infos",
|
||||
className="btn btn-outline-primary mt-3 me-2",
|
||||
)
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Handle `maj=succes` in `_feedback`**
|
||||
|
||||
Dans `_feedback`, ajouter avant `return out` :
|
||||
|
||||
```python
|
||||
if query.get("maj") == "succes":
|
||||
out.append(dbc.Alert("Votre abonnement a été mis à jour.", color="success"))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_compte_abonnement.py -v`
|
||||
Expected: PASS (tous, y compris les tests existants qui doivent rester verts)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git add src/pages/compte/abonnement.py tests/subscriptions/test_compte_abonnement.py
|
||||
git commit -m "feat(abonnement): prix + bouton Configurer sur /compte/abonnement (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `mes-infos` — mode configure/subscribe
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces:
|
||||
|
||||
- `_mode_for(row) -> str` — `"configure"` si `row` et `row["status"]` ∈ {active, trial, pending}, sinon `"subscribe"`.
|
||||
- `_submit_button(mode: str) -> html.Button` — `id="inf-submit"`, libellé + `disabled` selon le mode.
|
||||
- `_selectable_cards(trial_for, selected=None)` — la card dont la clé == `selected` reçoit la classe `selected`.
|
||||
- `_consent_checklists() -> list` et `_legal_note()` — extraits de l'ancien bloc `checkboxes`.
|
||||
- `layout()` rend un `dcc.Store(id="inf-sub-info")` et un `html.Div(id="inf-change-hint")` dans les deux modes (Task 5 s'en sert).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_mode_for_derives_from_status():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
for status in ("active", "trial", "pending"):
|
||||
assert m._mode_for({"status": status}) == "configure"
|
||||
assert m._mode_for({"status": "cancelled"}) == "subscribe"
|
||||
assert m._mode_for({"status": "expired"}) == "subscribe"
|
||||
assert m._mode_for(None) == "subscribe"
|
||||
|
||||
|
||||
def test_submit_button_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._submit_button("subscribe"))
|
||||
assert "Ajouter une carte de paiement" in text
|
||||
assert "disabled" in text
|
||||
|
||||
|
||||
def test_submit_button_configure_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
btn = m._submit_button("configure")
|
||||
text = str(btn)
|
||||
assert "Mettre à jour mon abonnement" in text
|
||||
assert btn.disabled is False
|
||||
|
||||
|
||||
def test_selectable_cards_preselects_current_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
text = str(m._selectable_cards(trial_for=lambda key: None, selected="soutien"))
|
||||
# la card soutien est marquée sélectionnée, pas la card simple
|
||||
assert "plan-selectable selected" in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_mode_for_derives_from_status -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_mode_for'`
|
||||
|
||||
- [ ] **Step 3a: Add import + helpers**
|
||||
|
||||
En haut de `src/pages/compte/abonnement_mes_infos.py`, ajouter l'import :
|
||||
|
||||
```python
|
||||
from src.utils.frontend import format_date_french
|
||||
```
|
||||
|
||||
Ajouter ces helpers (par ex. après `_trial_for`) :
|
||||
|
||||
```python
|
||||
def _mode_for(row) -> str:
|
||||
if row is not None and row["status"] in ("active", "trial", "pending"):
|
||||
return "configure"
|
||||
return "subscribe"
|
||||
|
||||
|
||||
def _submit_button(mode: str):
|
||||
label = (
|
||||
"Mettre à jour mon abonnement"
|
||||
if mode == "configure"
|
||||
else "Ajouter une carte de paiement"
|
||||
)
|
||||
return html.Button(
|
||||
label,
|
||||
id="inf-submit",
|
||||
type="submit",
|
||||
className="btn btn-primary",
|
||||
disabled=(mode == "subscribe"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Add `selected` param to `_selectable_cards`**
|
||||
|
||||
Remplacer la signature et le corps de `_selectable_cards` :
|
||||
|
||||
```python
|
||||
def _selectable_cards(trial_for, selected=None):
|
||||
cols = []
|
||||
for key in ("simple", "soutien"):
|
||||
meta = plans.plan_meta(key)
|
||||
if not meta:
|
||||
continue
|
||||
base = "plan-selectable selected" if key == selected else "plan-selectable"
|
||||
cols.append(
|
||||
dbc.Col(
|
||||
html.Div(
|
||||
_plan_card(meta, trial_for(key)),
|
||||
id=f"plan-card-{key}",
|
||||
n_clicks=0,
|
||||
className=base,
|
||||
),
|
||||
md=6,
|
||||
)
|
||||
)
|
||||
return dbc.Row(cols, className="g-4 mb-2")
|
||||
```
|
||||
|
||||
- [ ] **Step 3c: Extract legal note + consent checklists**
|
||||
|
||||
Remplacer le bloc `checkboxes = html.Div([...])` (dans `layout`) par deux helpers au niveau module :
|
||||
|
||||
```python
|
||||
def _legal_note():
|
||||
return dcc.Markdown(
|
||||
"""\\* Champ obligatoire
|
||||
|
||||
Si vous préférez régler par virement bancaire et une facturation annuelle plutôt qu'un réglement mensuel automatique par carte bancaire, [contactez-moi](/a-propos/contact)."""
|
||||
)
|
||||
|
||||
|
||||
def _consent_checklists():
|
||||
return [
|
||||
dcc.Checklist(
|
||||
id="inf-cb-retractation",
|
||||
options=[
|
||||
{
|
||||
"label": "Je renonce à mon droit de rétractation légal de 14 jours.",
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-2",
|
||||
),
|
||||
dcc.Checklist(
|
||||
id="inf-cb-cgu",
|
||||
options=[
|
||||
{
|
||||
"label": [
|
||||
"J'ai lu et accepte les ",
|
||||
html.A(
|
||||
"conditions générales d'utilisation du service",
|
||||
href="#",
|
||||
id="inf-cgu-link",
|
||||
style={"cursor": "pointer"},
|
||||
),
|
||||
".",
|
||||
],
|
||||
"value": "ok",
|
||||
}
|
||||
],
|
||||
value=[],
|
||||
className="mb-4",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3d: Rewire `layout()`**
|
||||
|
||||
Au début de `layout`, après le `guard`, calculer le mode et les valeurs dérivées :
|
||||
|
||||
```python
|
||||
row = sub_db.get_current(current_user.id)
|
||||
mode = _mode_for(row)
|
||||
selected = row["plan"] if mode == "configure" else None
|
||||
echeance = (
|
||||
format_date_french(row["current_period_end"])
|
||||
if mode == "configure" and row["current_period_end"]
|
||||
else None
|
||||
)
|
||||
sub_info = (
|
||||
{"current_plan": selected, "status": row["status"], "echeance": echeance}
|
||||
if mode == "configure"
|
||||
else {}
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le `html.Form(...)` existant par :
|
||||
|
||||
```python
|
||||
form = html.Form(
|
||||
method="POST",
|
||||
action="/subscriptions/subscribe"
|
||||
if mode == "subscribe"
|
||||
else "/subscriptions/update",
|
||||
children=[
|
||||
_csrf_input(),
|
||||
html.Div(
|
||||
"Choisissez votre formule :"
|
||||
if mode == "subscribe"
|
||||
else "Votre formule :",
|
||||
id="inf-plan-invite",
|
||||
className="fw-bold mb-2",
|
||||
),
|
||||
_selectable_cards(_trial_for(current_user.id), selected=selected),
|
||||
html.Div(id="inf-change-hint", className="d-none"),
|
||||
dcc.Store(id="inf-sub-info", data=sub_info),
|
||||
dcc.Input(
|
||||
type="hidden", id="inf-plan-hidden", name="plan", value=selected or ""
|
||||
),
|
||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||
_legal_note(),
|
||||
*(_consent_checklists() if mode == "subscribe" else []),
|
||||
_submit_button(mode),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Enfin, ne rendre la modale CGU qu'en mode `subscribe`. Remplacer le `return account_shell(...)` final par :
|
||||
|
||||
```python
|
||||
return account_shell(
|
||||
"abonnement",
|
||||
html.Div(
|
||||
[
|
||||
html.H2("Mes informations de facturation", className="mb-4"),
|
||||
dbc.Alert(
|
||||
"Informations récupérées depuis le prestataire de paiement, vous pouvez les modifier si besoin.",
|
||||
color="info",
|
||||
className="mb-4",
|
||||
)
|
||||
if prefill
|
||||
else None,
|
||||
form,
|
||||
_cgu_modal() if mode == "subscribe" else None,
|
||||
]
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (nouveaux + existants `test_selectable_cards_render_both_plans`, `test_selection_state_*`, `test_submit_disabled_without_plan`)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): mode configure sur mes-infos (formule préactivée, sans cases) (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `mes-infos` — hint « prochaine échéance »
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/compte/abonnement_mes_infos.py` (`_change_hint` + callback `_select_plan`)
|
||||
- Test: `tests/subscriptions/test_mes_infos_plan.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `dcc.Store(id="inf-sub-info")` et `html.Div(id="inf-change-hint")` rendus en Task 4.
|
||||
- Produces: `_change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]` — `(className, children)` du hint ; le callback `_select_plan` renvoie désormais 6 sorties (les 4 existantes + className/children du hint).
|
||||
|
||||
Règles : affiché uniquement si `sub_info` a un `current_plan`, `status` ∈ {active, trial}, et `selected != current_plan`. Sinon masqué (`d-none`, texte vide). `pending` → toujours masqué.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Ajouter dans `tests/subscriptions/test_mes_infos_plan.py` :
|
||||
|
||||
```python
|
||||
def test_change_hint_shown_when_plan_differs():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls != "d-none"
|
||||
assert "prochaine échéance : 1er janvier" in txt
|
||||
|
||||
|
||||
def test_change_hint_hidden_when_same_plan():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, txt = m._change_hint(
|
||||
"simple",
|
||||
{"current_plan": "simple", "status": "active", "echeance": "1er janvier"},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
assert txt == ""
|
||||
|
||||
|
||||
def test_change_hint_hidden_for_pending():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint(
|
||||
"soutien",
|
||||
{"current_plan": "simple", "status": "pending", "echeance": None},
|
||||
)
|
||||
assert cls == "d-none"
|
||||
|
||||
|
||||
def test_change_hint_hidden_in_subscribe_mode():
|
||||
from src.pages.compte import abonnement_mes_infos as m
|
||||
|
||||
cls, _ = m._change_hint("soutien", {})
|
||||
assert cls == "d-none"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py::test_change_hint_shown_when_plan_differs -v`
|
||||
Expected: FAIL avec `AttributeError: ... has no attribute '_change_hint'`
|
||||
|
||||
- [ ] **Step 3a: Add `_change_hint` helper**
|
||||
|
||||
Dans `src/pages/compte/abonnement_mes_infos.py`, ajouter :
|
||||
|
||||
```python
|
||||
def _change_hint(selected: str, sub_info: dict | None) -> tuple[str, str]:
|
||||
sub_info = sub_info or {}
|
||||
current = sub_info.get("current_plan")
|
||||
if (
|
||||
not current
|
||||
or sub_info.get("status") not in ("active", "trial")
|
||||
or selected == current
|
||||
):
|
||||
return "d-none", ""
|
||||
echeance = sub_info.get("echeance")
|
||||
return (
|
||||
"text-muted mt-2",
|
||||
f"Le changement d'abonnement sera appliqué à la prochaine échéance : {echeance}.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3b: Extend the `_select_plan` callback**
|
||||
|
||||
Remplacer le callback `_select_plan` et son décorateur par :
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("inf-plan-hidden", "value"),
|
||||
Output("plan-card-simple", "className"),
|
||||
Output("plan-card-soutien", "className"),
|
||||
Output("inf-plan-invite", "className"),
|
||||
Output("inf-change-hint", "className"),
|
||||
Output("inf-change-hint", "children"),
|
||||
Input("plan-card-simple", "n_clicks"),
|
||||
Input("plan-card-soutien", "n_clicks"),
|
||||
State("inf-sub-info", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _select_plan(_n_simple, _n_soutien, sub_info):
|
||||
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
|
||||
value, cls_simple, cls_soutien, cls_invite = _selection_state(selected)
|
||||
hint_cls, hint_txt = _change_hint(selected, sub_info)
|
||||
return value, cls_simple, cls_soutien, cls_invite, hint_cls, hint_txt
|
||||
```
|
||||
|
||||
(`State` est déjà importé depuis `dash` en tête de fichier.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||
Expected: PASS (tous)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
||||
git commit -m "feat(abonnement): message 'changement à la prochaine échéance' sous les cards (#109)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Vérification finale
|
||||
|
||||
- [ ] **Step 1: Run the whole subscriptions test suite**
|
||||
|
||||
Run: `uv run pytest tests/subscriptions/ -v`
|
||||
Expected: PASS (aucune régression)
|
||||
|
||||
- [ ] **Step 2: Run the full test suite**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: PASS (les tests Selenium nécessitent Chrome/Chromium ; si l'environnement n'en a pas, noter les échecs Selenium comme non liés et confirmer que `tests/subscriptions/` est vert).
|
||||
|
||||
- [ ] **Step 3: Sanity-check manuel (optionnel mais recommandé)**
|
||||
|
||||
Lancer l'app (`uv run run.py`), se connecter avec un compte ayant un abonnement `active`, aller sur `/compte/abonnement` : vérifier le prix affiché et le bouton « Configurer mon abonnement ». Cliquer → `/compte/abonnement/mes-infos` : formule courante présélectionnée, pas de cases à cocher, bouton « Mettre à jour mon abonnement ». Cliquer sur l'autre formule → le message « … prochaine échéance : {date} » apparaît.
|
||||
|
||||
---
|
||||
|
||||
## Notes d'implémentation
|
||||
|
||||
- **Pourquoi `inf-change-hint` et `inf-sub-info` sont rendus dans les deux modes** : le callback `_select_plan` est enregistré une seule fois et référence ces `id` en sortie/état. Les rendre toujours (vides en mode `subscribe`) évite tout souci de composant manquant et garde le callback identique quel que soit le mode.
|
||||
- **Pourquoi `_toggle_submit` n'est pas modifié** : ses `Input` (`inf-cb-retractation`, `inf-cb-cgu`) n'existent pas en mode `configure`, donc le callback ne se déclenche pas et le bouton conserve son `disabled=False` défini par `_submit_button("configure")`. En mode `subscribe`, comportement inchangé.
|
||||
- **Timing facturation** : `update_customer` prend effet immédiatement (adresse/SIRET), tandis que le changement de formule est différé à l'échéance (`renewal`) — d'où le message de succès générique « Votre abonnement a été mis à jour. ».
|
||||
@@ -0,0 +1,266 @@
|
||||
# Migration Dash 3.4 → 4.4 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Faire passer l'application de `dash==3.4.0` à Dash 4.4.x sans changement fonctionnel, en acceptant le nouveau style des composants DCC.
|
||||
|
||||
**Architecture:** Bump groupé des dépendances de l'écosystème Dash dans un worktree isolé basé sur `dev`, puis on laisse la suite de tests et le smoke test manuel révéler les cassures, qu'on corrige au fil de l'eau. Une seule PR vers `dev`.
|
||||
|
||||
**Tech Stack:** Dash 4.4, `uv` (gestion des dépendances), pytest + Selenium (`dash[testing]`), `dash-leaflet` + `dash-extensions` (cartes), `dash-bootstrap-components`.
|
||||
|
||||
**Spec de référence:** `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Base worktree :** créer le worktree depuis `dev` (PAS `origin/main`, qui est en retard sur `dev`).
|
||||
- **Version cible :** `dash` épinglé en **exact** sur la dernière 4.4.x (style d'épinglage existant `dash==3.4.0`).
|
||||
- **DataTable reste :** ne PAS toucher aux `dash_table.DataTable` ni à `src/figures.py`/`src/utils/table_sql.py` (relève de #41).
|
||||
- **Régressions visuelles :** accepter le look Dash 4 ; ne corriger que les cassures fonctionnelles ; **retirer** le CSS custom en conflit plutôt que le patcher.
|
||||
- **Bump libs tierces :** seulement si la compat Dash 4 l'exige.
|
||||
- **Tests :** toujours via `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
|
||||
- **Avant chaque commit :** exécuter `pre-commit` (ruff + prettier) — cf. CLAUDE.md.
|
||||
- **Imports :** modules de l'app toujours préfixés `src.` (ne pas régresser vers `utils.`/`pages.`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bump Dash et résoudre les dépendances (portail de compat)
|
||||
|
||||
C'est le portail de risque : la résolution `uv` et le démarrage de l'app révèlent immédiatement toute incompatibilité de `dash-leaflet` / `dash-extensions` / `dbc` avec Dash 4.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pyproject.toml` (bloc `dependencies` et groupe `dev`)
|
||||
- Modify: `uv.lock` (régénéré par `uv`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: environnement résolu sur Dash 4.4.x avec l'app qui démarre. Les tâches suivantes s'appuient sur cet état.
|
||||
|
||||
- [ ] **Step 1: Déterminer la dernière version 4.4.x**
|
||||
|
||||
Run: `uv run python -c "import urllib.request, json; d=json.load(urllib.request.urlopen('https://pypi.org/pypi/dash/json')); print([v for v in d['releases'] if v.startswith('4.4.')][-1])"`
|
||||
Expected: une version type `4.4.x`. Noter cette valeur (ci-après `<DASH_VERSION>`).
|
||||
|
||||
- [ ] **Step 2: Consolider la déclaration `dash` dans `pyproject.toml`**
|
||||
|
||||
Dans le bloc `dependencies`, remplacer les DEUX lignes actuelles :
|
||||
|
||||
```toml
|
||||
"dash==3.4.0",
|
||||
"dash[compress]",
|
||||
```
|
||||
|
||||
par une seule ligne épinglée :
|
||||
|
||||
```toml
|
||||
"dash[compress]==<DASH_VERSION>",
|
||||
```
|
||||
|
||||
Dans le groupe `[dependency-groups].dev`, remplacer :
|
||||
|
||||
```toml
|
||||
"dash[testing]",
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```toml
|
||||
"dash[testing]==<DASH_VERSION>",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Résoudre l'environnement**
|
||||
|
||||
Run: `uv sync`
|
||||
Expected: résolution réussie. **Si** échec de résolution mentionnant `dash-leaflet`, `dash-extensions` ou `dash-bootstrap-components` (peer-deps React/Dash) : bumper la lib en cause vers sa dernière version (`uv add 'dash-leaflet@latest'` et/ou `uv add 'dash-extensions@latest'`) puis relancer `uv sync`.
|
||||
|
||||
- [ ] **Step 4: Vérifier l'import et la version**
|
||||
|
||||
Run: `uv run python -c "import dash, dash_leaflet, dash_extensions, dash_bootstrap_components as dbc; print(dash.__version__, dash_leaflet.__version__, dash_extensions.__version__, dbc.__version__)"`
|
||||
Expected: `dash.__version__` commence par `4.4`, aucun ImportError.
|
||||
|
||||
- [ ] **Step 5: Vérifier le démarrage de l'app**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` puis attendre le log de démarrage et arrêter (Ctrl-C).
|
||||
Expected: l'app démarre sans traceback (le serveur écoute). Un warning de dépréciation sur `DataTable` est attendu et normal.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files pyproject.toml || true
|
||||
git add pyproject.toml uv.lock
|
||||
git commit -m "build: bump dash 3.4 -> 4.4 et résolution des dépendances (#101)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rendre la suite de tests verte sur Dash 4
|
||||
|
||||
Le restyling DCC de Dash 4 peut casser des sélecteurs Selenium (classes CSS changées) ou des assertions. On fait passer toute la suite.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: fichiers de `tests/` dont les assertions/sélecteurs cassent (inconnus a priori)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: environnement Dash 4.4 de la Task 1.
|
||||
- Produces: `uv run pytest` intégralement vert.
|
||||
|
||||
- [ ] **Step 1: Lancer la suite complète pour cartographier les cassures**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: soit tout vert (idéal → aller directement au Step 4), soit des échecs. Noter chaque test en échec et sa cause (sélecteur introuvable, timeout, assertion).
|
||||
|
||||
- [ ] **Step 2: Corriger chaque test en échec**
|
||||
|
||||
Pour chaque échec, appliquer la correction minimale :
|
||||
|
||||
- **Sélecteur Selenium cassé** (une classe/structure DCC a changé en Dash 4) : mettre à jour le sélecteur pour cibler la nouvelle structure rendue. Inspecter le DOM réel via `DEVELOPMENT=true uv run run.py` si besoin.
|
||||
- **Assertion sur du texte/markup restylé** : ajuster l'attendu à la sortie Dash 4.
|
||||
- **Ne PAS** contourner un échec qui révèle une vraie régression fonctionnelle : le noter pour la Task 3/4.
|
||||
|
||||
- [ ] **Step 3: Relancer les tests corrigés**
|
||||
|
||||
Run: `uv run pytest <chemins des tests précédemment en échec>`
|
||||
Expected: PASS pour les tests corrigés.
|
||||
|
||||
- [ ] **Step 4: Confirmer la suite complète**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: intégralement vert.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers de test modifiés> || true
|
||||
git add tests/
|
||||
git commit -m "test: adapter la suite au rendu Dash 4 (#101)"
|
||||
```
|
||||
|
||||
Si aucun test n'a nécessité de modification, ne pas créer de commit vide — passer directement à la Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Vérifier le comportement des Dropdown et nettoyer le CSS en conflit
|
||||
|
||||
Dash 4 change deux défauts de `dcc.Dropdown` (`optionHeight='auto'`, `closeOnSelect=False` en multi-select) et restyle les DCC. On vérifie les 11 Dropdown et on retire les overrides CSS devenus inutiles/cassants.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify (si nécessaire) : `src/pages/recherche.py`, `src/pages/tableau.py`, et autres pages contenant des `dcc.Dropdown`
|
||||
- Modify: `src/assets/css/style.css` (et autres fichiers de `src/assets/css/`) — retrait des overrides en conflit
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: environnement Dash 4.4, suite verte (Tasks 1-2).
|
||||
- Produces: Dropdown fonctionnels, CSS sans override cassant.
|
||||
|
||||
- [ ] **Step 1: Lister les Dropdown et repérer ceux en multi-select**
|
||||
|
||||
Run: `grep -rn "dcc.Dropdown" src/ | grep -v __pycache__`
|
||||
Puis pour le contexte multi : `grep -rn "multi=True" src/ | grep -v __pycache__`
|
||||
Expected: la liste des 11 emplacements ; identifier ceux en `multi=True` (impactés par le changement `closeOnSelect`).
|
||||
|
||||
- [ ] **Step 2: Vérifier visuellement les Dropdown impactés**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et ouvrir les pages concernées (notamment `/` recherche et `/tableau` filtres/sélecteur de colonnes).
|
||||
Expected: les Dropdown s'ouvrent, filtrent, et se ferment correctement. Un multi-select qui reste ouvert après sélection est le nouveau défaut Dash 4 — **acceptable** (ne pas corriger sauf si réellement cassé). Ne corriger (ex. forcer `optionHeight` numérique) que si l'affichage est rompu.
|
||||
|
||||
- [ ] **Step 3: Repérer les overrides CSS en conflit avec le restyling DCC**
|
||||
|
||||
Run: `grep -rn "Select\|dropdown\|Checklist\|RadioItems\|dash-loading\|input" src/assets/css/`
|
||||
Expected: liste des règles ciblant les DCC. Pour chacune, juger via le rendu (Step 2) si elle entre en conflit avec le nouveau style Dash 4.
|
||||
|
||||
- [ ] **Step 4: Retirer les overrides devenus inutiles ou cassants**
|
||||
|
||||
Supprimer (pas commenter) les règles CSS qui cassent ou déforment un composant DCC restylé. Conserver celles qui restent utiles et sans conflit. Ne PAS toucher au bloc CSS lié à `dash_table`/DataTable (hors périmètre, cf. `style.css:512`).
|
||||
|
||||
- [ ] **Step 5: Re-vérifier le rendu après nettoyage**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et re-parcourir les pages modifiées.
|
||||
Expected: aucun composant cassé ; le rendu adopte le style Dash 4.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers modifiés> || true
|
||||
git add src/
|
||||
git commit -m "style: accepter le rendu DCC Dash 4 et retirer le CSS en conflit (#101)"
|
||||
```
|
||||
|
||||
Si aucune modification n'a été nécessaire, ne pas créer de commit vide.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Smoke test complet et ouverture de la PR
|
||||
|
||||
Vérification finale (definition of done) : suite verte + parcours manuel des 6 pages + cartes Leaflet + exports.
|
||||
|
||||
**Files:** aucun a priori (corrections de dernière minute possibles selon findings).
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: état des Tasks 1-3.
|
||||
- Produces: PR vers `dev`.
|
||||
|
||||
- [ ] **Step 1: Suite de tests finale**
|
||||
|
||||
Run: `uv run pytest`
|
||||
Expected: intégralement vert.
|
||||
|
||||
- [ ] **Step 2: Smoke test manuel des 6 pages**
|
||||
|
||||
Run: `DEVELOPMENT=true uv run run.py` et parcourir : `/` (recherche), `/acheteur`, `/titulaire`, `/tableau`, `/marche`, `/observatoire`.
|
||||
Expected: chaque page se charge sans erreur console/serveur ; les tableaux DataTable s'affichent et filtrent ; les graphiques Plotly s'affichent.
|
||||
|
||||
- [ ] **Step 3: Vérifier spécifiquement les cartes Leaflet et le clustering**
|
||||
|
||||
Sur `/acheteur` et `/titulaire` (pages avec cartes), vérifier que la carte Leaflet s'affiche et que le clustering de marqueurs fonctionne (zoom/dézoom regroupe/éclate les marqueurs).
|
||||
Expected: cartes rendues et interactives. **Si cassé** : bumper `dash-leaflet`/`dash-extensions` (cf. Task 1 Step 3), re-`uv sync`, re-tester, et amender le commit de la Task 1.
|
||||
|
||||
- [ ] **Step 4: Vérifier les exports**
|
||||
|
||||
Depuis `/tableau`, déclencher un export xlsx et un export csv.
|
||||
Expected: fichiers téléchargés et ouvrables, contenu cohérent.
|
||||
|
||||
- [ ] **Step 5: Corriger les cassures résiduelles éventuelles**
|
||||
|
||||
Pour toute cassure trouvée aux Steps 2-4, appliquer la correction minimale et commiter séparément :
|
||||
|
||||
```bash
|
||||
pre-commit run --files <fichiers> || true
|
||||
git add <fichiers>
|
||||
git commit -m "fix: <cassure corrigée> sur Dash 4 (#101)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ouvrir la PR vers `dev`**
|
||||
|
||||
```bash
|
||||
git push -u origin <branche-worktree>
|
||||
gh pr create --base dev --title "Migration Dash 3.4 → 4.4 (#101)" --body "$(cat <<'EOF'
|
||||
Migration de `dash==3.4.0` vers Dash 4.4.x (pure montée de version, cf. spec `docs/superpowers/specs/2026-07-09-migration-dash-4-design.md`).
|
||||
|
||||
## Périmètre
|
||||
- Bump `dash` + libs écosystème si nécessaire
|
||||
- Acceptation du look DCC Dash 4, retrait du CSS en conflit
|
||||
- DataTable inchangées (→ #41), MCP hors périmètre (→ #111)
|
||||
|
||||
## Vérification
|
||||
- `uv run pytest` vert
|
||||
- Smoke manuel : 6 pages + cartes Leaflet + exports xlsx/csv
|
||||
|
||||
Closes #101
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: PR créée vers `dev`. Le merge déclenchera l'auto-deploy vers test.colibre.fr.
|
||||
|
||||
---
|
||||
|
||||
## Notes d'exécution
|
||||
|
||||
- La migration est **réactive** : les Tasks 2-4 corrigent ce que le bump casse. Si le bump ne casse rien (plausible vu que les ruptures Dash 3.0 sont déjà absorbées), plusieurs tasks se réduisent à leur étape de vérification, sans commit.
|
||||
- Le point de bascule risqué est concentré dans la Task 1 (résolution/boot) et la Task 4 Step 3 (cartes). En cas de blocage dur d'une lib tierce sans release Dash 4 : arrêter et réévaluer avec le mainteneur du plan.
|
||||
@@ -0,0 +1,381 @@
|
||||
# Colonnes configurables pour `rechercher_marches` (MCP) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rendre configurable la sélection des colonnes du tool MCP `rechercher_marches`, avec un champ `lien` vers chaque marché et un paramètre `colonnes` typé en `enum`.
|
||||
|
||||
**Architecture:** Le paramètre `colonnes` (défaut = jeu actuel, sinon « remplace ») est validé au runtime contre un ensemble sélectionnable dérivé du schéma de référence `DATA_SCHEMA ∩ duckdb_schema`, uni au défaut. Un champ virtuel `lien = APP_BASE_URL/marche/{uid}` est ajouté en Python après la requête. L'UX passe par une annotation `Literal` (enum) exposée dans le schéma du tool.
|
||||
|
||||
**Tech Stack:** Python, dash 4.4 (`mcp_enabled`), DuckDB, Polars, pytest, `typing.Literal`, pydantic `TypeAdapter`.
|
||||
|
||||
Design de référence : `docs/superpowers/specs/2026-07-14-mcp-rechercher-marches-colonnes-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Imports internes toujours préfixés `src.` (ex. `from src.mcp.queries import ColonneMarche`).
|
||||
- `pre-commit run --files <fichiers>` avant chaque `git add` (ruff formate).
|
||||
- Lancer les tests avec `uv run pytest` (l'activation du venv dans le shell n'est pas fiable ici).
|
||||
- Tests ciblés sur leur propre fichier ; ne pas lancer toute la suite (Selenium/Chrome) avant la fin.
|
||||
- `lien` toujours présent, non désactivable ; `uid` toujours en sortie.
|
||||
- Sémantique « remplace » : `colonnes=[...]` renvoie exactement ces colonnes (+ `uid` + `lien`).
|
||||
- Erreur colonne invalide : `{"error": "colonne inconnue: <col>", "champ": <col>}` (même patron que les erreurs de filtre).
|
||||
- `base = os.getenv("APP_BASE_URL", "").rstrip("/")` (cohérent avec `src/mcp/auth.py`, `oauth/routes.py`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/mcp/queries.py` — ajoute `SELECTABLE_COLUMNS`, `ColonneMarche`, le paramètre `colonnes` + `lien` dans `search_marches`, et `colonnes_disponibles` dans `describe_schema`.
|
||||
- Modify `src/mcp/tools.py` — ajoute le paramètre `colonnes: list[ColonneMarche] | None` (annotation enum) + docstring, et le transmet à `queries.search_marches`.
|
||||
- Modify `tests/mcp/test_queries.py` — comportement `search_marches` / `describe_schema`.
|
||||
- Modify `tests/mcp/test_tools.py` — enum du paramètre + passthrough bout-en-bout.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `queries.py` — colonnes configurables, `lien`, schéma
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/mcp/queries.py`
|
||||
- Test: `tests/mcp/test_queries.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `DATA_SCHEMA` (`src.utils.data`), `duckdb_schema` (= `from src.db import schema as duckdb_schema`, déjà importé), `MARCHES_COLUMNS`, `to_json_records`, `query_marches`, `count_marches`, `build_where`.
|
||||
- Produces :
|
||||
|
||||
- `SELECTABLE_COLUMNS: tuple[str, ...]` — ensemble sélectionnable (défaut ∪ (DATA_SCHEMA ∩ duckdb_schema)).
|
||||
- `ColonneMarche` — `typing.Literal[SELECTABLE_COLUMNS]`.
|
||||
- `search_marches(..., colonnes: list[str] | None = None) -> dict` — inchangé si `colonnes is None` ; sinon exactement ces colonnes (+ `uid` + `lien`) ; chaque marché gagne `lien`.
|
||||
- `describe_schema()` renvoie en plus `colonnes_disponibles: list[str]`, et `colonnes_retournees` inclut `"lien"`.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à la fin de `tests/mcp/test_queries.py` :
|
||||
|
||||
```python
|
||||
def test_search_marches_default_columns_and_lien(monkeypatch):
|
||||
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
|
||||
from src.mcp.queries import MARCHES_COLUMNS
|
||||
|
||||
result = search_marches(acheteur_id="123")
|
||||
m = result["marches"][0]
|
||||
# Toutes les colonnes du défaut + le lien
|
||||
assert set(MARCHES_COLUMNS).issubset(m.keys())
|
||||
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
|
||||
|
||||
|
||||
def test_search_marches_custom_columns_replace(monkeypatch):
|
||||
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
|
||||
result = search_marches(acheteur_id="123", colonnes=["objet", "montant"])
|
||||
m = result["marches"][0]
|
||||
# « remplace » : exactement les colonnes demandées + uid (clé) + lien
|
||||
assert set(m.keys()) == {"uid", "objet", "montant", "lien"}
|
||||
|
||||
|
||||
def test_search_marches_custom_columns_include_uid_only_once(monkeypatch):
|
||||
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
|
||||
result = search_marches(acheteur_id="123", colonnes=["uid", "objet"])
|
||||
m = result["marches"][0]
|
||||
assert set(m.keys()) == {"uid", "objet", "lien"}
|
||||
|
||||
|
||||
def test_search_marches_invalid_column_rejected():
|
||||
result = search_marches(acheteur_id="123", colonnes=["nexiste_pas"])
|
||||
assert result["error"] == "colonne inconnue: nexiste_pas"
|
||||
assert result["champ"] == "nexiste_pas"
|
||||
assert "marches" not in result
|
||||
|
||||
|
||||
def test_search_marches_lien_relative_when_base_unset(monkeypatch):
|
||||
monkeypatch.delenv("APP_BASE_URL", raising=False)
|
||||
result = search_marches(acheteur_id="123", colonnes=["objet"])
|
||||
m = result["marches"][0]
|
||||
assert m["lien"] == f"/marche/{m['uid']}"
|
||||
|
||||
|
||||
def test_describe_schema_exposes_colonnes_disponibles():
|
||||
from src.mcp.queries import describe_schema
|
||||
|
||||
schema = describe_schema()
|
||||
dispo = schema["colonnes_disponibles"]
|
||||
assert isinstance(dispo, list) and dispo
|
||||
# surensemble des colonnes filtrables (inclut le défaut)
|
||||
assert set(schema["colonnes_filtrables"]).issubset(set(dispo))
|
||||
assert "lien" in schema["colonnes_retournees"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/mcp/test_queries.py -k "colonnes or lien or disponibles or replace or invalid_column" -v`
|
||||
Expected: FAIL (`TypeError: search_marches() got an unexpected keyword argument 'colonnes'` et `KeyError: 'colonnes_disponibles'`).
|
||||
|
||||
- [ ] **Step 3: Ajouter `import os` et `Literal`**
|
||||
|
||||
En tête de `src/mcp/queries.py`, remplacer :
|
||||
|
||||
```python
|
||||
# src/mcp/queries.py
|
||||
import re
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
# src/mcp/queries.py
|
||||
import os
|
||||
import re
|
||||
from typing import Literal
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Définir `SELECTABLE_COLUMNS` et `ColonneMarche`**
|
||||
|
||||
Juste après la définition de `MARCHES_COLUMNS` (la liste des 10 colonnes) dans `src/mcp/queries.py`, ajouter :
|
||||
|
||||
```python
|
||||
# Colonnes sélectionnables par le client : le schéma de référence (présent en
|
||||
# base) uni aux colonnes du défaut, pour que tout le défaut reste re-sélectionnable
|
||||
# même si une colonne enrichie (ex. acheteur_nom) est absente de DATA_SCHEMA.
|
||||
_FILTRABLES = tuple(name for name in DATA_SCHEMA if name in duckdb_schema)
|
||||
SELECTABLE_COLUMNS = tuple(dict.fromkeys((*MARCHES_COLUMNS, *_FILTRABLES)))
|
||||
|
||||
# Enum exposé dans le schéma du tool (UX : liste fermée pour l'agent/le client).
|
||||
ColonneMarche = Literal[SELECTABLE_COLUMNS]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implémenter la résolution des colonnes + `lien` dans `search_marches`**
|
||||
|
||||
Dans `src/mcp/queries.py`, remplacer la signature de `search_marches` pour ajouter le paramètre `colonnes` (à la fin, après `filtres_avances`) :
|
||||
|
||||
```python
|
||||
def search_marches(
|
||||
*,
|
||||
acheteur_id: str | None = None,
|
||||
titulaire_id: str | None = None,
|
||||
cpv: str | None = None,
|
||||
objet_contient: str | None = None,
|
||||
montant_min: float | None = None,
|
||||
montant_max: float | None = None,
|
||||
date_min: str | None = None,
|
||||
date_max: str | None = None,
|
||||
departement: str | None = None,
|
||||
page: int = 1,
|
||||
filtres_avances: dict | None = None,
|
||||
colonnes: list[str] | None = None,
|
||||
) -> dict:
|
||||
```
|
||||
|
||||
Puis, dans le corps, remplacer le bloc allant de `args = build_where_args(...)` jusqu'au `return {...}` final par :
|
||||
|
||||
```python
|
||||
args = build_where_args(named, filtres_avances)
|
||||
try:
|
||||
where_sql, params, order_sql = build_where(args, duckdb_schema)
|
||||
except FilterError as e:
|
||||
return {"error": str(e), "champ": e.field}
|
||||
|
||||
if colonnes is None:
|
||||
out_columns = list(MARCHES_COLUMNS)
|
||||
else:
|
||||
invalid = [c for c in colonnes if c not in SELECTABLE_COLUMNS]
|
||||
if invalid:
|
||||
return {"error": f"colonne inconnue: {invalid[0]}", "champ": invalid[0]}
|
||||
# uid toujours présent (clé primaire + nécessaire au lien), sans doublon.
|
||||
out_columns = ["uid"] + [c for c in colonnes if c != "uid"]
|
||||
|
||||
page = max(1, int(page))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
order_by = order_sql or '"dateNotification" DESC, "uid" DESC'
|
||||
df = query_marches(
|
||||
where_sql,
|
||||
params,
|
||||
columns=out_columns,
|
||||
order_by=order_by,
|
||||
limit=PAGE_SIZE,
|
||||
offset=offset,
|
||||
)
|
||||
total = count_marches(where_sql, params)
|
||||
base = os.getenv("APP_BASE_URL", "").rstrip("/")
|
||||
marches = to_json_records(df)
|
||||
for marche in marches:
|
||||
marche["lien"] = f"{base}/marche/{marche['uid']}"
|
||||
return {
|
||||
"meta": {"page": page, "page_size": PAGE_SIZE, "total": total},
|
||||
"marches": marches,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Exposer `colonnes_disponibles` et `lien` dans `describe_schema`**
|
||||
|
||||
Dans `src/mcp/queries.py`, dans `describe_schema`, remplacer le `return {...}` final par :
|
||||
|
||||
```python
|
||||
return {
|
||||
"colonnes_filtrables": colonnes,
|
||||
"colonnes_retournees": [*MARCHES_COLUMNS, "lien"],
|
||||
"colonnes_disponibles": list(SELECTABLE_COLUMNS),
|
||||
"operateurs": sorted(OPERATORS),
|
||||
"filtres_nommes": {p: f"{c}__{o}" for p, c, o in _NAMED_FILTERS},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/mcp/test_queries.py -v`
|
||||
Expected: PASS (tous, y compris les tests existants inchangés).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/mcp/queries.py tests/mcp/test_queries.py
|
||||
git add src/mcp/queries.py tests/mcp/test_queries.py
|
||||
git commit -m "feat(mcp): colonnes configurables + lien dans rechercher_marches (#114)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `tools.py` — paramètre `colonnes` enum + docstring
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/mcp/tools.py`
|
||||
- Test: `tests/mcp/test_tools.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes : `ColonneMarche`, `search_marches` (Task 1).
|
||||
- Produces : `rechercher_marches(..., colonnes: list[ColonneMarche] | None = None) -> dict` — le paramètre `colonnes` porte un `enum` dans le schéma du tool et est transmis à `queries.search_marches`.
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui échouent**
|
||||
|
||||
Ajouter à la fin de `tests/mcp/test_tools.py` :
|
||||
|
||||
```python
|
||||
def test_rechercher_marches_colonnes_param_is_enum():
|
||||
import typing
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
hints = typing.get_type_hints(tools.rechercher_marches)
|
||||
schema = TypeAdapter(hints["colonnes"]).json_schema()
|
||||
# list[ColonneMarche] | None -> anyOf[array(items.enum), null]
|
||||
array_schema = next(s for s in schema["anyOf"] if s.get("type") == "array")
|
||||
enum = array_schema["items"]["enum"]
|
||||
assert "objet" in enum
|
||||
assert "montant" in enum
|
||||
assert "uid" in enum
|
||||
|
||||
|
||||
def test_rechercher_marches_colonnes_passthrough(monkeypatch):
|
||||
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
|
||||
result = tools.rechercher_marches(acheteur_id="123", colonnes=["objet"])
|
||||
m = result["marches"][0]
|
||||
assert set(m.keys()) == {"uid", "objet", "lien"}
|
||||
assert m["lien"] == f"https://colibre.fr/marche/{m['uid']}"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run: `uv run pytest tests/mcp/test_tools.py -k "colonnes" -v`
|
||||
Expected: FAIL (`KeyError: 'colonnes'` sur `get_type_hints`, et `TypeError` sur l'appel avec `colonnes=`).
|
||||
|
||||
- [ ] **Step 3: Importer `ColonneMarche`**
|
||||
|
||||
En tête de `src/mcp/tools.py`, remplacer :
|
||||
|
||||
```python
|
||||
from src.mcp import queries
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```python
|
||||
from src.mcp import queries
|
||||
from src.mcp.queries import ColonneMarche
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter le paramètre `colonnes` (signature, docstring, appel)**
|
||||
|
||||
Dans `src/mcp/tools.py`, remplacer entièrement la fonction `rechercher_marches` par :
|
||||
|
||||
```python
|
||||
@mcp_enabled(name="rechercher_marches", expose_docstring=True)
|
||||
def rechercher_marches(
|
||||
acheteur_id: str | None = None,
|
||||
titulaire_id: str | None = None,
|
||||
cpv: str | None = None,
|
||||
objet_contient: str | None = None,
|
||||
montant_min: float | None = None,
|
||||
montant_max: float | None = None,
|
||||
date_min: str | None = None,
|
||||
date_max: str | None = None,
|
||||
departement: str | None = None,
|
||||
page: int = 1,
|
||||
filtres_avances: dict | None = None,
|
||||
colonnes: list[ColonneMarche] | None = None,
|
||||
) -> dict:
|
||||
"""Recherche paginée de marchés publics (DECP).
|
||||
|
||||
Filtres nommés : acheteur_id, titulaire_id, cpv (code CPV, correspondance
|
||||
partielle), objet_contient (texte de l'objet), montant_min, montant_max,
|
||||
date_min / date_max (format YYYY-MM-DD, sur dateNotification),
|
||||
departement (code département de l'acheteur).
|
||||
filtres_avances : dict optionnel {"colonne__operateur": valeur} pour les
|
||||
besoins pointus. Colonnes et opérateurs disponibles via l'outil
|
||||
schema_donnees().
|
||||
colonnes : liste optionnelle de colonnes à renvoyer. Par défaut, un jeu
|
||||
standard (uid, objet, montant, dateNotification, codeCPV, acheteur_id,
|
||||
acheteur_nom, acheteur_departement_code, titulaire_id, titulaire_nom). Si
|
||||
fournie, REMPLACE le jeu par défaut (le champ uid reste toujours présent).
|
||||
Colonnes disponibles via schema_donnees().colonnes_disponibles.
|
||||
Chaque marché renvoyé contient en plus un champ `lien` (URL de la fiche
|
||||
marché sur colibre).
|
||||
page : numéro de page (50 résultats par page).
|
||||
Retourne {meta: {page, page_size, total}, marches: [...]}.
|
||||
"""
|
||||
track_mcp_tool("rechercher_marches", query=objet_contient)
|
||||
return queries.search_marches(
|
||||
acheteur_id=acheteur_id,
|
||||
titulaire_id=titulaire_id,
|
||||
cpv=cpv,
|
||||
objet_contient=objet_contient,
|
||||
montant_min=montant_min,
|
||||
montant_max=montant_max,
|
||||
date_min=date_min,
|
||||
date_max=date_max,
|
||||
departement=departement,
|
||||
page=page,
|
||||
filtres_avances=filtres_avances,
|
||||
colonnes=colonnes,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run: `uv run pytest tests/mcp/test_tools.py -v`
|
||||
Expected: PASS (tous).
|
||||
|
||||
- [ ] **Step 6: Vérification finale des tests MCP**
|
||||
|
||||
Run: `uv run pytest tests/mcp/ -v`
|
||||
Expected: PASS (aucune régression dans les autres modules MCP).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
pre-commit run --files src/mcp/tools.py tests/mcp/test_tools.py
|
||||
git add src/mcp/tools.py tests/mcp/test_tools.py
|
||||
git commit -m "feat(mcp): parametre colonnes (enum) pour rechercher_marches (#114)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes d'implémentation
|
||||
|
||||
- **Sécurité SQL** : `query_marches` (`src/db.py`) interpole les colonnes sans quoting. La validation `c not in SELECTABLE_COLUMNS` (Step 5, Task 1) est la barrière — ne pas la retirer. `SELECTABLE_COLUMNS` ne contient que des noms issus du schéma / du défaut, jamais d'entrée libre.
|
||||
- **`Literal[SELECTABLE_COLUMNS]`** : `Literal` accepte un tuple de littéraux (vérifié : produit bien `items.enum` via `TypeAdapter`). L'ordre suit `MARCHES_COLUMNS` puis les colonnes filtrables.
|
||||
- **`get_type_hints`** dans le test enum : `tools.py` n'utilise pas `from __future__ import annotations`, donc l'annotation est un objet résoluble ; `ColonneMarche` doit être importable au niveau module (Step 3).
|
||||
|
||||
```
|
||||
|
||||
```
|
||||